@superdoc/sdk 2.6.0 → 2.7.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.
Files changed (48) hide show
  1. package/dist/agent/actions.cjs +1869 -160
  2. package/dist/agent/actions.d.ts +77 -10
  3. package/dist/agent/actions.js +1870 -161
  4. package/dist/agent/catalog.cjs +102 -9
  5. package/dist/agent/catalog.d.ts +243 -0
  6. package/dist/agent/catalog.js +99 -9
  7. package/dist/agent/doc-snapshot.cjs +200 -2
  8. package/dist/agent/doc-snapshot.d.ts +91 -0
  9. package/dist/agent/doc-snapshot.js +199 -2
  10. package/dist/agent/runtime.cjs +9 -1
  11. package/dist/agent/runtime.d.ts +8 -0
  12. package/dist/agent/runtime.js +9 -1
  13. package/dist/generated/client.cjs +754 -770
  14. package/dist/generated/client.d.ts +9 -9
  15. package/dist/generated/client.js +754 -770
  16. package/dist/generated/contract.cjs +16170 -272
  17. package/dist/generated/contract.d.ts +38 -0
  18. package/dist/generated/contract.js +17411 -1510
  19. package/dist/index.cjs +5 -4
  20. package/dist/index.d.ts +2 -2
  21. package/dist/index.js +5 -4
  22. package/dist/introspection.cjs +59 -0
  23. package/dist/introspection.d.ts +3 -0
  24. package/dist/introspection.js +53 -0
  25. package/dist/runtime/document-rpc.cjs +179 -40
  26. package/dist/runtime/document-rpc.d.ts +13 -4
  27. package/dist/runtime/document-rpc.js +175 -40
  28. package/dist/runtime/embedded-cli.cjs +5 -68
  29. package/dist/runtime/embedded-cli.js +5 -67
  30. package/dist/runtime/embedded-document-host.cjs +28 -0
  31. package/dist/runtime/embedded-document-host.d.ts +1 -0
  32. package/dist/runtime/embedded-document-host.js +23 -0
  33. package/dist/runtime/embedded-platform.cjs +102 -0
  34. package/dist/runtime/embedded-platform.d.ts +5 -0
  35. package/dist/runtime/embedded-platform.js +93 -0
  36. package/dist/runtime/host.cjs +70 -19
  37. package/dist/runtime/host.d.ts +2 -0
  38. package/dist/runtime/host.js +70 -20
  39. package/dist/runtime/process.cjs +30 -9
  40. package/dist/runtime/process.d.ts +9 -0
  41. package/dist/runtime/process.js +29 -9
  42. package/dist/runtime/transport-common.cjs +1 -0
  43. package/dist/runtime/transport-common.d.ts +28 -10
  44. package/dist/runtime/transport-common.js +1 -1
  45. package/package.json +6 -6
  46. package/tools/__pycache__/__init__.cpython-311.pyc +0 -0
  47. package/tools/__pycache__/intent_dispatch_generated.cpython-311.pyc +0 -0
  48. package/tools/tools-policy.json +1 -1
@@ -18,6 +18,62 @@ async function safeCall(thunk, fallback, onError) {
18
18
  return fallback;
19
19
  }
20
20
  }
