agentic-documents 0.3.0 → 0.5.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 (38) hide show
  1. package/CHANGELOG.md +48 -0
  2. package/README.md +143 -5
  3. package/dist/{chunk-YBBQNCMC.js → chunk-6QOYS63L.js} +14 -189
  4. package/dist/{chunk-TFCRPEEM.js → chunk-7R7ZPPRF.js} +1 -1
  5. package/dist/{chunk-R5XUTBC3.js → chunk-E7B6T5WN.js} +29 -8
  6. package/dist/{chunk-IU3YIQ4Y.js → chunk-FX755I7V.js} +30 -8
  7. package/dist/{chunk-GYQFTF2E.js → chunk-L6W2AQUF.js} +15 -146
  8. package/dist/chunk-S6MQDWFK.js +150 -0
  9. package/dist/chunk-UPEGODUV.js +195 -0
  10. package/dist/{chunk-FZA5LQV3.js → chunk-YNHZCIGO.js} +86 -11
  11. package/dist/cli.js +8 -6
  12. package/dist/client.d.ts +188 -0
  13. package/dist/client.js +121 -0
  14. package/dist/{service-BKkxEnaJ.d.ts → doc-mJPajXEQ.d.ts} +5 -56
  15. package/dist/export-Cwx276UP.d.ts +17 -0
  16. package/dist/formats.d.ts +49 -0
  17. package/dist/formats.js +234 -0
  18. package/dist/index.d.ts +7 -99
  19. package/dist/index.js +11 -7
  20. package/dist/live-worker.d.ts +2 -1
  21. package/dist/live-worker.js +6 -4
  22. package/dist/live.d.ts +6 -1
  23. package/dist/live.js +4 -3
  24. package/dist/{mcp-P2C75IIG.js → mcp-ESSEG46C.js} +4 -2
  25. package/dist/review-Hgq8OgvD.d.ts +85 -0
  26. package/dist/service-CqKE9DKz.d.ts +91 -0
  27. package/dist/ui/_headers +4 -0
  28. package/dist/ui/app.css +1 -0
  29. package/dist/ui/app.js +270 -0
  30. package/dist/ui/assets/inter-latin-ext-wght-normal-HA22NDSG.woff2 +0 -0
  31. package/dist/ui/assets/inter-latin-wght-normal-NRMW37G5.woff2 +0 -0
  32. package/dist/ui/assets/jetbrains-mono-latin-ext-wght-normal-TSLVDSQR.woff2 +0 -0
  33. package/dist/ui/assets/jetbrains-mono-latin-wght-normal-VBS632QH.woff2 +0 -0
  34. package/dist/ui/index.html +13 -0
  35. package/dist/worker.d.ts +2 -1
  36. package/dist/worker.js +6 -4
  37. package/llms.txt +2 -0
  38. package/package.json +23 -3
