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
package/CHANGELOG.md CHANGED
@@ -1,5 +1,61 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.6.0
4
+
5
+ ### Added
6
+
7
+ - `agentic-documents/ui`: `mountEditor(element, { id, client, principal, room? })` and the `DocumentEditor` React component
8
+ render the editor in a host app, with live co-editing through agentic-realtime when a room is given. Its styles are
9
+ in `agentic-documents/ui.css`. `react` and `react-dom` are optional peer dependencies; the Tiptap packages and
10
+ `agentic-design` are now dependencies.
11
+ - The edit log has a new op, `live`. The editor writes one `live` entry when an agent principal types, so its text is
12
+ marked as agent work. Its counts are 0.
13
+
14
+ ### Changed
15
+
16
+ - The ODT export uses `agentic-formats`. The local ODT writer and the `fflate` dependency are gone. `exportOdt` and
17
+ `ODT_MIME` stay as thin wrappers. The ODT file now comes from the neutral text model, so its XML differs from before.
18
+ - The ODT export holds less than before, because the agentic-formats text model has no block for a quote, a rule, or a
19
+ code block, no list start number, one paragraph for each list item, and no WebP image. The text stays.
20
+ `ExportResult` has a `warnings` field that names each loss. It is empty for `md` and `html`. The field is required, so
21
+ code that builds an `ExportResult` by hand must add it. The CLI prints each warning on stderr. The HTTP API sends them
22
+ in the `x-export-warnings` header.
23
+
24
+ ## 0.5.0
25
+
26
+ ### Breaking
27
+
28
+ - A deploy from the template `wrangler.jsonc` needs `npm run build` first. The file now has an `assets` entry for
29
+ `dist/ui`, so `wrangler` stops when that folder is missing.
30
+
31
+ ### Added
32
+
33
+ - Commands `list`, `rename`, and `remove` in the SDK, CLI, MCP server, HTTP API (`GET /api/v1/docs`), and
34
+ the typed client. The catalog keeps a title and the last save time and author. An agent needs the
35
+ `docs:remove` scope to remove a document.
36
+ - A browser can open a live room: the token goes in a WebSocket subprotocol
37
+ (`agentic-docs.bearer.<base64url>`) on the live-room route only.
38
+ - A web UI that the Worker serves from `dist/ui` (the `assets` entry in `wrangler.jsonc`): a document list, a
39
+ rich-text editor on the live room, rename, delete, share, presence, and a mark on agent text. It is built on
40
+ `agentic-design` and the typed client. Playwright tests run it under `wrangler dev` with an axe check (WCAG 2.2 AA rules)
41
+ in the light and the dark theme, and screenshots are in `docs/screenshots`.
42
+ - The saved update may hold the marker root that the Tiptap editor writes when it opens a document.
43
+ - `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.
44
+ - Each edit log entry holds `client`, the Yjs client id of its writer, so a UI can mark agent text.
45
+ - `agentic-documents/formats`: `importFile` (md, odt, docx) and `exportFile` (md, html, odt, docx), with the
46
+ `toTextDoc` and `fromTextDoc` mapping to the agentic-formats text model. Each result names lost content in `warnings`.
47
+ - `AgenticDoc.fromBlocks(blocks, author)` makes a document from ProseMirror JSON blocks and checks them against the schema.
48
+
49
+ ### Changed
50
+
51
+ - `esbuild` is a declared dev dependency. `ui/build.mjs` uses it.
52
+
53
+ ### Fixed
54
+
55
+ - In the web UI, the caret goes after a new link, and text typed there does not join the link. A quick key
56
+ after Apply no longer replaces the linked words. Apply with no words selected keeps its link for the next text.
57
+ - In the web UI, a key typed right after a toolbar button goes to the text, not to the button.
58
+
3
59
  ## 0.4.0
4
60
 
5
61
  ### Added
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.4.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.6.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
@@ -119,7 +195,7 @@ try {
119
195
  }
120
196
  ```