21
+ /**
22
+ * Read a specific block's per-run formatting via query.match, matching STRICTLY
23
+ * by nodeId. When the text is ambiguous (multiple matches) and none matches the
24
+ * requested nodeId, returns [] rather than guessing a different block — so a
25
+ * duplicate-text sibling can never contribute the wrong runs. The single-match
26
+ * fallback is used only when there is exactly one candidate (unambiguous).
27
+ */
28
+ async function matchRunsForBlock(queryFn, text, nodeId) {
29
+ const res = asRecord(await queryFn({ select: { type: 'text', pattern: text } }));
30
+ const items = (Array.isArray(res?.items) ? res.items : []).map(asRecord);
31
+ const match = items.find((m) => asString(asRecord(m?.address)?.nodeId) === nodeId) ?? (items.length === 1 ? items[0] : null);
32
+ if (!match)
33
+ return [];
34
+ const blocksArr = Array.isArray(match.blocks) ? match.blocks.map(asRecord) : [];
35
+ const block = blocksArr.find((b) => asString(b?.blockId) === nodeId) ?? (blocksArr.length === 1 ? blocksArr[0] : null);
36
+ if (!block)
37
+ return [];
38
+ const rawRuns = Array.isArray(block.runs) ? block.runs : [];
39
+ return rawRuns.map(projectRun).filter((r) => r != null);
40
+ }
41
+ /**
42
+ * Project one raw query.match run into a compact SnapshotRun. Uses the
43
+ * EFFECTIVE (resolved) mark state and emits booleans only when true. Skips a
44
+ * default-black color so only meaningful colors surface.
45
+ */
46
+ function projectRun(raw) {
47
+ const rec = asRecord(raw);
48
+ if (!rec)
49
+ return null;
50
+ const range = asRecord(rec.range);
51
+ const styles = asRecord(rec.styles);
52
+ const eff = asRecord(styles?.effective);
53
+ const run = {
54
+ start: asNumber(range?.start),
55
+ end: asNumber(range?.end),
56
+ text: asString(rec.text),
57
+ };
58
+ if (eff?.bold === true)
59
+ run.bold = true;
60
+ if (eff?.italic === true)
61
+ run.italic = true;
62
+ if (eff?.underline === true)
63
+ run.underline = true;
64
+ if (eff?.strike === true)
65
+ run.strike = true;
66
+ const ff = asString(styles?.fontFamily);
67
+ if (ff)
68
+ run.fontFamily = ff;
69
+ const fs = asNumber(styles?.fontSizePt);
70
+ if (fs > 0)
71
+ run.fontSize = fs;
72
+ const color = asString(styles?.color);
73
+ if (color && color.toLowerCase() !== '#000000')
74
+ run.color = color;
75
+ return run;
76
+ }
21
77
  function maybeMethod(api, path) {
22
78
  let cursor = api;
23
79
  for (const token of path) {
@@ -54,8 +110,68 @@ async function buildDocumentSnapshot(doc, options = {}) {
54
110
  const recordError = (section) => (err) => {
55
111
  diagnostics.push({ section, message: err instanceof Error ? err.message : String(err) });
56
112
  };
57
- const blockOffset = Math.max(0, options.blockOffset ?? 0);
58
- const blockLimit = options.blockLimit != null && options.blockLimit > 0 ? options.blockLimit : 1000;
113
+ let blockOffset = Math.max(0, options.blockOffset ?? 0);
114
+ let blockLimit = options.blockLimit != null && options.blockLimit > 0 ? options.blockLimit : 1000;
115
+ // findText: full-text scan server-side, serialize only ordinals + previews,
116
+ // and (unless the caller pinned blockOffset) center the block window on the
117
+ // first match so one call both LOCATES and READS the target section.
118
+ let finds;
119
+ if (typeof options.findText === 'string' && options.findText.trim().length > 0) {
120
+ const scanFn = maybeMethod(doc, ['blocks', 'list']);
121
+ if (scanFn) {
122
+ // Paginated scan with a hard cap: bounded bridge payloads per page, and
123
+ // an explicit truncated flag instead of a silently-partial `total`.
124
+ const SCAN_PAGE = 2000;
125
+ const SCAN_CAP = 20000;
126
+ const needle = options.findText.toLowerCase();
127
+ const findLimit = options.findLimit != null && options.findLimit > 0 ? options.findLimit : 8;
128
+ const matches = [];
129
+ let scanned = 0;
130
+ let truncated = false;
131
+ for (let pageOffset = 0; pageOffset < SCAN_CAP; pageOffset += SCAN_PAGE) {
132
+ const scanRaw = await safeCall(() => scanFn({ offset: pageOffset, limit: SCAN_PAGE, includeText: true }), null, recordError('blocks.findText'));
133
+ const scanRec = asRecord(scanRaw);
134
+ const scanBlocks = Array.isArray(scanRec?.blocks) ? scanRec.blocks : [];
135
+ scanBlocks.forEach((b, i) => {
136
+ const rec = asRecord(b) ?? {};
137
+ const text = asString(rec.text);
138
+ if (!text || !text.toLowerCase().includes(needle))
139
+ return;
140
+ matches.push({
141
+ ordinal: asNumber(rec.ordinal, pageOffset + i) + 1,
142
+ nodeId: asString(rec.nodeId),
143
+ nodeType: asString(rec.nodeType, 'paragraph'),
144
+ preview: text.slice(0, 100),
145
+ index: asNumber(rec.ordinal, pageOffset + i),
146
+ });
147
+ });
148
+ scanned += scanBlocks.length;
149
+ const totalBlocksReported = explicitCount(scanRec ?? {}, 'total');
150
+ if (scanBlocks.length < SCAN_PAGE)
151
+ break; // last page
152
+ if (totalBlocksReported != null && scanned >= totalBlocksReported)
153
+ break;
154
+ if (pageOffset + SCAN_PAGE >= SCAN_CAP) {
155
+ truncated = true;
156
+ break;
157
+ }
158
+ }
159
+ finds = {
160
+ query: options.findText,
161
+ total: matches.length,
162
+ ...(truncated ? { truncated: true } : {}),
163
+ scannedBlocks: scanned,
164
+ matches: matches.slice(0, findLimit).map(({ index: _index, nodeId, ...m }) => ({
165
+ ...m,
166
+ ...(nodeId ? { nodeId } : {}),
167
+ })),
168
+ };
169
+ if (matches.length > 0 && options.blockOffset == null) {
170
+ blockOffset = Math.max(0, matches[0].index - 2);
171
+ blockLimit = options.blockLimit != null && options.blockLimit > 0 ? options.blockLimit : 12;
172
+ }
173
+ }
174
+ }
59
175
  const blockTextLimit = options.blockTextLimit != null && Number.isFinite(options.blockTextLimit) && options.blockTextLimit > 0
60
176
  ? options.blockTextLimit
61
177
  : null;
@@ -110,6 +226,7 @@ async function buildDocumentSnapshot(doc, options = {}) {
110
226
  permissionRanges: [],
111
227
  images: [],
112
228
  diagnostics,
229
+ ...(finds ? { finds } : {}),
113
230
  };
114
231
  }
115
232
  // Blocks
@@ -162,6 +279,35 @@ async function buildDocumentSnapshot(doc, options = {}) {
162
279
  blocks = blocks.filter((block) => (fullBlockText.get(block) ?? '').trim().length > 0 ||
163
280
  (block.nodeType !== 'paragraph' && block.nodeType !== 'listItem'));
164
281
  }
282
+ // Optionally enrich body blocks with per-run formatting so a reader can
283
+ // detect a section's intra-paragraph pattern (bold lead-ins, italic terms)
284
+ // and replicate it on an inserted paragraph. Opt-in and bounded — meant to
285
+ // be combined with a narrow blockOffset/blockLimit window. Runs before the
286
+ // dropTextPreview remap so fullBlockText lookups still hit.
287
+ if (options.includeBlockRuns && includeBlocksInSnapshot) {
288
+ const queryFn = maybeMethod(doc, ['query', 'match']);
289
+ if (queryFn) {
290
+ const cap = typeof options.blockRunsLimit === 'number' && options.blockRunsLimit > 0
291
+ ? Math.floor(options.blockRunsLimit)
292
+ : 30;
293
+ let enriched = 0;
294
+ for (const block of blocks) {
295
+ if (enriched >= cap)
296
+ break;
297
+ if (block.nodeType !== 'paragraph' && block.nodeType !== 'listItem' && block.nodeType !== 'heading')
298
+ continue;
299
+ // Match on the FULL block text (the snapshot copy may be truncated by
300
+ // blockTextLimit, and a partial pattern would misreport run offsets).
301
+ const text = (fullBlockText.get(block) ?? block.text).trim();
302
+ if (!text || !block.nodeId)
303
+ continue;
304
+ enriched += 1;
305
+ const runs = await safeCall(() => matchRunsForBlock(queryFn, text, block.nodeId), null, recordError('blocks.runs'));
306
+ if (runs && runs.length > 0)
307
+ block.runs = runs;
308
+ }
309
+ }
310
+ }
165
311
  if (includeBlocksInSnapshot && options.dropTextPreview === true) {
166
312
  blocks = blocks.map((block) => ({ ...block, textPreview: null }));
167
313
  }
@@ -194,6 +340,32 @@ async function buildDocumentSnapshot(doc, options = {}) {
194
340
  });
195
341
  }