package/CHANGELOG.md CHANGED
@@ -1,5 +1,53 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.5.0
4
+
5
+ ### Breaking
6
+
7
+ - A deploy from the template `wrangler.jsonc` needs `npm run build` first. The file now has an `assets` entry for
8
+ `dist/ui`, so `wrangler` stops when that folder is missing.
9
+
10
+ ### Added
11
+
12
+ - Commands `list`, `rename`, and `remove` in the SDK, CLI, MCP server, HTTP API (`GET /api/v1/docs`), and
13
+ the typed client. The catalog keeps a title and the last save time and author. An agent needs the
14
+ `docs:remove` scope to remove a document.
15
+ - A browser can open a live room: the token goes in a WebSocket subprotocol
16
+ (`agentic-docs.bearer.<base64url>`) on the live-room route only.
17
+ - A web UI that the Worker serves from `dist/ui` (the `assets` entry in `wrangler.jsonc`): a document list, a
18
+ rich-text editor on the live room, rename, delete, share, presence, and a mark on agent text. It is built on
19
+ `agentic-design` and the typed client. Playwright tests run it under `wrangler dev` with an axe check (WCAG 2.2 AA rules)
20
+ in the light and the dark theme, and screenshots are in `docs/screenshots`.
21
+ - The saved update may hold the marker root that the Tiptap editor writes when it opens a document.
22
+ - `POST /checkpoint?create=false` (client option `create: false`) fails with `NO_DOCUMENT` instead of making a missing document. The UI saves this way, so a removed document stays removed.
23
+ - Each edit log entry holds `client`, the Yjs client id of its writer, so a UI can mark agent text.
24
+ - `agentic-documents/formats`: `importFile` (md, odt, docx) and `exportFile` (md, html, odt, docx), with the
25
+ `toTextDoc` and `fromTextDoc` mapping to the agentic-formats text model. Each result names lost content in `warnings`.
26
+ - `AgenticDoc.fromBlocks(blocks, author)` makes a document from ProseMirror JSON blocks and checks them against the schema.
27
+
28
+ ### Changed
29
+
30
+ - `esbuild` is a declared dev dependency. `ui/build.mjs` uses it.
31
+
32
+ ### Fixed
33
+
34
+ - In the web UI, the caret goes after a new link, and text typed there does not join the link. A quick key
35
+ after Apply no longer replaces the linked words. Apply with no words selected keeps its link for the next text.
36
+ - In the web UI, a key typed right after a toolbar button goes to the text, not to the button.
37
+
38
+ ## 0.4.0
39
+
40
+ ### Added
41
+
42
+ - A typed client for the Worker HTTP API: `createClient({ baseUrl, token, fetch? })` from the new
43
+ `agentic-documents/client` subpath. It has one method per HTTP route, except the WebSocket route. It throws `DocsApiError` with the API
44
+ error code and the HTTP status, takes an `AbortSignal` on every call, and never retries. A 2xx reply
45
+ with the wrong content type or shape throws `DocsApiError` with the code `BAD_RESPONSE`. It uses only
46
+ `fetch`, so it runs in browsers, Workers, and Node 22. Tests run it against the real Worker under
47
+ `wrangler dev`.
48
+
49
+ Nothing else changed since 0.3.0.
50
+
3
51
  ## 0.3.0
4
52
 
5
53
  ### Breaking changes
package/README.md CHANGED
@@ -7,8 +7,8 @@
7
7
 
8
8
  Write documents with people and agents in real time.
9
9
 
