agentic-documents 0.1.0 → 0.2.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 ADDED
@@ -0,0 +1,36 @@
1
+ # Changelog
2
+
3
+ ## 0.2.0
4
+
5
+ ### Breaking changes
6
+
7
+ - **Worker deployments from the repo template.** `wrangler.jsonc` now sets `main` to
8
+ `src/live-worker.ts`. To host live rooms, add the `SYNC_ROOM` Durable Object binding and the `v1`
9
+ migration (`new_sqlite_classes`). Without the binding, the room routes fail with `MISCONFIGURED`.
10
+ The published package does not include `wrangler.jsonc`.
11
+ - **Token table.** An `AGENTIC_DOCS_TOKENS` entry in object form may now carry a `role`. A `role` that
12
+ is not `editor` or `viewer` refuses the whole token table, so a typo never leaves a token live with
13
+ the wrong role. Entries with no `role` stay editors, as before.
14
+ - **Dependencies.** `agentic-identity` moves from `^0.2.0` to `^0.3.0`.
15
+
16
+ ### Added
17
+
18
+ - Live rooms. Several people and agents edit one document at the same time. Import `LiveDoc` from
19
+ `agentic-documents/live`. Deploy `agentic-documents/live-worker` to host the rooms.
20
+ - The `agentic-realtime` dependency (`^0.1.0`, the version on npm) for the live rooms.
21
+ - `--live <url>` on the CLI and the MCP server, or `AGENTIC_DOCS_LIVE_URL`. Set `AGENTIC_DOCS_TOKEN`
22
+ to join a room. The server sets the author kind and the role from the token.
23
+ - With `--live`, the CLI refuses `--as`, `--scopes`, and `--dir`, and exits 2. The server sets these
24
+ from the token.
25
+ - A `viewer` role. A viewer reads and cannot write, over HTTP or in a live room. The default role is
26
+ `editor`.
27
+ - Worker routes for live rooms: `GET /api/v1/me`, `GET /api/v1/docs/:id/room`,
28
+ `GET /api/v1/docs/:id/ws`, `GET /api/v1/docs/:id/state`, and `POST /api/v1/docs/:id/checkpoint`.
29
+ - Live rooms save through the shared `Store` and `Blobs` ports. A stored document too big for a room
30
+ fails the join with a clear error.
31
+ - `npm pack` and `npm publish` build `dist/` first (`prepack`).
32
+
33
+ ## 0.1.0
34
+
35
+ - First release. The document model, the Markdown and ODT export, the text operations, the CLI, the
36
+ MCP server, and the Worker HTTP API.
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.1.0. The document model, the text operations, the CLI, the MCP server,
11
- > and the Worker API work. The web UI is not built yet.
10
+ > Status: early, version 0.2.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.
12
12
 
13
13
  ## Install
14
14
 
@@ -130,7 +130,60 @@ curl "$URL/api/v1/docs/plan/export?format=html" -H "authorization: Bearer $TOKEN
130
130
  its alt text, because a remote image tells its host who opened the file. `remoteImages` turns
131
131
  that back on.
132
132
  - Every edit, message, and suggestion needs an author. The edit log keeps the kind and the name, so