196
342
  const lists = [...listsById.values()];
343
+ // Optionally enrich list items with per-run formatting so a reader can detect
344
+ // and replicate an intra-item pattern. Costs one query.match per item, so it
345
+ // is opt-in and bounded. The block's runs tile the matched text in offset
346
+ // order; we match by the item's own nodeId to disambiguate duplicate text.
347
+ if (options.includeListItemRuns && includesDomain(requestedDomains, 'lists')) {
348
+ const queryFn = maybeMethod(doc, ['query', 'match']);
349
+ if (queryFn) {
350
+ const cap = typeof options.listItemRunsLimit === 'number' && options.listItemRunsLimit > 0
351
+ ? Math.floor(options.listItemRunsLimit)
352
+ : 40;
353
+ let enriched = 0;
354
+ outer: for (const list of listsById.values()) {
355
+ for (const item of list.items) {
356
+ if (enriched >= cap)
357
+ break outer;
358
+ const text = item.text?.trim();
359
+ if (!text || !item.nodeId)
360
+ continue;
361
+ enriched += 1;
362
+ const runs = await safeCall(() => matchRunsForBlock(queryFn, text, item.nodeId), null, recordError('lists.itemRuns'));
363
+ if (runs && runs.length > 0)
364
+ item.runs = runs;
365
+ }
366
+ }
367
+ }
368
+ }
197
369
  // Tables: derive table block list from `blocks`, then query shape per table.
198
370
  const tableBlocks = normalizedBlocks.filter((b) => b.nodeType === 'table');
199
371
  const precedingBlocksRaw = blockOffset > 0 && includesDomain(requestedDomains, 'tables') && tableBlocks.length > 0 && blocksFn