10
- > Status: early, version 0.3.0. The document model, the text operations, the CLI, the MCP server,
11
- > the Worker API, and live editing in a room work. The web UI is not built yet.
10
+ > Status: early, version 0.5.0. The document model, the text operations, the CLI, the MCP server,
11
+ > the Worker API, live editing in a room, and a small web UI work. See [UI](#ui).
12
12
 
13
13
  ## Install
14
14
 
@@ -39,6 +39,70 @@ suggestions.accept(id, person); // the text changes now
39
39
  new Threads(doc).add({ text: "Tuesday" }, "Is this date firm?", agent);
40
40
  ```
41
41
 
42
+ ## UI
43
+
44
+ The Worker serves a small web app, built on [agentic-design](https://www.npmjs.com/package/agentic-design)
45
+ and the typed client (`agentic-documents/client`). It has a document list, a rich-text editor (headings,
46
+ lists, bold, italic, links), rename, delete, a share dialog, the people and agents in the room, and a dotted
47
+ underline on text that an agent wrote. The tests check each main view with axe (WCAG 2.2 AA rules) in the light and the dark theme: sign in, list, empty and error list, new-document dialog, editor, share dialog, link, rename and delete dialogs, the actions menu, and the read-only view. Axe finds only part of the faults, so it does not replace a test with a screen reader. Screenshots of
48
+ every view are in [docs/screenshots](docs/screenshots).
49
+
50
+ Run it on your machine:
51
+
52
+ ```sh
53
+ npm ci
54
+ npm run build # writes dist/ and dist/ui
55
+ export WRANGLER_SEND_METRICS=false
56
+ TOKEN=$(openssl rand -hex 32); echo "$TOKEN"
57
+ npx wrangler dev --var AGENTIC_DOCS_REGION:unknown --var AGENTIC_DOCS_UNSAFE_REGION:1 \
58
+ --var "AGENTIC_DOCS_TOKENS:{\"$TOKEN\":\"human:Ada\"}"
59
+ ```
60
+
61
+ Open the address that wrangler prints, paste the token, and sign in. The token stays in that tab and goes
62
+ when you close it. A token for an agent, such as `agent_of_human:Scribe`, makes its edits show as agent text.
63
+ A `viewer` token opens documents read only. The region setting turns the residency check off, so use it for
64
+ local work only.
65
+
66
+ How it works:
67
+
68
+ - The list, rename, delete, and the share dialog use the HTTP API through the typed client.
69
+ - The editor joins the live room of the document. A browser WebSocket cannot send an `Authorization`
70
+ header, so the page offers the token as a subprotocol, which only the live-room route reads.
71
+ - The page saves the room to the stored document 1.5 seconds after a change, and when you leave the
72
+ document in the app (not when you close the tab). A command over HTTP then reads the new text. A save never makes a document: if someone deleted it, the page says so, stops saving, and turns read only.
73
+ - An agent's text is the text whose writer is a logged edit by a non-human author (see the `client` field of the
74
+ edit log). Typing in the editor does not add to the edit log.
75
+ - The page sets a content security policy: scripts, styles, fonts, and images come from the Worker only, and a document image that points to another host does not load.
76
+ - Run the tests with `npm run e2e`. They start `wrangler dev` with the built UI, drive Chromium with
77
+ Playwright, and run an axe check (WCAG 2.2 AA) on each view in both themes. Run them once with
78
+ `UPDATE_SCREENSHOTS=1` to write the screenshots again.
79
+
80
+ ### Serve the UI from the npm package
81
+
82
+ The npm package ships the built UI in `dist/ui`. You do not need a clone to serve it. Install the package in
83
+ your Worker project and point your `wrangler.jsonc` at the files in it:
84
+
85
+ ```sh
86
+ npm install agentic-documents
87
+ ```
88
+
89
+ ```jsonc
90
+ {
91
+ "name": "my-docs",
92
+ "main": "node_modules/agentic-documents/dist/live-worker.js",
93
+ "compatibility_date": "2026-01-01",
94
+ "compatibility_flags": ["nodejs_compat"],
95
+ "assets": { "directory": "node_modules/agentic-documents/dist/ui" },
96
+ "durable_objects": { "bindings": [{ "name": "SYNC_ROOM", "class_name": "SyncRoom" }] },
97
+ "migrations": [{ "tag": "v1", "new_sqlite_classes": ["SyncRoom"] }]
98
+ }
99
+ ```
100
+
101
+ Add the D1 and R2 bindings from the sample `wrangler.jsonc`, and set `AGENTIC_DOCS_TOKENS` as a secret.
102
+ The Worker serves the files in `dist/ui` first. It answers `/api`, `/health`, and any other path. If you deploy from a clone
103
+ with the sample `wrangler.jsonc`, run `npm run build` before `wrangler deploy`. The build writes `dist/ui`,
104
+ and the sample file reads it.
105
+
42
106
  ## CLI
43
107
 
44
108
  Every change needs an author: `--as <kind>:<name>` or `AGENTIC_DOCS_AS`. An agent that may accept
@@ -74,6 +138,8 @@ A Cloudflare Worker (Hono) mirrors the CLI one to one under `/api/v1/docs`. A re
74
138
  curl -X POST $URL/api/v1/docs -H "authorization: Bearer $TOKEN" -d '{"id":"plan","markdown":"# Plan"}'
75
139
  curl "$URL/api/v1/docs/plan/outline" -H "authorization: Bearer $TOKEN"
76
140
  curl -X POST $URL/api/v1/docs/plan/replace -H "authorization: Bearer $TOKEN" -d '{"text":"Plan","content":"Roadmap"}'
141
+ curl "$URL/api/v1/docs" -H "authorization: Bearer $TOKEN" # list: id, title, creator, last edit
142
+ curl -X POST $URL/api/v1/docs/plan/rename -H "authorization: Bearer $TOKEN" -d '{"title":"Roadmap"}'
77
143
  curl "$URL/api/v1/docs/plan/export?format=html" -H "authorization: Bearer $TOKEN"
78
144
  # add &remoteImages=true to keep web images; the default shows only images stored inside the document
79
145
  ```
@@ -89,6 +155,16 @@ curl "$URL/api/v1/docs/plan/export?format=html" -H "authorization: Bearer $TOKEN
89
155
  - Errors are `{ "error": { "code", "message", "matches" } }`. Status: 400 bad input, 401 no
90
156
  valid token, 403 `FORBIDDEN` (for example an agent without `docs:accept` that accepts), 404 not
91
157
  found, 409 a conflict (ambiguous match, stale anchor, already decided), 503 `LOCKED`.
158
+ - `list` shows each document's title, creator kind, and last edit. `rename` sets the title and keeps the text.
159
+ `remove` deletes the whole document. A person may remove. An agent needs the `docs:remove` scope, for example
160
+ `{"as":"agent_of_human:sweeper","scopes":["docs:remove"]}`. A live room keeps its own copy of the text after `remove`, so
161
+ an open client can bring the document back with a save.
162
+ - A browser cannot send an `Authorization` header on a WebSocket. It offers two subprotocols instead:
163
+ `agentic-docs` and `agentic-docs.bearer.<token in base64url>`. Only the live-room route reads it, and the server
164
+ answers with `agentic-docs`, never the token. A header value can show in the logs of a proxy or a host, so
165
+ keep access logs of that header off.
166
+ - Each edit log entry holds `client`, the Yjs client id of the replica that wrote it. A UI uses it to mark text
167
+ an agent wrote. A client writes it, like the rest of the log.
92
168
  - `GET /health` needs no token. It reveals nothing else.
93
169
  - Create an R2 bucket with `wrangler r2 bucket create agentic-documents-data --jurisdiction eu` and a D1
94
170
  database with `wrangler d1 create agentic-documents --jurisdiction eu`. Put the database id in
@@ -98,6 +174,59 @@ curl "$URL/api/v1/docs/plan/export?format=html" -H "authorization: Bearer $TOKEN
98
174
  residency check off.
99
175
  - Run it locally with `npx wrangler dev`. Set `WRANGLER_SEND_METRICS=false` to turn off wrangler telemetry.
100
176
 
177
+ ## SDK
178
+
179
+ `agentic-documents/client` is a typed client for the Worker HTTP API. It uses only `fetch`, so it runs in
180
+ browsers, Workers, and Node 22. The token sets the author and the role.
181
+
182
+ ```ts
183
+ import { createClient, DocsApiError } from "agentic-documents/client";
184
+
185
+ const docs = createClient({ baseUrl: "https://docs.example.com", token: process.env.AGENTIC_DOCS_TOKEN! });
186
+ await docs.create({ id: "plan", markdown: "# Plan" });
187
+ const [section] = await docs.outline("plan");
188
+ await docs.replace("plan", { section: section.id, content: "Ship it." });
189
+ console.log(await docs.read("plan"));
190
+
191
+ try {
192
+ await docs.read("missing", {}, { signal: AbortSignal.timeout(5000) });
193
+ } catch (error) {
194
+ if (error instanceof DocsApiError) console.log(error.code, error.status); // NO_DOCUMENT 404
195
+ }
196
+ ```
197
+
198
+ - One method per route: `create`, `read`, `outline`, `edits`, `insert`, `replace`, `delete`, `append`,
199
+ `suggest`, `suggestions`, `accept`, `reject`, `comment`, `reply`, `resolve`, `reopen`, `threads`,
200
+ `export`, `room`, `me`, `health`, `state`, and `checkpoint`. The WebSocket route is not a method. Use
201
+ `LiveDoc` from `agentic-documents/live` for it.
202
+ - Every method takes an optional `{ signal }` last. An abort rejects with what `fetch` throws.
203
+ - An API error throws `DocsApiError` with `code` (the API error code), `status` (the HTTP status), and
204
+ `matches`. A reply that is not an API error has the code `HTTP_ERROR`. A 2xx reply with the wrong content type or shape has the code `BAD_RESPONSE`.
205
+ - The client never retries. A write is not safe to send twice.
206
+ - Pass `fetch` to use your own, for a Worker service binding or for tests.
207
+
208
+ ## File formats
209
+
210
+ `agentic-documents/formats` imports and exports files, so an app that embeds the editor needs no mapping
211
+ code of its own. ODT and DOCX go through the neutral text model of
212
+ [agentic-formats](https://github.com/pooriaarab/agentic-formats). Markdown and HTML use the mapping of this package.
213
+
214
+ ```ts
215
+ import { exportFile, importFile } from "agentic-documents/formats";
216
+
217
+ const { doc, title, warnings } = importFile(bytes, "docx", { kind: "human", name: "Ada" });
218
+ const odt = exportFile(doc, "odt", { title }); // { contentType, data: Uint8Array, warnings }
219
+ ```
220
+
221
+ - `importFile(input, format, author, { limits? })` reads `md` (text or UTF-8 bytes), `odt`, or `docx` (bytes) into a
222
+ new `AgenticDoc`. The author makes the one `create` edit, so a file that an agent imports stays labeled as agent work.
223
+ - `exportFile(doc, format, { title?, remoteImages? })` writes `md` or `html` (text), or `odt` or `docx` (bytes).
224
+ - Each result has `warnings`: one line for each kind of content that the other side cannot hold. The text of that
225
+ content stays.
226
+ - A file that is not valid, or that goes past a limit in `limits` (the agentic-formats defaults), fails with
227
+ `INVALID_INPUT`. For `md`, only `maxInputBytes` applies. A link that is not a web, mail, or relative address is dropped, and its text stays.
228
+ - `toTextDoc(blocks)` and `fromTextDoc(model)` are the mapping itself, for an app that works with the model.
229
+
101
230
  ## Features
102
231
 
103
232
  - A Yjs document that a Tiptap editor can open. It uses the y-prosemirror layout.
@@ -194,14 +323,18 @@ Limits of live editing:
194
323
 
195
324
  ## Limits
196
325
 
197
- - No web UI yet.
326
+ - The UI has no accounts. You paste a token, and the token sets your role. The share dialog shows a link and who
327
+ changed the document. Roles, invites, and expiring links wait for the sharing API in the Worker.
328
+ - The UI marks agent text from the edit log, which a client writes. A modified client can mark its own text as a
329
+ person's, or a person's as an agent's. The last edit shown in the list is approximate when two writes race.
330
+ - The UI is English only. It needs a browser with `WebSocket` and ES2022.
198
331
  - A document holds no roles of its own. The token table gives `editor` or `viewer`. To share a document
199
332
  by person, use `createSharing` from `agentic-identity` 0.4. A grant names its subject by kind
200
333
  (`human` or `agent`) and id, so a person and an agent with the same id hold separate roles. When you
201
334
  delete a document, call `deleteResource` on its resource so its grants do not outlive it. If
202
335
  `deleteResource` fails, treat the delete as failed and run it again. If it throws
203
336
  `EraseRefusedError`, remove the child resources or the extra owners first. Do not reuse the id
204
- until it succeeds. This package has no command that deletes a whole document.
337
+ until it succeeds. The `remove` command deletes the document and its catalog entry. It does not touch grants.
205
338
  - The Worker keeps each document in R2 and a catalog (who made it, and when) in D1. Both come from the
206
339
  shared `agentic-identity` ports. A save is conditional on the version it read, so two writers cannot
207
340
  overwrite each other. The CLI and MCP server keep the same data in local files.
@@ -220,7 +353,12 @@ Limits of live editing:
220
353
  (tokens) and an MCP server that a trusted host starts fix the author for the caller.
221
354
  - Suggestions work on text, not on whole sections.
222
355
  - A scope on a principal is trusted as given. Nothing here proves who a caller is.
223
- - Export covers Markdown, HTML, and ODT. DOCX export, and ODT and DOCX import, are planned.
356
+ - The `export` command covers Markdown, HTML, and ODT. DOCX export, and ODT and DOCX import, are in
357
+ `agentic-documents/formats` only.
358
+ - File formats through `agentic-documents/formats`: a quote becomes plain paragraphs, a code block becomes a paragraph
359
+ of code text, a horizontal rule is left out, a numbered list starts at 1, and a task list becomes a list that starts
360
+ each item with `☐` or `☑`. On import, underline, superscript, and subscript are left out, and a footnote becomes
361
+ text in brackets, and code text keeps only its code mark. Each one is named in `warnings`.
224
362
  - ODT export: comments and suggestions are not written. A code block loses its language. A table
225
363
  cell span is not written (the model has none yet). A task list becomes a bullet list that starts each
226
364
  item with `☐` or `☑`. A remote image is not linked, because an office program would ask its host
@@ -361,9 +361,18 @@ var AgenticDoc = class _AgenticDoc {
361
361
  });
362
362
  }