121
197
 
122
- - One method per route: `create`, `read`, `outline`, `edits`, `insert`, `replace`, `delete`, `append`,
198
+ - One method per route: `create`, `list`, `read`, `rename`, `remove`, `outline`, `edits`, `insert`, `replace`, `delete`, `append`,
123
199
  `suggest`, `suggestions`, `accept`, `reject`, `comment`, `reply`, `resolve`, `reopen`, `threads`,
124
200
  `export`, `room`, `me`, `health`, `state`, and `checkpoint`. The WebSocket route is not a method. Use
125
201
  `LiveDoc` from `agentic-documents/live` for it.
@@ -129,6 +205,69 @@ try {
129
205
  - The client never retries. A write is not safe to send twice.
130
206
  - Pass `fetch` to use your own, for a Worker service binding or for tests.
131
207
 
208
+ ## Embed the editor
209
+
210
+ `agentic-documents/ui` renders the editor inside your own app. You give it a document id, a client, the principal who
211
+ uses it, and an optional live room. With a room, people and agents edit together through agentic-realtime and see each
212
+ other. It needs `react` and `react-dom` 19 (peer dependencies).
213
+
214
+ ```ts
215
+ import { createClient } from "agentic-documents/client";
216
+ import { mountEditor } from "agentic-documents/ui";
217
+ import "agentic-design/styles.css";
218
+ import "agentic-documents/ui.css";
219
+
220
+ const client = createClient({ baseUrl: "https://docs.example.com", token });
221
+ const principal = await client.me(); // { kind, name, role }
222
+ const editor = mountEditor(document.getElementById("editor")!, {
223
+ id: "plan",
224
+ client,
225
+ principal,
226
+ room: { url: "wss://docs.example.com/api/v1/docs/plan/ws", token },
227
+ });
228
+ // Later: editor.update({ ...options, id: "other" }), or await editor.unmount() to save this tab's changes and leave.
229
+ ```
230
+
231
+ - In a React app, render `<DocumentEditor id client principal room onError onLeave />` instead. `onLeave` gets the
232
+ promise of the last save.
233
+ - `client` needs only `state` and `checkpoint`. A client from `createClient` has them, or pass your own. Keep the
234
+ same client object between renders: a new one opens the document again.
235
+ - `principal` needs a `kind` (one of the principal kinds) and a `name`. A missing or unknown kind throws a
236
+ `TypeError`. It is never given a default. Only the `role` `editor` can change the text. Any other role, or no role,
237
+ opens it read only. The server still checks every write against the token. Use the principal that `client.me()`
238
+ returns for the same token, so the editor labels the text with the kind that the server gives the token.
239
+ - When an agent principal types, the editor writes one `live` entry to the edit log for its Yjs client. The editor
240
+ and the web UI then mark that text as agent work for everyone who opens the document.
241
+ - `room.url` is the WebSocket address of the document room. In a browser the token goes in a WebSocket subprotocol.
242
+ Pass `room.WebSocket` for a room that takes its token another way. Without a room, the editor saves through the
243
+ client and shows no other people.
244
+ - The editor saves 1.5 seconds after this tab changes the text, and again on `unmount`. The promise from `unmount`
245
+ rejects when that last save fails. A save never makes a document, so a removed document stays removed.
246
+ - The styles sit under the `.adoc` class and use the agentic-design tokens. Load `agentic-design/styles.css` first.
247
+ - File import and export are in `agentic-documents/formats` (see [File formats](#file-formats)).
248
+
249
+ ## File formats
250
+
251
+ `agentic-documents/formats` imports and exports files, so an app that embeds the editor needs no mapping
252
+ code of its own. ODT and DOCX go through the neutral text model of
253
+ [agentic-formats](https://github.com/pooriaarab/agentic-formats). Markdown and HTML use the mapping of this package.
254
+
255
+ ```ts
256
+ import { exportFile, importFile } from "agentic-documents/formats";
257
+
258
+ const { doc, title, warnings } = importFile(bytes, "docx", { kind: "human", name: "Ada" });
259
+ const odt = exportFile(doc, "odt", { title }); // { contentType, data (Uint8Array for odt), warnings }
260
+ ```
261
+
262
+ - `importFile(input, format, author, { limits? })` reads `md` (text or UTF-8 bytes), `odt`, or `docx` (bytes) into a
263
+ new `AgenticDoc`. The author makes the one `create` edit, so a file that an agent imports stays labeled as agent work.
264
+ - `exportFile(doc, format, { title?, remoteImages? })` writes `md` or `html` (text), or `odt` or `docx` (bytes).
265
+ - Each result has `warnings`: one line for each kind of content that the other side cannot hold. The text of that
266
+ content stays.
267
+ - A file that is not valid, or that goes past a limit in `limits` (the agentic-formats defaults), fails with
268
+ `INVALID_INPUT`. For `md`, only `maxInputBytes` applies. A link that is not a web, mail, or relative address is dropped, and its text stays.
269
+ - `toTextDoc(blocks)` and `fromTextDoc(model)` are the mapping itself, for an app that works with the model.
270
+
132
271
  ## Features
133
272
 
134
273
  - A Yjs document that a Tiptap editor can open. It uses the y-prosemirror layout.
@@ -152,10 +291,10 @@ try {
152
291
  one application and undoes the other, so the text and the edit log hold it once. An accept
153
292
  beats a reject made at the same moment.
154
293
  - Export to ODT (OpenDocument text): headings, paragraphs, bold, italic, strike, code, links, lists,
155
- quotes, rules, tables, and images as embedded files. The package follows ODF 1.3 with the mimetype
156
- stored first. The CLI and the API send the file bytes. LibreOffice opens it, lists keep their
157
- numbers, and its own re-save reads back to the same Markdown (checked by hand with LibreOffice 24.8
158
- in the PR that added it).
294
+ tables, and PNG, JPEG, and GIF images as embedded files. `agentic-formats` writes the file, with the
295
+ mimetype stored first. The CLI and the API send the file bytes. A quote, a code block, a rule, a list
296
+ start number, and a WebP image are not kept in full (see Limits). Each loss is in `warnings` (SDK and
297
+ MCP), on stderr (CLI), and in the `x-export-warnings` header (API).
159
298
  - Export to Markdown and to self-contained HTML. The HTML loads nothing from other hosts. An image
160
299
  stored in the document as a `data:` URI (PNG, JPEG, GIF, or WebP) shows. Any other image becomes
161
300
  its alt text, because a remote image tells its host who opened the file. `remoteImages` turns
@@ -225,14 +364,18 @@ Limits of live editing:
225
364
 
226
365
  ## Limits
227
366
 
228
- - No web UI yet.
367
+ - The UI has no accounts. You paste a token, and the token sets your role. The share dialog shows a link and who
368
+ changed the document. Roles, invites, and expiring links wait for the sharing API in the Worker.
369
+ - The UI marks agent text from the edit log, which a client writes. A modified client can mark its own text as a
370
+ person's, or a person's as an agent's. The last edit shown in the list is approximate when two writes race.
371
+ - The UI is English only. It needs a browser with `WebSocket` and ES2022.
229
372
  - A document holds no roles of its own. The token table gives `editor` or `viewer`. To share a document
230
373
  by person, use `createSharing` from `agentic-identity` 0.4. A grant names its subject by kind
231
374
  (`human` or `agent`) and id, so a person and an agent with the same id hold separate roles. When you
232
375
  delete a document, call `deleteResource` on its resource so its grants do not outlive it. If
233
376
  `deleteResource` fails, treat the delete as failed and run it again. If it throws
234
377
  `EraseRefusedError`, remove the child resources or the extra owners first. Do not reuse the id
235
- until it succeeds. This package has no command that deletes a whole document.
378
+ until it succeeds. The `remove` command deletes the document and its catalog entry. It does not touch grants.
236
379
  - The Worker keeps each document in R2 and a catalog (who made it, and when) in D1. Both come from the
237
380
  shared `agentic-identity` ports. A save is conditional on the version it read, so two writers cannot
238
381
  overwrite each other. The CLI and MCP server keep the same data in local files.
@@ -251,8 +394,15 @@ Limits of live editing:
251
394
  (tokens) and an MCP server that a trusted host starts fix the author for the caller.
252
395
  - Suggestions work on text, not on whole sections.
253
396
  - A scope on a principal is trusted as given. Nothing here proves who a caller is.
254
- - Export covers Markdown, HTML, and ODT. DOCX export, and ODT and DOCX import, are planned.
255
- - ODT export: comments and suggestions are not written. A code block loses its language. A table
397
+ - The `export` command covers Markdown, HTML, and ODT. DOCX export, and ODT and DOCX import, are in
398
+ `agentic-documents/formats` only.
399
+ - File formats through `agentic-documents/formats`: a quote becomes plain paragraphs, a code block becomes a paragraph
400
+ of code text, a horizontal rule is left out, a numbered list starts at 1, and a task list becomes a list that starts
401
+ each item with `☐` or `☑`. On import, underline, superscript, and subscript are left out, and a footnote becomes
402
+ text in brackets, and code text keeps only its code mark. Each one is named in `warnings`.
403
+ - ODT export: a quote becomes plain paragraphs, a code block becomes a paragraph of code text, a rule is
404
+ left out, a numbered list starts at 1, a list item keeps one paragraph, and a WebP image becomes its alt text.
405
+ Comments and suggestions are not written. A code block loses its language. A table
256
406
  cell span is not written (the model has none yet). A task list becomes a bullet list that starts each
257
407
  item with `☐` or `☑`. A remote image is not linked, because an office program would ask its host
258
408
  for it when the file opens. It becomes its alt text.
@@ -1,6 +1,6 @@
1
1
  import {
2
2
  DocError
3
- } from "./chunk-YBBQNCMC.js";
3
+ } from "./chunk-GQ5E6T7Z.js";
4
4
 
5
5
  // src/adapters/file-blobs.ts
6
6
  import { createHash, randomUUID } from "crypto";
@@ -0,0 +1,195 @@
1
+ import {
2
+ APPLIED,
3
+ DocError,
4
+ checkAuthor
5
+ } from "./chunk-GQ5E6T7Z.js";
6
+
7
+ // src/review.ts
8
+ import * as Y from "yjs";
9
+ var newId = () => globalThis.crypto.randomUUID();
10
+ function nonEmpty(value, what) {
11
+ if (typeof value !== "string" || value.trim() === "") throw new DocError("INVALID_INPUT", `${what} must not be empty`);
12
+ return value;
13
+ }
14
+ function anchorFor(doc, target) {
15
+ if ("anchor" in target) {
16
+ doc.resolve(target);
17
+ return target.anchor;
18
+ }
19
+ return doc.anchor(target.text, target.occurrence);
20
+ }
21
+ function isStale(doc, anchor) {
22
+ try {
23
+ doc.resolve({ anchor });
24
+ return { stale: false, currentText: anchor.text };
25
+ } catch (error) {
26
+ if (!(error instanceof DocError) || error.code !== "STALE_ANCHOR") throw error;
27
+ return { stale: true, currentText: null };
28
+ }
29
+ }
30
+ function fieldsOf(doc, name) {
31
+ return doc.ydoc.getMap(name);
32
+ }
33
+ var optional = (map, key) => map.has(key) ? { [key]: map.get(key) } : {};
34
+ function setAll(map, values) {
35
+ for (const [key, value] of Object.entries(values)) if (value !== void 0) map.set(key, value);
36
+ }
37
+ var Threads = class {
38
+ constructor(doc) {
39
+ this.doc = doc;
40
+ }
41
+ doc;
42
+ get map() {
43
+ return fieldsOf(this.doc, "threads");
44
+ }
45
+ get(id) {
46
+ const thread = this.map.get(id);
47
+ if (!thread) throw new DocError("NO_THREAD", `no thread with id "${id}"`);
48
+ return thread;
49
+ }
50
+ message(body, author) {
51
+ return { id: newId(), at: this.doc.now().toISOString(), author, body };
52
+ }
53
+ add(target, body, author) {
54
+ const who = checkAuthor(author);
55
+ nonEmpty(body, "a comment");
56
+ const anchor = anchorFor(this.doc, target);
57
+ const id = newId();
58
+ this.doc.ydoc.transact(() => {
59
+ const thread = new Y.Map();
60
+ this.map.set(id, thread);
61
+ setAll(thread, { anchor, status: "open" });
62
+ const messages = new Y.Array();
63
+ thread.set("messages", messages);
64
+ messages.push([this.message(body, who)]);
65
+ });
66
+ return id;
67
+ }
68
+ reply(id, body, author) {
69
+ const who = checkAuthor(author);
70
+ nonEmpty(body, "a reply");
71
+ this.get(id).get("messages").push([this.message(body, who)]);
72
+ }
73
+ setStatus(id, status, author) {
74
+ const who = checkAuthor(author);
75
+ const thread = this.get(id);
76
+ this.doc.ydoc.transact(() => {
77
+ thread.set("status", status);
78
+ if (status === "open") {
79
+ thread.delete("resolvedBy");
80
+ thread.delete("resolvedAt");
81
+ } else setAll(thread, { resolvedBy: who, resolvedAt: this.doc.now().toISOString() });
82
+ });
83
+ }
84
+ resolve(id, author) {
85
+ this.setStatus(id, "resolved", author);
86
+ }
87
+ reopen(id, author) {
88
+ this.setStatus(id, "open", author);
89
+ }
90
+ list(filter = {}) {
91
+ return [...this.map.entries()].map(([id, t]) => {
92
+ const anchor = t.get("anchor");
93
+ return {
94
+ id,
95
+ anchor,
96
+ status: t.get("status"),
97
+ ...optional(t, "resolvedBy"),
98
+ ...optional(t, "resolvedAt"),
99
+ messages: t.get("messages").toArray(),
100
+ ...isStale(this.doc, anchor)
101
+ };
102
+ }).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));
103
+ }
104
+ };
105
+ var ACCEPT_SCOPE = "docs:accept";
106
+ var sameWho = (a, b) => a.kind === b.kind && a.name === b.name;
107
+ var isPerson = (who) => who.kind === "human" || who.kind === "user_token";
108
+ var mayDecide = (who) => isPerson(who) || who.scopes?.includes(ACCEPT_SCOPE) === true;
109
+ var Suggestions = class {
110
+ constructor(doc) {
111
+ this.doc = doc;
112
+ }
113
+ doc;
114
+ get map() {
115
+ return fieldsOf(this.doc, "suggestions");
116
+ }
117
+ get(id) {
118
+ const fields = this.map.get(id);
119
+ if (!fields) throw new DocError("NO_SUGGESTION", `no suggestion with id "${id}"`);
120
+ return { fields, suggestion: this.read(id, fields) };
121
+ }
122
+ read(id, f) {
123
+ const anchor = f.get("anchor");
124
+ return {
125
+ id,
126
+ kind: f.get("kind"),
127
+ ...optional(f, "at"),
128
+ anchor,
129
+ replacement: f.get("replacement"),
130
+ author: f.get("author"),
131
+ createdAt: f.get("createdAt"),
132
+ status: f.get("status"),
133
+ ...optional(f, "decidedBy"),
134
+ ...optional(f, "decidedAt"),
135
+ stale: isStale(this.doc, anchor).stale
136
+ };
137
+ }
138
+ /** An `at` on the target means insert. An empty replacement means delete. */
139
+ add(target, replacement, author) {
140
+ const who = checkAuthor(author);
141
+ const kind = target.at ? "insert" : replacement === "" ? "delete" : "replace";
142
+ if (kind === "insert") nonEmpty(replacement, "an inserted text");
143
+ const anchor = anchorFor(this.doc, target);
144
+ const id = newId();
145
+ this.doc.ydoc.transact(() => {
146
+ const fields = new Y.Map();
147
+ this.map.set(id, fields);
148
+ setAll(fields, { kind, at: target.at, anchor, replacement, author: who, createdAt: this.doc.now().toISOString(), status: "pending" });
149
+ });
150
+ return id;
151
+ }
152
+ decide(fields, status, author, at) {
153
+ setAll(fields, { status, decidedBy: author, decidedAt: at });
154
+ }
155
+ /**
156
+ * Apply the edit as the accepting author. The check and the edit share one transaction. Accepting
157
+ * an accepted suggestion changes nothing. A stale anchor throws and the suggestion stays pending.
158
+ */
159
+ accept(id, author) {
160
+ const who = checkAuthor(author);
161
+ this.doc.ydoc.transact(() => {
162
+ const { fields, suggestion: s } = this.get(id);
163
+ if (!mayDecide(author) || sameWho(s.author, who)) {
164
+ 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`);
165
+ }
166
+ if (s.status === "accepted") return;
167
+ if (s.status !== "pending") throw new DocError("ALREADY_DECIDED", `suggestion "${id}" is already ${s.status}`);
168
+ const { at, inserted } = this.doc.edit(s.kind, { anchor: s.anchor, at: s.at }, s.replacement, who, { suggestion: { id, author: s.author } });
169
+ const record = { by: who, at, ...inserted };
170
+ this.doc.ydoc.getMap(APPLIED).set(`${id}:${this.doc.ydoc.clientID}`, record);
171
+ this.decide(fields, "accepted", who, at);
172
+ });
173
+ }
174
+ /** Same right as accept. A person can withdraw their own suggestion. An agent can never decide its own. */
175
+ reject(id, author) {
176
+ const who = checkAuthor(author);
177
+ this.doc.ydoc.transact(() => {
178
+ const { fields, suggestion: s } = this.get(id);
179
+ if (!mayDecide(author) || !isPerson(who) && sameWho(s.author, who)) {
180
+ 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`);
181
+ }
182
+ if (s.status !== "pending") throw new DocError("ALREADY_DECIDED", `suggestion "${id}" is already ${s.status}`);
183
+ this.decide(fields, "rejected", who, this.doc.now().toISOString());
184
+ });
185
+ }
186
+ list(filter = {}) {
187
+ 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));
188
+ }
189
+ };
190
+
191
+ export {
192
+ Threads,
193
+ isPerson,
194
+ Suggestions
195
+ };
@@ -0,0 +1,163 @@
1
+ import {
2
+ Suggestions,
3
+ Threads
4
+ } from "./chunk-5IQX2W52.js";
5
+ import {
6
+ EXPORT_FORMATS,
7
+ exportFile,
8
+ exportText,
9
+ toBase64
10
+ } from "./chunk-KC5KFB5R.js";
11
+ import {
12
+ DocError
13
+ } from "./chunk-GQ5E6T7Z.js";
14
+
15
+ // src/export.ts
16
+ function exportDoc(doc, format, options = {}) {
17
+ if (format === "odt") {
18
+ const file = exportFile(doc, "odt");
19
+ return { format, contentType: file.contentType, encoding: "base64", data: toBase64(file.data), warnings: file.warnings };
20
+ }
21
+ const contentType = format === "html" ? "text/html; charset=utf-8" : "text/markdown; charset=utf-8";
22
+ return { format, contentType, encoding: "utf8", data: exportText(doc, format, options), warnings: [] };
23
+ }
24
+
25
+ // src/commands.ts
26
+ import { z } from "zod";
27
+ var id = z.string().describe("Document id.");
28
+ var content = z.string().describe("Markdown for a section target. Plain text for a text target.");
29
+ var text = z.string().describe("Exact text to match. It must sit inside one block.");
30
+ var occurrence = z.coerce.number().int().min(1).optional().describe("Which match, counting from 1, when the text matches more than once.");
31
+ var side = z.enum(["before", "after"]).optional().describe("Put the new text before or after the match.");
32
+ var thread = z.string().describe("Thread id.");
33
+ var where = {
34
+ section: z.string().optional().describe("Section id from the outline. Use this or text."),
35
+ text: text.optional(),
36
+ occurrence,
37
+ at: z.enum(["start", "end", "before", "after"]).optional().describe("Insert at the start or end of a section, or before or after matched text.")
38
+ };
39
+ function target(input) {
40
+ const { section, text: text2, occurrence: occurrence2, at } = input;
41
+ if (section !== void 0) {
42
+ if (at !== void 0 && at !== "start" && at !== "end") throw new DocError("INVALID_INPUT", `at "${at}" does not fit a section; use start or end`);
43
+ return { section, at };
44
+ }
45
+ if (text2 !== void 0) {
46
+ if (at === "start" || at === "end") throw new DocError("INVALID_INPUT", `at "${at}" does not fit text; use before or after`);
47
+ return { text: text2, occurrence: occurrence2, at };
48
+ }
49
+ throw new DocError("INVALID_INPUT", "pass section or text");
50
+ }
51
+ var textTarget = (input) => ({
52
+ text: input.text,
53
+ occurrence: input.occurrence,
54
+ at: input.at
55
+ });
56
+ function command(name, description, readOnly, shape, run) {
57
+ return {
58
+ name,
59
+ description,
60
+ shape,
61
+ readOnly,
62
+ async run(service, raw, author) {
63
+ const parsed = z.object(shape).strict().safeParse(raw);
64
+ if (!parsed.success) {
65
+ throw new DocError("INVALID_INPUT", parsed.error.issues.map((i) => `${i.path.join(".") || "input"}: ${i.message}`).join("; "));
66
+ }
67
+ return run(service, parsed.data, author);
68
+ }
69
+ };
70
+ }
71
+ var ok = () => ({ ok: true });
72
+ var commands = [
73
+ command("new", "Create a document, from Markdown if given.", false, { id, markdown: z.string().optional() }, async (s, i, a) => {
74
+ await s.create(i.id, i.markdown ?? "", a);
75
+ return { id: i.id };
76
+ }),
77
+ command("list", "List documents with their titles, creators, and last edits.", true, {}, (s) => s.list()),
78
+ command("rename", "Give a document a title. The text stays.", false, { id, title: z.string().describe("1 to 120 characters of plain text.") }, async (s, i, a) => {
79
+ await s.rename(i.id, i.title, a);
80
+ return ok();
81
+ }),
82
+ command("remove", "Delete a whole document. A person may. An agent needs the docs:remove scope.", false, { id }, async (s, i, a) => {
83
+ await s.remove(i.id, a);
84
+ return ok();
85
+ }),
86
+ command(
87
+ "read",
88
+ "Read a document, or one section, as Markdown.",
89
+ true,
90
+ { id, section: where.section },
91
+ (s, i) => s.read(i.id, (d) => i.section ? d.readSection(i.section) : d.toMarkdown())
92
+ ),
93
+ command("outline", "List sections with their ids, levels, and titles.", true, { id }, (s, i) => s.read(i.id, (d) => d.outline())),
94
+ command("edits", "Read the edit log. It names each author kind and holds no content.", true, { id }, (s, i) => s.read(i.id, (d) => d.edits())),
95
+ command("insert", "Insert Markdown into a section, or plain text next to matched text.", false, { id, ...where, content }, async (s, i, a) => {
96
+ await s.write(i.id, (d) => d.insert(target(i), i.content, a), a);
97
+ return ok();
98
+ }),
99
+ command("replace", "Replace a section body (the heading stays), or the matched text.", false, { id, ...where, content }, async (s, i, a) => {
100
+ await s.write(i.id, (d) => d.replace(target(i), i.content, a), a);
101
+ return ok();
102
+ }),
103
+ command("delete", "Delete a section with its subsections, or the matched text.", false, { id, ...where }, async (s, i, a) => {
104
+ await s.write(i.id, (d) => d.delete(target(i), a), a);
105
+ return ok();
106
+ }),
107
+ command("append", "Add Markdown at the end of the document.", false, { id, content: z.string() }, async (s, i, a) => {
108
+ await s.write(i.id, (d) => d.append(i.content, a), a);
109
+ return ok();
110
+ }),
111
+ command("suggest", "Suggest an edit to matched text. An empty replacement suggests a delete.", false, { id, text, occurrence, at: side, replacement: z.string() }, async (s, i, a) => ({
112
+ suggestion: await s.write(i.id, (d) => new Suggestions(d).add(textTarget(i), i.replacement, a), a)
113
+ })),
114
+ command(
115
+ "suggestions",
116
+ "List suggested edits.",
117
+ true,
118
+ { id, status: z.enum(["pending", "accepted", "rejected"]).optional() },
119
+ (s, i) => s.read(i.id, (d) => new Suggestions(d).list({ status: i.status }))
120
+ ),
121
+ command("accept", "Accept a suggestion. The text changes now.", false, { id, suggestion: z.string() }, async (s, i, a) => {
122
+ await s.write(i.id, (d) => new Suggestions(d).accept(i.suggestion, a), a);
123
+ return ok();
124
+ }),
125
+ command("reject", "Reject a suggestion. The text stays.", false, { id, suggestion: z.string() }, async (s, i, a) => {
126
+ await s.write(i.id, (d) => new Suggestions(d).reject(i.suggestion, a), a);
127
+ return ok();
128
+ }),
129
+ command("comment", "Start a comment thread on matched text.", false, { id, text, occurrence, body: z.string() }, async (s, i, a) => ({
130
+ thread: await s.write(i.id, (d) => new Threads(d).add(textTarget(i), i.body, a), a)
131
+ })),
132
+ command("reply", "Reply in a comment thread.", false, { id, thread, body: z.string() }, async (s, i, a) => {
133
+ await s.write(i.id, (d) => new Threads(d).reply(i.thread, i.body, a), a);
134
+ return ok();
135
+ }),
136
+ command("resolve", "Resolve a comment thread.", false, { id, thread }, async (s, i, a) => {
137
+ await s.write(i.id, (d) => new Threads(d).resolve(i.thread, a), a);
138
+ return ok();
139
+ }),
140
+ command("reopen", "Reopen a comment thread.", false, { id, thread }, async (s, i, a) => {
141
+ await s.write(i.id, (d) => new Threads(d).reopen(i.thread, a), a);
142
+ return ok();
143
+ }),
144
+ command(
145
+ "threads",
146
+ "List comment threads with their messages.",
147
+ true,
148
+ { id, status: z.enum(["open", "resolved"]).optional() },
149
+ (s, i) => s.read(i.id, (d) => new Threads(d).list({ status: i.status }))
150
+ ),
151
+ command(
152
+ "export",
153
+ "Export the document as Markdown (md), self-contained HTML (html), or OpenDocument text (odt, base64). HTML shows no remote image unless remoteImages is true.",
154
+ true,
155
+ { id, format: z.enum(EXPORT_FORMATS), remoteImages: z.union([z.boolean(), z.enum(["true", "false"])]).optional() },
156
+ (s, i) => s.read(i.id, (d) => exportDoc(d, i.format, { remoteImages: i.remoteImages === true || i.remoteImages === "true" }))
157
+ )
158
+ ];
159
+
160
+ export {
161
+ exportDoc,
162
+ commands
163
+ };