@@ -273,6 +445,30 @@ async function buildDocumentSnapshot(doc, options = {}) {
273
445
  table.cells = [...cellMap.values()].sort((left, right) => left.rowIndex - right.rowIndex || left.columnIndex - right.columnIndex);
274
446
  }
275
447
  }
448
+ // Optionally enrich table cells with per-run formatting (opt-in; one
449
+ // query.match per cell, bounded) so a reader can match a cell's pattern.
450
+ if (options.includeTableCellRuns && includesDomain(requestedDomains, 'tables')) {
451
+ const queryFn = maybeMethod(doc, ['query', 'match']);
452
+ if (queryFn) {
453
+ const cap = typeof options.tableCellRunsLimit === 'number' && options.tableCellRunsLimit > 0
454
+ ? Math.floor(options.tableCellRunsLimit)
455
+ : 60;
456
+ let enriched = 0;
457
+ outer: for (const table of tables) {
458
+ for (const cell of table.cells) {
459
+ if (enriched >= cap)
460
+ break outer;
461
+ const text = cell.text?.trim();
462
+ if (!text || !cell.nodeId)
463
+ continue;
464
+ enriched += 1;
465
+ const runs = await safeCall(() => matchRunsForBlock(queryFn, text, cell.nodeId), null, recordError('tables.cellRuns'));
466
+ if (runs && runs.length > 0)
467
+ cell.runs = runs;
468
+ }
469
+ }
470
+ }
471
+ }
276
472
  // Comments
277
473
  const commentsFn = maybeMethod(doc, ['comments', 'list']);
278
474
  const commentsRaw = includesDomain(requestedDomains, 'comments') && commentsFn
@@ -549,6 +745,7 @@ async function buildDocumentSnapshot(doc, options = {}) {
549
745
  permissionRanges,
550
746
  images,
551
747
  diagnostics,
748
+ ...(finds ? { finds } : {}),
552
749
  };
553
750
  }
554
751
  /**
@@ -686,4 +883,5 @@ function resolveSnapshotSelector(snapshot, selector) {
686
883
 
687
884
  exports.AmbiguousSelectorError = AmbiguousSelectorError;
688
885
  exports.buildDocumentSnapshot = buildDocumentSnapshot;
886
+ exports.matchRunsForBlock = matchRunsForBlock;
689
887
  exports.resolveSnapshotSelector = resolveSnapshotSelector;
@@ -38,6 +38,29 @@ export type SnapshotBlock = {
38
38
  path: number[] | null;
39
39
  kind: string | null;
40
40
  } | null;
41
+ /** Per-run formatting, present only when inspect was asked to include it. */
42
+ runs?: ReadonlyArray<SnapshotRun>;
43
+ };
44
+ /**
45
+ * One inline run within a list item, in offset order (runs tile the item text).
46
+ * Marks are the EFFECTIVE (resolved) state — what the run visually IS — so the
47
+ * agent can read a formatting pattern across sibling items (e.g. "first word
48
+ * plain, second word italic") and reproduce it on a new item. Boolean marks are
49
+ * emitted only when true to keep the payload compact; a run with no marks is
50
+ * plain. Sourced from query.match's per-run style projection.
51
+ */
52
+ export type SnapshotRun = {
53
+ start: number;
54
+ end: number;
55
+ text: string;
56
+ bold?: boolean;
57
+ italic?: boolean;
58
+ underline?: boolean;
59
+ strike?: boolean;
60
+ fontFamily?: string;
61
+ /** Point size. */
62
+ fontSize?: number;
63
+ color?: string;
41
64
  };
42
65
  export type SnapshotList = {
43
66
  listId: string;
@@ -47,6 +70,8 @@ export type SnapshotList = {
47
70
  ordinal: number;
48
71
  level: number;
49
72
  text: string;
73
+ /** Per-run formatting, present only when inspect was asked to include it. */
74
+ runs?: ReadonlyArray<SnapshotRun>;
50
75
  }>;
51
76
  };
52
77
  export type SnapshotTable = {
@@ -59,6 +84,8 @@ export type SnapshotTable = {
59
84
  columnIndex: number;
60
85
  text: string;
61
86
  nodeId?: string;
87
+ /** Per-run formatting, present only when inspect was asked to include it. */
88
+ runs?: ReadonlyArray<SnapshotRun>;
62
89
  }>;
63
90
  };
