@stll/folio-agents 0.0.1 → 0.1.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/README.md ADDED
@@ -0,0 +1,225 @@
1
+ # @stll/folio-agents
2
+
3
+ A framework-neutral (React-free, DOM-free) LLM tool layer over
4
+ [`@stll/folio-core`](https://www.npmjs.com/package/@stll/folio-core)'s AI-edits
5
+ engine: function-calling tool definitions and an executor so a model can read
6
+ and mutate a `.docx` document, with every mutation landing as a tracked change
7
+ or comment pending human review.
8
+
9
+ Part of [stella](https://github.com/stella/stella), an open-source legal workspace.
10
+
11
+ ## Install
12
+
13
+ ```sh
14
+ bun add @stll/folio-agents
15
+ ```
16
+
17
+ `@stll/folio-core` is installed automatically as a dependency.
18
+
19
+ ## Tools
20
+
21
+ | Tool | What it does |
22
+ | ----------------- | ----------------------------------------------------------------------------------------------------------------------------- |
23
+ | `read_document` | Read the document body as `{ blockId, kind, text }` blocks |
24
+ | `find_text` | Search block text for a string; returns block id, occurrence, and context per match |
25
+ | `read_comments` | Read comment threads (author, text, resolved, anchored block, replies) |
26
+ | `read_changes` | Read pending tracked changes (insertions/deletions) awaiting review |
27
+ | `add_comment` | Attach a comment to a block, optionally quoting specific text |
28
+ | `suggest_changes` | Propose `replaceInBlock` / `insertAfterBlock` / `insertBeforeBlock` / `replaceBlock` / `deleteBlock` edits as tracked changes |
29
+ | `reply_comment` | Reply to a comment thread |
30
+ | `resolve_comment` | Resolve or reopen a comment thread |
31
+ | `read_page` | Read a page's plain text (live editor only) |
32
+ | `read_selection` | Read the current text selection (live editor only) |
33
+ | `scroll_to_block` | Scroll the live editor to a block (live editor only) |
34
+
35
+ Block ids and comment ids always come from a prior tool call
36
+ (`read_document`, `find_text`, `read_comments`) within the same conversation —
37
+ never guess them. `suggest_changes` reports a plain-language reason when an
38
+ operation is skipped (e.g. the block changed since it was last read), so the
39
+ model can re-read and retry.
40
+
41
+ ### Untrusted documents
42
+
43
+ `read_document`, `read_comments`, `read_changes`, and `find_text` return
44
+ document content verbatim. If a `.docx` comes from an untrusted party, its
45
+ text can carry prompt-injection payloads straight into the model's context —
46
+ treat any document-derived tool result as untrusted model input, the same way
47
+ you would treat a fetched web page. Mutations stay safe by design regardless:
48
+ `suggest_changes` and `add_comment` land as tracked changes or comments
49
+ pending human review, so an injected instruction can propose an edit but
50
+ cannot silently apply one.
51
+
52
+ ## Headless quickstart
53
+
54
+ ```ts
55
+ import { FolioDocxReviewer } from "@stll/folio-core/server";
56
+ import {
57
+ createReviewerBridge,
58
+ executeFolioToolCall,
59
+ getFolioToolDefinitions,
60
+ toAnthropicTools,
61
+ } from "@stll/folio-agents";
62
+
63
+ const reviewer = await FolioDocxReviewer.fromBuffer(docxBuffer, { author: "AI" });
64
+ const bridge = createReviewerBridge(reviewer);
65
+ const tools = toAnthropicTools(getFolioToolDefinitions());
66
+
67
+ // Inside your tool-use loop, for each tool_use block the model emits:
68
+ const result = executeFolioToolCall(toolName, toolInput, bridge);
69
+ // result: { ok: true, result } | { ok: false, error } — feed either back to the model.
70
+
71
+ const reviewedBuffer = await reviewer.toBuffer();
72
+ ```
73
+
74
+ ## Live-editor quickstart
75
+
76
+ ```ts
77
+ import { createEditorRefBridge, executeFolioToolCall } from "@stll/folio-agents";
78
+
79
+ // `docxEditorRef` is a DocxEditorRef from @stll/folio-react (or any object
80
+ // structurally matching FolioAgentEditorRefLike).
81
+ const bridge = createEditorRefBridge({
82
+ ref: docxEditorRef.current,
83
+ author: "AI",
84
+ getComments: () => comments,
85
+ setComments: (next) => setComments(next),
86
+ });
87
+
88
+ const result = executeFolioToolCall("suggest_changes", { operations: [...] }, bridge);
89
+ ```
90
+
91
+ On a `DocxEditorRef` that implements the read surface (`getTrackedChanges`,
92
+ `getCommentAnchors`, `getSelectionText`, `getPageText`), the editor-ref bridge
93
+ has full parity with the headless one: `read_changes` returns real tracked
94
+ changes, comment entries carry a resolved `blockId` / `quote`, and `read_page`
95
+ / `read_selection` work against the live view. Against an older ref that
96
+ predates those methods, the bridge degrades per-member: `read_changes`
97
+ returns `[]`, comment entries fall back to `blockId: null` / `quote: ""`, and
98
+ `read_page` / `read_selection` report an unsupported-capability error — see
99
+ `src/bridges/editor-ref.ts` for the exact fallback per method.
100
+
101
+ ### Host-managed review queue
102
+
103
+ A host with its own review-queue UX (its own place to store proposed edits
104
+ pending approval, distinct from folio's tracked-changes redlines) can validate
105
+ a model's `suggest_changes` / `add_comment` tool-call arguments with the same
106
+ canonical rules `executeFolioToolCall` uses, without applying them through a
107
+ bridge at all:
108
+
109
+ ```ts
110
+ import { parseSuggestChangesInput } from "@stll/folio-agents";
111
+
112
+ const parsed = parseSuggestChangesInput(toolInput);
113
+ if (!parsed.ok) {
114
+ // Feed `parsed.error` back to the model as the tool result, same as
115
+ // executeFolioToolCall would.
116
+ } else {
117
+ // `parsed.operations` is FolioAIEditOperation[] — route it into your own
118
+ // review queue instead of bridge.applyOperations(...).
119
+ reviewQueue.enqueue(parsed.operations);
120
+ }
121
+ ```
122
+
123
+ `parseAddCommentInput` is the equivalent for `add_comment`, returning
124
+ `{ ok: true; operation }` on success.
125
+
126
+ ## Summarizing changes
127
+
128
+ Two different questions come up under "what changed":
129
+
130
+ **1. Pending tracked changes in one document** — what a human reviewer would
131
+ see as redlines right now. Use the `read_changes` tool (or
132
+ `reviewer.getChanges()` directly) and hand the insertions/deletions to the
133
+ model:
134
+
135
+ ```ts
136
+ const changes = reviewer.getChanges();
137
+ const prompt = `Summarize these pending edits for a reviewer:\n${changes
138
+ .map((c) => `${c.type === "insertion" ? "+" : "-"} [${c.blockId}] ${c.text}`)
139
+ .join("\n")}`;
140
+ ```
141
+
142
+ **2. Between two saved versions** — what changed across two `.docx` buffers,
143
+ independent of whether either one has any tracked changes at all. Use
144
+ `compareDocxVersions` + `formatVersionDiffForLLM`:
145
+
146
+ ```ts
147
+ import { compareDocxVersions, formatVersionDiffForLLM } from "@stll/folio-agents";
148
+
149
+ const diff = await compareDocxVersions(previousVersionBuffer, currentVersionBuffer);
150
+ const prompt = `Summarize what changed between these two document versions:\n${formatVersionDiffForLLM(diff)}`;
151
+ // -> feed `prompt` to your model as a normal user/system message.
152
+ ```
153
+
154
+ Both recipes compare the AS-ACCEPTED view of a document: any tracked changes
155
+ already pending in a buffer count as already applied before the comparison
156
+ runs (`compareDocxVersions` parses each buffer through the same
157
+ `FolioDocxReviewer` snapshot `read_document` uses). Diffing two versions that
158
+ each have their own uncommitted redlines still produces a clean, readable diff
159
+ instead of raw markup noise.
160
+
161
+ `compareDocxVersions` ships as a plain async function, not a tool definition:
162
+ a model can describe a tool call, but it can't attach two document buffers to
163
+ one — buffers aren't JSON-serializable tool arguments a model could produce.
164
+ The natural shape is a host-side tool keyed by version identifiers instead
165
+ (e.g. a server tool the model calls with two stored version ids, which your
166
+ backend resolves to buffers, diffs, and returns the formatted text for).
167
+
168
+ ## TanStack AI
169
+
170
+ TanStack AI's `toolDefinition` accepts a raw JSON Schema object as
171
+ `inputSchema`, so the definitions plug in without any wrapper:
172
+
173
+ ```ts
174
+ import { toolDefinition } from "@tanstack/ai";
175
+ import { getFolioToolDefinitions } from "@stll/folio-agents";
176
+
177
+ const defs = getFolioToolDefinitions().map((def) =>
178
+ toolDefinition({
179
+ name: def.name,
180
+ description: def.description,
181
+ inputSchema: def.inputSchema,
182
+ }),
183
+ );
184
+ // Client-executed tools: run executeFolioToolCall(name, args, bridge) where the
185
+ // live editor lives and report the payload back via your chat client's
186
+ // addToolResult; server-executed tools: chain .server((args) => ...) instead.
187
+ ```
188
+
189
+ The schemas stay within a conservative JSON Schema subset (`type: "object"`,
190
+ `properties`, `required`, `enum`, `additionalProperties: false`, plain arrays).
191
+ Some providers (e.g. Gemini's OpenAPI-3.0 subset) reject less common keywords;
192
+ if your stack projects tool schemas through a provider-safe filter, these
193
+ definitions pass through it unchanged.
194
+
195
+ ## Vercel AI SDK
196
+
197
+ This package ships no `ai` dependency; map its tool definitions with the AI
198
+ SDK's own `jsonSchema()` / `tool()` helpers:
199
+
200
+ ```ts
201
+ import { jsonSchema, tool } from "ai";
202
+ import { executeFolioToolCall, getFolioToolDefinitions } from "@stll/folio-agents";
203
+
204
+ const tools = Object.fromEntries(
205
+ getFolioToolDefinitions().map((def) => [
206
+ def.name,
207
+ tool({
208
+ description: def.description,
209
+ inputSchema: jsonSchema(def.inputSchema),
210
+ execute: async (input) => executeFolioToolCall(def.name, input, bridge),
211
+ }),
212
+ ]),
213
+ );
214
+ ```
215
+
216
+ ## Acknowledgements
217
+
218
+ folio began as a private fork of [Eigenpal](https://eigenpal.com)'s
219
+ [docx-editor](https://github.com/eigenpal/docx-editor). The original license
220
+ and copyright are preserved in
221
+ [`NOTICE.md`](https://github.com/stella/folio/blob/main/packages/core/NOTICE.md).
222
+
223
+ ## License
224
+
225
+ Apache-2.0
@@ -0,0 +1,36 @@
1
+ import { FolioAgentChange, FolioAgentComment } from "./types.js";
2
+ import { FolioAIEditApplyResult, FolioAIEditOperation, FolioAIEditSnapshot } from "@stll/folio-core/server";
3
+
4
+ //#region src/bridge.d.ts
5
+ /**
6
+ * The structural contract every folio surface (headless reviewer, live
7
+ * editor) implements so the tool executor can drive either one identically.
8
+ * `bridges/reviewer.ts` and `bridges/editor-ref.ts` are the two shipped
9
+ * implementations; a host app can also hand-write one.
10
+ *
11
+ * The required members are available on every surface. The optional members
12
+ * are live-editor-only capabilities (paging, selection, scrolling): when a
13
+ * bridge omits one, {@link executeFolioToolCall} reports the corresponding
14
+ * tool call as an unsupported-capability error rather than throwing, so a
15
+ * model driving a headless reviewer gets a plain-language reason instead of a
16
+ * crash.
17
+ */
18
+ type FolioAgentBridge = {
19
+ /** Snapshot the current document into AI-facing blocks + anchors. */snapshot(): FolioAIEditSnapshot;
20
+ /**
21
+ * Apply operations against the current document. The bridge decides mode
22
+ * (tracked-changes by default) and author internally — callers only supply
23
+ * the operations.
24
+ */
25
+ applyOperations(operations: FolioAIEditOperation[]): FolioAIEditApplyResult; /** The comment threads present in the document. */
26
+ getComments(): FolioAgentComment[]; /** The pending tracked changes (insertions/deletions) present in the document. */
27
+ getChanges(): FolioAgentChange[]; /** Reply to a comment thread. Returns `false` when the target comment does not exist. */
28
+ replyToComment(commentId: string, text: string): boolean; /** Mark a comment thread resolved or reopen it. Returns `false` when the target comment does not exist. */
29
+ resolveComment(commentId: string, resolved: boolean): boolean; /** Scroll the live editor to the given block and select it. */
30
+ scrollToBlock?(blockId: string): boolean; /** The user's current text selection in the live editor, as plain text. */
31
+ getSelectionText?(): string; /** Total page count in the live, paginated editor. */
32
+ getPageCount?(): number; /** Plain text of the given 1-based page in the live editor. */
33
+ getPageText?(page: number): string;
34
+ };
35
+ //#endregion
36
+ export { FolioAgentBridge };
package/dist/bridge.js ADDED
File without changes
@@ -0,0 +1,90 @@
1
+ import { FolioAgentBridge } from "../bridge.js";
2
+ import { FolioAIEditApplyMode, FolioAIEditApplyResult, FolioAIEditOperation, FolioAIEditSnapshot } from "@stll/folio-core/server";
3
+ import { FolioCommentAnchor, FolioReviewChange as FolioReviewChange$1 } from "@stll/folio-core/ai-edits";
4
+ import { Comment } from "@stll/folio-core/types/content";
5
+
6
+ //#region src/bridges/editor-ref.d.ts
7
+ /**
8
+ * Minimal structural slice of `DocxEditorRef` (`packages/react`) this bridge
9
+ * drives — declared locally per AGENTS.md's react-free-core rule (agents
10
+ * stays framework-neutral; it may not import a React-package type), covering
11
+ * ONLY the members actually called below.
12
+ *
13
+ * The read-surface members (`getTrackedChanges`, `getCommentAnchors`,
14
+ * `getSelectionText`, `getPageText`) are OPTIONAL here even though the
15
+ * current `DocxEditorRef` always implements them: a ref built against an
16
+ * older `@stll/folio-react` (before these methods existed) still
17
+ * structurally satisfies this type, and `createEditorRefBridge` below falls
18
+ * back to the pre-existing degraded behavior for each one it does not find.
19
+ */
20
+ type FolioAgentEditorRefLike = {
21
+ /** `DocxEditorRef.createAIEditSnapshot`. `null` before the editor view mounts. */createAIEditSnapshot(): FolioAIEditSnapshot | null; /** `DocxEditorRef.applyAIEditOperations`. */
22
+ applyAIEditOperations(options: {
23
+ snapshot: FolioAIEditSnapshot;
24
+ operations: FolioAIEditOperation[];
25
+ mode?: FolioAIEditApplyMode;
26
+ author?: string;
27
+ }): FolioAIEditApplyResult; /** `DocxEditorRef.scrollToBlock`. */
28
+ scrollToBlock(blockId: string, snapshot?: FolioAIEditSnapshot): boolean; /** `DocxEditorRef.getTotalPages`. */
29
+ getTotalPages(): number;
30
+ /**
31
+ * `DocxEditorRef.getTrackedChanges`. When present, `getChanges()` maps its
32
+ * output to {@link FolioAgentChange} (same mapping as the reviewer
33
+ * bridge's); when absent, `getChanges()` returns `[]`.
34
+ */
35
+ getTrackedChanges?(): FolioReviewChange$1[];
36
+ /**
37
+ * `DocxEditorRef.getCommentAnchors`. When present, `getComments()` merges
38
+ * each anchor's `blockId` / `quote` into the matching host-state comment
39
+ * by `commentId`; when absent, those fields stay `null` / `""`.
40
+ */
41
+ getCommentAnchors?(): FolioCommentAnchor[];
42
+ /**
43
+ * `DocxEditorRef.getSelectionText`. The bridge exposes `getSelectionText`
44
+ * only when this is present, so `read_selection` reports an
45
+ * unsupported-capability error on a ref that lacks it.
46
+ */
47
+ getSelectionText?(): string;
48
+ /**
49
+ * `DocxEditorRef.getPageText`. The bridge exposes `getPageText` only when
50
+ * this is present, so `read_page` reports an unsupported-capability error
51
+ * on a ref that lacks it. See {@link createEditorRefBridge} for how a
52
+ * `null` return (page in range, layout not yet computed) is handled.
53
+ */
54
+ getPageText?(page: number): string | null;
55
+ };
56
+ /** Options for {@link createEditorRefBridge}. */
57
+ type CreateEditorRefBridgeOptions = {
58
+ ref: FolioAgentEditorRefLike; /** Author attributed to tracked changes, comments, and replies this bridge creates. */
59
+ author: string; /** Read the host app's current comment state (e.g. the `DocxEditor` `comments` prop). */
60
+ getComments(): Comment[]; /** Replace the host app's comment state (e.g. the setter backing that same prop). */
61
+ setComments(comments: Comment[]): void; /** `"tracked-changes"` (default) produces ins/del redlines; `"direct"` edits in place. */
62
+ mode?: FolioAIEditApplyMode;
63
+ };
64
+ /**
65
+ * Build a {@link FolioAgentBridge} over a live `DocxEditor` ref plus the host
66
+ * app's comment state.
67
+ *
68
+ * Comments live in app-controlled React state (the `DocxEditor` `comments`
69
+ * prop), not on the ref, so this factory takes `getComments`/`setComments` to
70
+ * read and write that state directly — the same pair the host already passes
71
+ * to `DocxEditor`.
72
+ *
73
+ * KNOWN LIMITATIONS (only apply to a `ref` that predates the read-surface
74
+ * additions below; the current `DocxEditorRef` implements all four):
75
+ * - `getChanges()` returns `[]` when `ref.getTrackedChanges` is absent, since
76
+ * there is then no ref-level way to enumerate tracked changes from
77
+ * ProseMirror mark attributes.
78
+ * - Comment entries fall back to `blockId: null` / `quote: ""` when
79
+ * `ref.getCommentAnchors` is absent, since there is then no ref-level way
80
+ * to resolve a comment's anchor against the live ProseMirror document.
81
+ * - `read_page` / `read_selection` report an unsupported-capability error
82
+ * when `ref.getPageText` / `ref.getSelectionText` are absent: this bridge
83
+ * omits the corresponding `getPageText` / `getSelectionText` member
84
+ * entirely rather than implementing it as a no-op, which is what tells
85
+ * `executeFolioToolCall` to report the tool as unsupported instead of
86
+ * throwing.
87
+ */
88
+ declare const createEditorRefBridge: (options: CreateEditorRefBridgeOptions) => FolioAgentBridge;
89
+ //#endregion
90
+ export { CreateEditorRefBridgeOptions, FolioAgentEditorRefLike, createEditorRefBridge };
@@ -0,0 +1,139 @@
1
+ import { toAgentChange } from "./shared.js";
2
+ import { createReply } from "@stll/folio-core/docx/replyToComment";
3
+ //#region src/bridges/editor-ref.ts
4
+ const toAgentCommentReply = (reply) => ({
5
+ id: String(reply.id),
6
+ author: reply.author,
7
+ text: replyPlainText(reply)
8
+ });
9
+ /**
10
+ * Parse a tool-supplied comment id into the numeric `Comment.id` this bridge
11
+ * matches against, rejecting anything but a bare non-negative integer.
12
+ * `Number.parseInt` alone would silently accept trailing junk (`"12abc"` ->
13
+ * `12`), which could reply to or resolve the wrong comment on malformed tool
14
+ * input.
15
+ */
16
+ const parseCommentId = (commentId) => /^\d+$/.test(commentId) ? Number.parseInt(commentId, 10) : null;
17
+ /** A host-state `Comment`'s plain text, its paragraphs joined by newlines (mirrors `FolioDocxReviewer`'s reading). */
18
+ const replyPlainText = (comment) => comment.content.map(paragraphPlainText).join("\n");
19
+ const paragraphPlainText = (paragraph) => {
20
+ const parts = [];
21
+ for (const item of paragraph.content ?? []) {
22
+ if (item.type !== "run") continue;
23
+ for (const runItem of item.content ?? []) if (runItem.type === "text") parts.push(runItem.text);
24
+ }
25
+ return parts.join("");
26
+ };
27
+ /**
28
+ * Build a {@link FolioAgentBridge} over a live `DocxEditor` ref plus the host
29
+ * app's comment state.
30
+ *
31
+ * Comments live in app-controlled React state (the `DocxEditor` `comments`
32
+ * prop), not on the ref, so this factory takes `getComments`/`setComments` to
33
+ * read and write that state directly — the same pair the host already passes
34
+ * to `DocxEditor`.
35
+ *
36
+ * KNOWN LIMITATIONS (only apply to a `ref` that predates the read-surface
37
+ * additions below; the current `DocxEditorRef` implements all four):
38
+ * - `getChanges()` returns `[]` when `ref.getTrackedChanges` is absent, since
39
+ * there is then no ref-level way to enumerate tracked changes from
40
+ * ProseMirror mark attributes.
41
+ * - Comment entries fall back to `blockId: null` / `quote: ""` when
42
+ * `ref.getCommentAnchors` is absent, since there is then no ref-level way
43
+ * to resolve a comment's anchor against the live ProseMirror document.
44
+ * - `read_page` / `read_selection` report an unsupported-capability error
45
+ * when `ref.getPageText` / `ref.getSelectionText` are absent: this bridge
46
+ * omits the corresponding `getPageText` / `getSelectionText` member
47
+ * entirely rather than implementing it as a no-op, which is what tells
48
+ * `executeFolioToolCall` to report the tool as unsupported instead of
49
+ * throwing.
50
+ */
51
+ const createEditorRefBridge = (options) => {
52
+ const { ref, author, getComments, setComments } = options;
53
+ const mode = options.mode ?? "tracked-changes";
54
+ const requireSnapshot = () => {
55
+ const snapshot = ref.createAIEditSnapshot();
56
+ if (!snapshot) throw new Error("The editor view is not mounted; no snapshot is available yet.");
57
+ return snapshot;
58
+ };
59
+ const bridge = {
60
+ snapshot: requireSnapshot,
61
+ applyOperations: (operations) => ref.applyAIEditOperations({
62
+ snapshot: requireSnapshot(),
63
+ operations,
64
+ mode,
65
+ author
66
+ }),
67
+ getComments: () => {
68
+ const comments = getComments();
69
+ const anchors = ref.getCommentAnchors?.();
70
+ const anchorByCommentId = /* @__PURE__ */ new Map();
71
+ if (anchors) for (const anchor of anchors) anchorByCommentId.set(anchor.commentId, anchor);
72
+ const repliesByParent = /* @__PURE__ */ new Map();
73
+ const topLevel = [];
74
+ for (const comment of comments) {
75
+ if (comment.parentId == null) {
76
+ topLevel.push(comment);
77
+ continue;
78
+ }
79
+ const siblings = repliesByParent.get(comment.parentId) ?? [];
80
+ siblings.push(comment);
81
+ repliesByParent.set(comment.parentId, siblings);
82
+ }
83
+ return topLevel.map((comment) => {
84
+ const anchor = anchorByCommentId.get(comment.id);
85
+ return {
86
+ id: String(comment.id),
87
+ author: comment.author,
88
+ text: replyPlainText(comment),
89
+ resolved: comment.done ?? false,
90
+ blockId: anchor?.blockId ?? null,
91
+ quote: anchor?.quote ?? "",
92
+ replies: (repliesByParent.get(comment.id) ?? []).map(toAgentCommentReply)
93
+ };
94
+ });
95
+ },
96
+ getChanges: () => {
97
+ const changes = ref.getTrackedChanges?.();
98
+ return changes ? changes.map(toAgentChange) : [];
99
+ },
100
+ replyToComment: (commentId, text) => {
101
+ const parentId = parseCommentId(commentId);
102
+ if (parentId === null) return false;
103
+ const comments = getComments();
104
+ const reply = createReply(comments, parentId, {
105
+ author,
106
+ text
107
+ });
108
+ if (!reply) return false;
109
+ setComments([...comments, reply]);
110
+ return true;
111
+ },
112
+ resolveComment: (commentId, resolved) => {
113
+ const targetId = parseCommentId(commentId);
114
+ if (targetId === null) return false;
115
+ const comments = getComments();
116
+ let found = false;
117
+ const next = comments.map((comment) => {
118
+ if (comment.id !== targetId) return comment;
119
+ found = true;
120
+ return {
121
+ ...comment,
122
+ done: resolved
123
+ };
124
+ });
125
+ if (!found) return false;
126
+ setComments(next);
127
+ return true;
128
+ },
129
+ scrollToBlock: (blockId) => ref.scrollToBlock(blockId),
130
+ getPageCount: () => ref.getTotalPages()
131
+ };
132
+ const getSelectionText = ref.getSelectionText;
133
+ if (getSelectionText) bridge.getSelectionText = () => getSelectionText();
134
+ const getPageText = ref.getPageText;
135
+ if (getPageText) bridge.getPageText = (page) => getPageText(page) ?? "";
136
+ return bridge;
137
+ };
138
+ //#endregion
139
+ export { createEditorRefBridge };
@@ -0,0 +1,19 @@
1
+ import { FolioAgentBridge } from "../bridge.js";
2
+ import { FolioAIEditApplyMode, FolioDocxReviewer } from "@stll/folio-core/server";
3
+
4
+ //#region src/bridges/reviewer.d.ts
5
+ /** Options for {@link createReviewerBridge}. */
6
+ type CreateReviewerBridgeOptions = {
7
+ /** `"tracked-changes"` (default) produces ins/del redlines; `"direct"` edits in place. */mode?: FolioAIEditApplyMode;
8
+ };
9
+ /**
10
+ * Build a {@link FolioAgentBridge} over a headless {@link FolioDocxReviewer}
11
+ * (`@stll/folio-core/server`). No optional capability members are
12
+ * implemented: a headless document has no live page/selection/scroll
13
+ * surface, so `read_page`, `read_selection`, and `scroll_to_block` report an
14
+ * unsupported-capability error via {@link executeFolioToolCall} on this
15
+ * bridge.
16
+ */
17
+ declare const createReviewerBridge: (reviewer: FolioDocxReviewer, options?: CreateReviewerBridgeOptions) => FolioAgentBridge;
18
+ //#endregion
19
+ export { CreateReviewerBridgeOptions, createReviewerBridge };
@@ -0,0 +1,48 @@
1
+ import { toAgentChange } from "./shared.js";
2
+ //#region src/bridges/reviewer.ts
3
+ const toAgentCommentReply = (reply) => ({
4
+ id: String(reply.id),
5
+ author: reply.author,
6
+ text: reply.text
7
+ });
8
+ const toAgentComment = (comment) => ({
9
+ id: String(comment.id),
10
+ author: comment.author,
11
+ text: comment.text,
12
+ resolved: comment.done,
13
+ blockId: comment.blockId,
14
+ quote: comment.anchoredText,
15
+ replies: comment.replies.map(toAgentCommentReply)
16
+ });
17
+ /**
18
+ * Parse a tool-supplied comment id into the numeric id `FolioDocxReviewer`
19
+ * expects, rejecting anything but a bare non-negative integer. `Number.
20
+ * parseInt` alone would silently accept trailing junk (`"12abc"` -> `12`),
21
+ * which could reply to the wrong comment thread on malformed tool input.
22
+ */
23
+ const parseCommentId = (commentId) => /^\d+$/.test(commentId) ? Number.parseInt(commentId, 10) : null;
24
+ /**
25
+ * Build a {@link FolioAgentBridge} over a headless {@link FolioDocxReviewer}
26
+ * (`@stll/folio-core/server`). No optional capability members are
27
+ * implemented: a headless document has no live page/selection/scroll
28
+ * surface, so `read_page`, `read_selection`, and `scroll_to_block` report an
29
+ * unsupported-capability error via {@link executeFolioToolCall} on this
30
+ * bridge.
31
+ */
32
+ const createReviewerBridge = (reviewer, options = {}) => {
33
+ const mode = options.mode ?? "tracked-changes";
34
+ return {
35
+ snapshot: () => reviewer.snapshot(),
36
+ applyOperations: (operations) => reviewer.applyOperations(operations, { mode }),
37
+ getComments: () => reviewer.getComments().map(toAgentComment),
38
+ getChanges: () => reviewer.getChanges().map(toAgentChange),
39
+ replyToComment: (commentId, text) => {
40
+ const parentId = parseCommentId(commentId);
41
+ if (parentId === null) return false;
42
+ return reviewer.replyTo(parentId, { text }) !== null;
43
+ },
44
+ resolveComment: (commentId, resolved) => reviewer.resolveComment(commentId, { resolved })
45
+ };
46
+ };
47
+ //#endregion
48
+ export { createReviewerBridge };
@@ -0,0 +1,16 @@
1
+ import { FolioAgentChange } from "../types.js";
2
+ import { FolioReviewChange } from "@stll/folio-core/server";
3
+
4
+ //#region src/bridges/shared.d.ts
5
+ /**
6
+ * Map a core {@link FolioReviewChange} — from `FolioDocxReviewer.getChanges`
7
+ * (headless bridge, `bridges/reviewer.ts`) or `DocxEditorRef.getTrackedChanges`
8
+ * (live-editor bridge, `bridges/editor-ref.ts`) — to the tool-facing
9
+ * {@link FolioAgentChange} shape. Both bridges read the same underlying
10
+ * tracked-change record (`getTrackedChangesFromDoc` in
11
+ * `@stll/folio-core/ai-edits`), so they share this one mapping instead of
12
+ * drifting into two subtly different shapes.
13
+ */
14
+ declare const toAgentChange: (change: FolioReviewChange) => FolioAgentChange;
15
+ //#endregion
16
+ export { toAgentChange };
@@ -0,0 +1,19 @@
1
+ //#region src/bridges/shared.ts
2
+ /**
3
+ * Map a core {@link FolioReviewChange} — from `FolioDocxReviewer.getChanges`
4
+ * (headless bridge, `bridges/reviewer.ts`) or `DocxEditorRef.getTrackedChanges`
5
+ * (live-editor bridge, `bridges/editor-ref.ts`) — to the tool-facing
6
+ * {@link FolioAgentChange} shape. Both bridges read the same underlying
7
+ * tracked-change record (`getTrackedChangesFromDoc` in
8
+ * `@stll/folio-core/ai-edits`), so they share this one mapping instead of
9
+ * drifting into two subtly different shapes.
10
+ */
11
+ const toAgentChange = (change) => ({
12
+ id: String(change.id),
13
+ type: change.type,
14
+ author: change.author,
15
+ text: change.text,
16
+ blockId: change.blockId
17
+ });
18
+ //#endregion
19
+ export { toAgentChange };
@@ -0,0 +1,53 @@
1
+ import { WordDiffSegment } from "@stll/folio-core/ai-edits";
2
+
3
+ //#region src/compare.d.ts
4
+ /** One word-level diff segment within a `modified` block. Mirrors {@link WordDiffSegment}. */
5
+ type FolioAgentVersionDiffSegment = WordDiffSegment;
6
+ /** One block-level change between two document versions, in revised-side document order. */
7
+ type FolioAgentBlockDiff = {
8
+ type: "added";
9
+ blockId: string;
10
+ kind: string;
11
+ text: string;
12
+ } | {
13
+ type: "deleted";
14
+ blockId: string;
15
+ kind: string;
16
+ text: string;
17
+ } | {
18
+ type: "modified";
19
+ blockId: string;
20
+ kind: string;
21
+ segments: FolioAgentVersionDiffSegment[];
22
+ };
23
+ /** Result of {@link compareDocxVersions}. */
24
+ type FolioAgentVersionDiff = {
25
+ /** Every added, deleted, or modified block, in revised-side document order (deletions slotted where they sat). */changes: FolioAgentBlockDiff[]; /** Counts across every paired/unpaired block, including the unchanged blocks `changes` omits. */
26
+ summaryCounts: {
27
+ added: number;
28
+ deleted: number;
29
+ modified: number;
30
+ unchanged: number;
31
+ };
32
+ };
33
+ /** True when an `unpairedBaseCount * unpairedRevisedCount` LCS table would exceed {@link MAX_LCS_CELLS}. */
34
+ declare const exceedsLcsBudget: (unpairedBaseCount: number, unpairedRevisedCount: number) => boolean;
35
+ /**
36
+ * Compare two `.docx` buffers and return a structured, block-level diff.
37
+ * See the module doc comment for the as-accepted comparison semantics and
38
+ * the three-pass alignment algorithm.
39
+ */
40
+ declare const compareDocxVersions: (base: ArrayBuffer, revised: ArrayBuffer) => Promise<FolioAgentVersionDiff>;
41
+ /**
42
+ * Render a {@link FolioAgentVersionDiff} as compact, deterministic text for a
43
+ * model prompt: a header line with the summary counts, then one line per
44
+ * change — `~ [blockId] modified: …`, `+ [blockId] added: …`,
45
+ * `- [blockId] deleted: …`. A modified block's segments render inline using
46
+ * `git diff --word-diff` porcelain conventions (`[-removed-]`, `{+added+}`,
47
+ * plain text for unchanged runs, truncated to ~30 characters on each side
48
+ * when long). The format is stable across calls — do not change it without
49
+ * checking `compare.test.ts`.
50
+ */
51
+ declare const formatVersionDiffForLLM: (diff: FolioAgentVersionDiff) => string;
52
+ //#endregion
53
+ export { FolioAgentBlockDiff, FolioAgentVersionDiff, FolioAgentVersionDiffSegment, compareDocxVersions, exceedsLcsBudget, formatVersionDiffForLLM };