363
363
  static fromMarkdown(markdown, author, options) {
364
+ checkAuthor(author);
365
+ return _AgenticDoc.fromBlocks(markdownToBlocks(markdown), author, options);
366
+ }
367
+ /** Make a document from blocks in ProseMirror JSON form. Blocks that the schema does not allow fail with INVALID_INPUT. */
368
+ static fromBlocks(content, author, options) {
364
369
  const who = checkAuthor(author);
370
+ try {
371
+ if (content.length > 0) schema.nodeFromJSON({ type: "doc", content }).check();
372
+ } catch (error) {
373
+ throw new DocError("INVALID_INPUT", `the blocks do not fit the document schema: ${error instanceof Error ? error.message : String(error)}`);
374
+ }
365
375
  const doc = new _AgenticDoc(new Y2.Doc(), options);
366
- const content = markdownToBlocks(markdown);
367
376
  doc.change("create", who, () => {
368
377
  doc.fragment.insert(0, toYBlocks(content));
369
378
  return { inserted: content.length, deleted: 0 };
@@ -397,7 +406,7 @@ var AgenticDoc = class _AgenticDoc {
397
406
  change(op, author, run, extra = {}) {
398
407
  const at = this.now().toISOString();
399
408
  this.ydoc.transact(() => {
400
- const entry = { op, at, author, ...run(), ...extra };
409
+ const entry = { op, at, author, client: this.ydoc.clientID, ...run(), ...extra };
401
410
  if (entry.section === void 0) delete entry.section;
402
411
  this.log.push([entry]);
403
412
  });
@@ -546,199 +555,15 @@ var AgenticDoc = class _AgenticDoc {
546
555
  }
547
556
  };
548
557
 
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
558
  export {
734
559
  DOC_ERROR_CODES,
735
560
  DocError,
736
561
  markdownToBlocks,
737
562
  blocksToMarkdown,
738
563
  schema,
564
+ APPLIED,
739
565
  PRINCIPAL_KINDS,
566
+ checkAuthor,
740
567
  parseAuthor,
741
- AgenticDoc,
742
- Threads,
743
- Suggestions
568
+ AgenticDoc
744
569
  };
@@ -1,6 +1,6 @@
1
1
  import {
2
2
  DocError
3
- } from "./chunk-YBBQNCMC.js";
3
+ } from "./chunk-6QOYS63L.js";
4
4
 
5
5
  // src/adapters/file-blobs.ts
6
6
  import { createHash, randomUUID } from "crypto";
@@ -1,12 +1,12 @@
1
1
  import {
2
2
  MAX_LIVE_BYTES
3
- } from "./chunk-FZA5LQV3.js";
3
+ } from "./chunk-YNHZCIGO.js";
4
4
  import {
5
5
  AgenticDoc,
6
6
  DOC_ERROR_CODES,
7
7
  DocError,
8
8
  PRINCIPAL_KINDS
9
- } from "./chunk-YBBQNCMC.js";
9
+ } from "./chunk-6QOYS63L.js";
10
10
 
11
11
  // src/live.ts
12
12
  import * as Y from "yjs";
@@ -128,16 +128,37 @@ var LiveService = class {
128
128
  throw unreachable(error);
129
129
  }
130
130
  }
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 })
131
+ async call(path, init) {
132
+ const response = await fetch(`${base(this.options.server)}${path}`, {
133
+ method: init.method,
134
+ headers: { authorization: `Bearer ${this.options.token}`, ...init.body ? { "content-type": "application/json" } : {} },
135
+ body: init.body ? JSON.stringify(init.body) : void 0
137
136
  }).catch((error) => {
138
137
  throw unreachable(error);
139
138
  });
140
139
  if (!response.ok) throw await refusal(response, "the server refused the request");
140
+ return response;
141
+ }
142
+ /** The room is only a copy, so a new document is made in the store. A room seeds from it on the next join. */
143
+ async create(id, markdown) {
144
+ await this.call("/api/v1/docs", { method: "POST", body: { id, markdown } });
145
+ }
146
+ // The catalog lives in the store, not in a room, so these go to the HTTP API.
147
+ async list() {
148
+ const body = await (await this.call("/api/v1/docs", { method: "GET" })).json().catch(() => void 0);
149
+ const who = (w) => typeof w === "object" && w !== null && PRINCIPAL_KINDS.includes(w.kind) && typeof w.name === "string" && w.name !== "";
150
+ const row = (r) => {
151
+ const d = r;
152
+ 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);
153
+ };
154
+ if (!Array.isArray(body) || !body.every(row)) throw new DocError("INVALID_INPUT", "the server sent a document list that is not valid");
155
+ return body;
156
+ }
157
+ async rename(id, title) {
158
+ await this.call(`/api/v1/docs/${encodeURIComponent(id)}/rename`, { method: "POST", body: { title } });
159
+ }
160
+ async remove(id) {
161
+ await this.call(`/api/v1/docs/${encodeURIComponent(id)}/remove`, { method: "POST", body: {} });
141
162
  }
