agentic-documents 0.4.0 → 0.6.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.
Files changed (43) hide show
  1. package/CHANGELOG.md +56 -0
  2. package/README.md +161 -11
  3. package/dist/{chunk-TFCRPEEM.js → chunk-34C47PDC.js} +1 -1
  4. package/dist/chunk-5IQX2W52.js +195 -0
  5. package/dist/chunk-6UYJV2EJ.js +163 -0
  6. package/dist/{chunk-R5XUTBC3.js → chunk-CN6UAW7B.js} +32 -9
  7. package/dist/chunk-DWVJIKVT.js +8 -0
  8. package/dist/{chunk-YBBQNCMC.js → chunk-GQ5E6T7Z.js} +18 -191
  9. package/dist/{chunk-FZA5LQV3.js → chunk-JBAQEIOH.js} +89 -12
  10. package/dist/chunk-KC5KFB5R.js +354 -0
  11. package/dist/{chunk-IU3YIQ4Y.js → chunk-V6TBTOHW.js} +38 -13
  12. package/dist/cli.js +16 -12
  13. package/dist/client.d.ts +23 -6
  14. package/dist/client.js +9 -2
  15. package/dist/{doc-C_8kCsl7.d.ts → doc-BXG9qG5X.d.ts} +8 -2
  16. package/dist/editor.css +37 -0
  17. package/dist/embed.d.ts +86 -0
  18. package/dist/embed.js +431 -0
  19. package/dist/formats.d.ts +52 -0
  20. package/dist/formats.js +24 -0
  21. package/dist/index.d.ts +12 -10
  22. package/dist/index.js +15 -9
  23. package/dist/live-worker.d.ts +2 -2
  24. package/dist/live-worker.js +7 -4
  25. package/dist/live.d.ts +6 -2
  26. package/dist/live.js +5 -3
  27. package/dist/{mcp-P2C75IIG.js → mcp-ZTUTC4CS.js} +5 -2
  28. package/dist/render-463xHb-x.d.ts +16 -0
  29. package/dist/{review-BQW2Ci2h.d.ts → review-Ds-i5Vnc.d.ts} +2 -16
  30. package/dist/{service-Do2Jwb5U.d.ts → service-Ck6oX-Kq.d.ts} +38 -5
  31. package/dist/ui/_headers +4 -0
  32. package/dist/ui/app.css +1 -0
  33. package/dist/ui/app.js +270 -0
  34. package/dist/ui/assets/inter-latin-ext-wght-normal-HA22NDSG.woff2 +0 -0
  35. package/dist/ui/assets/inter-latin-wght-normal-NRMW37G5.woff2 +0 -0
  36. package/dist/ui/assets/jetbrains-mono-latin-ext-wght-normal-TSLVDSQR.woff2 +0 -0
  37. package/dist/ui/assets/jetbrains-mono-latin-wght-normal-VBS632QH.woff2 +0 -0
  38. package/dist/ui/index.html +13 -0
  39. package/dist/worker.d.ts +2 -2
  40. package/dist/worker.js +7 -4
  41. package/llms.txt +1 -0
  42. package/package.json +43 -10
  43. package/dist/chunk-GYQFTF2E.js +0 -389
@@ -1,12 +1,14 @@
1
1
  import {
2
2
  MAX_LIVE_BYTES
3
- } from "./chunk-FZA5LQV3.js";
3
+ } from "./chunk-JBAQEIOH.js";
4
4
  import {
5
5
  AgenticDoc,
6
6
  DOC_ERROR_CODES,
7
- DocError,
7
+ DocError
8
+ } from "./chunk-GQ5E6T7Z.js";
9
+ import {
8
10
  PRINCIPAL_KINDS
9
- } from "./chunk-YBBQNCMC.js";
11
+ } from "./chunk-DWVJIKVT.js";
10
12
 
11
13
  // src/live.ts
12
14
  import * as Y from "yjs";
@@ -128,16 +130,37 @@ var LiveService = class {
128
130
  throw unreachable(error);
129
131
  }
130
132
  }