133
- agent work stays labeled. The log holds no content.
133
+ agent work stays labeled. The log holds no content. Over the CLI, MCP, and HTTP the server or
134
+ the operator sets the author. In a live room a client writes its own log entries, suggestion decisions, and
135
+ threads. A save merges them into the stored document. So after a live client saves, treat all three
136
+ as advisory. The room's own record of who sent updates in the room is at `GET /api/v1/docs/:id/room`. It does not cover
137
+ text written over HTTP, the CLI, or MCP before the room opened: the client that seeds the room appears there.
138
+
139
+ ## Live editing
140
+
141
+ A person and an agent can edit one document at the same time. Each sees the other's changes. The Worker
142
+ hosts one room per document, on a Durable Object, through [agentic-realtime](https://www.npmjs.com/package/agentic-realtime).
143
+ Deploy `agentic-documents/live-worker`. The sample `wrangler.jsonc` binds `SYNC_ROOM` and sets the migration.
144
+
145
+ ```ts
146
+ import { LiveDoc } from "agentic-documents/live";
147
+
148
+ const live = await LiveDoc.join({ server: "https://docs.example.com", id: "plan", token });
149
+ await live.edit((doc, author) => doc.append("Ship on Friday.", author));
150
+ console.log(live.peers); // the other people and agents. `machine` is true for an agent.
151
+ await live.save(); // write the room state to the stored document
152
+ await live.leave();
153
+ ```
154
+
155
+ The CLI and the MCP server join rooms too. Every command then runs in the room and saves:
156
+
157
+ ```sh
158
+ export AGENTIC_DOCS_TOKEN=... # a bearer token. The server sets the author kind and the role.
159
+ export AGENTIC_DOCS_LIVE_URL=https://docs.example.com # or pass --live <url>
160
+ npx agentic-documents append plan --content "Ship on Friday." --live https://docs.example.com
161
+ npx agentic-documents mcp --live https://docs.example.com
162
+ ```
163
+
164
+ - The server reads your author kind and role from the token (`GET /api/v1/me`). An agent token writes
165
+ agent edits. `--as`, `--scopes`, and `--dir` do not apply with `--live`: the CLI refuses them and exits 2. This
166
+ holds when `AGENTIC_DOCS_LIVE_URL` is set too, so unset it for a local call.
167
+ - A room starts from the stored document. `save` merges the room back into the stored document, so
168
+ `read`, `export`, and the other commands see live edits. A save also creates a document that has none.
169
+ The CLI and MCP server save after each change. If a save fails, the room keeps the change and the error says so: do not repeat it. `new` makes the document in the store, and a room picks it up on the next join. A document over 1,000,000 bytes cannot open in a room or be saved from one. A save refuses an update with a wrong
170
+ kind of content or a malformed thread, suggestion, or log entry. HTTP edits reach an open room only when a client joins and seeds it.
171
+ - A `viewer` token joins and reads. The room drops its updates, and `edit` and `save` fail with `FORBIDDEN`.
172
+ Set a role in `AGENTIC_DOCS_TOKENS` with `{ "as": "human:Vic", "role": "viewer" }`. A token with no role is an editor.
173
+ - `GET /api/v1/docs/:id/room` lists who sent updates in the room, and whether each is machine-made.
174
+ - Pass `reconnect: {}` to `join` to keep trying after a drop. Edits made offline merge on return.
175
+ A room that restarts recovers its state from the Durable Object.
176
+
177
+ Limits of live editing:
178
+
179
+ - The edit log, the suggestion decisions, and the threads live in the document, and a client writes them.
180
+ A modified client can write a log entry with another author, or accept its own suggestion. The room's
181
+ list at `/room` is the record to trust for who sent updates. It does not cover text written before the
182
+ room opened.
183
+ - Until a client saves, a command over HTTP reads the stored copy, not the room.
184
+ - After the room refuses an edit, the local copy of `LiveDoc` still holds it. Join again.
185
+ - Durable Objects have no US jurisdiction. With the region `us` a room sits near the first client that opens it.
186
+ With `eu` it stays in the EU. The local `wrangler dev` cannot place rooms.
134
187
 
135
188
  ## Rules for text matching
136
189
 
@@ -147,7 +200,7 @@ curl "$URL/api/v1/docs/plan/export?format=html" -H "authorization: Bearer $TOKEN
147
200
  overwrite each other. The CLI and MCP server keep the same data in local files.
148
201
  - The CLI and MCP server keep files under `<dir>/blobs` and `<dir>/store`. Files from an older release that sit
149
202
  directly in `<dir>` are not read.
150
- - Token auth is a stopgap until the `Auth` port exists. There is no per-document sharing or role yet.
203
+ - Token auth is a stopgap until the `Auth` port exists. A token has one role for the whole server: `editor` (the default) or `viewer`. A viewer cannot call a write command or write in a live room. There is no per-document sharing yet.
151
204
  - A save is a compare-and-set. The file store takes a lock file, checks the version, and renames a
152
205
  uniquely named temp file into place. A writer that loses loads the document again and runs
153
206
  its change again. A lock older than 10 seconds counts as dead and is taken over. A lock that
@@ -0,0 +1,150 @@
1
+ import {
2
+ AgenticDoc,
3
+ DocError,
4
+ PRINCIPAL_KINDS,
5
+ Suggestions,
6
+ Threads
7
+ } from "./chunk-YBBQNCMC.js";
8
+
9
+ // src/service.ts
10
+ import * as Y from "yjs";
11
+ var ATTEMPTS = 64;
12
+ var ID = /^[a-z0-9][a-z0-9_-]{0,63}$/;
13
+ var CATALOG = "documents";
14
+ var MAX_LIVE_BYTES = 1e6;
15
+ var isObject = (v) => typeof v === "object" && v !== null && !Array.isArray(v);
16
+ var isString = (v) => typeof v === "string";
17
+ var isWho = (v) => isObject(v) && PRINCIPAL_KINDS.includes(v.kind) && isString(v.name) && v.name !== "";
18
+ var isAnchor = (v) => isObject(v) && isString(v.text) && isObject(v.start) && isObject(v.end);
19
+ var ROOTS = /* @__PURE__ */ new Set(["default", "edits", "suggestions", "threads", "applied"]);
20
+ var DocsService = class {
21
+ constructor(ports, options = {}) {
22
+ this.options = options;
23
+ this.blobs = ports.blobs;
24
+ this.store = ports.store;
25
+ }
26
+ options;
27
+ blobs;
28
+ store;
29
+ key(id) {
30
+ if (!ID.test(id)) throw new DocError("INVALID_INPUT", `invalid document id "${id}": use 1 to 64 of a-z, 0-9, "-" and "_"`);
31
+ return `${id}.ydoc`;
32
+ }
33
+ async create(id, markdown, author) {
34
+ const key = this.key(id);
35
+ const created = await this.blobs.put(key, AgenticDoc.fromMarkdown(markdown, author, this.options).encode(), { ifNoneMatch: "*" });
36
+ if (!created) throw new DocError("DOCUMENT_EXISTS", `document "${id}" already exists`);
37
+ await this.catalog(id, author);
38
+ }
39
+ /** The stored document as a Yjs update. A live client seeds its room from it. */
40
+ async state(id) {
41
+ const found = await this.blobs.get(this.key(id));
42
+ if (!found) throw new DocError("NO_DOCUMENT", `no document with id "${id}"`);
43
+ return found.data;
44
+ }
45
+ /**
46
+ * Merge a Yjs update from a live room into the stored document, and create the document when it
47
+ * has none. A merge is safe to repeat and to run in any order. The save is conditional, so two
48
+ * checkpoints at once both land.
49
+ */
50
+ async checkpoint(id, update, author) {
51
+ const key = this.key(id);
52
+ const scratch = new Y.Doc();
53
+ try {
54
+ Y.applyUpdate(scratch, update);
55
+ } catch {
56
+ throw new DocError("INVALID_INPUT", "the update is not a Yjs update");
57
+ }
58
+ if (scratch.store.clients.size === 0 || scratch.store.pendingStructs || scratch.store.pendingDs) {
59
+ throw new DocError("INVALID_INPUT", "the update is empty or lacks the changes it depends on");
60
+ }
61
+ for (const root of scratch.share.keys()) if (!ROOTS.has(root)) throw new DocError("INVALID_INPUT", `the update holds a part this package does not use: "${root}"`);
62
+ for (let attempt = 0; attempt < ATTEMPTS; attempt++) {
63
+ const found = await this.blobs.get(key);
64
+ if (!found) {
65
+ const created = AgenticDoc.fromUpdate(update, this.options);
66
+ const fresh = this.checked(id, created);
67
+ this.fits(id, fresh);
68
+ if (!await this.blobs.put(key, fresh, { ifNoneMatch: "*" })) continue;
69
+ await this.catalog(id, author);
70
+ return;
71
+ }
72
+ const doc = AgenticDoc.fromUpdate(found.data, this.options);
73
+ doc.applyUpdate(update);
74
+ const merged = this.checked(id, doc);
75
+ this.fits(id, merged);
76
+ if (await this.blobs.put(key, merged, { ifMatch: found.version })) return;
77
+ }
78
+ throw new DocError("CONFLICT", `document "${id}" kept changing; try again`);
79
+ }
80
+ /**
81
+ * The encoded document, once it reads back and every stored entry has the shape the commands rely on. Yjs does not
82
+ * check types, so an update can put text where the editor expects blocks, or a thread with no messages, and then a
83
+ * command fails on every later call.
84
+ */
85
+ checked(id, doc) {
86
+ const refuse = (what) => new DocError("INVALID_INPUT", `the update would leave document "${id}" unreadable: ${what}`);
87
+ try {
88
+ doc.toMarkdown();
89
+ if (!doc.edits().every((e) => isObject(e) && isWho(e.author) && isString(e.at) && isString(e.op))) throw refuse("an edit log entry is malformed");
90
+ for (const [, t] of doc.ydoc.getMap("threads")) {
91
+ const messages = t instanceof Y.Map ? t.get("messages") : void 0;
92
+ const ok = t instanceof Y.Map && isAnchor(t.get("anchor")) && ["open", "resolved"].includes(t.get("status")) && messages instanceof Y.Array && messages.length > 0 && messages.toArray().every((m) => isObject(m) && isString(m.id) && isString(m.at) && isString(m.body) && isWho(m.author));
93
+ if (!ok) throw refuse("a thread is malformed");
94
+ }
95
+ for (const [, f] of doc.ydoc.getMap("suggestions")) {
96
+ const ok = f instanceof Y.Map && ["insert", "replace", "delete"].includes(f.get("kind")) && ["pending", "accepted", "rejected"].includes(f.get("status")) && isAnchor(f.get("anchor")) && isString(f.get("replacement")) && isString(f.get("createdAt")) && isWho(f.get("author"));
97
+ if (!ok) throw refuse("a suggestion is malformed");
98
+ }
99
+ new Suggestions(doc).list();
100
+ new Threads(doc).list();
101
+ } catch (error) {
102
+ throw error instanceof DocError ? error : refuse("a part holds the wrong kind of content");
103
+ }
104
+ return doc.encode();
105
+ }
106
+ fits(id, state) {
107
+ if (state.length > MAX_LIVE_BYTES) throw new DocError("INVALID_INPUT", `document "${id}" would pass ${MAX_LIVE_BYTES} bytes, the most a live room takes`);
108
+ }
109
+ async catalog(id, author) {
110
+ await this.store.transaction(async (tx) => {
111
+ if (await tx.get(CATALOG, id) === null) await tx.put(CATALOG, id, { createdBy: { kind: author.kind, name: author.name }, createdAt: Date.now() });
112
+ });
113
+ }
114
+ /** Who created the document and when, or null when it has no catalog entry (made before the catalog, or a failed catalog write). */
115
+ async info(id) {
116
+ this.key(id);
117
+ const entry = await this.store.get(CATALOG, id);
118
+ if (entry === null) return null;
119
+ const { createdBy, createdAt } = entry;
120
+ if (!createdBy || !PRINCIPAL_KINDS.includes(createdBy.kind) || typeof createdBy.name !== "string" || typeof createdAt !== "number") {
121
+ throw new DocError("INVALID_INPUT", `the catalog entry for "${id}" is damaged`);
122
+ }
123
+ return { createdBy, createdAt };
124
+ }
125
+ async read(id, run) {
126
+ const found = await this.blobs.get(this.key(id));
127
+ if (!found) throw new DocError("NO_DOCUMENT", `no document with id "${id}"`);
128
+ return run(AgenticDoc.fromUpdate(found.data, this.options));
129
+ }
130
+ /**
131
+ * Load, run the change, and save only if nobody saved in between. When someone did, load again and
132
+ * run the change again, so a check such as "is it still pending" sees the new state.
133
+ */
134
+ async write(id, run) {
135
+ const key = this.key(id);
136
+ for (let attempt = 0; attempt < ATTEMPTS; attempt++) {
137
+ const found = await this.blobs.get(key);
138
+ if (!found) throw new DocError("NO_DOCUMENT", `no document with id "${id}"`);
139
+ const doc = AgenticDoc.fromUpdate(found.data, this.options);
140
+ const result = run(doc);
141
+ if (await this.blobs.put(key, doc.encode(), { ifMatch: found.version })) return result;
142
+ }
143
+ throw new DocError("CONFLICT", `document "${id}" kept changing; try again`);
144
+ }
145
+ };
146
+
147
+ export {
148
+ MAX_LIVE_BYTES,
149
+ DocsService
150
+ };
@@ -0,0 +1,389 @@
1
+ import {
2
+ DocError,
3
+ Suggestions,
4
+ Threads
5
+ } from "./chunk-YBBQNCMC.js";
6
+
7
+ // src/odt.ts
8
+ import { strToU8, zipSync } from "fflate";
9
+
10
+ // src/package.ts
11
+ var IMAGE_TYPES = { png: "image/png", jpg: "image/jpeg", jpeg: "image/jpeg", gif: "image/gif", webp: "image/webp" };
12
+ var DATA_IMAGE = /^data:(image\/(?:png|jpeg|gif|webp));base64,([a-z0-9+/=]+)$/i;
13
+ var FIXED_DATE = new Date(2e3, 0, 1);
14
+ function toBase64(bytes) {
15
+ let text2 = "";
16
+ for (let i = 0; i < bytes.length; i += 32768) text2 += String.fromCharCode(...bytes.subarray(i, i + 32768));
17
+ return btoa(text2);
18
+ }
19
+ var fromBase64 = (value) => Uint8Array.from(atob(value), (c) => c.charCodeAt(0));
20
+ function imageSize(b) {
21
+ const v = new DataView(b.buffer, b.byteOffset, b.byteLength);
22
+ if (b.length > 24 && v.getUint32(0) === 2303741511) return [v.getUint32(16), v.getUint32(20)];
23
+ if (b.length > 10 && v.getUint32(0) === 1195984440) return [v.getUint16(6, true), v.getUint16(8, true)];
24
+ if (b.length > 4 && v.getUint16(0) === 65496) {
25
+ for (let i = 2; i + 9 < b.length && b[i] === 255; i += 2 + v.getUint16(i + 2)) {
26
+ if (b[i + 1] >= 192 && b[i + 1] <= 207 && ![196, 200, 204].includes(b[i + 1])) return [v.getUint16(i + 7), v.getUint16(i + 5)];
27
+ }
28
+ }
29
+ return void 0;
30
+ }
31
+
32
+ // src/xml.ts
33
+ var xmlEscape = (value) => String(value ?? "").replace(/[^\u0009\u000A\u000D -퟿-�\u{10000}-\u{10FFFF}]/gu, "").replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;");
34
+ var safeHref = (value) => {
35
+ const href = String(value ?? "").trim();
36
+ return /^(https?:|mailto:|#|[^:]*$)/i.test(href) ? href : void 0;
37
+ };
38
+
39
+ // src/odt.ts
40
+ var ODT_MIME = "application/vnd.oasis.opendocument.text";
41
+ var NS = 'xmlns:office="urn:oasis:names:tc:opendocument:xmlns:office:1.0" xmlns:text="urn:oasis:names:tc:opendocument:xmlns:text:1.0" xmlns:table="urn:oasis:names:tc:opendocument:xmlns:table:1.0" xmlns:style="urn:oasis:names:tc:opendocument:xmlns:style:1.0" xmlns:fo="urn:oasis:names:tc:opendocument:xmlns:xsl-fo-compatible:1.0" xmlns:xlink="http://www.w3.org/1999/xlink" xmlns:draw="urn:oasis:names:tc:opendocument:xmlns:drawing:1.0" xmlns:svg="urn:oasis:names:tc:opendocument:xmlns:svg-compatible:1.0" xmlns:meta="urn:oasis:names:tc:opendocument:xmlns:meta:1.0" xmlns:manifest="urn:oasis:names:tc:opendocument:xmlns:manifest:1.0" office:version="1.3"';
42
+ var XML = '<?xml version="1.0" encoding="UTF-8"?>\n';
43
+ var SPANS = { bold: "B", italic: "I", strike: "S", code: "Source_20_Text" };
44
+ var TASK = ["\u2610 ", "\u2611 "];
45
+ var LEVELS = (kind) => Array.from(
46
+ { length: 10 },
47
+ (_, i) => kind === "bullet" ? `<text:list-level-style-bullet text:level="${i + 1}" text:bullet-char="\u2022"><style:list-level-properties text:list-level-position-and-space-mode="label-alignment"><style:list-level-label-alignment text:label-followed-by="listtab" fo:margin-left="${(i + 1) * 0.63}cm" fo:text-indent="-0.63cm"/></style:list-level-properties></text:list-level-style-bullet>` : `<text:list-level-style-number text:level="${i + 1}" style:num-suffix="." style:num-format="1"><style:list-level-properties text:list-level-position-and-space-mode="label-alignment"><style:list-level-label-alignment text:label-followed-by="listtab" fo:margin-left="${(i + 1) * 0.63}cm" fo:text-indent="-0.63cm"/></style:list-level-properties></text:list-level-style-number>`
48
+ ).join("");
49
+ var SIZES = [24, 18, 14, 12, 11, 10];
50
+ var STYLES = `${XML}<office:document-styles ${NS}><office:styles>
51
+ <style:style style:name="Standard" style:family="paragraph" style:class="text"/>
52
+ ${SIZES.map((s, i) => `<style:style style:name="Heading_20_${i + 1}" style:display-name="Heading ${i + 1}" style:family="paragraph" style:parent-style-name="Standard" style:class="text"><style:text-properties fo:font-size="${s}pt" fo:font-weight="bold"/></style:style>`).join("\n")}
53
+ <style:style style:name="Quotations" style:family="paragraph" style:parent-style-name="Standard" style:class="html"><style:paragraph-properties fo:margin-left="1cm" fo:margin-right="1cm"/></style:style>
54
+ <style:style style:name="Preformatted_20_Text" style:display-name="Preformatted Text" style:family="paragraph" style:parent-style-name="Standard" style:class="html"><style:text-properties fo:font-family="Liberation Mono, Courier New, monospace" fo:font-size="10pt"/></style:style>
55
+ <style:style style:name="Horizontal_20_Line" style:display-name="Horizontal Line" style:family="paragraph" style:parent-style-name="Standard" style:class="html"><style:paragraph-properties fo:border-bottom="0.5pt solid #808080" fo:padding="0cm" fo:margin-bottom="0.3cm"/></style:style>
56
+ <style:style style:name="Source_20_Text" style:display-name="Source Text" style:family="text"><style:text-properties fo:font-family="Liberation Mono, Courier New, monospace"/></style:style>
57
+ </office:styles></office:document-styles>`;
58
+ var AUTO = `<style:style style:name="B" style:family="text"><style:text-properties fo:font-weight="bold"/></style:style>
59
+ <style:style style:name="I" style:family="text"><style:text-properties fo:font-style="italic"/></style:style>
60
+ <style:style style:name="S" style:family="text"><style:text-properties style:text-line-through-style="solid"/></style:style>
61
+ <text:list-style style:name="Bullets">${LEVELS("bullet")}</text:list-style>
62
+ <text:list-style style:name="Numbering">${LEVELS("number")}</text:list-style>`;
63
+ var spaced = (s) => xmlEscape(s).replace(/\t/g, "<text:tab/>").replace(/\n/g, "<text:line-break/>").replace(/ +/g, (run, at, all) => at === 0 || at + run.length === all.length ? `<text:s text:c="${run.length}"/>` : run.length > 1 ? ` <text:s text:c="${run.length - 1}"/>` : run);
64
+ function image(node, out) {
65
+ const alt = String(node.attrs?.alt ?? "");
66
+ const found = DATA_IMAGE.exec(String(node.attrs?.src ?? "").trim());
67
+ let data;
68
+ try {
69
+ data = found ? fromBase64(found[2]) : void 0;
70
+ } catch {
71
+ data = void 0;
72
+ }
73
+ if (!found || !data) return spaced(`[image${alt ? `: ${alt}` : ""}]`);
74
+ const ext = Object.keys(IMAGE_TYPES).find((k) => IMAGE_TYPES[k] === found[1].toLowerCase());
75
+ const name = `Image${out.images.length + 1}`;
76
+ out.images.push({ name: `Pictures/${name}.${ext}`, type: found[1].toLowerCase(), data });
77
+ const [w, h] = imageSize(data) ?? [320, 240];
78
+ const scale = Math.min(1, 16 / (w * 2.54 / 96));
79
+ const cm = (px) => (px * 2.54 * scale / 96).toFixed(2);
80
+ return `<draw:frame draw:name="${name}" text:anchor-type="as-char" svg:width="${cm(w)}cm" svg:height="${cm(h)}cm"><draw:image xlink:href="Pictures/${name}.${ext}" xlink:type="simple" xlink:show="embed" xlink:actuate="onLoad"/><svg:title>${xmlEscape(alt)}</svg:title></draw:frame>`;
81
+ }
82
+ function inline(nodes, out) {
83
+ return nodes.map((node) => {
84
+ if (node.type === "hardBreak") return "<text:line-break/>";
85
+ if (node.type === "image") return image(node, out);
86
+ return (node.marks ?? []).reduce((xml, mark) => {
87
+ if (mark.type === "link") {
88
+ const href = safeHref(mark.attrs?.href);
89
+ return href ? `<text:a xlink:type="simple" xlink:href="${xmlEscape(href)}">${xml}</text:a>` : xml;
90
+ }
91
+ return SPANS[mark.type] ? `<text:span text:style-name="${SPANS[mark.type]}">${xml}</text:span>` : xml;
92
+ }, spaced(node.text ?? ""));
93
+ }).join("");
94
+ }
95
+ function block(node, out, style = "Standard") {
96
+ const kids = node.content ?? [];
97
+ const p = (name, inner) => `<text:p text:style-name="${name}">${inner}</text:p>`;
98
+ switch (node.type) {
99
+ case "paragraph":
100
+ return p(style, inline(kids, out));
101
+ case "heading": {
102
+ const level = Math.min(6, Math.max(1, Math.trunc(Number(node.attrs?.level)) || 1));
103
+ return `<text:h text:style-name="Heading_20_${level}" text:outline-level="${level}">${inline(kids, out)}</text:h>`;
104
+ }
105
+ case "blockquote":
106
+ return kids.map((k) => block(k, out, "Quotations")).join("");
107
+ case "codeBlock":
108
+ return p("Preformatted_20_Text", spaced(kids.map((k) => k.text ?? "").join("")));
109
+ case "horizontalRule":
110
+ return `<text:p text:style-name="Horizontal_20_Line"/>`;
111
+ case "bulletList":
112
+ case "orderedList":
113
+ case "taskList": {
114
+ const ordered = node.type === "orderedList";
115
+ const start = Number(node.attrs?.start ?? 1);
116
+ const items = kids.map((item, i) => {
117
+ const [first, ...rest] = item.content ?? [];
118
+ const mark = item.type === "taskItem" && first ? { ...first, content: [{ type: "text", text: TASK[item.attrs?.checked ? 1 : 0] }, ...first.content ?? []] } : first;
119
+ const attr = ordered && i === 0 && start !== 1 ? ` text:start-value="${Math.trunc(start) || 1}"` : "";
120
+ return `<text:list-item${attr}>${[mark, ...rest].filter(Boolean).map((k) => block(k, out)).join("")}</text:list-item>`;
121
+ });
122
+ return `<text:list text:style-name="${ordered ? "Numbering" : "Bullets"}">${items.join("")}</text:list>`;
123
+ }
124
+ case "table": {
125
+ const cols = Math.max(0, ...kids.map((r) => r.content?.length ?? 0));
126
+ const row = (r) => `<table:table-row>${(r.content ?? []).map((c) => `<table:table-cell office:value-type="string">${(c.content ?? []).map((b) => block(b, out)).join("")}</table:table-cell>`).join("")}</table:table-row>`;
127
+ const header = kids[0]?.content?.length && kids[0].content.every((c) => c.type === "tableHeader");
128
+ const rows = header ? `<table:table-header-rows>${row(kids[0])}</table:table-header-rows>${kids.slice(1).map(row).join("")}` : kids.map(row).join("");
129
+ return `<table:table table:name="Table${++out.tables}">${cols ? `<table:table-column table:number-columns-repeated="${cols}"/>` : ""}${rows}</table:table>`;
130
+ }
131
+ default:
132
+ return "";
133
+ }
134
+ }
135
+ function exportOdt(blocks) {
136
+ const out = { images: [], tables: 0 };
137
+ const body = blocks.map((b) => block(b, out)).join("\n");
138
+ const content2 = `${XML}<office:document-content ${NS}><office:automatic-styles>${AUTO}</office:automatic-styles><office:body><office:text>${body}</office:text></office:body></office:document-content>`;
139
+ const entries = [
140
+ `<manifest:file-entry manifest:full-path="/" manifest:version="1.3" manifest:media-type="${ODT_MIME}"/>`,
141
+ ...["content.xml", "styles.xml", "meta.xml"].map((n) => `<manifest:file-entry manifest:full-path="${n}" manifest:media-type="text/xml"/>`),
142
+ ...out.images.map((i) => `<manifest:file-entry manifest:full-path="${i.name}" manifest:media-type="${i.type}"/>`)
143
+ ];
144
+ const meta = `${XML}<office:document-meta ${NS}><office:meta><meta:generator>agentic-documents</meta:generator></office:meta></office:document-meta>`;
145
+ const part = (text2) => [strToU8(text2), { mtime: FIXED_DATE }];
146
+ return zipSync({
147
+ mimetype: [strToU8(ODT_MIME), { level: 0, mtime: FIXED_DATE }],
148
+ // first and stored, so file sniffers can read it
149
+ "META-INF/manifest.xml": part(`${XML}<manifest:manifest ${NS}>${entries.join("")}</manifest:manifest>`),
150
+ "content.xml": part(content2),
151
+ "styles.xml": part(STYLES),
152
+ "meta.xml": part(meta),
153
+ ...Object.fromEntries(out.images.map((i) => [i.name, [i.data, { mtime: FIXED_DATE }]]))
154
+ });
155
+ }
156
+
157
+ // src/export.ts
158
+ var EXPORT_FORMATS = ["md", "html", "odt"];
159
+ var esc = (s) => String(s ?? "").replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;");
160
+ var url = (value) => /^(https?:|mailto:|[^:]*$)/i.test(String(value ?? "").trim()) ? esc(String(value).trim()) : "#";
161
+ var integer = (value, fallback) => Number.isFinite(Number(value)) ? Math.trunc(Number(value)) : fallback;
162
+ var INLINE_IMAGE = /^data:image\/(png|jpeg|gif|webp);base64,[a-z0-9+/=]+$/i;
163
+ var textOf = (node) => node.text ?? (node.content ?? []).map(textOf).join("");
164
+ var MARK_TAGS = { bold: "strong", italic: "em", strike: "s", code: "code" };
165
+ function image2(node, remote) {
166
+ const src = String(node.attrs?.src ?? "").trim();
167
+ const alt = esc(node.attrs?.alt);
168
+ if (INLINE_IMAGE.test(src)) return `<img src="${src}" alt="${alt}">`;
169
+ if (remote && url(src) !== "#") return `<img src="${url(src)}" alt="${alt}">`;
170
+ return `<span class="image">[image${alt ? `: ${alt}` : ""}]</span>`;
171
+ }
172
+ function inline2(node, remote) {
173
+ if (node.type === "hardBreak") return "<br>";
174
+ if (node.type === "image") return image2(node, remote);
175
+ return (node.marks ?? []).reduce((html, mark) => {
176
+ if (mark.type === "link") return `<a href="${url(mark.attrs?.href)}">${html}</a>`;
177
+ const tag = MARK_TAGS[mark.type];
178
+ return tag ? `<${tag}>${html}</${tag}>` : html;
179
+ }, esc(node.text));
180
+ }
181
+ function block2(node, remote) {
182
+ const kids = (node.content ?? []).map((n) => block2(n, remote)).join("");
183
+ const text2 = (node.content ?? []).map((n) => inline2(n, remote)).join("");
184
+ switch (node.type) {
185
+ case "paragraph":
186
+ return `<p>${text2}</p>
187
+ `;
188
+ case "heading": {
189
+ const level = Math.min(6, Math.max(1, integer(node.attrs?.level, 1)));
190
+ return `<h${level}>${text2}</h${level}>
191
+ `;
192
+ }
193
+ case "blockquote":
194
+ return `<blockquote>
195
+ ${kids}</blockquote>
196
+ `;
197
+ case "codeBlock": {
198
+ const lang = node.attrs?.language ? ` class="language-${esc(node.attrs.language)}"` : "";
199
+ return `<pre><code${lang}>${esc(textOf(node))}</code></pre>
200
+ `;
201
+ }
202
+ case "horizontalRule":
203
+ return "<hr>\n";
204
+ case "bulletList":
205
+ case "taskList":
206
+ return `<ul>
207
+ ${kids}</ul>
208
+ `;
209
+ case "orderedList":
210
+ return `<ol start="${integer(node.attrs?.start, 1)}">
211
+ ${kids}</ol>
212
+ `;
213
+ case "listItem":
214
+ return `<li>${kids}</li>
215
+ `;
216
+ case "taskItem":
217
+ return `<li><input type="checkbox" disabled${node.attrs?.checked ? " checked" : ""}> ${kids}</li>
218
+ `;
219
+ case "table":
220
+ return `<table>
221
+ ${kids}</table>
222
+ `;
223
+ case "tableRow":
224
+ return `<tr>${kids}</tr>
225
+ `;
226
+ case "tableHeader":
227
+ return `<th>${kids}</th>`;
228
+ case "tableCell":
229
+ return `<td>${kids}</td>`;
230
+ default:
231
+ return "";
232
+ }
233
+ }
234
+ function toHtml(blocks, remote) {
235
+ const title = textOf(blocks.find((b) => b.type === "heading") ?? { type: "doc" }) || "Document";
236
+ const css = "body{font-family:system-ui,sans-serif;max-width:46rem;margin:2rem auto;padding:0 1rem;line-height:1.5}table{border-collapse:collapse}td,th{border:1px solid #888;padding:.25rem .5rem}pre{overflow:auto}";
237
+ return `<!doctype html>
238
+ <html lang="en">
239
+ <head>
240
+ <meta charset="utf-8">
241
+ <title>${esc(title)}</title>
242
+ <style>${css}</style>
243
+ </head>
244
+ <body>
245
+ ${blocks.map((b) => block2(b, remote)).join("")}</body>
246
+ </html>
247
+ `;
248
+ }
249
+ function exportDoc(doc, format, options = {}) {
250
+ if (format === "html") {
251
+ return { format, contentType: "text/html; charset=utf-8", encoding: "utf8", data: toHtml(doc.toJSON().content ?? [], options.remoteImages === true) };
252
+ }
253
+ if (format === "odt") return { format, contentType: ODT_MIME, encoding: "base64", data: toBase64(exportOdt(doc.toJSON().content ?? [])) };
254
+ return { format, contentType: "text/markdown; charset=utf-8", encoding: "utf8", data: doc.toMarkdown() };
255
+ }
256
+
257
+ // src/commands.ts
258
+ import { z } from "zod";
259
+ var id = z.string().describe("Document id.");
260
+ var content = z.string().describe("Markdown for a section target. Plain text for a text target.");
261
+ var text = z.string().describe("Exact text to match. It must sit inside one block.");
262
+ var occurrence = z.coerce.number().int().min(1).optional().describe("Which match, counting from 1, when the text matches more than once.");
263
+ var side = z.enum(["before", "after"]).optional().describe("Put the new text before or after the match.");
264
+ var thread = z.string().describe("Thread id.");
265
+ var where = {
266
+ section: z.string().optional().describe("Section id from the outline. Use this or text."),
267
+ text: text.optional(),
268
+ occurrence,
269
+ at: z.enum(["start", "end", "before", "after"]).optional().describe("Insert at the start or end of a section, or before or after matched text.")
270
+ };
271
+ function target(input) {
272
+ const { section, text: text2, occurrence: occurrence2, at } = input;
273
+ if (section !== void 0) {
274
+ if (at !== void 0 && at !== "start" && at !== "end") throw new DocError("INVALID_INPUT", `at "${at}" does not fit a section; use start or end`);
275
+ return { section, at };
276
+ }
277
+ if (text2 !== void 0) {
278
+ if (at === "start" || at === "end") throw new DocError("INVALID_INPUT", `at "${at}" does not fit text; use before or after`);
279
+ return { text: text2, occurrence: occurrence2, at };
280
+ }
281
+ throw new DocError("INVALID_INPUT", "pass section or text");
282
+ }
283
+ var textTarget = (input) => ({
284
+ text: input.text,
285
+ occurrence: input.occurrence,
286
+ at: input.at
287
+ });
288
+ function command(name, description, readOnly, shape, run) {
289
+ return {
290
+ name,
291
+ description,
292
+ shape,
293
+ readOnly,
294
+ async run(service, raw, author) {
295
+ const parsed = z.object(shape).strict().safeParse(raw);
296
+ if (!parsed.success) {
297
+ throw new DocError("INVALID_INPUT", parsed.error.issues.map((i) => `${i.path.join(".") || "input"}: ${i.message}`).join("; "));
298
+ }
299
+ return run(service, parsed.data, author);
300
+ }
301
+ };
302
+ }
303
+ var ok = () => ({ ok: true });
304
+ var commands = [
305
+ command("new", "Create a document, from Markdown if given.", false, { id, markdown: z.string().optional() }, async (s, i, a) => {
306
+ await s.create(i.id, i.markdown ?? "", a);
307
+ return { id: i.id };
308
+ }),
309
+ command(
310
+ "read",
311
+ "Read a document, or one section, as Markdown.",
312
+ true,
313
+ { id, section: where.section },
314
+ (s, i) => s.read(i.id, (d) => i.section ? d.readSection(i.section) : d.toMarkdown())
315
+ ),
316
+ command("outline", "List sections with their ids, levels, and titles.", true, { id }, (s, i) => s.read(i.id, (d) => d.outline())),
317
+ 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())),
318
+ command("insert", "Insert Markdown into a section, or plain text next to matched text.", false, { id, ...where, content }, async (s, i, a) => {
319
+ await s.write(i.id, (d) => d.insert(target(i), i.content, a));
320
+ return ok();
321
+ }),
322
+ command("replace", "Replace a section body (the heading stays), or the matched text.", false, { id, ...where, content }, async (s, i, a) => {
323
+ await s.write(i.id, (d) => d.replace(target(i), i.content, a));
324
+ return ok();
325
+ }),
326
+ command("delete", "Delete a section with its subsections, or the matched text.", false, { id, ...where }, async (s, i, a) => {
327
+ await s.write(i.id, (d) => d.delete(target(i), a));
328
+ return ok();
329
+ }),
330
+ command("append", "Add Markdown at the end of the document.", false, { id, content: z.string() }, async (s, i, a) => {
331
+ await s.write(i.id, (d) => d.append(i.content, a));
332
+ return ok();
333
+ }),
334
+ 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) => ({
335
+ suggestion: await s.write(i.id, (d) => new Suggestions(d).add(textTarget(i), i.replacement, a))
336
+ })),
337
+ command(
338
+ "suggestions",
339
+ "List suggested edits.",
340
+ true,
341
+ { id, status: z.enum(["pending", "accepted", "rejected"]).optional() },
342
+ (s, i) => s.read(i.id, (d) => new Suggestions(d).list({ status: i.status }))
343
+ ),
344
+ command("accept", "Accept a suggestion. The text changes now.", false, { id, suggestion: z.string() }, async (s, i, a) => {
345
+ await s.write(i.id, (d) => new Suggestions(d).accept(i.suggestion, a));
346
+ return ok();
347
+ }),
348
+ command("reject", "Reject a suggestion. The text stays.", false, { id, suggestion: z.string() }, async (s, i, a) => {
349
+ await s.write(i.id, (d) => new Suggestions(d).reject(i.suggestion, a));
350
+ return ok();
351
+ }),
352
+ command("comment", "Start a comment thread on matched text.", false, { id, text, occurrence, body: z.string() }, async (s, i, a) => ({
353
+ thread: await s.write(i.id, (d) => new Threads(d).add(textTarget(i), i.body, a))
354
+ })),
355
+ command("reply", "Reply in a comment thread.", false, { id, thread, body: z.string() }, async (s, i, a) => {
356
+ await s.write(i.id, (d) => new Threads(d).reply(i.thread, i.body, a));
357
+ return ok();
358
+ }),
359
+ command("resolve", "Resolve a comment thread.", false, { id, thread }, async (s, i, a) => {
360
+ await s.write(i.id, (d) => new Threads(d).resolve(i.thread, a));
361
+ return ok();
362
+ }),
363
+ command("reopen", "Reopen a comment thread.", false, { id, thread }, async (s, i, a) => {
364
+ await s.write(i.id, (d) => new Threads(d).reopen(i.thread, a));
365
+ return ok();
366
+ }),
367
+ command(
368
+ "threads",
369
+ "List comment threads with their messages.",
370
+ true,
371
+ { id, status: z.enum(["open", "resolved"]).optional() },
372
+ (s, i) => s.read(i.id, (d) => new Threads(d).list({ status: i.status }))
373
+ ),
374
+ command(
375
+ "export",
376
+ "Export the document as Markdown (md), self-contained HTML (html), or OpenDocument text (odt, base64). HTML shows no remote image unless remoteImages is true.",
377
+ true,
378
+ { id, format: z.enum(EXPORT_FORMATS), remoteImages: z.union([z.boolean(), z.enum(["true", "false"])]).optional() },
379
+ (s, i) => s.read(i.id, (d) => exportDoc(d, i.format, { remoteImages: i.remoteImages === true || i.remoteImages === "true" }))
380
+ )
381
+ ];
382
+
383
+ export {
384
+ ODT_MIME,
385
+ exportOdt,
386
+ EXPORT_FORMATS,
387
+ exportDoc,
388
+ commands
389
+ };