64
91
  export type SnapshotComment = {
@@ -180,7 +207,30 @@ export type DocumentSnapshot = {
180
207
  permissionRanges: readonly SnapshotPermissionRange[];
181
208
  images: readonly SnapshotImage[];
182
209
  diagnostics: readonly SnapshotDiagnostic[];
210
+ /** Present when the snapshot was built with `findText` — see SnapshotOptions. */
211
+ finds?: {
212
+ query: string;
213
+ /** Matches within the scanned range; see truncated/scannedBlocks. */
214
+ total: number;
215
+ /** True when the document has more blocks than the scan cap covered. */
216
+ truncated?: boolean;
217
+ scannedBlocks?: number;
218
+ matches: ReadonlyArray<{
219
+ ordinal: number;
220
+ nodeId?: string;
221
+ nodeType: string;
222
+ preview: string;
223
+ }>;
224
+ };
183
225
  };
226
+ /**
227
+ * Read a specific block's per-run formatting via query.match, matching STRICTLY
228
+ * by nodeId. When the text is ambiguous (multiple matches) and none matches the
229
+ * requested nodeId, returns [] rather than guessing a different block — so a
230
+ * duplicate-text sibling can never contribute the wrong runs. The single-match
231
+ * fallback is used only when there is exactly one candidate (unambiguous).
232
+ */
233
+ export declare function matchRunsForBlock(queryFn: (args: Record<string, unknown>) => Promise<unknown>, text: string, nodeId: string): Promise<SnapshotRun[]>;
184
234
  type SnapshotOptions = {
185
235
  countsOnly?: boolean;
186
236
  includeDomains?: readonly SnapshotDomain[];
@@ -201,6 +251,17 @@ type SnapshotOptions = {
201
251
  */
202
252
  blockOffset?: number;
203
253
  blockLimit?: number;
254
+ /**
255
+ * Locate blocks whose text contains this string (case-insensitive) BEFORE
256
+ * windowing. The scan runs server-side over the full block list; only the
257
+ * match ordinals + short previews serialize back (snapshot.finds), and when
258
+ * the caller did not pin blockOffset the block window auto-centers on the
259
+ * first match. This is the section-jump primitive for large documents —
260
+ * one findText inspect replaces a linear sweep of wide windows.
261
+ */
262
+ findText?: string;
263
+ /** Cap on reported findText matches (default 8). */
264
+ findLimit?: number;
204
265
  /**
205
266
  * Drop empty paragraphs/list-items from the returned blocks. A real DOCX is
206
267
  * ~40% empty spacer paragraphs that carry full JSON overhead but no reading
@@ -214,6 +275,36 @@ type SnapshotOptions = {
214
275
  * `text`.
215
276
  */
216
277
  dropTextPreview?: boolean;
278
+ /**
279
+ * Attach per-run formatting (`items[].runs`) to the returned list items, so a
280
+ * reader can detect an intra-item formatting pattern (which segment is bold/
281
+ * italic, the font/size) and replicate it on a new item. Off by default: it
282
+ * costs one query.match per list item, so only request it when matching a
283
+ * list's formatting matters. Bounded by `listItemRunsLimit`.
284
+ */
285
+ includeListItemRuns?: boolean;
286
+ /** Max list items to enrich with runs when includeListItemRuns is set (default 40). */
287
+ listItemRunsLimit?: number;
288
+ /**
289
+ * Attach per-run formatting (`cells[].runs`) to returned table cells, so a
290
+ * reader can match a cell's formatting pattern. OFF by default: tables can be
291
+ * large and it costs one query.match per cell, so only request it when cell
292
+ * styling actually matters. Bounded by `tableCellRunsLimit`.
293
+ */
294
+ includeTableCellRuns?: boolean;
295
+ /** Max table cells to enrich with runs when includeTableCellRuns is set (default 60). */
296
+ tableCellRunsLimit?: number;
297
+ /**
298
+ * Attach per-run formatting (`blocks[].runs`) to the returned body blocks so
299
+ * a reader can detect a section's formatting pattern (e.g. lettered clauses
300
+ * with a bold "(m)" lead-in) and replicate it on an inserted paragraph. OFF
301
+ * by default: it costs one query.match per block, so request it with a
302
+ * narrow blockOffset/blockLimit window around the insertion target. Bounded
303
+ * by `blockRunsLimit`.
304
+ */
305
+ includeBlockRuns?: boolean;
306
+ /** Max blocks to enrich with runs when includeBlockRuns is set (default 30). */
307
+ blockRunsLimit?: number;
217
308
  };
218
309
  /**
219
310
  * Build a deterministic snapshot of a document. The snapshot uses only
@@ -16,6 +16,62 @@ async function safeCall(thunk, fallback, onError) {
16
16
  return fallback;
17
17
  }
18
18
  }
19
+ /**
20
+ * Read a specific block's per-run formatting via query.match, matching STRICTLY
21
+ * by nodeId. When the text is ambiguous (multiple matches) and none matches the
22
+ * requested nodeId, returns [] rather than guessing a different block — so a
23
+ * duplicate-text sibling can never contribute the wrong runs. The single-match
24
+ * fallback is used only when there is exactly one candidate (unambiguous).
25
+ */
26
+ export async function matchRunsForBlock(queryFn, text, nodeId) {
27
+ const res = asRecord(await queryFn({ select: { type: 'text', pattern: text } }));
28
+ const items = (Array.isArray(res?.items) ? res.items : []).map(asRecord);
29
+ const match = items.find((m) => asString(asRecord(m?.address)?.nodeId) === nodeId) ?? (items.length === 1 ? items[0] : null);
30
+ if (!match)
31
+ return [];
32
+ const blocksArr = Array.isArray(match.blocks) ? match.blocks.map(asRecord) : [];
33
+ const block = blocksArr.find((b) => asString(b?.blockId) === nodeId) ?? (blocksArr.length === 1 ? blocksArr[0] : null);
34
+ if (!block)
35
+ return [];
36
+ const rawRuns = Array.isArray(block.runs) ? block.runs : [];
37
+ return rawRuns.map(projectRun).filter((r) => r != null);
38
+ }
39
+ /**
40
+ * Project one raw query.match run into a compact SnapshotRun. Uses the
41
+ * EFFECTIVE (resolved) mark state and emits booleans only when true. Skips a
42
+ * default-black color so only meaningful colors surface.
43
+ */
44
+ function projectRun(raw) {
45
+ const rec = asRecord(raw);
46
+ if (!rec)
47
+ return null;
48
+ const range = asRecord(rec.range);
49
+ const styles = asRecord(rec.styles);
50
+ const eff = asRecord(styles?.effective);
51
+ const run = {
52
+ start: asNumber(range?.start),
53
+ end: asNumber(range?.end),
54
+ text: asString(rec.text),
55
+ };
56
+ if (eff?.bold === true)
57
+ run.bold = true;
58
+ if (eff?.italic === true)
59
+ run.italic = true;
60
+ if (eff?.underline === true)
61
+ run.underline = true;
62
+ if (eff?.strike === true)
63
+ run.strike = true;
64
+ const ff = asString(styles?.fontFamily);
65
+ if (ff)
66
+ run.fontFamily = ff;
67
+ const fs = asNumber(styles?.fontSizePt);
68
+ if (fs > 0)
69
+ run.fontSize = fs;
70
+ const color = asString(styles?.color);
71
+ if (color && color.toLowerCase() !== '#000000')
72
+ run.color = color;
73
+ return run;
74
+ }
19
75
  function maybeMethod(api, path) {
20
76
  let cursor = api;
21
77
  for (const token of path) {
@@ -52,8 +108,68 @@ export async function buildDocumentSnapshot(doc, options = {}) {
52
108
  const recordError = (section) => (err) => {
53
109
  diagnostics.push({ section, message: err instanceof Error ? err.message : String(err) });
54
110
  };
55
- const blockOffset = Math.max(0, options.blockOffset ?? 0);
56
- const blockLimit = options.blockLimit != null && options.blockLimit > 0 ? options.blockLimit : 1000;
111
+ let blockOffset = Math.max(0, options.blockOffset ?? 0);
112
+ let blockLimit = options.blockLimit != null && options.blockLimit > 0 ? options.blockLimit : 1000;
113
+ // findText: full-text scan server-side, serialize only ordinals + previews,
114
+ // and (unless the caller pinned blockOffset) center the block window on the
115
+ // first match so one call both LOCATES and READS the target section.
116
+ let finds;
117
+ if (typeof options.findText === 'string' && options.findText.trim().length > 0) {
118
+ const scanFn = maybeMethod(doc, ['blocks', 'list']);
119
+ if (scanFn) {
120
+ // Paginated scan with a hard cap: bounded bridge payloads per page, and
121
+ // an explicit truncated flag instead of a silently-partial `total`.
122
+ const SCAN_PAGE = 2000;
123
+ const SCAN_CAP = 20000;
124
+ const needle = options.findText.toLowerCase();
125
+ const findLimit = options.findLimit != null && options.findLimit > 0 ? options.findLimit : 8;
126
+ const matches = [];
127
+ let scanned = 0;
128
+ let truncated = false;
129
+ for (let pageOffset = 0; pageOffset < SCAN_CAP; pageOffset += SCAN_PAGE) {
130
+ const scanRaw = await safeCall(() => scanFn({ offset: pageOffset, limit: SCAN_PAGE, includeText: true }), null, recordError('blocks.findText'));
131
+ const scanRec = asRecord(scanRaw);
132
+ const scanBlocks = Array.isArray(scanRec?.blocks) ? scanRec.blocks : [];
133
+ scanBlocks.forEach((b, i) => {
134
+ const rec = asRecord(b) ?? {};
135
+ const text = asString(rec.text);
136
+ if (!text || !text.toLowerCase().includes(needle))
137
+ return;
138
+ matches.push({
139
+ ordinal: asNumber(rec.ordinal, pageOffset + i) + 1,
140
+ nodeId: asString(rec.nodeId),
141
+ nodeType: asString(rec.nodeType, 'paragraph'),
142
+ preview: text.slice(0, 100),
143
+ index: asNumber(rec.ordinal, pageOffset + i),
144
+ });
145
+ });
146
+ scanned += scanBlocks.length;
147
+ const totalBlocksReported = explicitCount(scanRec ?? {}, 'total');
148
+ if (scanBlocks.length < SCAN_PAGE)
149
+ break; // last page
150
+ if (totalBlocksReported != null && scanned >= totalBlocksReported)
151
+ break;
152
+ if (pageOffset + SCAN_PAGE >= SCAN_CAP) {
153
+ truncated = true;
154
+ break;
155
+ }
156
+ }
157
+ finds = {
158
+ query: options.findText,
159
+ total: matches.length,
160
+ ...(truncated ? { truncated: true } : {}),
161
+ scannedBlocks: scanned,
162
+ matches: matches.slice(0, findLimit).map(({ index: _index, nodeId, ...m }) => ({
163
+ ...m,
164
+ ...(nodeId ? { nodeId } : {}),
165
+ })),
166
+ };
167
+ if (matches.length > 0 && options.blockOffset == null) {
168
+ blockOffset = Math.max(0, matches[0].index - 2);
169
+ blockLimit = options.blockLimit != null && options.blockLimit > 0 ? options.blockLimit : 12;
170
+ }
171
+ }
172
+ }
57
173
  const blockTextLimit = options.blockTextLimit != null && Number.isFinite(options.blockTextLimit) && options.blockTextLimit > 0
58
174
  ? options.blockTextLimit
59
175
  : null;
@@ -108,6 +224,7 @@ export async function buildDocumentSnapshot(doc, options = {}) {
108
224
  permissionRanges: [],
109
225
  images: [],
110
226
  diagnostics,
227
+ ...(finds ? { finds } : {}),
111
228
  };
112
229
  }
113
230
  // Blocks
@@ -160,6 +277,35 @@ export async function buildDocumentSnapshot(doc, options = {}) {
160
277
  blocks = blocks.filter((block) => (fullBlockText.get(block) ?? '').trim().length > 0 ||
161
278
  (block.nodeType !== 'paragraph' && block.nodeType !== 'listItem'));
162
279
  }
280
+ // Optionally enrich body blocks with per-run formatting so a reader can
281
+ // detect a section's intra-paragraph pattern (bold lead-ins, italic terms)
282
+ // and replicate it on an inserted paragraph. Opt-in and bounded — meant to
283
+ // be combined with a narrow blockOffset/blockLimit window. Runs before the
284
+ // dropTextPreview remap so fullBlockText lookups still hit.
285
+ if (options.includeBlockRuns && includeBlocksInSnapshot) {
286
+ const queryFn = maybeMethod(doc, ['query', 'match']);
287
+ if (queryFn) {
288
+ const cap = typeof options.blockRunsLimit === 'number' && options.blockRunsLimit > 0
289
+ ? Math.floor(options.blockRunsLimit)
290
+ : 30;
291
+ let enriched = 0;
292
+ for (const block of blocks) {
293
+ if (enriched >= cap)
294
+ break;
295
+ if (block.nodeType !== 'paragraph' && block.nodeType !== 'listItem' && block.nodeType !== 'heading')
296
+ continue;
297
+ // Match on the FULL block text (the snapshot copy may be truncated by
298
+ // blockTextLimit, and a partial pattern would misreport run offsets).
299
+ const text = (fullBlockText.get(block) ?? block.text).trim();
300
+ if (!text || !block.nodeId)
301
+ continue;
302
+ enriched += 1;
303
+ const runs = await safeCall(() => matchRunsForBlock(queryFn, text, block.nodeId), null, recordError('blocks.runs'));
304
+ if (runs && runs.length > 0)
305
+ block.runs = runs;
306
+ }
307
+ }
308
+ }
163
309
  if (includeBlocksInSnapshot && options.dropTextPreview === true) {
164
310
  blocks = blocks.map((block) => ({ ...block, textPreview: null }));
165
311
  }
@@ -192,6 +338,32 @@ export async function buildDocumentSnapshot(doc, options = {}) {
192
338
  });
193
339
  }
194
340
  const lists = [...listsById.values()];
341
+ // Optionally enrich list items with per-run formatting so a reader can detect
342
+ // and replicate an intra-item pattern. Costs one query.match per item, so it
343
+ // is opt-in and bounded. The block's runs tile the matched text in offset
344
+ // order; we match by the item's own nodeId to disambiguate duplicate text.
345
+ if (options.includeListItemRuns && includesDomain(requestedDomains, 'lists')) {
346
+ const queryFn = maybeMethod(doc, ['query', 'match']);
347
+ if (queryFn) {
348
+ const cap = typeof options.listItemRunsLimit === 'number' && options.listItemRunsLimit > 0
349
+ ? Math.floor(options.listItemRunsLimit)
350
+ : 40;
351
+ let enriched = 0;
352
+ outer: for (const list of listsById.values()) {
353
+ for (const item of list.items) {
354
+ if (enriched >= cap)
355
+ break outer;
356
+ const text = item.text?.trim();
357
+ if (!text || !item.nodeId)
358
+ continue;
359
+ enriched += 1;
360
+ const runs = await safeCall(() => matchRunsForBlock(queryFn, text, item.nodeId), null, recordError('lists.itemRuns'));
361
+ if (runs && runs.length > 0)
362
+ item.runs = runs;
363
+ }
364
+ }
365
+ }
366
+ }
195
367
  // Tables: derive table block list from `blocks`, then query shape per table.
196
368
  const tableBlocks = normalizedBlocks.filter((b) => b.nodeType === 'table');
197
369
  const precedingBlocksRaw = blockOffset > 0 && includesDomain(requestedDomains, 'tables') && tableBlocks.length > 0 && blocksFn
@@ -271,6 +443,30 @@ export async function buildDocumentSnapshot(doc, options = {}) {
271
443
  table.cells = [...cellMap.values()].sort((left, right) => left.rowIndex - right.rowIndex || left.columnIndex - right.columnIndex);
272
444
  }
273
445
  }
446
+ // Optionally enrich table cells with per-run formatting (opt-in; one
447
+ // query.match per cell, bounded) so a reader can match a cell's pattern.
448
+ if (options.includeTableCellRuns && includesDomain(requestedDomains, 'tables')) {
449
+ const queryFn = maybeMethod(doc, ['query', 'match']);
450
+ if (queryFn) {
451
+ const cap = typeof options.tableCellRunsLimit === 'number' && options.tableCellRunsLimit > 0
452
+ ? Math.floor(options.tableCellRunsLimit)
453
+ : 60;
454
+ let enriched = 0;
455
+ outer: for (const table of tables) {
456
+ for (const cell of table.cells) {
457
+ if (enriched >= cap)
458
+ break outer;
459
+ const text = cell.text?.trim();
460
+ if (!text || !cell.nodeId)
461
+ continue;
462
+ enriched += 1;
463
+ const runs = await safeCall(() => matchRunsForBlock(queryFn, text, cell.nodeId), null, recordError('tables.cellRuns'));
464
+ if (runs && runs.length > 0)
465
+ cell.runs = runs;
466
+ }
467
+ }
468
+ }
469
+ }
274
470
  // Comments
275
471
  const commentsFn = maybeMethod(doc, ['comments', 'list']);
276
472
  const commentsRaw = includesDomain(requestedDomains, 'comments') && commentsFn
@@ -547,6 +743,7 @@ export async function buildDocumentSnapshot(doc, options = {}) {
547
743
  permissionRanges,
548
744
  images,
549
745
  diagnostics,
746
+ ...(finds ? { finds } : {}),
550
747
  };
551
748
  }
