agentic-documents 0.4.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.
- package/CHANGELOG.md +35 -0
- package/README.md +112 -5
- package/dist/{chunk-YBBQNCMC.js → chunk-6QOYS63L.js} +14 -189
- package/dist/{chunk-TFCRPEEM.js → chunk-7R7ZPPRF.js} +1 -1
- package/dist/{chunk-R5XUTBC3.js → chunk-E7B6T5WN.js} +29 -8
- package/dist/{chunk-IU3YIQ4Y.js → chunk-FX755I7V.js} +30 -8
- package/dist/{chunk-GYQFTF2E.js → chunk-L6W2AQUF.js} +15 -146
- package/dist/chunk-S6MQDWFK.js +150 -0
- package/dist/chunk-UPEGODUV.js +195 -0
- package/dist/{chunk-FZA5LQV3.js → chunk-YNHZCIGO.js} +86 -11
- package/dist/cli.js +8 -6
- package/dist/client.d.ts +23 -6
- package/dist/client.js +9 -2
- package/dist/{doc-C_8kCsl7.d.ts → doc-mJPajXEQ.d.ts} +5 -1
- package/dist/export-Cwx276UP.d.ts +17 -0
- package/dist/formats.d.ts +49 -0
- package/dist/formats.js +234 -0
- package/dist/index.d.ts +6 -5
- package/dist/index.js +11 -7
- package/dist/live-worker.d.ts +2 -2
- package/dist/live-worker.js +6 -4
- package/dist/live.d.ts +6 -2
- package/dist/live.js +4 -3
- package/dist/{mcp-P2C75IIG.js → mcp-ESSEG46C.js} +4 -2
- package/dist/{review-BQW2Ci2h.d.ts → review-Hgq8OgvD.d.ts} +2 -16
- package/dist/{service-Do2Jwb5U.d.ts → service-CqKE9DKz.d.ts} +38 -5
- package/dist/ui/_headers +4 -0
- package/dist/ui/app.css +1 -0
- package/dist/ui/app.js +270 -0
- package/dist/ui/assets/inter-latin-ext-wght-normal-HA22NDSG.woff2 +0 -0
- package/dist/ui/assets/inter-latin-wght-normal-NRMW37G5.woff2 +0 -0
- package/dist/ui/assets/jetbrains-mono-latin-ext-wght-normal-TSLVDSQR.woff2 +0 -0
- package/dist/ui/assets/jetbrains-mono-latin-wght-normal-VBS632QH.woff2 +0 -0
- package/dist/ui/index.html +13 -0
- package/dist/worker.d.ts +2 -2
- package/dist/worker.js +6 -4
- package/llms.txt +1 -0
- package/package.json +19 -3
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,40 @@
|
|
|
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
|
+
|
|
3
38
|
## 0.4.0
|
|
4
39
|
|
|
5
40
|
### 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.
|
|
11
|
-
> the Worker API,
|
|
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
|
|
@@ -129,6 +205,28 @@ 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
|
+
## 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
|
+
|
|
132
230
|
## Features
|
|
133
231
|
|
|
134
232
|
- A Yjs document that a Tiptap editor can open. It uses the y-prosemirror layout.
|
|
@@ -225,14 +323,18 @@ Limits of live editing:
|
|
|
225
323
|
|
|
226
324
|
## Limits
|
|
227
325
|
|
|
228
|
-
-
|
|
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.
|
|
229
331
|
- A document holds no roles of its own. The token table gives `editor` or `viewer`. To share a document
|
|
230
332
|
by person, use `createSharing` from `agentic-identity` 0.4. A grant names its subject by kind
|
|
231
333
|
(`human` or `agent`) and id, so a person and an agent with the same id hold separate roles. When you
|
|
232
334
|
delete a document, call `deleteResource` on its resource so its grants do not outlive it. If
|
|
233
335
|
`deleteResource` fails, treat the delete as failed and run it again. If it throws
|
|
234
336
|
`EraseRefusedError`, remove the child resources or the extra owners first. Do not reuse the id
|
|
235
|
-
until it succeeds.
|
|
337
|
+
until it succeeds. The `remove` command deletes the document and its catalog entry. It does not touch grants.
|
|
236
338
|
- The Worker keeps each document in R2 and a catalog (who made it, and when) in D1. Both come from the
|
|
237
339
|
shared `agentic-identity` ports. A save is conditional on the version it read, so two writers cannot
|
|
238
340
|
overwrite each other. The CLI and MCP server keep the same data in local files.
|
|
@@ -251,7 +353,12 @@ Limits of live editing:
|
|
|
251
353
|
(tokens) and an MCP server that a trusted host starts fix the author for the caller.
|
|
252
354
|
- Suggestions work on text, not on whole sections.
|
|
253
355
|
- A scope on a principal is trusted as given. Nothing here proves who a caller is.
|
|
254
|
-
-
|
|
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`.
|
|
255
362
|
- ODT export: comments and suggestions are not written. A code block loses its language. A table
|
|
256
363
|
cell span is not written (the model has none yet). A task list becomes a bullet list that starts each
|
|
257
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,12 +1,12 @@
|
|
|
1
1
|
import {
|
|
2
2
|
MAX_LIVE_BYTES
|
|
3
|
-
} from "./chunk-
|
|
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-
|
|
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
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
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-
|
|
4
|
+
} from "./chunk-YNHZCIGO.js";
|
|
5
5
|
import {
|
|
6
6
|
commands
|
|
7
|
-
} from "./chunk-
|
|
7
|
+
} from "./chunk-S6MQDWFK.js";
|
|
8
8
|
import {
|
|
9
9
|
DocError,
|
|
10
10
|
PRINCIPAL_KINDS,
|
|
11
11
|
parseAuthor
|
|
12
|
-
} from "./chunk-
|
|
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
|
-
|
|
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
|
-
|
|
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") }));
|