@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 +225 -0
- package/dist/bridge.d.ts +36 -0
- package/dist/bridge.js +0 -0
- package/dist/bridges/editor-ref.d.ts +90 -0
- package/dist/bridges/editor-ref.js +139 -0
- package/dist/bridges/reviewer.d.ts +19 -0
- package/dist/bridges/reviewer.js +48 -0
- package/dist/bridges/shared.d.ts +16 -0
- package/dist/bridges/shared.js +19 -0
- package/dist/compare.d.ts +53 -0
- package/dist/compare.js +285 -0
- package/dist/execute.d.ts +19 -0
- package/dist/execute.js +188 -0
- package/dist/index.d.ts +10 -0
- package/dist/index.js +9 -0
- package/dist/parse.d.ts +45 -0
- package/dist/parse.js +157 -0
- package/dist/providers.d.ts +24 -0
- package/dist/providers.js +18 -0
- package/dist/tools.d.ts +16 -0
- package/dist/tools.js +224 -0
- package/dist/types.d.ts +115 -0
- package/dist/types.js +23 -0
- package/package.json +61 -1
- package/skills/folio-agents/SKILL.md +145 -0
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
|
package/dist/bridge.d.ts
ADDED
|
@@ -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 };
|