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.
- package/CHANGELOG.md +56 -0
- package/README.md +161 -11
- package/dist/{chunk-TFCRPEEM.js → chunk-34C47PDC.js} +1 -1
- package/dist/chunk-5IQX2W52.js +195 -0
- package/dist/chunk-6UYJV2EJ.js +163 -0
- package/dist/{chunk-R5XUTBC3.js → chunk-CN6UAW7B.js} +32 -9
- package/dist/chunk-DWVJIKVT.js +8 -0
- package/dist/{chunk-YBBQNCMC.js → chunk-GQ5E6T7Z.js} +18 -191
- package/dist/{chunk-FZA5LQV3.js → chunk-JBAQEIOH.js} +89 -12
- package/dist/chunk-KC5KFB5R.js +354 -0
- package/dist/{chunk-IU3YIQ4Y.js → chunk-V6TBTOHW.js} +38 -13
- package/dist/cli.js +16 -12
- package/dist/client.d.ts +23 -6
- package/dist/client.js +9 -2
- package/dist/{doc-C_8kCsl7.d.ts → doc-BXG9qG5X.d.ts} +8 -2
- package/dist/editor.css +37 -0
- package/dist/embed.d.ts +86 -0
- package/dist/embed.js +431 -0
- package/dist/formats.d.ts +52 -0
- package/dist/formats.js +24 -0
- package/dist/index.d.ts +12 -10
- package/dist/index.js +15 -9
- package/dist/live-worker.d.ts +2 -2
- package/dist/live-worker.js +7 -4
- package/dist/live.d.ts +6 -2
- package/dist/live.js +5 -3
- package/dist/{mcp-P2C75IIG.js → mcp-ZTUTC4CS.js} +5 -2
- package/dist/render-463xHb-x.d.ts +16 -0
- package/dist/{review-BQW2Ci2h.d.ts → review-Ds-i5Vnc.d.ts} +2 -16
- package/dist/{service-Do2Jwb5U.d.ts → service-Ck6oX-Kq.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 +7 -4
- package/llms.txt +1 -0
- package/package.json +43 -10
- 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.
|
|
11
|
-
> the Worker API,
|
|
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
|
-
|
|
156
|
-
stored first. The CLI and the API send the file bytes.
|
|
157
|
-
|
|
158
|
-
in the
|
|
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
|
-
-
|
|
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.
|
|
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
|
-
-
|
|
255
|
-
-
|
|
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.
|
|
@@ -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
|
+
};
|