552
749
  /**
@@ -153,7 +153,15 @@ function resolveDocMethod(doc, operationId) {
153
153
  return cursor;
154
154
  }
155
155
  async function agentInspect(doc, args = {}) {
156
- return docSnapshot.buildDocumentSnapshot(doc, args);
156
+ // For the MODEL-facing inspect, expose each list item's per-run formatting BY
157
+ // DEFAULT so the agent always sees the intra-item pattern it must match when
158
+ // adding items. (Internal pre/post snapshots call buildDocumentSnapshot
159
+ // directly and stay cheap — this default lives only on the tool path.) Opt out
160
+ // with includeListItemRuns:false; countsOnly skips all domains anyway.
161
+ const listsInScope = !args.countsOnly &&
162
+ (!Array.isArray(args.includeDomains) || args.includeDomains.length === 0 || args.includeDomains.includes('lists'));
163
+ const effective = args.includeListItemRuns === undefined && listsInScope ? { ...args, includeListItemRuns: true } : args;
164
+ return docSnapshot.buildDocumentSnapshot(doc, effective);
157
165
  }
158
166
  function checkAgainstSnapshot(snapshot, check) {
159
167
  switch (check.kind) {
@@ -31,8 +31,16 @@ export type AgentInspectArgs = {
31
31
  trackedChangeLimit?: number;
32
32
  blockOffset?: number;
33
33
  blockLimit?: number;
34
+ findText?: string;
35
+ findLimit?: number;
34
36
  omitEmptyBlocks?: boolean;
35
37
  dropTextPreview?: boolean;
38
+ includeListItemRuns?: boolean;
39
+ listItemRunsLimit?: number;
40
+ includeTableCellRuns?: boolean;
41
+ tableCellRunsLimit?: number;
42
+ includeBlockRuns?: boolean;
43
+ blockRunsLimit?: number;
36
44
  };
37
45
  export type AgentApplyArgs = {
38
46
  plan: AgentPlan;