142
163
  async read(id, run) {
143
164
  const live = await this.join(id);
@@ -1,15 +1,15 @@
1
1
  import {
2
2
  DocsService,
3
3
  MAX_LIVE_BYTES
4
- } from "./chunk-FZA5LQV3.js";
4
+ } from "./chunk-YNHZCIGO.js";
5
5
  import {
6
6
  commands
7
- } from "./chunk-GYQFTF2E.js";
7
+ } from "./chunk-S6MQDWFK.js";
8
8
  import {
9
9
  DocError,
10
10
  PRINCIPAL_KINDS,
11
11
  parseAuthor
12
- } from "./chunk-YBBQNCMC.js";
12
+ } from "./chunk-6QOYS63L.js";
13
13
 
14
14
  // src/worker.ts
15
15
  import { Hono } from "hono";
@@ -104,6 +104,22 @@ async function readCapped(request, cap) {
104
104
  }
105
105
  return whole;
106
106
  }
107
+ var WS_PROTOCOL = "agentic-docs";
108
+ var WS_BEARER = `${WS_PROTOCOL}.bearer.`;
109
+ function socketBearer(c) {
110
+ if (c.req.header("upgrade") !== "websocket" || !c.req.path.endsWith("/ws")) return void 0;
111
+ for (const offered of (c.req.header("sec-websocket-protocol") ?? "").split(",")) {
112
+ const part = offered.trim();
113
+ if (!part.startsWith(WS_BEARER)) continue;
114
+ try {
115
+ const base64 = part.slice(WS_BEARER.length).replace(/-/g, "+").replace(/_/g, "/");
116
+ return new TextDecoder("utf-8", { fatal: true }).decode(Uint8Array.from(atob(base64), (ch) => ch.charCodeAt(0)));
117
+ } catch {
118
+ return void 0;
119
+ }
120
+ }
121
+ return void 0;
122
+ }
107
123
  var DOC_ID = /^[a-z0-9][a-z0-9_-]{0,63}$/;
