@stll/folio-cli 0.3.4 → 0.4.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 +37 -9
- package/dist/batch-operations.d.ts +29 -0
- package/dist/batch-operations.js +226 -0
- package/dist/document.d.ts +16 -2
- package/dist/document.js +32 -8
- package/dist/execute-read.d.ts +14 -10
- package/dist/execute-read.js +27 -15
- package/dist/execute-write.d.ts +1 -1
- package/dist/execute-write.js +36 -16
- package/dist/mcp-kit/envelope.d.ts +27 -0
- package/dist/mcp-kit/envelope.js +80 -0
- package/dist/mcp-kit/index.d.ts +6 -0
- package/dist/mcp-kit/index.js +5 -0
- package/dist/mcp-kit/input.d.ts +23 -0
- package/dist/mcp-kit/input.js +76 -0
- package/dist/mcp-kit/schema.d.ts +20 -0
- package/dist/mcp-kit/schema.js +97 -0
- package/dist/mcp-kit/surface.d.ts +19 -0
- package/dist/mcp-kit/surface.js +312 -0
- package/dist/mcp-kit/types.d.ts +98 -0
- package/dist/mcp-kit/types.js +1 -0
- package/dist/mcp.d.ts +8 -2
- package/dist/mcp.js +486 -72
- package/dist/package.js +1 -1
- package/dist/registry.js +4 -2
- package/dist/save.d.ts +1 -1
- package/package.json +3 -2
package/README.md
CHANGED
|
@@ -69,6 +69,14 @@ bare array. The operation types and fields are those of `suggest_changes` in
|
|
|
69
69
|
targets come from a read of the same `fileVersion`. Edits are tracked changes;
|
|
70
70
|
`--direct` edits the text instead and must be asked for.
|
|
71
71
|
|
|
72
|
+
Two more operations are expanded against the file before the batch applies:
|
|
73
|
+
`{ "type": "replaceAll", "find", "replace", "matchCase"?, "wholeWord"? }`
|
|
74
|
+
replaces every match in the body, tables included, keeping each run's
|
|
75
|
+
formatting (refused when nothing matches); and
|
|
76
|
+
`{ "type": "addComment", "comment", "blockId"?, "quote"? }` comments on a
|
|
77
|
+
block, or with only `quote` on the one block containing that text
|
|
78
|
+
(`ambiguous_target` when several do).
|
|
79
|
+
|
|
72
80
|
Every change commits, so there is no separate save:
|
|
73
81
|
|
|
74
82
|
- **Destination.** Exactly one of `--in-place` or `-o <path>`, and only a
|
|
@@ -235,8 +243,11 @@ unless the file still has that version.
|
|
|
235
243
|
|
|
236
244
|
A block id is the paragraph's own `w14:paraId` when it has one
|
|
237
245
|
(`blockIdSource: "package"`). A paragraph without one gets an id derived from
|
|
238
|
-
|
|
239
|
-
|
|
246
|
+
the file's bytes (`blockIdSource: "synthetic"`): stable across reads of the
|
|
247
|
+
same bytes, and written into the file as its `w14:paraId` by the first change,
|
|
248
|
+
so a paragraph keeps its id across edits. A signed package is never rewritten
|
|
249
|
+
for this; its id-less paragraphs keep ids derived from text and position,
|
|
250
|
+
valid only for the `fileVersion` they were read at.
|
|
240
251
|
|
|
241
252
|
`read` pages with `--max-blocks`; a page that stops early carries
|
|
242
253
|
`nextCursor`, which `--cursor` continues. A cursor is bound to the version it
|
|
@@ -280,11 +291,17 @@ args = ["-y", "@stll/folio-cli", "mcp", "--root", "/path/to/contracts"]
|
|
|
280
291
|
env = { FOLIO_AUTHOR = "Jane Doe" }
|
|
281
292
|
```
|
|
282
293
|
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
`
|
|
286
|
-
`
|
|
287
|
-
|
|
294
|
+
A model is sent the tool list on every turn, so the server lists only the
|
|
295
|
+
frequent tools, with compact schemas: `read_document`, `find_text`,
|
|
296
|
+
`read_comments`, `read_changes`, `suggest_changes`, and `add_comment`. The
|
|
297
|
+
rest (`get_document_outline`, `read_section`, `list_stories`, `read_story`,
|
|
298
|
+
`reply_comment`, `resolve_comment`, `resolve_changes`, and
|
|
299
|
+
`compare_documents`) are reached through `list_capabilities`,
|
|
300
|
+
`describe_capability` (a tool's parameter outline, short guidance and an
|
|
301
|
+
example, listed tools included; `detail: "full"` for its full input schema),
|
|
302
|
+
and `invoke_capability` (`{ capability, input, validate_only }`).
|
|
303
|
+
Every tool is also callable by name. Each takes its folio-agents arguments
|
|
304
|
+
plus a file envelope:
|
|
288
305
|
|
|
289
306
|
| Field | Meaning |
|
|
290
307
|
| ---------------------------- | ----------------------------------------------------------------------------------------------------------------------------- |
|
|
@@ -308,8 +325,19 @@ annotated `destructiveHint: true`; there is no way to skip the version check.
|
|
|
308
325
|
One call returns at most 200 `read_document` blocks by default (up to
|
|
309
326
|
1,000 with `maxBlocks`), 100 `find_text` matches, and 256 KiB; a longer read
|
|
310
327
|
pages with `nextCursor`, and a result that cannot be paged is refused with
|
|
311
|
-
`too_large` and a hint to narrow it.
|
|
312
|
-
|
|
328
|
+
`too_large` and a hint to narrow it.
|
|
329
|
+
|
|
330
|
+
Results carry what the next call needs. `read_document` returns
|
|
331
|
+
`[id] text` lines, a table row as `| [id] cell | [id] cell |`
|
|
332
|
+
(`formatting: true` for each block's fields), a write returns the new
|
|
333
|
+
`fileVersion` plus what it produced (`applied`, `replaced`, `commentIds`,
|
|
334
|
+
...), and block ids stay valid across writes, so a successful write needs no
|
|
335
|
+
verification read. A failure is
|
|
336
|
+
`{ "error": { code, message, hint, retryable } }` with `isError` set.
|
|
337
|
+
Arguments are read leniently (`"true"`, `"20"`, an enum value in another
|
|
338
|
+
case) with an `Input read:` note; an unknown or ambiguous argument is refused
|
|
339
|
+
with `validation_error`, and `overwrite` and `allowRepack` count only as JSON
|
|
340
|
+
`true`.
|
|
313
341
|
|
|
314
342
|
Resources: `folio://about` (these rules and the roots) and
|
|
315
343
|
`folio://schema/operations` (the JSON Schema of an operation batch).
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import { FolioCliError } from "./errors.js";
|
|
2
|
+
import { JsonObjectSchema } from "./registry.js";
|
|
3
|
+
import { Result } from "better-result";
|
|
4
|
+
import { FolioAgentBridge } from "@stll/folio-agents/bridge";
|
|
5
|
+
import { FolioAgentToolOptions } from "@stll/folio-agents/suggest-changes-options";
|
|
6
|
+
//#region src/batch-operations.d.ts
|
|
7
|
+
declare const BATCH_OPERATION_TYPES: readonly ["replaceAll", "addComment"];
|
|
8
|
+
/**
|
|
9
|
+
* `suggest_changes`' arguments with the batch operations added to the
|
|
10
|
+
* operation schema: their types in the `type` enum and their extra fields.
|
|
11
|
+
*/
|
|
12
|
+
declare const withBatchOperations: (schema: JsonObjectSchema) => JsonObjectSchema;
|
|
13
|
+
type ExpandedBatch = {
|
|
14
|
+
operations: unknown[];
|
|
15
|
+
/** Options the expanded batch needs from `suggest_changes`. */
|
|
16
|
+
options: FolioAgentToolOptions;
|
|
17
|
+
/** How many replacements each `replaceAll`, in batch order, produced. */
|
|
18
|
+
replaced: {
|
|
19
|
+
find: string;
|
|
20
|
+
count: number;
|
|
21
|
+
}[];
|
|
22
|
+
};
|
|
23
|
+
/**
|
|
24
|
+
* Expand the batch operations of a `suggest_changes` call against the
|
|
25
|
+
* document the bridge holds. Contract operations pass through unchanged.
|
|
26
|
+
*/
|
|
27
|
+
declare const expandBatchOperations: (operations: unknown, bridge: FolioAgentBridge) => Result<ExpandedBatch, FolioCliError>;
|
|
28
|
+
//#endregion
|
|
29
|
+
export { BATCH_OPERATION_TYPES, ExpandedBatch, expandBatchOperations, withBatchOperations };
|
|
@@ -0,0 +1,226 @@
|
|
|
1
|
+
import { FOLIO_CLI_ERROR_CODES, cliError } from "./errors.js";
|
|
2
|
+
import { Result } from "better-result";
|
|
3
|
+
import { executeFolioToolCallUntyped } from "@stll/folio-agents/execute";
|
|
4
|
+
import { DEFAULT_MAX_OPERATIONS_PER_CALL, MAX_OPERATIONS_PER_CALL_LIMIT } from "@stll/folio-agents/suggest-changes-options";
|
|
5
|
+
import { SUGGEST_CHANGES_OPERATION_TYPES } from "@stll/folio-agents/tools";
|
|
6
|
+
import { FOLIO_AGENT_TOOL_NAMES } from "@stll/folio-agents/types";
|
|
7
|
+
//#region src/batch-operations.ts
|
|
8
|
+
/**
|
|
9
|
+
* Batch operations the file surface adds to `suggest_changes`, expanded into
|
|
10
|
+
* contract operations against the document they are about to change:
|
|
11
|
+
*
|
|
12
|
+
* - `replaceAll {find, replace, matchCase?, wholeWord?}` becomes one
|
|
13
|
+
* `replaceRange` per `find_text` match in the body (tables included), so a
|
|
14
|
+
* rename needs no search call and keeps each run's formatting.
|
|
15
|
+
* - `addComment {comment, blockId?, quote?}` becomes `commentOnBlock`; with
|
|
16
|
+
* only a quote, it anchors to the one block containing that text.
|
|
17
|
+
*
|
|
18
|
+
* Matching is `find_text`'s own; nothing here searches text itself.
|
|
19
|
+
*/
|
|
20
|
+
const BATCH_OPERATION_TYPES = ["replaceAll", "addComment"];
|
|
21
|
+
/** Fields each batch operation accepts, besides `type`. */
|
|
22
|
+
const BATCH_OPERATION_KEYS = {
|
|
23
|
+
replaceAll: /* @__PURE__ */ new Set([
|
|
24
|
+
"id",
|
|
25
|
+
"find",
|
|
26
|
+
"replace",
|
|
27
|
+
"matchCase",
|
|
28
|
+
"wholeWord",
|
|
29
|
+
"severity",
|
|
30
|
+
"area"
|
|
31
|
+
]),
|
|
32
|
+
addComment: /* @__PURE__ */ new Set([
|
|
33
|
+
"id",
|
|
34
|
+
"blockId",
|
|
35
|
+
"quote",
|
|
36
|
+
"comment",
|
|
37
|
+
"severity",
|
|
38
|
+
"area"
|
|
39
|
+
])
|
|
40
|
+
};
|
|
41
|
+
const BATCH_PROPERTY_SCHEMAS = {
|
|
42
|
+
matchCase: {
|
|
43
|
+
type: "boolean",
|
|
44
|
+
description: "For `replaceAll`: match case exactly."
|
|
45
|
+
},
|
|
46
|
+
wholeWord: {
|
|
47
|
+
type: "boolean",
|
|
48
|
+
description: "For `replaceAll`: match whole words only."
|
|
49
|
+
}
|
|
50
|
+
};
|
|
51
|
+
const isRecord = (value) => typeof value === "object" && value !== null && !Array.isArray(value);
|
|
52
|
+
/**
|
|
53
|
+
* `suggest_changes`' arguments with the batch operations added to the
|
|
54
|
+
* operation schema: their types in the `type` enum and their extra fields.
|
|
55
|
+
*/
|
|
56
|
+
const withBatchOperations = (schema) => {
|
|
57
|
+
const operations = schema.properties["operations"];
|
|
58
|
+
if (!isRecord(operations) || !isRecord(operations["items"])) return schema;
|
|
59
|
+
const items = operations["items"];
|
|
60
|
+
const properties = isRecord(items["properties"]) ? items["properties"] : {};
|
|
61
|
+
const type = isRecord(properties["type"]) ? properties["type"] : {};
|
|
62
|
+
const types = Array.isArray(type["enum"]) ? type["enum"] : [];
|
|
63
|
+
return {
|
|
64
|
+
...schema,
|
|
65
|
+
properties: {
|
|
66
|
+
...schema.properties,
|
|
67
|
+
operations: {
|
|
68
|
+
...operations,
|
|
69
|
+
items: {
|
|
70
|
+
...items,
|
|
71
|
+
properties: {
|
|
72
|
+
...properties,
|
|
73
|
+
type: {
|
|
74
|
+
...type,
|
|
75
|
+
enum: [...types, ...BATCH_OPERATION_TYPES],
|
|
76
|
+
description: `${typeof type["description"] === "string" ? type["description"] : ""} replaceAll (replace every match of \`find\` in the body and tables); addComment (comment on \`blockId\`, or on the one block containing \`quote\`).`
|
|
77
|
+
},
|
|
78
|
+
...BATCH_PROPERTY_SCHEMAS
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
};
|
|
84
|
+
};
|
|
85
|
+
const invalidInput = (message, hint) => cliError({
|
|
86
|
+
code: FOLIO_CLI_ERROR_CODES.invalidInput,
|
|
87
|
+
message,
|
|
88
|
+
hint
|
|
89
|
+
});
|
|
90
|
+
const findMatches = (bridge, query, matchCase, wholeWord) => {
|
|
91
|
+
const found = executeFolioToolCallUntyped(FOLIO_AGENT_TOOL_NAMES.findText, {
|
|
92
|
+
query,
|
|
93
|
+
matchCase,
|
|
94
|
+
wholeWord
|
|
95
|
+
}, bridge);
|
|
96
|
+
if (!found.ok) return Result.err(invalidInput(found.error));
|
|
97
|
+
const result = isRecord(found.result) ? found.result : {};
|
|
98
|
+
const matches = (Array.isArray(result["matches"]) ? result["matches"] : []).flatMap((match) => isRecord(match) && typeof match["blockId"] === "string" && isRecord(match["range"]) ? [{
|
|
99
|
+
blockId: match["blockId"],
|
|
100
|
+
range: match["range"]
|
|
101
|
+
}] : []);
|
|
102
|
+
return Result.ok({
|
|
103
|
+
matches,
|
|
104
|
+
truncated: result["truncated"] === true,
|
|
105
|
+
total: typeof result["totalMatches"] === "number" ? result["totalMatches"] : matches.length
|
|
106
|
+
});
|
|
107
|
+
};
|
|
108
|
+
const checkKeys = (operation, type, index) => {
|
|
109
|
+
const allowed = BATCH_OPERATION_KEYS[type] ?? /* @__PURE__ */ new Set();
|
|
110
|
+
const extra = Object.keys(operation).filter((key) => key !== "type" && !allowed.has(key));
|
|
111
|
+
return extra.length === 0 ? null : invalidInput(`operations[${index}] (${type}) does not take ${extra.join(", ")}.`, `${type} takes ${[...allowed].join(", ")}.`);
|
|
112
|
+
};
|
|
113
|
+
const metaOf = (operation) => ({
|
|
114
|
+
...operation["severity"] !== void 0 && { severity: operation["severity"] },
|
|
115
|
+
...operation["area"] !== void 0 && { area: operation["area"] }
|
|
116
|
+
});
|
|
117
|
+
const expandReplaceAll = (operation, index, bridge) => {
|
|
118
|
+
const { find, replace, id } = operation;
|
|
119
|
+
if (typeof find !== "string" || find === "") return Result.err(invalidInput(`operations[${index}] (replaceAll) needs a non-empty find.`));
|
|
120
|
+
if (typeof replace !== "string") return Result.err(invalidInput(`operations[${index}] (replaceAll) needs replace.`));
|
|
121
|
+
const found = findMatches(bridge, find, operation["matchCase"] === true, operation["wholeWord"] === true);
|
|
122
|
+
if (found.isErr()) return found;
|
|
123
|
+
const { matches, truncated, total } = found.value;
|
|
124
|
+
if (matches.length === 0) return Result.err(cliError({
|
|
125
|
+
code: FOLIO_CLI_ERROR_CODES.notFound,
|
|
126
|
+
message: `operations[${index}] (replaceAll) found no ${JSON.stringify(find)}; nothing was written.`,
|
|
127
|
+
hint: "Check the spelling, matchCase and wholeWord."
|
|
128
|
+
}));
|
|
129
|
+
if (truncated) return Result.err(invalidInput(`operations[${index}] (replaceAll) matches ${total} times, more than one batch can change.`, "Narrow find, or split the rename across calls with replaceInBlock."));
|
|
130
|
+
const meta = metaOf(operation);
|
|
131
|
+
return Result.ok(matches.map((match, occurrence) => {
|
|
132
|
+
const replacement = {
|
|
133
|
+
type: "replaceRange",
|
|
134
|
+
range: match.range,
|
|
135
|
+
replace
|
|
136
|
+
};
|
|
137
|
+
if (typeof id === "string") replacement["id"] = `${id}.${occurrence + 1}`;
|
|
138
|
+
return Object.assign(replacement, meta);
|
|
139
|
+
}));
|
|
140
|
+
};
|
|
141
|
+
const expandAddComment = (operation, index, bridge) => {
|
|
142
|
+
const { blockId, quote, comment, id } = operation;
|
|
143
|
+
if (typeof comment !== "string" || comment === "") return Result.err(invalidInput(`operations[${index}] (addComment) needs comment text.`));
|
|
144
|
+
if (quote !== void 0 && (typeof quote !== "string" || quote === "")) return Result.err(invalidInput(`operations[${index}] (addComment) quote must be text.`));
|
|
145
|
+
const anchored = (target) => Result.ok({
|
|
146
|
+
type: "commentOnBlock",
|
|
147
|
+
blockId: target,
|
|
148
|
+
comment,
|
|
149
|
+
...quote !== void 0 && { quote },
|
|
150
|
+
...typeof id === "string" && { id },
|
|
151
|
+
...metaOf(operation)
|
|
152
|
+
});
|
|
153
|
+
if (typeof blockId === "string" && blockId !== "") return anchored(blockId);
|
|
154
|
+
if (quote === void 0) return Result.err(invalidInput(`operations[${index}] (addComment) needs blockId or quote.`));
|
|
155
|
+
const found = findMatches(bridge, quote, true, false);
|
|
156
|
+
if (found.isErr()) return found;
|
|
157
|
+
const blocks = [...new Set(found.value.matches.map((match) => match.blockId))];
|
|
158
|
+
if (blocks.length === 0) return Result.err(cliError({
|
|
159
|
+
code: FOLIO_CLI_ERROR_CODES.notFound,
|
|
160
|
+
message: `operations[${index}] (addComment) quote ${JSON.stringify(quote)} is not in the document; nothing was written.`,
|
|
161
|
+
hint: "Quote exact text from read_document, or pass blockId."
|
|
162
|
+
}));
|
|
163
|
+
const [only] = blocks;
|
|
164
|
+
if (blocks.length > 1 || only === void 0) return Result.err(cliError({
|
|
165
|
+
code: FOLIO_CLI_ERROR_CODES.ambiguousTarget,
|
|
166
|
+
message: `operations[${index}] (addComment) quote ${JSON.stringify(quote)} is in ${blocks.length} blocks; nothing was written.`,
|
|
167
|
+
hint: "Pass blockId, or quote longer text.",
|
|
168
|
+
details: { blockIds: blocks.slice(0, 20) }
|
|
169
|
+
}));
|
|
170
|
+
return anchored(only);
|
|
171
|
+
};
|
|
172
|
+
/**
|
|
173
|
+
* Expand the batch operations of a `suggest_changes` call against the
|
|
174
|
+
* document the bridge holds. Contract operations pass through unchanged.
|
|
175
|
+
*/
|
|
176
|
+
const expandBatchOperations = (operations, bridge) => {
|
|
177
|
+
if (!Array.isArray(operations)) return Result.ok({
|
|
178
|
+
operations: [],
|
|
179
|
+
options: {},
|
|
180
|
+
replaced: []
|
|
181
|
+
});
|
|
182
|
+
const expanded = [];
|
|
183
|
+
const replaced = [];
|
|
184
|
+
let comments = false;
|
|
185
|
+
let batchOperations = false;
|
|
186
|
+
for (const [index, operation] of operations.entries()) {
|
|
187
|
+
const type = isRecord(operation) ? operation["type"] : void 0;
|
|
188
|
+
if (!isRecord(operation) || type !== "replaceAll" && type !== "addComment") {
|
|
189
|
+
expanded.push(operation);
|
|
190
|
+
continue;
|
|
191
|
+
}
|
|
192
|
+
batchOperations = true;
|
|
193
|
+
const keys = checkKeys(operation, type, index);
|
|
194
|
+
if (keys !== null) return Result.err(keys);
|
|
195
|
+
if (type === "replaceAll") {
|
|
196
|
+
const replacements = expandReplaceAll(operation, index, bridge);
|
|
197
|
+
if (replacements.isErr()) return replacements;
|
|
198
|
+
expanded.push(...replacements.value);
|
|
199
|
+
replaced.push({
|
|
200
|
+
find: String(operation["find"]),
|
|
201
|
+
count: replacements.value.length
|
|
202
|
+
});
|
|
203
|
+
} else {
|
|
204
|
+
const comment = expandAddComment(operation, index, bridge);
|
|
205
|
+
if (comment.isErr()) return comment;
|
|
206
|
+
expanded.push(comment.value);
|
|
207
|
+
comments = true;
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
if (!batchOperations) return Result.ok({
|
|
211
|
+
operations,
|
|
212
|
+
options: {},
|
|
213
|
+
replaced
|
|
214
|
+
});
|
|
215
|
+
if (expanded.length > MAX_OPERATIONS_PER_CALL_LIMIT) return Result.err(invalidInput(`The batch expands to ${expanded.length} edits, over the ${MAX_OPERATIONS_PER_CALL_LIMIT}-edit limit; nothing was written.`, "Split it across calls."));
|
|
216
|
+
return Result.ok({
|
|
217
|
+
operations: expanded,
|
|
218
|
+
options: { suggestChanges: {
|
|
219
|
+
maxOperations: Math.max(DEFAULT_MAX_OPERATIONS_PER_CALL, expanded.length),
|
|
220
|
+
...comments && { operationTypes: [...SUGGEST_CHANGES_OPERATION_TYPES, "commentOnBlock"] }
|
|
221
|
+
} },
|
|
222
|
+
replaced
|
|
223
|
+
});
|
|
224
|
+
};
|
|
225
|
+
//#endregion
|
|
226
|
+
export { BATCH_OPERATION_TYPES, expandBatchOperations, withBatchOperations };
|
package/dist/document.d.ts
CHANGED
|
@@ -23,12 +23,26 @@ type LoadedFile = {
|
|
|
23
23
|
* a main document part as an empty document.
|
|
24
24
|
*/
|
|
25
25
|
declare const readDocumentFile: (inputPath: string) => Promise<Result<LoadedFile, FolioCliError>>;
|
|
26
|
+
/**
|
|
27
|
+
* Where a block id comes from. `package` ids are the paragraph's own
|
|
28
|
+
* `w14:paraId` in the file; `synthetic` ids were minted for a paragraph the
|
|
29
|
+
* file gives none, and are valid for the fileVersion they were read at. The
|
|
30
|
+
* first change written to such a file stores them, so from then on they are
|
|
31
|
+
* the package's own.
|
|
32
|
+
*/
|
|
33
|
+
type FolioBlockIdSource = "package" | "synthetic";
|
|
34
|
+
/** A reviewer over one file and which of its block ids the file itself carries. */
|
|
35
|
+
type OpenedDocument = {
|
|
36
|
+
reviewer: FolioDocxReviewer;
|
|
37
|
+
/** Ids minted for paragraphs without one; `null` when none could be minted up front. */
|
|
38
|
+
mintedIds: ReadonlySet<string> | null;
|
|
39
|
+
};
|
|
26
40
|
/**
|
|
27
41
|
* Parse a loaded file into a headless reviewer. `author` is required for a
|
|
28
42
|
* reviewer that will author changes; reads pass none and never write.
|
|
29
43
|
*/
|
|
30
|
-
declare const openReviewer: (file: LoadedFile, author?: string) => Promise<Result<
|
|
44
|
+
declare const openReviewer: (file: LoadedFile, author?: string) => Promise<Result<OpenedDocument, FolioCliError>>;
|
|
31
45
|
/** Refuse when the caller's expected version is not the file's current one. */
|
|
32
46
|
declare const checkExpectedVersion: (file: LoadedFile, expected: string | undefined) => Result<void, FolioCliError>;
|
|
33
47
|
//#endregion
|
|
34
|
-
export { type FileIdentity, LoadedFile, MAX_DOCUMENT_BYTES, NO_FOLLOW, checkExpectedVersion, errnoCode, fileVersionOf, isFileVersion, openReviewer, readDocumentFile, sameFile };
|
|
48
|
+
export { type FileIdentity, FolioBlockIdSource, LoadedFile, MAX_DOCUMENT_BYTES, NO_FOLLOW, OpenedDocument, checkExpectedVersion, errnoCode, fileVersionOf, isFileVersion, openReviewer, readDocumentFile, sameFile };
|
package/dist/document.js
CHANGED
|
@@ -5,7 +5,7 @@ import { Result } from "better-result";
|
|
|
5
5
|
import { constants } from "node:fs";
|
|
6
6
|
import { open, realpath, stat } from "node:fs/promises";
|
|
7
7
|
import path from "node:path";
|
|
8
|
-
import { FolioDocxReviewer } from "@stll/folio-core/server";
|
|
8
|
+
import { FolioDocxReviewer, ensureParaIds } from "@stll/folio-core/server";
|
|
9
9
|
//#region src/document.ts
|
|
10
10
|
/** Largest `.docx` the CLI opens; larger inputs are refused before reading. */
|
|
11
11
|
const MAX_DOCUMENT_BYTES = 64 * 1024 * 1024;
|
|
@@ -95,16 +95,40 @@ const readDocumentFile = async (inputPath) => {
|
|
|
95
95
|
});
|
|
96
96
|
};
|
|
97
97
|
/**
|
|
98
|
+
* Give every paragraph a `w14:paraId`, deterministically from the file's
|
|
99
|
+
* bytes, so the ids a read reports are the ids a later change writes into the
|
|
100
|
+
* file: a paragraph edited by one call is found by the same id in the next.
|
|
101
|
+
* A package the pass cannot or may not rewrite (a signed one) is opened as it
|
|
102
|
+
* is, with position-derived ids.
|
|
103
|
+
*/
|
|
104
|
+
const withParagraphIds = async (bytes) => {
|
|
105
|
+
const normalized = await Result.tryPromise(() => ensureParaIds(bytes));
|
|
106
|
+
return normalized.isOk() ? {
|
|
107
|
+
bytes: normalized.value.docx,
|
|
108
|
+
mintedIds: new Set(normalized.value.mintedParaIds)
|
|
109
|
+
} : {
|
|
110
|
+
bytes,
|
|
111
|
+
mintedIds: null
|
|
112
|
+
};
|
|
113
|
+
};
|
|
114
|
+
/**
|
|
98
115
|
* Parse a loaded file into a headless reviewer. `author` is required for a
|
|
99
116
|
* reviewer that will author changes; reads pass none and never write.
|
|
100
117
|
*/
|
|
101
|
-
const openReviewer = async (file, author) =>
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
})
|
|
118
|
+
const openReviewer = async (file, author) => {
|
|
119
|
+
const { bytes, mintedIds } = await withParagraphIds(file.bytes);
|
|
120
|
+
const reviewer = await Result.tryPromise({
|
|
121
|
+
try: () => FolioDocxReviewer.fromBuffer(bytes.slice().buffer, { ...author !== void 0 && { author } }),
|
|
122
|
+
catch: (error) => cliError({
|
|
123
|
+
code: FOLIO_CLI_ERROR_CODES.invalidDocument,
|
|
124
|
+
message: `${file.path} is not a readable .docx package: ${describeError(error)}`
|
|
125
|
+
})
|
|
126
|
+
});
|
|
127
|
+
return reviewer.isErr() ? Result.err(reviewer.error) : Result.ok({
|
|
128
|
+
reviewer: reviewer.value,
|
|
129
|
+
mintedIds
|
|
130
|
+
});
|
|
131
|
+
};
|
|
108
132
|
/** Refuse when the caller's expected version is not the file's current one. */
|
|
109
133
|
const checkExpectedVersion = (file, expected) => {
|
|
110
134
|
if (expected === void 0 || expected === file.fileVersion) return Result.ok();
|
package/dist/execute-read.d.ts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { FolioCliError } from "./errors.js";
|
|
2
2
|
import { FolioFileToolSpec } from "./registry.js";
|
|
3
|
+
import { FolioBlockIdSource, OpenedDocument } from "./document.js";
|
|
3
4
|
import { Result } from "better-result";
|
|
4
|
-
import { FolioDocxReviewer } from "@stll/folio-core/server";
|
|
5
5
|
//#region src/execute-read.d.ts
|
|
6
6
|
/** One call against one file: the envelope plus the tool's own arguments. */
|
|
7
7
|
type FileToolCall = {
|
|
@@ -26,14 +26,17 @@ type FileReadData = {
|
|
|
26
26
|
fileVersion: string;
|
|
27
27
|
result: unknown;
|
|
28
28
|
};
|
|
29
|
-
/**
|
|
30
|
-
* Where a block id comes from. `package` ids are the paragraph's own
|
|
31
|
-
* `w14:paraId`; `synthetic` ids are derived from the paragraph's text and
|
|
32
|
-
* position, valid only for the fileVersion they were read at.
|
|
33
|
-
*/
|
|
34
|
-
type FolioBlockIdSource = "package" | "synthetic";
|
|
35
29
|
type BlockIdSources = ReadonlyMap<string, FolioBlockIdSource>;
|
|
36
|
-
declare const blockIdSources: (reviewer:
|
|
30
|
+
declare const blockIdSources: ({ reviewer, mintedIds }: OpenedDocument) => BlockIdSources;
|
|
31
|
+
/** Where a block inside a table sits: zero-based table, row, and cell indexes. */
|
|
32
|
+
type FolioTableCell = {
|
|
33
|
+
table: number;
|
|
34
|
+
row: number;
|
|
35
|
+
cell: number;
|
|
36
|
+
};
|
|
37
|
+
type TableCells = ReadonlyMap<string, FolioTableCell>;
|
|
38
|
+
/** The table cell of every block that sits in one. */
|
|
39
|
+
declare const tableCells: ({ reviewer }: OpenedDocument) => TableCells;
|
|
37
40
|
type ReadCursor = {
|
|
38
41
|
fileVersion: string;
|
|
39
42
|
afterBlockId: string;
|
|
@@ -48,6 +51,7 @@ type ReadDocumentPage = {
|
|
|
48
51
|
type PageReadDocumentOptions = {
|
|
49
52
|
blocks: readonly unknown[];
|
|
50
53
|
sources: BlockIdSources;
|
|
54
|
+
cells?: TableCells;
|
|
51
55
|
fileVersion: string;
|
|
52
56
|
args: Readonly<Record<string, unknown>>;
|
|
53
57
|
bounds: FolioReadBounds;
|
|
@@ -59,8 +63,8 @@ type PageReadDocumentOptions = {
|
|
|
59
63
|
* the last block of the previous page and the version it was read at, so a
|
|
60
64
|
* page from one version never continues on another.
|
|
61
65
|
*/
|
|
62
|
-
declare const pageReadDocument: ({ blocks, sources, fileVersion, args, bounds, pageByteBudget }: PageReadDocumentOptions) => Result<ReadDocumentPage, FolioCliError>;
|
|
66
|
+
declare const pageReadDocument: ({ blocks, sources, cells, fileVersion, args, bounds, pageByteBudget }: PageReadDocumentOptions) => Result<ReadDocumentPage, FolioCliError>;
|
|
63
67
|
/** Run one read tool, or a compare without a destination. Never writes. */
|
|
64
68
|
declare const executeReadTool: (tool: FolioFileToolSpec, call: FileToolCall, bounds: FolioReadBounds) => Promise<Result<FileReadData, FolioCliError>>;
|
|
65
69
|
//#endregion
|
|
66
|
-
export { CLI_READ_BOUNDS, FileReadData, FileToolCall, FolioBlockIdSource, FolioReadBounds, blockIdSources, encodeReadCursor, executeReadTool, pageReadDocument };
|
|
70
|
+
export { CLI_READ_BOUNDS, FileReadData, FileToolCall, type FolioBlockIdSource, FolioReadBounds, FolioTableCell, blockIdSources, encodeReadCursor, executeReadTool, pageReadDocument, tableCells };
|
package/dist/execute-read.js
CHANGED
|
@@ -2,27 +2,38 @@ import { checkExpectedVersion, openReviewer, readDocumentFile } from "./document
|
|
|
2
2
|
import { FOLIO_CLI_ERROR_CODES, cliError } from "./errors.js";
|
|
3
3
|
import { MAX_READ_BLOCKS } from "./registry.js";
|
|
4
4
|
import { Result, panic } from "better-result";
|
|
5
|
-
import { createReviewerBridge } from "@stll/folio-agents/bridges/reviewer";
|
|
6
|
-
import { compareDocxVersions, formatVersionDiffForLLM } from "@stll/folio-agents/compare";
|
|
7
5
|
import { executeFolioToolCallUntyped } from "@stll/folio-agents/execute";
|
|
8
6
|
import { FOLIO_AGENT_TOOL_NAMES } from "@stll/folio-agents/types";
|
|
7
|
+
import { createReviewerBridge } from "@stll/folio-agents/bridges/reviewer";
|
|
8
|
+
import { compareDocxVersions, formatVersionDiffForLLM } from "@stll/folio-agents/compare";
|
|
9
9
|
//#region src/execute-read.ts
|
|
10
10
|
const CLI_READ_BOUNDS = {
|
|
11
11
|
defaultMaxBlocks: null,
|
|
12
12
|
maxMatches: 200,
|
|
13
13
|
maxResponseBytes: null
|
|
14
14
|
};
|
|
15
|
-
const idSourceOf = ({ idStability }) =>
|
|
16
|
-
|
|
15
|
+
const idSourceOf = ({ id, idStability }, mintedIds) => {
|
|
16
|
+
if (mintedIds !== null) return mintedIds.has(id) ? "synthetic" : "package";
|
|
17
|
+
return idStability === "positional" ? "synthetic" : "package";
|
|
18
|
+
};
|
|
19
|
+
const blockIdSources = ({ reviewer, mintedIds }) => new Map(reviewer.snapshot().blocks.map((block) => [block.id, idSourceOf(block, mintedIds)]));
|
|
17
20
|
const isRecord = (value) => typeof value === "object" && value !== null && !Array.isArray(value);
|
|
18
21
|
const blockIdOf = (block) => isRecord(block) && typeof block["blockId"] === "string" ? block["blockId"] : void 0;
|
|
19
|
-
/**
|
|
20
|
-
const
|
|
22
|
+
/** The table cell of every block that sits in one. */
|
|
23
|
+
const tableCells = ({ reviewer }) => new Map(reviewer.snapshot().blocks.flatMap(({ id, table }) => table === void 0 ? [] : [[id, {
|
|
24
|
+
table: table.tableIndex,
|
|
25
|
+
row: table.rowIndex,
|
|
26
|
+
cell: table.cellIndex
|
|
27
|
+
}]]));
|
|
28
|
+
/** Label each block of a read with where its id comes from and, in a table, its cell. */
|
|
29
|
+
const labelBlocks = (blocks, sources, cells = /* @__PURE__ */ new Map()) => blocks.map((block) => {
|
|
21
30
|
const blockId = blockIdOf(block);
|
|
22
31
|
if (blockId === void 0 || !isRecord(block)) return block;
|
|
32
|
+
const cell = cells.get(blockId);
|
|
23
33
|
return {
|
|
24
34
|
...block,
|
|
25
|
-
blockIdSource: sources.get(blockId) ?? "synthetic"
|
|
35
|
+
blockIdSource: sources.get(blockId) ?? "synthetic",
|
|
36
|
+
...cell !== void 0 && { tableCell: cell }
|
|
26
37
|
};
|
|
27
38
|
});
|
|
28
39
|
const encodeReadCursor = ({ fileVersion, afterBlockId }) => Buffer.from(JSON.stringify({
|
|
@@ -51,7 +62,7 @@ const PAGE_ENVELOPE_RESERVE_BYTES = 512;
|
|
|
51
62
|
* the last block of the previous page and the version it was read at, so a
|
|
52
63
|
* page from one version never continues on another.
|
|
53
64
|
*/
|
|
54
|
-
const pageReadDocument = ({ blocks, sources, fileVersion, args, bounds, pageByteBudget }) => {
|
|
65
|
+
const pageReadDocument = ({ blocks, sources, cells, fileVersion, args, bounds, pageByteBudget }) => {
|
|
55
66
|
const { maxBlocks, cursor } = args;
|
|
56
67
|
if (maxBlocks !== void 0 && (typeof maxBlocks !== "number" || !Number.isInteger(maxBlocks) || maxBlocks < 1 || maxBlocks > 1e3)) return Result.err(invalidInput(`maxBlocks must be an integer from 1 to ${MAX_READ_BLOCKS}.`));
|
|
57
68
|
let start = 0;
|
|
@@ -73,7 +84,7 @@ const pageReadDocument = ({ blocks, sources, fileVersion, args, bounds, pageByte
|
|
|
73
84
|
}
|
|
74
85
|
const limit = typeof maxBlocks === "number" ? maxBlocks : bounds.defaultMaxBlocks;
|
|
75
86
|
const end = limit === null ? blocks.length : Math.min(blocks.length, start + limit);
|
|
76
|
-
const labelled = labelBlocks(blocks.slice(start, end), sources);
|
|
87
|
+
const labelled = labelBlocks(blocks.slice(start, end), sources, cells);
|
|
77
88
|
const page = [];
|
|
78
89
|
let bytes = 0;
|
|
79
90
|
for (const block of labelled) {
|
|
@@ -102,8 +113,8 @@ const unexpectedShape = (tool) => cliError({
|
|
|
102
113
|
code: FOLIO_CLI_ERROR_CODES.internal,
|
|
103
114
|
message: `${tool} returned an unexpected result shape.`
|
|
104
115
|
});
|
|
105
|
-
const runAgentRead = ({ tool,
|
|
106
|
-
const bridge = createReviewerBridge(reviewer);
|
|
116
|
+
const runAgentRead = ({ tool, opened, fileVersion, args, bounds, pageByteBudget }) => {
|
|
117
|
+
const bridge = createReviewerBridge(opened.reviewer);
|
|
107
118
|
const isReadDocument = tool.agentTool === FOLIO_AGENT_TOOL_NAMES.readDocument;
|
|
108
119
|
const agentArgs = isReadDocument ? {} : args;
|
|
109
120
|
const executed = executeFolioToolCallUntyped(tool.agentTool, agentArgs, bridge);
|
|
@@ -113,7 +124,8 @@ const runAgentRead = ({ tool, reviewer, fileVersion, args, bounds, pageByteBudge
|
|
|
113
124
|
if (!Array.isArray(result)) return Result.err(unexpectedShape(tool.name));
|
|
114
125
|
return pageReadDocument({
|
|
115
126
|
blocks: result,
|
|
116
|
-
sources: blockIdSources(
|
|
127
|
+
sources: blockIdSources(opened),
|
|
128
|
+
cells: tableCells(opened),
|
|
117
129
|
fileVersion,
|
|
118
130
|
args,
|
|
119
131
|
bounds,
|
|
@@ -124,7 +136,7 @@ const runAgentRead = ({ tool, reviewer, fileVersion, args, bounds, pageByteBudge
|
|
|
124
136
|
if (!isRecord(result) || !Array.isArray(result["blocks"])) return Result.err(unexpectedShape(tool.name));
|
|
125
137
|
return Result.ok({
|
|
126
138
|
...result,
|
|
127
|
-
blocks: labelBlocks(result["blocks"], blockIdSources(
|
|
139
|
+
blocks: labelBlocks(result["blocks"], blockIdSources(opened), tableCells(opened))
|
|
128
140
|
});
|
|
129
141
|
}
|
|
130
142
|
if (tool.agentTool === FOLIO_AGENT_TOOL_NAMES.findText) {
|
|
@@ -188,7 +200,7 @@ const readWithAgentTool = async (tool, call, bounds) => {
|
|
|
188
200
|
const { path, fileVersion } = file.value;
|
|
189
201
|
const result = runAgentRead({
|
|
190
202
|
tool,
|
|
191
|
-
|
|
203
|
+
opened: reviewer.value,
|
|
192
204
|
fileVersion,
|
|
193
205
|
args: call.args,
|
|
194
206
|
bounds,
|
|
@@ -217,4 +229,4 @@ const executeReadTool = async (tool, call, bounds) => {
|
|
|
217
229
|
}
|
|
218
230
|
};
|
|
219
231
|
//#endregion
|
|
220
|
-
export { CLI_READ_BOUNDS, blockIdSources, encodeReadCursor, executeReadTool, pageReadDocument };
|
|
232
|
+
export { CLI_READ_BOUNDS, blockIdSources, encodeReadCursor, executeReadTool, pageReadDocument, tableCells };
|
package/dist/execute-write.d.ts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { FolioCliError } from "./errors.js";
|
|
2
|
+
import { FolioFileToolSpec } from "./registry.js";
|
|
2
3
|
import { FileIdentity } from "./file-system.js";
|
|
3
4
|
import "./document.js";
|
|
4
|
-
import { FolioFileToolSpec } from "./registry.js";
|
|
5
5
|
import { FileToolCall } from "./execute-read.js";
|
|
6
6
|
import { WriteDestination } from "./transaction.js";
|
|
7
7
|
import { Result } from "better-result";
|