131
- /** The room is only a copy, so a new document is made in the store. A room seeds from it on the next join. */
132
- async create(id, markdown) {
133
- const response = await fetch(`${base(this.options.server)}/api/v1/docs`, {
134
- method: "POST",
135
- headers: { authorization: `Bearer ${this.options.token}`, "content-type": "application/json" },
136
- body: JSON.stringify({ id, markdown })
133
+ async call(path, init) {
134
+ const response = await fetch(`${base(this.options.server)}${path}`, {
135
+ method: init.method,
136
+ headers: { authorization: `Bearer ${this.options.token}`, ...init.body ? { "content-type": "application/json" } : {} },
137
+ body: init.body ? JSON.stringify(init.body) : void 0
137
138
  }).catch((error) => {
138
139
  throw unreachable(error);
139
140
  });
140
141
  if (!response.ok) throw await refusal(response, "the server refused the request");
142
+ return response;
143
+ }
144
+ /** The room is only a copy, so a new document is made in the store. A room seeds from it on the next join. */
145
+ async create(id, markdown) {
146
+ await this.call("/api/v1/docs", { method: "POST", body: { id, markdown } });
147
+ }
148
+ // The catalog lives in the store, not in a room, so these go to the HTTP API.
149
+ async list() {
150
+ const body = await (await this.call("/api/v1/docs", { method: "GET" })).json().catch(() => void 0);
151
+ const who = (w) => typeof w === "object" && w !== null && PRINCIPAL_KINDS.includes(w.kind) && typeof w.name === "string" && w.name !== "";
152
+ const row = (r) => {
153
+ const d = r;
154
+ return typeof r === "object" && r !== null && typeof d.id === "string" && typeof d.title === "string" && typeof d.createdAt === "number" && typeof d.updatedAt === "number" && who(d.createdBy) && who(d.updatedBy);
155
+ };
156
+ if (!Array.isArray(body) || !body.every(row)) throw new DocError("INVALID_INPUT", "the server sent a document list that is not valid");
157
+ return body;
158
+ }
159
+ async rename(id, title) {
160
+ await this.call(`/api/v1/docs/${encodeURIComponent(id)}/rename`, { method: "POST", body: { title } });
161
+ }
162
+ async remove(id) {
163
+ await this.call(`/api/v1/docs/${encodeURIComponent(id)}/remove`, { method: "POST", body: {} });
141
164
  }
142
165
  async read(id, run) {
143
166
  const live = await this.join(id);
@@ -0,0 +1,8 @@
1
+ // src/kinds.ts
2
+ var PRINCIPAL_KINDS = ["human", "agent_of_human", "agent_of_agent", "agent", "user_token"];
3
+ var isMachine = (kind) => kind !== "human";
4
+
5
+ export {
6
+ PRINCIPAL_KINDS,
7
+ isMachine
8
+ };
@@ -1,3 +1,7 @@
1
+ import {
2
+ PRINCIPAL_KINDS
3
+ } from "./chunk-DWVJIKVT.js";
4
+
1
5
  // src/errors.ts
2
6
  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
7
  var DocError = class extends Error {
@@ -314,7 +318,6 @@ function settle(ydoc) {
314
318
  var PREAMBLE = "preamble";
315
319
  var FRAGMENT = "default";
316
320
  var CONTEXT_CHARS = 20;
317
- var PRINCIPAL_KINDS = ["human", "agent_of_human", "agent_of_agent", "agent", "user_token"];
318
321
  var isSection = (t) => "section" in t;
319
322
  var isAnchor = (t) => "anchor" in t;
320
323
  function checkAuthor(author) {
@@ -361,9 +364,18 @@ var AgenticDoc = class _AgenticDoc {
361
364
  });
362
365
  }
363
366
  static fromMarkdown(markdown, author, options) {
367
+ checkAuthor(author);
368
+ return _AgenticDoc.fromBlocks(markdownToBlocks(markdown), author, options);
369
+ }
370
+ /** Make a document from blocks in ProseMirror JSON form. Blocks that the schema does not allow fail with INVALID_INPUT. */
371
+ static fromBlocks(content, author, options) {
364
372
  const who = checkAuthor(author);
373
+ try {
374
+ if (content.length > 0) schema.nodeFromJSON({ type: "doc", content }).check();
375
+ } catch (error) {
376
+ throw new DocError("INVALID_INPUT", `the blocks do not fit the document schema: ${error instanceof Error ? error.message : String(error)}`);
377
+ }
365
378
  const doc = new _AgenticDoc(new Y2.Doc(), options);
366
- const content = markdownToBlocks(markdown);
367
379
  doc.change("create", who, () => {
368
380
  doc.fragment.insert(0, toYBlocks(content));
369
381
  return { inserted: content.length, deleted: 0 };
@@ -397,7 +409,7 @@ var AgenticDoc = class _AgenticDoc {
397
409
  change(op, author, run, extra = {}) {
398
410
  const at = this.now().toISOString();
399
411
  this.ydoc.transact(() => {
400
- const entry = { op, at, author, ...run(), ...extra };
412
+ const entry = { op, at, author, client: this.ydoc.clientID, ...run(), ...extra };
401
413
  if (entry.section === void 0) delete entry.section;
402
414
  this.log.push([entry]);
403
415
  });
@@ -546,199 +558,14 @@ var AgenticDoc = class _AgenticDoc {
546
558
  }
547
559
  };
548
560
 
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
561
  export {
734
562
  DOC_ERROR_CODES,
735
563
  DocError,
736
564
  markdownToBlocks,
737
565
  blocksToMarkdown,
738
566
  schema,
739
- PRINCIPAL_KINDS,
567
+ APPLIED,
568
+ checkAuthor,
740
569
  parseAuthor,
741
- AgenticDoc,
742
- Threads,
743
- Suggestions
570
+ AgenticDoc
744
571
  };
@@ -1,10 +1,15 @@
1
1
  import {
2
- AgenticDoc,
3
- DocError,
4
- PRINCIPAL_KINDS,
5
2
  Suggestions,
6
- Threads
7
- } from "./chunk-YBBQNCMC.js";
3
+ Threads,
4
+ isPerson
5
+ } from "./chunk-5IQX2W52.js";
6
+ import {
7
+ AgenticDoc,
8
+ DocError
9
+ } from "./chunk-GQ5E6T7Z.js";
10
+ import {
11
+ PRINCIPAL_KINDS
12
+ } from "./chunk-DWVJIKVT.js";
8
13
 
9
14
  // src/service.ts
10
15
  import * as Y from "yjs";
@@ -16,7 +21,10 @@ var isObject = (v) => typeof v === "object" && v !== null && !Array.isArray(v);
16
21
  var isString = (v) => typeof v === "string";
17
22
  var isWho = (v) => isObject(v) && PRINCIPAL_KINDS.includes(v.kind) && isString(v.name) && v.name !== "";
18
23
  var isAnchor = (v) => isObject(v) && isString(v.text) && isObject(v.start) && isObject(v.end);
19
- var ROOTS = /* @__PURE__ */ new Set(["default", "edits", "suggestions", "threads", "applied"]);
24
+ var ROOTS = /* @__PURE__ */ new Set(["default", "edits", "suggestions", "threads", "applied", "__tiptap_position_migrations:root:default"]);
25
+ var MAX_TITLE = 120;
26
+ var REMOVE_SCOPE = "docs:remove";
27
+ var MAX_LISTED = 500;
20
28
  var DocsService = class {
21
29
  constructor(ports, options = {}) {
22
30
  this.options = options;
@@ -47,7 +55,7 @@ var DocsService = class {
47
55
  * has none. A merge is safe to repeat and to run in any order. The save is conditional, so two
48
56
  * checkpoints at once both land.
49
57
  */
50
- async checkpoint(id, update, author) {
58
+ async checkpoint(id, update, author, options = {}) {
51
59
  const key = this.key(id);
52
60
  const scratch = new Y.Doc();
53
61
  try {
@@ -62,6 +70,7 @@ var DocsService = class {
62
70
  for (let attempt = 0; attempt < ATTEMPTS; attempt++) {
63
71
  const found = await this.blobs.get(key);
64
72
  if (!found) {
73
+ if (options.create === false) throw new DocError("NO_DOCUMENT", `no document with id "${id}"`);
65
74
  const created = AgenticDoc.fromUpdate(update, this.options);
66
75
  const fresh = this.checked(id, created);
67
76
  this.fits(id, fresh);
@@ -73,7 +82,10 @@ var DocsService = class {
73
82
  doc.applyUpdate(update);
74
83
  const merged = this.checked(id, doc);
75
84
  this.fits(id, merged);
76
- if (await this.blobs.put(key, merged, { ifMatch: found.version })) return;
85
+ if (await this.blobs.put(key, merged, { ifMatch: found.version })) {
86
+ await this.touch(id, author);
87
+ return;
88
+ }
77
89
  }
78
90
  throw new DocError("CONFLICT", `document "${id}" kept changing; try again`);
79
91
  }
@@ -86,7 +98,7 @@ var DocsService = class {
86
98
  const refuse = (what) => new DocError("INVALID_INPUT", `the update would leave document "${id}" unreadable: ${what}`);
87
99
  try {
88
100
  doc.toMarkdown();
89
- if (!doc.edits().every((e) => isObject(e) && isWho(e.author) && isString(e.at) && isString(e.op))) throw refuse("an edit log entry is malformed");
101
+ if (!doc.edits().every((e) => isObject(e) && isWho(e.author) && isString(e.at) && isString(e.op) && (e.client === void 0 || Number.isSafeInteger(e.client) && e.client >= 0))) throw refuse("an edit log entry is malformed");
90
102
  for (const [, t] of doc.ydoc.getMap("threads")) {
91
103
  const messages = t instanceof Y.Map ? t.get("messages") : void 0;
92
104
  const ok = t instanceof Y.Map && isAnchor(t.get("anchor")) && ["open", "resolved"].includes(t.get("status")) && messages instanceof Y.Array && messages.length > 0 && messages.toArray().every((m) => isObject(m) && isString(m.id) && isString(m.at) && isString(m.body) && isWho(m.author));
@@ -108,9 +120,71 @@ var DocsService = class {
108
120
  }
109
121
  async catalog(id, author) {
110
122
  await this.store.transaction(async (tx) => {
111
- if (await tx.get(CATALOG, id) === null) await tx.put(CATALOG, id, { createdBy: { kind: author.kind, name: author.name }, createdAt: Date.now() });
123
+ if (await tx.get(CATALOG, id) === null) {
124
+ const now = Date.now();
125
+ const who = { kind: author.kind, name: author.name };
126
+ await tx.put(CATALOG, id, { createdBy: who, createdAt: now, updatedAt: now, updatedBy: who });
127
+ }
128
+ });
129
+ }
130
+ /**
131
+ * Record a save. The catalog is a side record that follows the blob, so a failure here never fails a save that worked:
132
+ * the caller would repeat a change that is not safe to repeat. A document with no catalog entry stays without one.
133
+ */
134
+ async touch(id, author) {
135
+ if (!author) return;
136
+ try {
137
+ await this.store.transaction(async (tx) => {
138
+ const entry = await tx.get(CATALOG, id);
139
+ if (entry !== null) await tx.put(CATALOG, id, { ...entry, updatedAt: Date.now(), updatedBy: { kind: author.kind, name: author.name } });
140
+ });
141
+ } catch {
142
+ }
143
+ }
144
+ /** Every cataloged document, newest first, up to ${MAX_LISTED}. A damaged entry is skipped, so one bad entry never hides the list. */
145
+ async list() {
146
+ const found = [];
147
+ let cursor;
148
+ do {
149
+ const page = await this.store.query(CATALOG, { orderBy: { field: "createdAt", direction: "desc" }, limit: 100, cursor });
150
+ for (const { id, data } of page.items) {
151
+ const entry = isObject(data) ? data : {};
152
+ const { createdBy, createdAt } = entry;
153
+ if (!isWho(createdBy) || typeof createdAt !== "number") continue;
154
+ const by = entry.updatedBy;
155
+ if (by !== void 0 && !isWho(by)) continue;
156
+ found.push({ id, title: typeof entry.title === "string" && entry.title !== "" ? entry.title : id, createdBy, createdAt, updatedAt: typeof entry.updatedAt === "number" ? entry.updatedAt : createdAt, updatedBy: by ?? createdBy });
157
+ }
158
+ cursor = page.nextCursor;
159
+ } while (cursor !== void 0 && found.length < MAX_LISTED);
160
+ return found.slice(0, MAX_LISTED);
161
+ }
162
+ /** Give the document a name. It does not change the text. */
163
+ async rename(id, title, author) {
164
+ const key = this.key(id);
165
+ const clean = title.trim();
166
+ if (clean === "" || [...clean].length > MAX_TITLE || /[\u0000-\u001f\u007f-\u009f\u00ad\u200b-\u200f\u2028-\u202e\u2066-\u2069\ufeff]/.test(clean)) {
167
+ throw new DocError("INVALID_INPUT", `a title is 1 to ${MAX_TITLE} characters of plain text`);
168
+ }
169
+ const found = await this.blobs.get(key);
170
+ if (!found) throw new DocError("NO_DOCUMENT", `no document with id "${id}"`);
171
+ const who = { kind: author.kind, name: author.name };
172
+ const first = AgenticDoc.fromUpdate(found.data, this.options).edits()[0];
173
+ const created = first ? { createdBy: { kind: first.author.kind, name: first.author.name }, createdAt: Date.parse(first.at) } : { createdBy: who, createdAt: Date.now() };
174
+ await this.store.transaction(async (tx) => {
175
+ const entry = await tx.get(CATALOG, id);
176
+ const now = Date.now();
177
+ await tx.put(CATALOG, id, entry === null ? { ...created, createdAt: Number.isFinite(created.createdAt) ? created.createdAt : now, title: clean, updatedAt: now, updatedBy: who } : { ...entry, title: clean, updatedAt: now, updatedBy: who });
112
178
  });
113
179
  }
180
+ /** Delete the document and its catalog entry. An agent needs the remove scope. A live room keeps its own copy of the text. */
181
+ async remove(id, author) {
182
+ const key = this.key(id);
183
+ if (!isPerson(author) && author.scopes?.includes(REMOVE_SCOPE) !== true) throw new DocError("FORBIDDEN", `an agent needs the ${REMOVE_SCOPE} scope to remove a document`);
184
+ if (!await this.blobs.head(key)) throw new DocError("NO_DOCUMENT", `no document with id "${id}"`);
185
+ await this.store.delete(CATALOG, id);
186
+ await this.blobs.delete(key);
187
+ }
114
188
  /** Who created the document and when, or null when it has no catalog entry (made before the catalog, or a failed catalog write). */
115
189
  async info(id) {
116
190
  this.key(id);
@@ -131,14 +205,17 @@ var DocsService = class {
131
205
  * Load, run the change, and save only if nobody saved in between. When someone did, load again and
132
206
  * run the change again, so a check such as "is it still pending" sees the new state.
133
207
  */
134
- async write(id, run) {
208
+ async write(id, run, author) {
135
209
  const key = this.key(id);
136
210
  for (let attempt = 0; attempt < ATTEMPTS; attempt++) {
137
211
  const found = await this.blobs.get(key);
138
212
  if (!found) throw new DocError("NO_DOCUMENT", `no document with id "${id}"`);
139
213
  const doc = AgenticDoc.fromUpdate(found.data, this.options);
140
214
  const result = run(doc);
141
- if (await this.blobs.put(key, doc.encode(), { ifMatch: found.version })) return result;
215
+ if (await this.blobs.put(key, doc.encode(), { ifMatch: found.version })) {
216
+ await this.touch(id, author);
217
+ return result;
218
+ }
142
219
  }
143
220
  throw new DocError("CONFLICT", `document "${id}" kept changing; try again`);
144
221
  }