108
124
  function createApp(source, options = {}) {
109
125
  const app2 = new Hono();
@@ -113,7 +129,7 @@ function createApp(source, options = {}) {
113
129
  });
114
130
  app2.get("/health", (c) => c.json({ ok: true, pack: "agentic-documents" }));
115
131
  app2.use("/api/v1/*", async (c, next) => {
116
- const bearer = /^Bearer (.+)$/.exec(c.req.header("authorization") ?? "")?.[1];
132
+ const bearer = /^Bearer (.+)$/.exec(c.req.header("authorization") ?? "")?.[1] ?? socketBearer(c);
117
133
  const identity = bearer === void 0 ? void 0 : identityFor(c.env?.AGENTIC_DOCS_TOKENS, bearer);
118
134
  if (!identity) return fail("UNAUTHENTICATED", "send a valid bearer token", 401);
119
135
  c.set("author", identity.principal);
@@ -130,13 +146,17 @@ function createApp(source, options = {}) {
130
146
  const { contributors } = await options.live(c.env ?? {}).meta(c.req.param("id"));
131
147
  return c.json({ contributors: contributors.map(({ id, kind, machine }) => ({ name: id, kind, machine })) });
132
148
  });
133
- app2.get("/api/v1/docs/:id/ws", (c) => {
149
+ app2.get("/api/v1/docs/:id/ws", async (c) => {
134
150
  const id = c.req.param("id");
135
151
  if (!options.live) return fail("NOT_FOUND", "this server hosts no live rooms", 404);
136
152
  if (c.req.header("upgrade") !== "websocket") return fail("UPGRADE_REQUIRED", "open this route as a WebSocket", 426);
137
153
  if (!DOC_ID.test(id)) return fail("INVALID_INPUT", `invalid document id "${id}"`, 400);
138
154
  const { kind, name } = c.get("author");
139
- return options.live(c.env ?? {}).connect(id, c.req.raw, { principal: { id: name, kind }, role: c.get("role"), name });
155
+ const opened = options.live(c.env ?? {}).connect(id, c.req.raw, { principal: { id: name, kind }, role: c.get("role"), name });
156
+ if (!(c.req.header("sec-websocket-protocol") ?? "").split(",").some((p) => p.trim() === WS_PROTOCOL)) return opened;
157
+ const room = await opened;
158
+ const socket = room.webSocket;
159
+ return socket ? new Response(null, { status: 101, webSocket: socket, headers: new Headers([...room.headers, ["sec-websocket-protocol", WS_PROTOCOL]]) }) : room;
140
160
  });
141
161
  async function run(c, command, input) {
142
162
  try {
@@ -171,12 +191,14 @@ function createApp(source, options = {}) {
171
191
  app2.post("/api/v1/docs/:id/checkpoint", async (c) => {
172
192
  if (c.get("role") === "viewer") throw new DocError("FORBIDDEN", "this token is a viewer; it cannot change a document");
173
193
  const update = await readCapped(c.req.raw, MAX_LIVE_BYTES);
174
- await (await serviceOf(c)).checkpoint(c.req.param("id"), update, c.get("author"));
194
+ await (await serviceOf(c)).checkpoint(c.req.param("id"), update, c.get("author"), { create: c.req.query("create") !== "false" });
175
195
  return c.json({ ok: true });
176
196
  });
177
197
  const create = commands.find((c) => c.name === "new");
198
+ const listing = commands.find((c) => c.name === "list");
178
199
  app2.post("/api/v1/docs", async (c) => run(c, create, await body(c)));
179
- for (const command of commands.filter((c) => c !== create)) {
200
+ app2.get("/api/v1/docs", (c) => run(c, listing, {}));
201
+ for (const command of commands.filter((c) => c !== create && c !== listing)) {
180
202
  const path = `/api/v1/docs/:id/${command.name}`;
181
203
  if (command.readOnly) app2.get(path, (c) => run(c, command, { ...c.req.query(), id: c.req.param("id") }));
182
204
  else app2.post(path, async (c) => run(c, command, { ...await body(c), id: c.req.param("id") }));