@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 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
- its text and position (`blockIdSource: "synthetic"`): stable across reads of
239
- the same bytes, but valid only for the `fileVersion` it was read at.
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
- Tools: `read_document`, `get_document_outline`, `read_section`,
284
- `list_stories`, `read_story`, `find_text`, `read_comments`, `read_changes`,
285
- `suggest_changes`, `add_comment`, `reply_comment`, `resolve_comment`,
286
- `resolve_changes`, and `compare_documents`. Each takes its folio-agents
287
- arguments plus a file envelope:
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. Results are the `{ ok, data | error }`
312
- envelope as JSON text, with `isError` set on failures.
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 };
@@ -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<FolioDocxReviewer, FolioCliError>>;
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) => await Result.tryPromise({
102
- try: () => FolioDocxReviewer.fromBuffer(file.bytes.slice().buffer, { ...author !== void 0 && { author } }),
103
- catch: (error) => cliError({
104
- code: FOLIO_CLI_ERROR_CODES.invalidDocument,
105
- message: `${file.path} is not a readable .docx package: ${describeError(error)}`
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();
@@ -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: FolioDocxReviewer) => BlockIdSources;
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 };
@@ -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 }) => idStability === "positional" ? "synthetic" : "package";
16
- const blockIdSources = (reviewer) => new Map(reviewer.snapshot().blocks.map((block) => [block.id, idSourceOf(block)]));
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
- /** Label each block of a read with where its id comes from. */
20
- const labelBlocks = (blocks, sources) => blocks.map((block) => {
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, reviewer, fileVersion, args, bounds, pageByteBudget }) => {
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(reviewer),
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(reviewer))
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
- reviewer: reviewer.value,
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 };
@@ -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";