agentic-documents 0.1.0 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,744 @@
1
+ // src/errors.ts
2
+ var DOC_ERROR_CODES = ["NO_MATCH", "AMBIGUOUS_MATCH", "STALE_ANCHOR", "NO_SECTION", "INVALID_AUTHOR", "INVALID_INPUT", "NO_THREAD", "NO_SUGGESTION", "ALREADY_DECIDED", "FORBIDDEN", "NO_DOCUMENT", "DOCUMENT_EXISTS", "LOCKED", "CONFLICT", "MISCONFIGURED"];
3
+ var DocError = class extends Error {
4
+ constructor(code, message, matches = []) {
5
+ super(message);
6
+ this.code = code;
7
+ this.matches = matches;
8
+ this.name = "DocError";
9
+ }
10
+ code;
11
+ matches;
12
+ };
13
+
14
+ // src/markdown.ts
15
+ import { fromMarkdown } from "mdast-util-from-markdown";
16
+ import { gfmFromMarkdown, gfmToMarkdown } from "mdast-util-gfm";
17
+ import { toMarkdown } from "mdast-util-to-markdown";
18
+ import { gfm } from "micromark-extension-gfm";
19
+ var definitions = /* @__PURE__ */ new Map();
20
+ function unsupported(what) {
21
+ throw new DocError("INVALID_INPUT", `Markdown ${what} is not supported and would be lost; remove it or write it inline`);
22
+ }
23
+ function collectDefinitions(nodes2, into) {
24
+ for (const node of nodes2) {
25
+ if (node.type === "definition") into.set(node.identifier, node);
26
+ const children = node.children;
27
+ if (children) collectDefinitions(children, into);
28
+ }
29
+ }
30
+ var withContent = (node, content) => content.length ? { ...node, content } : node;
31
+ function inline(nodes2, marks2 = []) {
32
+ return nodes2.flatMap((node) => {
33
+ const text = (value, own) => value ? [{ type: "text", text: value, ...own.length ? { marks: own } : {} }] : [];
34
+ switch (node.type) {
35
+ case "text":
36
+ return text(node.value, marks2);
37
+ case "html":
38
+ return text(node.value, marks2);
39
+ case "inlineCode":
40
+ return text(node.value, [{ type: "code" }]);
41
+ case "strong":
42
+ return inline(node.children, [...marks2, { type: "bold" }]);
43
+ case "emphasis":
44
+ return inline(node.children, [...marks2, { type: "italic" }]);
45
+ case "delete":
46
+ return inline(node.children, [...marks2, { type: "strike" }]);
47
+ case "link":
48
+ return inline(node.children, [...marks2, { type: "link", attrs: { href: node.url } }]);
49
+ case "image":
50
+ return [{ type: "image", attrs: { src: node.url, alt: node.alt ?? null, title: node.title ?? null } }];
51
+ case "break":
52
+ return [{ type: "hardBreak" }];
53
+ case "linkReference":
54
+ return inline(node.children, [...marks2, { type: "link", attrs: { href: definitions.get(node.identifier)?.url ?? unsupported("link reference without a definition") } }]);
55
+ case "imageReference": {
56
+ const url = definitions.get(node.identifier)?.url ?? unsupported("image reference without a definition");
57
+ return [{ type: "image", attrs: { src: url, alt: node.alt ?? null, title: null } }];
58
+ }
59
+ default:
60
+ return unsupported(`"${node.type}"`);
61
+ }
62
+ });
63
+ }
64
+ function itemContent(children) {
65
+ const content = blocks(children);
66
+ return content[0]?.type === "paragraph" ? content : [{ type: "paragraph" }, ...content];
67
+ }
68
+ function cell(type, node) {
69
+ return { type, content: [withContent({ type: "paragraph" }, inline(node.children))] };
70
+ }
71
+ function blocks(nodes2) {
72
+ return nodes2.flatMap((node) => {
73
+ switch (node.type) {
74
+ case "heading":
75
+ return [withContent({ type: "heading", attrs: { level: node.depth } }, inline(node.children))];
76
+ case "paragraph":
77
+ return [withContent({ type: "paragraph" }, inline(node.children))];
78
+ case "html":
79
+ return [withContent({ type: "paragraph" }, inline([node]))];
80
+ case "blockquote":
81
+ return [{ type: "blockquote", content: blocks(node.children) }];
82
+ case "code":
83
+ return [withContent({ type: "codeBlock", attrs: { language: node.lang ?? null } }, node.value ? [{ type: "text", text: node.value }] : [])];
84
+ case "thematicBreak":
85
+ return [{ type: "horizontalRule" }];
86
+ case "list": {
87
+ const task = node.children.some((item) => item.checked != null);
88
+ const items = node.children.map(
89
+ (item) => ({
90
+ type: task ? "taskItem" : "listItem",
91
+ ...task ? { attrs: { checked: item.checked === true } } : {},
92
+ content: itemContent(item.children)
93
+ })
94
+ );
95
+ if (task) return [{ type: "taskList", content: items }];
96
+ return node.ordered ? [{ type: "orderedList", attrs: { start: node.start ?? 1 }, content: items }] : [{ type: "bulletList", content: items }];
97
+ }
98
+ case "table":
99
+ return [
100
+ {
101
+ type: "table",
102
+ content: node.children.map((row, i) => ({
103
+ type: "tableRow",
104
+ content: row.children.map((c) => cell(i === 0 ? "tableHeader" : "tableCell", c))
105
+ }))
106
+ }
107
+ ];
108
+ case "definition":
109
+ return [];
110
+ default:
111
+ return unsupported(`"${node.type}"`);
112
+ }
113
+ });
114
+ }
115
+ function markdownToBlocks(markdown) {
116
+ const tree = fromMarkdown(markdown, { extensions: [gfm()], mdastExtensions: [gfmFromMarkdown()] });
117
+ definitions = /* @__PURE__ */ new Map();
118
+ collectDefinitions(tree.children, definitions);
119
+ let out;
120
+ try {
121
+ out = blocks(tree.children);
122
+ } finally {
123
+ definitions = /* @__PURE__ */ new Map();
124
+ }
125
+ return out.length ? out : [{ type: "paragraph" }];
126
+ }
127
+ var MARK_ORDER = ["link", "bold", "italic", "strike", "code"];
128
+ function leaf(node) {
129
+ if (node.type === "image") {
130
+ return { type: "image", url: String(node.attrs?.src ?? ""), alt: node.attrs?.alt ?? null, title: node.attrs?.title ?? null };
131
+ }
132
+ if (node.type === "hardBreak") return { type: "break" };
133
+ return { type: "text", value: node.text ?? "" };
134
+ }
135
+ function wrap(nodes2, depth) {
136
+ if (depth === MARK_ORDER.length) return nodes2.map(leaf);
137
+ const name = MARK_ORDER[depth];
138
+ const find = (node) => node.marks?.find((m) => m.type === name);
139
+ const out = [];
140
+ for (let i = 0; i < nodes2.length; ) {
141
+ const mark = find(nodes2[i]);
142
+ let j = i + 1;
143
+ while (j < nodes2.length && JSON.stringify(find(nodes2[j])?.attrs?.href) === JSON.stringify(mark?.attrs?.href) && !!find(nodes2[j]) === !!mark) j++;
144
+ const run = nodes2.slice(i, j);
145
+ i = j;
146
+ if (!mark) out.push(...wrap(run, depth + 1));
147
+ else if (name === "code") out.push({ type: "inlineCode", value: run.map((n) => n.text ?? "").join("") });
148
+ else {
149
+ const children = wrap(run, depth + 1);
150
+ if (name === "link") out.push({ type: "link", url: String(mark.attrs?.href ?? ""), children });
151
+ else out.push({ type: name === "bold" ? "strong" : name === "italic" ? "emphasis" : "delete", children });
152
+ }
153
+ }
154
+ return out;
155
+ }
156
+ function phrasing(node) {
157
+ const nodes2 = [...node.content ?? []];
158
+ const first = nodes2[0];
159
+ if (first?.type === "text") nodes2[0] = { ...first, text: first.text?.trimStart() };
160
+ const last = nodes2[nodes2.length - 1];
161
+ if (last?.type === "text") nodes2[nodes2.length - 1] = { ...last, text: last.text?.trimEnd() };
162
+ return wrap(nodes2.filter((n) => n.type !== "text" || n.text), 0);
163
+ }
164
+ function cellText(node) {
165
+ return (node.content ?? []).flatMap((p, i) => i ? [{ type: "break" }, ...phrasing(p)] : phrasing(p));
166
+ }
167
+ function blocksOut(nodes2) {
168
+ return nodes2.flatMap((node) => {
169
+ const children = node.content ?? [];
170
+ switch (node.type) {
171
+ case "paragraph":
172
+ return children.length ? [{ type: "paragraph", children: phrasing(node) }] : [];
173
+ case "heading":
174
+ return [{ type: "heading", depth: Math.min(6, Math.max(1, Number(node.attrs?.level ?? 1))), children: phrasing(node) }];
175
+ case "blockquote":
176
+ return [{ type: "blockquote", children: blocksOut(children) }];
177
+ case "codeBlock":
178
+ return [{ type: "code", lang: node.attrs?.language ?? null, value: children.map((c) => c.text ?? "").join("") }];
179
+ case "horizontalRule":
180
+ return [{ type: "thematicBreak" }];
181
+ case "bulletList":
182
+ case "orderedList":
183
+ case "taskList": {
184
+ const task = node.type === "taskList";
185
+ return [
186
+ {
187
+ type: "list",
188
+ ordered: node.type === "orderedList",
189
+ start: node.type === "orderedList" ? Number(node.attrs?.start ?? 1) : null,
190
+ spread: false,
191
+ children: children.map((item) => ({
192
+ type: "listItem",
193
+ spread: false,
194
+ ...task ? { checked: item.attrs?.checked === true } : {},
195
+ children: blocksOut(item.content ?? [])
196
+ }))
197
+ }
198
+ ];
199
+ }
200
+ case "table":
201
+ return [
202
+ {
203
+ type: "table",
204
+ children: children.map((row) => ({
205
+ type: "tableRow",
206
+ children: (row.content ?? []).map((c) => ({ type: "tableCell", children: cellText(c) }))
207
+ }))
208
+ }
209
+ ];
210
+ default:
211
+ return [];
212
+ }
213
+ });
214
+ }
215
+ function blocksToMarkdown(nodes2) {
216
+ const root = { type: "root", children: blocksOut(nodes2) };
217
+ return toMarkdown(root, { extensions: [gfmToMarkdown()], bullet: "-", rule: "-", fences: true, emphasis: "*", strong: "*" });
218
+ }
219
+
220
+ // src/schema.ts
221
+ import { Schema } from "prosemirror-model";
222
+ var cell2 = {
223
+ content: "block+",
224
+ attrs: { colspan: { default: 1 }, rowspan: { default: 1 }, colwidth: { default: null } }
225
+ };
226
+ var nodes = {
227
+ doc: { content: "block+" },
228
+ paragraph: { group: "block", content: "inline*" },
229
+ text: { group: "inline" },
230
+ heading: { group: "block", content: "inline*", attrs: { level: { default: 1 } } },
231
+ blockquote: { group: "block", content: "block+" },
232
+ codeBlock: { group: "block", content: "text*", marks: "", code: true, attrs: { language: { default: null } } },
233
+ horizontalRule: { group: "block" },
234
+ bulletList: { group: "block", content: "listItem+" },
235
+ orderedList: { group: "block", content: "listItem+", attrs: { start: { default: 1 }, type: { default: null } } },
236
+ listItem: { content: "paragraph block*" },
237
+ taskList: { group: "block", content: "taskItem+" },
238
+ taskItem: { content: "paragraph block*", attrs: { checked: { default: false } } },
239
+ hardBreak: { group: "inline", inline: true },
240
+ image: {
241
+ group: "inline",
242
+ inline: true,
243
+ attrs: { src: { default: null }, alt: { default: null }, title: { default: null } }
244
+ },
245
+ table: { group: "block", content: "tableRow+" },
246
+ tableRow: { content: "(tableCell | tableHeader)*" },
247
+ tableCell: cell2,
248
+ tableHeader: cell2
249
+ };
250
+ var marks = {
251
+ bold: {},
252
+ italic: {},
253
+ strike: {},
254
+ code: { excludes: "_" },
255
+ link: {
256
+ inclusive: false,
257
+ attrs: {
258
+ href: { default: null },
259
+ target: { default: "_blank" },
260
+ rel: { default: "noopener noreferrer nofollow" },
261
+ class: { default: null }
262
+ }
263
+ }
264
+ };
265
+ var schema = new Schema({ nodes, marks });
266
+
267
+ // src/doc.ts
268
+ import { prosemirrorJSONToYXmlFragment, yXmlFragmentToProsemirrorJSON } from "y-prosemirror";
269
+ import * as Y2 from "yjs";
270
+
271
+ // src/settle.ts
272
+ import * as Y from "yjs";
273
+ var APPLIED = "applied";
274
+ var clientOf = (key) => Number(key.slice(key.lastIndexOf(":") + 1));
275
+ function settle(ydoc) {
276
+ const applied = ydoc.getMap(APPLIED);
277
+ const groups = /* @__PURE__ */ new Map();
278
+ for (const [key, record] of applied) {
279
+ const id = key.slice(0, key.lastIndexOf(":"));
280
+ groups.set(id, [...groups.get(id) ?? [], [key, record]]);
281
+ }
282
+ if (groups.size === 0) return;
283
+ const suggestions = ydoc.getMap("suggestions");
284
+ const log = ydoc.getArray("edits");
285
+ ydoc.transact(() => {
286
+ for (const [id, records] of groups) {
287
+ records.sort((a, b) => clientOf(a[0]) - clientOf(b[0]));
288
+ const [[, winner], ...losers] = records;
289
+ const fields = suggestions.get(id);
290
+ const decided = (key) => JSON.stringify(fields?.get(key));
291
+ if (fields && (decided("status") !== '"accepted"' || decided("decidedBy") !== JSON.stringify(winner.by) || decided("decidedAt") !== JSON.stringify(winner.at))) {
292
+ fields.set("status", "accepted");
293
+ fields.set("decidedBy", winner.by);
294
+ fields.set("decidedAt", winner.at);
295
+ }
296
+ if (losers.length === 0) continue;
297
+ for (const [key, loser] of losers) {
298
+ applied.delete(key);
299
+ if (!loser.start || !loser.end) continue;
300
+ const from = Y.createAbsolutePositionFromRelativePosition(Y.createRelativePositionFromJSON(loser.start), ydoc);
301
+ const to = Y.createAbsolutePositionFromRelativePosition(Y.createRelativePositionFromJSON(loser.end), ydoc);
302
+ if (from && to && from.type === to.type && to.index > from.index && from.type instanceof Y.XmlText) {
303
+ from.type.delete(from.index, to.index - from.index);
304
+ }
305
+ }
306
+ const entries = log.toArray().flatMap((e, i) => e.suggestion?.id === id ? [{ e, i }] : []);
307
+ const keep = entries.find(({ e }) => e.at === winner.at && JSON.stringify(e.author) === JSON.stringify(winner.by)) ?? entries[0];
308
+ for (const { i } of entries.reverse()) if (i !== keep?.i) log.delete(i, 1);
309
+ }
310
+ });
311
+ }
312
+
313
+ // src/doc.ts
314
+ var PREAMBLE = "preamble";
315
+ var FRAGMENT = "default";
316
+ var CONTEXT_CHARS = 20;
317
+ var PRINCIPAL_KINDS = ["human", "agent_of_human", "agent_of_agent", "agent", "user_token"];
318
+ var isSection = (t) => "section" in t;
319
+ var isAnchor = (t) => "anchor" in t;
320
+ function checkAuthor(author) {
321
+ const scopes = author?.scopes;
322
+ const ok = typeof author === "object" && author !== null && PRINCIPAL_KINDS.includes(author.kind) && typeof author.name === "string" && author.name.trim() !== "" && (scopes === void 0 || Array.isArray(scopes) && scopes.every((s) => typeof s === "string" && s !== ""));
323
+ if (!ok) throw new DocError("INVALID_AUTHOR", `author needs a kind (${PRINCIPAL_KINDS.join(", ")}), a name, and scopes that are a list of text`);
324
+ return { kind: author.kind, name: author.name };
325
+ }
326
+ function parseAuthor(value) {
327
+ if (value === void 0) return void 0;
328
+ const cut = value.indexOf(":");
329
+ return { kind: cut < 0 ? value : value.slice(0, cut), name: cut < 0 ? "" : value.slice(cut + 1) };
330
+ }
331
+ var unitText = (unit) => unit.toDelta().map((d) => typeof d.insert === "string" ? d.insert : "").join("");
332
+ function unitsOf(node) {
333
+ return node.toArray().flatMap((child) => child instanceof Y2.XmlText ? [child] : child instanceof Y2.XmlElement ? unitsOf(child) : []);
334
+ }
335
+ function attrsAt(unit, index) {
336
+ let pos = 0;
337
+ for (const op of unit.toDelta()) {
338
+ pos += typeof op.insert === "string" ? op.insert.length : 1;
339
+ if (index < pos) return { ...op.attributes };
340
+ }
341
+ return {};
342
+ }
343
+ function sectionId(el) {
344
+ const { client, clock } = el._item.id;
345
+ return `s${client.toString(36)}-${clock}`;
346
+ }
347
+ function toYBlocks(content) {
348
+ const scratch = new Y2.Doc().getXmlFragment(FRAGMENT);
349
+ prosemirrorJSONToYXmlFragment(schema, { type: "doc", content }, scratch);
350
+ return scratch.toArray().map((el) => el.clone());
351
+ }
352
+ var AgenticDoc = class _AgenticDoc {
353
+ ydoc;
354
+ now;
355
+ constructor(ydoc = new Y2.Doc(), options = {}) {
356
+ this.ydoc = ydoc;
357
+ this.now = options.now ?? (() => /* @__PURE__ */ new Date());
358
+ const watched = [ydoc.getMap(APPLIED), ydoc.getMap("suggestions")];
359
+ ydoc.on("afterTransaction", (tr) => {
360
+ if (!tr.local && watched.some((type) => tr.changedParentTypes.has(type))) settle(ydoc);
361
+ });
362
+ }
363
+ static fromMarkdown(markdown, author, options) {
364
+ const who = checkAuthor(author);
365
+ const doc = new _AgenticDoc(new Y2.Doc(), options);
366
+ const content = markdownToBlocks(markdown);
367
+ doc.change("create", who, () => {
368
+ doc.fragment.insert(0, toYBlocks(content));
369
+ return { inserted: content.length, deleted: 0 };
370
+ });
371
+ return doc;
372
+ }
373
+ static fromUpdate(update, options) {
374
+ const doc = new _AgenticDoc(new Y2.Doc(), options);
375
+ doc.applyUpdate(update);
376
+ return doc;
377
+ }
378
+ encode() {
379
+ return Y2.encodeStateAsUpdate(this.ydoc);
380
+ }
381
+ applyUpdate(update) {
382
+ Y2.applyUpdate(this.ydoc, update);
383
+ }
384
+ /** A new replica with its own client id, holding the same state. */
385
+ fork() {
386
+ return _AgenticDoc.fromUpdate(this.encode(), { now: this.now });
387
+ }
388
+ get fragment() {
389
+ return this.ydoc.getXmlFragment(FRAGMENT);
390
+ }
391
+ get log() {
392
+ return this.ydoc.getArray("edits");
393
+ }
394
+ edits() {
395
+ return this.log.toArray();
396
+ }
397
+ change(op, author, run, extra = {}) {
398
+ const at = this.now().toISOString();
399
+ this.ydoc.transact(() => {
400
+ const entry = { op, at, author, ...run(), ...extra };
401
+ if (entry.section === void 0) delete entry.section;
402
+ this.log.push([entry]);
403
+ });
404
+ return at;
405
+ }
406
+ toJSON() {
407
+ return yXmlFragmentToProsemirrorJSON(this.fragment);
408
+ }
409
+ toMarkdown() {
410
+ return blocksToMarkdown(this.toJSON().content ?? []);
411
+ }
412
+ /** Sections in order. Blocks before the first heading come first, as the `preamble` section at level 0. */
413
+ outline() {
414
+ const headings = this.headings();
415
+ const sections = headings.map(({ el }) => ({
416
+ id: sectionId(el),
417
+ level: Number(el.getAttribute("level") ?? 1),
418
+ title: unitsOf(el).map(unitText).join("")
419
+ }));
420
+ const before = headings.length ? headings[0].index : this.fragment.length;
421
+ return before > 0 ? [{ id: PREAMBLE, level: 0, title: "Preamble" }, ...sections] : sections;
422
+ }
423
+ readSection(id) {
424
+ const { start, end } = this.range(id);
425
+ return blocksToMarkdown((this.toJSON().content ?? []).slice(start, end));
426
+ }
427
+ headings() {
428
+ return this.fragment.toArray().flatMap((el, index) => el instanceof Y2.XmlElement && el.nodeName === "heading" ? [{ el, index }] : []);
429
+ }
430
+ /**
431
+ * A section runs from its heading to the next heading of the same or a higher level. The preamble
432
+ * has no heading and runs to the first heading. It is always a valid target, even when empty.
433
+ */
434
+ range(id) {
435
+ const all = this.headings();
436
+ if (id === PREAMBLE) return { start: 0, end: all.length ? all[0].index : this.fragment.length, headed: false };
437
+ const at = all.findIndex(({ el }) => sectionId(el) === id);
438
+ if (at < 0) throw new DocError("NO_SECTION", `no section with id "${id}"`);
439
+ const level = Number(all[at].el.getAttribute("level") ?? 1);
440
+ const next = all.slice(at + 1).find(({ el }) => Number(el.getAttribute("level") ?? 1) <= level);
441
+ return { start: all[at].index, end: next ? next.index : this.fragment.length, headed: true };
442
+ }
443
+ find(text) {
444
+ if (text === "") return [];
445
+ return unitsOf(this.fragment).flatMap((unit) => {
446
+ const found = [];
447
+ const value = unitText(unit);
448
+ for (let at = value.indexOf(text); at >= 0; at = value.indexOf(text, at + 1)) {
449
+ found.push({ unit, start: at, end: at + text.length });
450
+ }
451
+ return found;
452
+ });
453
+ }
454
+ pick(text, occurrence) {
455
+ const found = this.find(text);
456
+ if (found.length === 0) throw new DocError("NO_MATCH", `no text matches "${text}"`);
457
+ if (occurrence !== void 0) {
458
+ if (found[occurrence - 1] === void 0) throw new DocError("NO_MATCH", `"${text}" has ${found.length} matches, not ${occurrence}`);
459
+ return found[occurrence - 1];
460
+ }
461
+ if (found.length > 1) {
462
+ const matches = found.map((m, i) => {
463
+ const value = unitText(m.unit);
464
+ return { occurrence: i + 1, context: value.slice(Math.max(0, m.start - CONTEXT_CHARS), m.end + CONTEXT_CHARS) };
465
+ });
466
+ throw new DocError("AMBIGUOUS_MATCH", `"${text}" matches ${found.length} places; pass an occurrence`, matches);
467
+ }
468
+ return found[0];
469
+ }
470
+ /** Pin a text range so later edits by other replicas do not move it. */
471
+ anchor(text, occurrence) {
472
+ const { unit, start, end } = this.pick(text, occurrence);
473
+ return {
474
+ text,
475
+ start: Y2.relativePositionToJSON(Y2.createRelativePositionFromTypeIndex(unit, start, 0)),
476
+ end: Y2.relativePositionToJSON(Y2.createRelativePositionFromTypeIndex(unit, end, -1))
477
+ };
478
+ }
479
+ resolve(target) {
480
+ if (!isAnchor(target)) return this.pick(target.text, target.occurrence);
481
+ const { anchor } = target;
482
+ const start = Y2.createAbsolutePositionFromRelativePosition(Y2.createRelativePositionFromJSON(anchor.start), this.ydoc);
483
+ const end = Y2.createAbsolutePositionFromRelativePosition(Y2.createRelativePositionFromJSON(anchor.end), this.ydoc);
484
+ const unit = start?.type;
485
+ if (!(unit instanceof Y2.XmlText) || end?.type !== unit || unitText(unit).slice(start.index, end.index) !== anchor.text) {
486
+ throw new DocError("STALE_ANCHOR", `the anchored text "${anchor.text}" changed or was removed`);
487
+ }
488
+ return { unit, start: start.index, end: end.index };
489
+ }
490
+ edit(op, target, content, author, extra = {}) {
491
+ const who = checkAuthor(author);
492
+ const { unit, start, end } = this.resolve(target);
493
+ let inserted;
494
+ const at = this.change(op, who, () => {
495
+ const marks2 = attrsAt(unit, op === "insert" && target.at !== "before" ? end - 1 : start);
496
+ if (op === "insert") delete marks2.link;
497
+ const index = op === "insert" ? target.at === "before" ? start : end : start;
498
+ if (op !== "insert") unit.delete(start, end - start);
499
+ if (op !== "delete") {
500
+ unit.insert(index, content, marks2);
501
+ inserted = {
502
+ start: Y2.relativePositionToJSON(Y2.createRelativePositionFromTypeIndex(unit, index, 0)),
503
+ end: Y2.relativePositionToJSON(Y2.createRelativePositionFromTypeIndex(unit, index + content.length, -1))
504
+ };
505
+ }
506
+ return { inserted: op === "delete" ? 0 : content.length, deleted: op === "insert" ? 0 : end - start };
507
+ }, extra);
508
+ return { at, inserted };
509
+ }
510
+ editSection(op, target, markdown, author) {
511
+ const who = checkAuthor(author);
512
+ const { start, end, headed } = this.range(target.section);
513
+ const body = headed ? start + 1 : start;
514
+ const content = op === "delete" ? [] : markdownToBlocks(markdown);
515
+ this.change(op, who, () => {
516
+ const deleted = op === "insert" ? 0 : op === "delete" ? end - start : end - body;
517
+ const from = op === "delete" ? start : op === "replace" || target.at === "start" ? body : end;
518
+ if (op === "replace") this.fragment.delete(body, end - body);
519
+ if (op === "delete") this.fragment.delete(start, end - start);
520
+ if (content.length) this.fragment.insert(from, toYBlocks(content));
521
+ return { inserted: content.length, deleted, section: target.section };
522
+ });
523
+ }
524
+ /** Insert Markdown into a section, or plain text next to matched text. */
525
+ insert(target, content, author) {
526
+ if (isSection(target)) this.editSection("insert", target, content, author);
527
+ else this.edit("insert", target, content, author);
528
+ }
529
+ /** Replace a section body (the heading stays), or the matched text. */
530
+ replace(target, content, author) {
531
+ if (isSection(target)) this.editSection("replace", target, content, author);
532
+ else this.edit("replace", target, content, author);
533
+ }
534
+ /** Delete a whole section with its subsections, or the matched text. */
535
+ delete(target, author) {
536
+ if (isSection(target)) this.editSection("delete", target, "", author);
537
+ else this.edit("delete", target, "", author);
538
+ }
539
+ append(markdown, author) {
540
+ const who = checkAuthor(author);
541
+ const content = markdownToBlocks(markdown);
542
+ this.change("append", who, () => {
543
+ this.fragment.insert(this.fragment.length, toYBlocks(content));
544
+ return { inserted: content.length, deleted: 0 };
545
+ });
546
+ }
547
+ };
548
+
549
+ // src/review.ts
550
+ import * as Y3 from "yjs";
551
+ var newId = () => globalThis.crypto.randomUUID();
552
+ function nonEmpty(value, what) {
553
+ if (typeof value !== "string" || value.trim() === "") throw new DocError("INVALID_INPUT", `${what} must not be empty`);
554
+ return value;
555
+ }
556
+ function anchorFor(doc, target) {
557
+ if ("anchor" in target) {
558
+ doc.resolve(target);
559
+ return target.anchor;
560
+ }
561
+ return doc.anchor(target.text, target.occurrence);
562
+ }
563
+ function isStale(doc, anchor) {
564
+ try {
565
+ doc.resolve({ anchor });
566
+ return { stale: false, currentText: anchor.text };
567
+ } catch (error) {
568
+ if (!(error instanceof DocError) || error.code !== "STALE_ANCHOR") throw error;
569
+ return { stale: true, currentText: null };
570
+ }
571
+ }
572
+ function fieldsOf(doc, name) {
573
+ return doc.ydoc.getMap(name);
574
+ }
575
+ var optional = (map, key) => map.has(key) ? { [key]: map.get(key) } : {};
576
+ function setAll(map, values) {
577
+ for (const [key, value] of Object.entries(values)) if (value !== void 0) map.set(key, value);
578
+ }
579
+ var Threads = class {
580
+ constructor(doc) {
581
+ this.doc = doc;
582
+ }
583
+ doc;
584
+ get map() {
585
+ return fieldsOf(this.doc, "threads");
586
+ }
587
+ get(id) {
588
+ const thread = this.map.get(id);
589
+ if (!thread) throw new DocError("NO_THREAD", `no thread with id "${id}"`);
590
+ return thread;
591
+ }
592
+ message(body, author) {
593
+ return { id: newId(), at: this.doc.now().toISOString(), author, body };
594
+ }
595
+ add(target, body, author) {
596
+ const who = checkAuthor(author);
597
+ nonEmpty(body, "a comment");
598
+ const anchor = anchorFor(this.doc, target);
599
+ const id = newId();
600
+ this.doc.ydoc.transact(() => {
601
+ const thread = new Y3.Map();
602
+ this.map.set(id, thread);
603
+ setAll(thread, { anchor, status: "open" });
604
+ const messages = new Y3.Array();
605
+ thread.set("messages", messages);
606
+ messages.push([this.message(body, who)]);
607
+ });
608
+ return id;
609
+ }
610
+ reply(id, body, author) {
611
+ const who = checkAuthor(author);
612
+ nonEmpty(body, "a reply");
613
+ this.get(id).get("messages").push([this.message(body, who)]);
614
+ }
615
+ setStatus(id, status, author) {
616
+ const who = checkAuthor(author);
617
+ const thread = this.get(id);
618
+ this.doc.ydoc.transact(() => {
619
+ thread.set("status", status);
620
+ if (status === "open") {
621
+ thread.delete("resolvedBy");
622
+ thread.delete("resolvedAt");
623
+ } else setAll(thread, { resolvedBy: who, resolvedAt: this.doc.now().toISOString() });
624
+ });
625
+ }
626
+ resolve(id, author) {
627
+ this.setStatus(id, "resolved", author);
628
+ }
629
+ reopen(id, author) {
630
+ this.setStatus(id, "open", author);
631
+ }
632
+ list(filter = {}) {
633
+ return [...this.map.entries()].map(([id, t]) => {
634
+ const anchor = t.get("anchor");
635
+ return {
636
+ id,
637
+ anchor,
638
+ status: t.get("status"),
639
+ ...optional(t, "resolvedBy"),
640
+ ...optional(t, "resolvedAt"),
641
+ messages: t.get("messages").toArray(),
642
+ ...isStale(this.doc, anchor)
643
+ };
644
+ }).filter((t) => filter.status === void 0 || t.status === filter.status).sort((a, b) => a.messages[0].at.localeCompare(b.messages[0].at) || a.id.localeCompare(b.id));
645
+ }
646
+ };
647
+ var ACCEPT_SCOPE = "docs:accept";
648
+ var sameWho = (a, b) => a.kind === b.kind && a.name === b.name;
649
+ var isPerson = (who) => who.kind === "human" || who.kind === "user_token";
650
+ var mayDecide = (who) => isPerson(who) || who.scopes?.includes(ACCEPT_SCOPE) === true;
651
+ var Suggestions = class {
652
+ constructor(doc) {
653
+ this.doc = doc;
654
+ }
655
+ doc;
656
+ get map() {
657
+ return fieldsOf(this.doc, "suggestions");
658
+ }
659
+ get(id) {
660
+ const fields = this.map.get(id);
661
+ if (!fields) throw new DocError("NO_SUGGESTION", `no suggestion with id "${id}"`);
662
+ return { fields, suggestion: this.read(id, fields) };
663
+ }
664
+ read(id, f) {
665
+ const anchor = f.get("anchor");
666
+ return {
667
+ id,
668
+ kind: f.get("kind"),
669
+ ...optional(f, "at"),
670
+ anchor,
671
+ replacement: f.get("replacement"),
672
+ author: f.get("author"),
673
+ createdAt: f.get("createdAt"),
674
+ status: f.get("status"),
675
+ ...optional(f, "decidedBy"),
676
+ ...optional(f, "decidedAt"),
677
+ stale: isStale(this.doc, anchor).stale
678
+ };
679
+ }
680
+ /** An `at` on the target means insert. An empty replacement means delete. */
681
+ add(target, replacement, author) {
682
+ const who = checkAuthor(author);
683
+ const kind = target.at ? "insert" : replacement === "" ? "delete" : "replace";
684
+ if (kind === "insert") nonEmpty(replacement, "an inserted text");
685
+ const anchor = anchorFor(this.doc, target);
686
+ const id = newId();
687
+ this.doc.ydoc.transact(() => {
688
+ const fields = new Y3.Map();
689
+ this.map.set(id, fields);
690
+ setAll(fields, { kind, at: target.at, anchor, replacement, author: who, createdAt: this.doc.now().toISOString(), status: "pending" });
691
+ });
692
+ return id;
693
+ }
694
+ decide(fields, status, author, at) {
695
+ setAll(fields, { status, decidedBy: author, decidedAt: at });
696
+ }
697
+ /**
698
+ * Apply the edit as the accepting author. The check and the edit share one transaction. Accepting
699
+ * an accepted suggestion changes nothing. A stale anchor throws and the suggestion stays pending.
700
+ */
701
+ accept(id, author) {
702
+ const who = checkAuthor(author);
703
+ this.doc.ydoc.transact(() => {
704
+ const { fields, suggestion: s } = this.get(id);
705
+ if (!mayDecide(author) || sameWho(s.author, who)) {
706
+ throw new DocError("FORBIDDEN", `${who.kind}:${who.name} cannot accept this suggestion: a person who is not its author, or an agent with the ${ACCEPT_SCOPE} scope, can`);
707
+ }
708
+ if (s.status === "accepted") return;
709
+ if (s.status !== "pending") throw new DocError("ALREADY_DECIDED", `suggestion "${id}" is already ${s.status}`);
710
+ const { at, inserted } = this.doc.edit(s.kind, { anchor: s.anchor, at: s.at }, s.replacement, who, { suggestion: { id, author: s.author } });
711
+ const record = { by: who, at, ...inserted };
712
+ this.doc.ydoc.getMap(APPLIED).set(`${id}:${this.doc.ydoc.clientID}`, record);
713
+ this.decide(fields, "accepted", who, at);
714
+ });
715
+ }
716
+ /** Same right as accept. A person can withdraw their own suggestion. An agent can never decide its own. */
717
+ reject(id, author) {
718
+ const who = checkAuthor(author);
719
+ this.doc.ydoc.transact(() => {
720
+ const { fields, suggestion: s } = this.get(id);
721
+ if (!mayDecide(author) || !isPerson(who) && sameWho(s.author, who)) {
722
+ throw new DocError("FORBIDDEN", `${who.kind}:${who.name} cannot reject this suggestion: a person, or an agent with the ${ACCEPT_SCOPE} scope that did not write it, can`);
723
+ }
724
+ if (s.status !== "pending") throw new DocError("ALREADY_DECIDED", `suggestion "${id}" is already ${s.status}`);
725
+ this.decide(fields, "rejected", who, this.doc.now().toISOString());
726
+ });
727
+ }
728
+ list(filter = {}) {
729
+ return [...this.map.entries()].map(([id, f]) => this.read(id, f)).filter((s) => filter.status === void 0 || s.status === filter.status).sort((a, b) => a.createdAt.localeCompare(b.createdAt) || a.id.localeCompare(b.id));
730
+ }
731
+ };
732
+
733
+ export {
734
+ DOC_ERROR_CODES,
735
+ DocError,
736
+ markdownToBlocks,
737
+ blocksToMarkdown,
738
+ schema,
739
+ PRINCIPAL_KINDS,
740
+ parseAuthor,
741
+ AgenticDoc,
742
+ Threads,
743
+ Suggestions
744
+ };