@superdoc/sdk 2.5.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 (55) 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/embedded-tools.generated.cjs +5 -5
  14. package/dist/embedded-tools.generated.js +5 -5
  15. package/dist/generated/client.cjs +754 -770
  16. package/dist/generated/client.d.ts +14 -9
  17. package/dist/generated/client.js +754 -770
  18. package/dist/generated/contract.cjs +16207 -274
  19. package/dist/generated/contract.d.ts +38 -0
  20. package/dist/generated/contract.js +17443 -1503
  21. package/dist/index.cjs +5 -4
  22. package/dist/index.d.ts +2 -2
  23. package/dist/index.js +5 -4
  24. package/dist/introspection.cjs +59 -0
  25. package/dist/introspection.d.ts +3 -0
  26. package/dist/introspection.js +53 -0
  27. package/dist/runtime/document-rpc.cjs +328 -0
  28. package/dist/runtime/document-rpc.d.ts +24 -0
  29. package/dist/runtime/document-rpc.js +312 -0
  30. package/dist/runtime/embedded-cli.cjs +5 -68
  31. package/dist/runtime/embedded-cli.js +5 -67
  32. package/dist/runtime/embedded-document-host.cjs +28 -0
  33. package/dist/runtime/embedded-document-host.d.ts +1 -0
  34. package/dist/runtime/embedded-document-host.js +23 -0
  35. package/dist/runtime/embedded-platform.cjs +102 -0
  36. package/dist/runtime/embedded-platform.d.ts +5 -0
  37. package/dist/runtime/embedded-platform.js +93 -0
  38. package/dist/runtime/host.cjs +128 -16
  39. package/dist/runtime/host.d.ts +11 -5
  40. package/dist/runtime/host.js +126 -16
  41. package/dist/runtime/process.cjs +34 -5
  42. package/dist/runtime/process.d.ts +12 -3
  43. package/dist/runtime/process.js +33 -5
  44. package/dist/runtime/transport-common.cjs +1 -0
  45. package/dist/runtime/transport-common.d.ts +28 -10
  46. package/dist/runtime/transport-common.js +1 -1
  47. package/package.json +7 -6
  48. package/tools/__pycache__/__init__.cpython-311.pyc +0 -0
  49. package/tools/__pycache__/intent_dispatch_generated.cpython-311.pyc +0 -0
  50. package/tools/catalog.json +2 -2
  51. package/tools/tools-policy.json +1 -1
  52. package/tools/tools.anthropic.json +2 -2
  53. package/tools/tools.generic.json +2 -2
  54. package/tools/tools.openai.json +2 -2
  55. package/tools/tools.vercel.json +2 -2
@@ -57,14 +57,14 @@ const ACTION_NAMES = [
57
57
  * case; the compiler enforces all but the group, which a unit test covers.
58
58
  */
59
59
  const ACTION_HINTS = {
60
- insert_paragraphs: 'texts[] (or text for a single paragraph), placement?, headingLevel? (first item as heading 1-6), changeMode?',
60
+ insert_paragraphs: "texts[] (or text for a single paragraph), placement?, headingLevel? (first item as heading 1-6), changeMode? — THE way to ADD a new paragraph/clause; never replace_text/rewrite_block an existing paragraph to append content. Each texts entry is a plain string OR a STYLED object {runs:[{text,marks}], marks?} (marks: bold/italic/underline/strikethrough/fontFamily/fontSize(points)/color(hex)/highlight). MATCH THE SECTION'S PATTERN: when siblings share intra-paragraph formatting (e.g. lettered clauses with a bold '(m)' lead-in), inspect a narrow block window with includeBlockRuns:true, read the sibling runs, and reproduce the pattern with runs on the new text IN THIS CALL — e.g. runs:[{text:'(n)',marks:{bold:true}},{text:' rest of the clause…'}]. Do not add plain and format_text afterward",
61
61
  insert_heading: 'text, level, placement?, changeMode?',
62
- replace_text: 'edits[{find,replace}], optional selector to scope replacements to one inspected block, caseSensitive?, changeMode?',
62
+ replace_text: 'edits[{find,replace}], optional selector to scope replacements to one inspected block, caseSensitive?, changeMode? — for EDITING existing wording only; the replaced span is rewritten as PLAIN text (intra-span bold/italic is dropped), so keep find/replace spans MINIMAL (the words that change, not the whole paragraph) and NEVER use it to append a new sentence/clause/paragraph (use insert_paragraphs)',
63
63
  delete_text: "finds[], optional selector to scope deletions to ONE inspected block (required to delete stray whitespace — an unscoped whitespace find matches document-wide), caseSensitive?, changeMode? — deletes TEXT ONLY, leaving the block (and a list item's bullet/number) in place. To remove a whole list item, paragraph or heading use delete_blocks",
64
64
  delete_blocks: 'selectors[] (each resolving to ONE inspected block: list item, paragraph or heading), changeMode? — THE way to DELETE a whole LIST ITEM, paragraph or heading, bullet/number included. Use for "remove the first item under Article II" / "delete that clause". delete_text only strikes the text and leaves an empty numbered item behind. Deletes several blocks in ONE call; use delete_table for a whole table and delete_text to clear a table cell (a cell must keep a paragraph)',
65
65
  append_list: 'items[], kind?: ordered|bullet, headingText?, headingLevel?, placement? {at:"after"|"before",selector} builds the list at that block instead of document end',
66
- create_table: 'rows, columns, cellTexts?, placement?, changeMode? — changeMode:"tracked" makes the table insertion itself a tracked change',
67
- rewrite_block: 'selector, text, changeMode?',
66
+ create_table: 'rows, columns, cellTexts? (2D: rows of cells; each cell is a plain string OR a STYLED object {runs:[{text,marks}], marks?} to format the cell — marks: bold/italic/underline/strikethrough/fontFamily/fontSize(points)/color(hex)/highlight), placement?, changeMode? — style cells HERE, do not format_text afterward; changeMode:"tracked" makes the table insertion itself a tracked change',
67
+ rewrite_block: "selector, text, changeMode? — REPLACES the whole block text; the block's intra-paragraph run pattern is preserved best-effort over the unchanged prefix/suffix. For appending a NEW paragraph/clause use insert_paragraphs, and for small wording edits prefer replace_text with a minimal find span",
68
68
  fill_placeholders: 'values[] and/or fields[{label?,value}], changeMode?',
69
69
  move_range: 'fromText (text in the FIRST block of the range to move), toText? (text in the LAST block — omit to auto-extend across the whole VISUAL SECTION: from fromText up to the next heading-like/ALL-CAPS/bold title), then exactly ONE destination: afterText OR beforeText (text in the block to land after/before). Direct-only today: changeMode:"tracked" fails with no mutation because block-range deletion cannot be tracked. Moves plain paragraph/heading text only; a range containing a table, list, or image is refused with nothing changed. THE way to MOVE a contiguous block range or a visual "section" identified BY TEXT (works on styled-paragraph sections like PREAMBLE / SCHEDULE A that are NOT Word heading nodes). afterText on a heading-like block lands the range after that block\'s WHOLE section.',
70
70
  comment_paragraphs: 'commentText, scope?: all|body, excludeBlockQuotes?',
@@ -79,8 +79,8 @@ const ACTION_HINTS = {
79
79
  apply_style: 'selector (block to restyle), then ONE of: styleId (e.g. "Heading2"), headingLevel (1-6), or likeText (text inside another block whose style AND effective look to copy) — THE way to restyle an EXISTING block ("make Summary match the Parties heading"). Never delete-and-recreate a block to change its style',
80
80
  apply_letter_spacing: 'selector, letterSpacing, changeMode?',
81
81
  insert_toc: 'title?, placement?, changeMode?',
82
- insert_table_row: 'tableOrdinal?, rowIndex?, position?: before|after|above|below, cellTexts?, changeMode?, dryRun?',
83
- insert_table_column: 'tableOrdinal?, columnIndex?, position?, headerText?, changeMode?',
82
+ insert_table_row: "tableOrdinal?, rowIndex?, position?: before|after|above|below, cellTexts? (one per column; each a plain string OR a STYLED {runs, marks?} cell to reproduce the table's cell formatting), changeMode?, dryRun? — to match existing cell styling, inspect the table with includeTableCellRuns:true first, then style each new cell HERE. PLACEMENT: when the user does not say WHERE the row goes, append it after the LAST existing row (rowIndex = last row's index, position: below); never pick a middle position on your own",
83
+ insert_table_column: 'tableOrdinal?, columnIndex?, position?: left|right, headerText? OR cellTexts? (one per row; each a plain string OR a STYLED {runs, marks?} cell), changeMode? — style the new column cells HERE, not with format_text',
84
84
  delete_table_row: 'tableOrdinal?, rowIndex, changeMode?',
85
85
  delete_table_column: 'tableOrdinal?, columnIndex, changeMode?',
86
86
  split_table: 'tableOrdinal?, rowIndex, separatorText?, changeMode?',
@@ -89,7 +89,11 @@ const ACTION_HINTS = {
89
89
  undo_changes: 'untilMarker? rendered marker to restore e.g. "2.1.", steps? 1-25 — deterministic revert that steps document history back until the marker reappears',
90
90
  redo_changes: 'steps? 1-25 (default 1) — the inverse of undo_changes: steps history FORWARD to re-apply edits a prior undo removed. THE recovery when an undo overshot. Only works right after an undo, before any new edit.',
91
91
  attach_numbering: 'anchorText|nodeId, likeMarker e.g. "10.", changeMode? — make an existing block a numbered clause at the same scheme/level as the sibling rendering likeMarker; changeMode:"tracked" records the former unnumbered state as a w:pPrChange',
92
- add_list_items: "anchorText (text inside the item to add relative to) + entries[{text, level?}] level is RELATIVE to the anchor: 0 = same level as the anchor, positive nests deeper, NEGATIVE promotes toward the top (anchor on a sub-item like \"12(e)\" with level:-1 → a new TOP-LEVEL item 13). The item lands after the anchor's whole sub-tree at that level, and the receipt reports the marker it landed on. New items also inherit the anchor item's font/size/bold/colour automatically (receipt.formattingMatched) — do NOT re-format after adding unless asked. To control level ALWAYS use anchorText (listOrdinal/items[] just append at the list's trailing level and cannot set level). THE way to ADD items (incl. nested sub-items or a new top-level item) into an EXISTING list, reusing its numbering + markers — NOT append_list (which starts a brand-new list)",
92
+ add_list_items: 'THE way to ADD items into an EXISTING list (reusing its numbering + markers) NOT append_list (which starts a brand-new list). Locate the list with anchorText (text inside an existing item) or listOrdinal (from inspect). Each item in entries/items is EITHER a plain string / {text, level?}, OR a STYLED object {runs, marks?, level?}. ' +
93
+ 'STYLING IN THIS ONE CALL — if any part of the new item needs inline formatting (bold/italic/underline/color/font/size on some or all of it), you MUST express it with `runs` and/or `marks` HERE. Do NOT add the item plain and then call format_text/format_paragraph on it afterward — that is the wrong tool for a just-added item and risks matching stray text elsewhere. ' +
94
+ '`runs` = ordered segments [{text, marks?}] whose texts concatenate to the item text; put the pattern on each segment (marks: bold/italic/underline/strikethrough/fontFamily/fontSize(points)/color(hex e.g. #1a1a1a)/highlight). `marks` on the item = a whole-item default (e.g. Arial 16pt) applied under the per-run marks. Example — pattern \'first word bold, rest italic\': runs:[{text:"Term:",marks:{bold:true}},{text:" the rest",marks:{italic:true}}]. ' +
95
+ "MATCHING THE LIST'S FORMATTING IS THE DEFAULT EXPECTATION, and it means per-run inline formatting — NOT numbering (numbering is always reused automatically). superdoc_inspect returns each list item's `runs` (its inline segments with marks) by default: READ the sibling items' runs, infer the rule (which segment is bold/italic/etc., the font/size), and REPRODUCE that run structure with `runs` on the new item's OWN words IN THIS CALL. If the siblings are uniformly styled, a plain add already inherits their font/size/colour, so no marks are needed; but if any sibling has intra-item formatting, you MUST supply `runs`. " +
96
+ 'level is RELATIVE to the anchor (0 = same level, positive nests deeper, NEGATIVE promotes toward the top e.g. anchor "12(e)" + level:-1 → new top-level item 13); only anchorText can set explicit level (listOrdinal appends at the trailing level). PLACEMENT: anchorText inserts the new item RIGHT AFTER the item containing that text — this is how you position within a list. One Word list often spans SEVERAL visual groups (the same numbering id continues past e.g. "Label: value" entries into unrelated paragraphs), so when the new item belongs to a visible GROUP of similar siblings, anchor the LAST item OF THAT GROUP — tail-append (listOrdinal, or anchoring the list\'s final item) can land it in the wrong region. Only when there is no such group and the user gives no placement signal append at the END of the list. The receipt\'s note/addedItems echo the rendered marker AND the immediate neighbor items — verify placement from the receipt; do NOT re-inspect wide block windows after a successful add. receipt.formattingMatched reports what was applied.',
93
97
  format_paragraph: 'selector (block to align), alignment: left|center|right|justify, changeMode? — THE way to set paragraph alignment; changeMode:"tracked" records the former alignment as a w:pPrChange so accept/reject toggles it (never apply alignment untracked when tracking is on)',
94
98
  move_text: 'text (exact clause/phrase to relocate), afterText? (relocate to immediately after this text), changeMode? — THE way to MOVE a text span. Direct by default (physically relocates; REQUIRES afterText). changeMode:"tracked" records it as a redline (tracked delete of the source + tracked insert at the destination; accept keeps the move, reject restores the order) and may omit afterText (lands right after the struck source).',
95
99
  style_table: 'tableOrdinal? (1-based, default 1), accentColor? (header fill hex, default dark navy) — THE way to make a table look professional: filled accent header row with white bold text, bold first-column labels, banded rows, clean borders. Use after create_table or on any existing table.',
@@ -187,7 +191,7 @@ const ACTION_ARGS = {
187
191
  move_range: ['fromText', 'toText', 'afterText', 'beforeText', 'changeMode'],
188
192
  insert_toc: ['title', 'placement', 'changeMode'],
189
193
  insert_table_row: ['tableOrdinal', 'rowIndex', 'position', 'cellTexts', 'changeMode', 'dryRun'],
190
- insert_table_column: ['tableOrdinal', 'columnIndex', 'position', 'headerText', 'changeMode'],
194
+ insert_table_column: ['tableOrdinal', 'columnIndex', 'position', 'headerText', 'cellTexts', 'changeMode'],
191
195
  delete_table_row: ['tableOrdinal', 'rowIndex', 'changeMode'],
192
196
  delete_table_column: ['tableOrdinal', 'columnIndex', 'changeMode'],
193
197
  split_table: ['tableOrdinal', 'rowIndex', 'separatorText', 'changeMode'],
@@ -227,6 +231,30 @@ function isActionName(value) {
227
231
  function isRecord(value) {
228
232
  return value != null && typeof value === 'object' && !Array.isArray(value);
229
233
  }
234
+ /** The message to put in a receipt when something best-effort did not happen. */
235
+ function reasonOf(err) {
236
+ return err instanceof Error ? err.message : String(err);
237
+ }
238
+ /**
239
+ * Run a best-effort polish step: never throw, and report WHY it did nothing.
240
+ *
241
+ * Contextual formatting must not fail an edit that already landed — but a
242
+ * silently swallowed failure leaves the caller believing the look was matched.
243
+ * Returns the reason, so the receipt can carry it.
244
+ */
245
+ async function bestEffort(step) {
246
+ try {
247
+ await step();
248
+ return null;
249
+ }
250
+ catch (err) {
251
+ return reasonOf(err);
252
+ }
253
+ }
254
+ /** A NO_OP means "already in the requested state" — the goal, not a failure. */
255
+ function isNoOp(err) {
256
+ return err?.code === 'NO_OP';
257
+ }
230
258
  function asRecord(value) {
231
259
  return isRecord(value) ? value : null;
232
260
  }
@@ -485,7 +513,7 @@ function revisionVerification(preRevision, postRevision, expectChanged) {
485
513
  };
486
514
  }
487
515
  function failedReceipt(intent, err, preSnapshot) {
488
- const message = err instanceof Error ? err.message : String(err);
516
+ const message = reasonOf(err);
489
517
  return {
490
518
  status: 'failed',
491
519
  intent,
@@ -557,15 +585,19 @@ function flattenCellTexts(cellTexts) {
557
585
  for (let rowIndex = 0; rowIndex < cellTexts.length; rowIndex += 1) {
558
586
  const row = cellTexts[rowIndex] ?? [];
559
587
  for (let columnIndex = 0; columnIndex < row.length; columnIndex += 1) {
560
- flattened.push({
561
- rowIndex,
562
- columnIndex,
563
- text: row[columnIndex] ?? '',
564
- });
588
+ flattened.push(cellTextFromSpec(row[columnIndex], rowIndex, columnIndex));
565
589
  }
566
590
  }
567
591
  return flattened;
568
592
  }
593
+ /** Normalize a cell input (string or object) into a positioned TableCellText. */
594
+ function cellTextFromSpec(cell, rowIndex, columnIndex) {
595
+ if (typeof cell === 'string' || cell == null) {
596
+ return { rowIndex, columnIndex, text: cell ?? '' };
597
+ }
598
+ const spec = normalizeItemSpec(cell) ?? {};
599
+ return { rowIndex, columnIndex, text: itemSpecText(spec), runs: spec.runs, marks: spec.marks };
600
+ }
569
601
  function findExtractedCellBlock(blocks, rowIndex, columnIndex) {
570
602
  return (blocks.find((block) => block.tableContext?.rowIndex === rowIndex && block.tableContext?.columnIndex === columnIndex) ??
571
603
  blocks.find((block) => {
@@ -785,7 +817,14 @@ async function executeCommentCreate(doc, snapshot, commentText, blockId) {
785
817
  target,
786
818
  });
787
819
  }
788
- async function applyTableCellTexts(doc, tableNodeId, tableOrdinal, cellTexts, changeMode) {
820
+ async function applyTableCellTexts(doc, tableNodeId, tableOrdinal, cellTexts, changeMode,
821
+ /**
822
+ * Force the cell fill to run as a direct edit even under `changeMode:'tracked'`.
823
+ * Set only by the column-insert caller, whose new cells land in pre-existing
824
+ * rows that carry no tracked-insertion marker for the adapter to absorb them
825
+ * into. See the tracked-mode note below.
826
+ */
827
+ forceDirectFill = false) {
789
828
  const nonEmptyCells = cellTexts.filter((cell) => cell.text.trim().length > 0);
790
829
  if (nonEmptyCells.length === 0)
791
830
  return [];
@@ -820,36 +859,170 @@ async function applyTableCellTexts(doc, tableNodeId, tableOrdinal, cellTexts, ch
820
859
  },
821
860
  ];
822
861
  });
823
- const steps = [];
862
+ // Prefer tables.setCellText: it emits a `table.cell.setText` STRUCTURAL delta
863
+ // that survives collaboration. The legacy `text.rewrite` path is a generic
864
+ // block rewrite the v2 collab backend silently drops for freshly-inserted
865
+ // cells (rows/columns land empty), so it is only a fallback for non-collab or
866
+ // older hosts without the op.
867
+ //
868
+ // Tracked mode: the adapter accepts a tracked cell fill only where the target
869
+ // cell's row carries a same-author `<w:trPr><w:ins/>` marker, which already
870
+ // owns the accept/reject fate of everything inside it. A tracked row insert
871
+ // and a tracked new table both produce such rows, so their fills go through
872
+ // tracked and the transport now lets them reach the adapter
873
+ // (supportsConditionalTrackedMode, SD-4035).
874
+ //
875
+ // A tracked COLUMN insert is the exception: its new cells are added to
876
+ // PRE-EXISTING rows, which carry no marker, so the adapter fails closed with
877
+ // CAPABILITY_UNAVAILABLE and the column would land empty. Those fills stay
878
+ // direct — the table-level insertion still owns the redline.
879
+ const cellFillChangeMode = forceDirectFill && changeMode === 'tracked' ? undefined : changeMode;
880
+ const setCellFn = maybeMethod(doc, ['tables', 'setCellText']);
881
+ const legacySteps = [];
882
+ const resolved = [];
824
883
  for (const cell of nonEmptyCells) {
825
884
  const block = findExtractedCellBlock(blocks, cell.rowIndex, cell.columnIndex);
826
885
  if (!block?.nodeId) {
827
886
  throw new errors.SuperDocCliError('Unable to locate a paragraph block for the requested table cell.', {
828
887
  code: 'INVALID_ARGUMENT',
829
- details: {
830
- tableNodeId,
831
- rowIndex: cell.rowIndex,
832
- columnIndex: cell.columnIndex,
833
- },
888
+ details: { tableNodeId, rowIndex: cell.rowIndex, columnIndex: cell.columnIndex },
834
889
  });
835
890
  }
836
- steps.push({
837
- id: `set-table-cell-${cell.rowIndex}-${cell.columnIndex}`,
838
- op: 'text.rewrite',
839
- where: {
840
- by: 'block',
841
- nodeType: block.type ?? 'paragraph',
842
- nodeId: block.nodeId,
843
- },
844
- args: {
845
- replacement: { text: cell.text },
846
- },
891
+ resolved.push({ ...cell, nodeId: block.nodeId });
892
+ if (setCellFn) {
893
+ await setCellFn({
894
+ target: { kind: 'block', nodeType: 'table', nodeId: tableNodeId },
895
+ rowIndex: cell.rowIndex,
896
+ columnIndex: cell.columnIndex,
897
+ text: cell.text,
898
+ ...(cellFillChangeMode ? { changeMode: cellFillChangeMode } : {}),
899
+ });
900
+ }
901
+ else {
902
+ legacySteps.push({
903
+ id: `set-table-cell-${cell.rowIndex}-${cell.columnIndex}`,
904
+ op: 'text.rewrite',
905
+ where: { by: 'block', nodeType: block.type ?? 'paragraph', nodeId: block.nodeId },
906
+ args: { replacement: { text: cell.text } },
907
+ });
908
+ }
909
+ }
910
+ if (legacySteps.length > 0)
911
+ await executeMutations(doc, legacySteps, cellFillChangeMode);
912
+ return resolved;
913
+ }
914
+ /**
915
+ * Sample a block's leading inline look through `query.match`, which is the only
916
+ * read that reports run styling (`blocks.list` omits it and `getNodeById`
917
+ * carries no run props). Returns null when nothing is worth copying.
918
+ */
919
+ async function inlineLookFromBlockText(doc, text, blockId) {
920
+ const queryFn = maybeMethod(doc, ['query', 'match']);
921
+ if (!queryFn || !text.trim())
922
+ return null;
923
+ const result = asRecord(await queryFn({ select: { type: 'text', pattern: text } }));
924
+ const items = Array.isArray(result?.items) ? result.items : [];
925
+ for (const item of items) {
926
+ const blocks = Array.isArray(asRecord(item)?.blocks) ? asRecord(item).blocks : [];
927
+ for (const block of blocks) {
928
+ const rec = asRecord(block);
929
+ if (asString(rec?.blockId) !== blockId)
930
+ continue;
931
+ const runs = Array.isArray(rec?.runs) ? rec.runs : [];
932
+ if (runs.length === 0)
933
+ continue;
934
+ // Same reader the list path uses, so a table cell inherits COLOUR too —
935
+ // a white-on-blue header cell that keeps its shading but loses its white
936
+ // text reads as an empty cell.
937
+ const inline = inlineFromMatchStyle(matchRunStyle(runs[0]));
938
+ return Object.keys(inline).length > 0 ? inline : null;
939
+ }
940
+ }
941
+ return null;
942
+ }
943
+ /**
944
+ * Make freshly filled cells look like the cells they were inserted next to.
945
+ *
946
+ * Cell text written into a NEW row or column lands with no run properties at
947
+ * all (under collaboration for rows; always for columns, whose cells live in
948
+ * pre-existing rows), so the new cells render at the default face while their
949
+ * neighbours are 10pt bold — the table equivalent of the list-item base layer.
950
+ * Only cells the agent did not style explicitly are touched; explicit
951
+ * runs/marks always win.
952
+ *
953
+ * `reference` names the cell each new cell should copy: the same column in the
954
+ * displaced ROW for a row insert, the same row in the adjacent COLUMN for a
955
+ * column insert. Returning null skips that cell.
956
+ *
957
+ * Best-effort: never throws. Reports how many cells were matched AND why any
958
+ * were not — one cell that fails must neither abort the others nor pass for
959
+ * "matched the neighbours" in the receipt.
960
+ */
961
+ async function matchTableCellsToNeighbour(doc, tableOrdinal, cells, reference) {
962
+ const candidates = cells.filter((c) => !(c.runs && c.runs.length > 0) && c.marks == null);
963
+ if (candidates.length === 0)
964
+ return { matched: 0 };
965
+ const extractFn = maybeMethod(doc, ['extract']);
966
+ const formatFn = maybeMethod(doc, ['format', 'apply']);
967
+ if (!extractFn || !formatFn)
968
+ return { matched: 0 };
969
+ let blocks = [];
970
+ const readFailure = await bestEffort(async () => {
971
+ const extracted = asRecord(await extractFn({}));
972
+ blocks = Array.isArray(extracted?.blocks) ? extracted.blocks : [];
973
+ });
974
+ if (readFailure)
975
+ return { matched: 0, skipped: readFailure };
976
+ let matched = 0;
977
+ const failures = [];
978
+ for (const cell of candidates) {
979
+ const failure = await bestEffort(async () => {
980
+ const ref = reference(cell);
981
+ if (!ref || ref.rowIndex < 0 || ref.columnIndex < 0)
982
+ return;
983
+ const neighbour = blocks
984
+ .map((b) => asRecord(b))
985
+ .find((b) => {
986
+ const ctx = asRecord(b?.tableContext);
987
+ return (asNumber(ctx?.tableOrdinal, -1) === tableOrdinal - 1 &&
988
+ asNumber(ctx?.rowIndex, -1) === ref.rowIndex &&
989
+ asNumber(ctx?.columnIndex, -1) === ref.columnIndex);
990
+ });
991
+ const neighbourId = asString(neighbour?.nodeId);
992
+ const neighbourText = asString(neighbour?.text, '');
993
+ if (!neighbourId || !neighbourText)
994
+ return;
995
+ const inline = await inlineLookFromBlockText(doc, neighbourText, neighbourId);
996
+ if (!inline)
997
+ return;
998
+ try {
999
+ await formatFn({ blockId: cell.nodeId, start: 0, end: Math.max(cell.text.length, 1), inline });
1000
+ matched += 1;
1001
+ }
1002
+ catch (err) {
1003
+ if (!isNoOp(err))
1004
+ throw err;
1005
+ }
847
1006
  });
1007
+ if (failure)
1008
+ failures.push(`cell ${cell.rowIndex},${cell.columnIndex}: ${failure}`);
848
1009
  }
849
- if (steps.length === 0)
850
- return [];
851
- await executeMutations(doc, steps, changeMode);
852
- return nonEmptyCells;
1010
+ return { matched, ...(failures.length > 0 ? { skipped: failures.join('; ') } : {}) };
1011
+ }
1012
+ /**
1013
+ * Apply per-run / whole-cell styling to table cells that requested it, after
1014
+ * their text has been written. Reuses the list-item styling pass (format.apply
1015
+ * per run range on each cell's paragraph nodeId). Best-effort; returns the
1016
+ * number of cells styled.
1017
+ */
1018
+ async function styleTableCells(doc, cells, changeMode) {
1019
+ const styled = cells.filter((c) => (c.runs && c.runs.length > 0) || c.marks != null);
1020
+ if (styled.length === 0)
1021
+ return 0;
1022
+ const nodeIds = styled.map((c) => c.nodeId);
1023
+ const specs = styled.map((c) => ({ text: c.text, runs: c.runs, marks: c.marks }));
1024
+ await applyItemStyling(doc, nodeIds, specs, changeMode);
1025
+ return styled.length;
853
1026
  }
854
1027
  async function runInsertParagraphs(doc, args) {
855
1028
  const pre = await docSnapshot.buildDocumentSnapshot(doc, { includeDomains: ['blocks'] });
@@ -885,6 +1058,8 @@ async function runInsertParagraphs(doc, args) {
885
1058
  ? { kind: 'after', target: { kind: 'block', nodeType: last.nodeType, nodeId: last.nodeId } }
886
1059
  : { kind: 'documentEnd' };
887
1060
  };
1061
+ const styledNodeIds = [];
1062
+ const styledSpecs = [];
888
1063
  for (let i = 0; i < texts.length; i += 1) {
889
1064
  const text = texts[i];
890
1065
  const isFirst = i === 0;
@@ -895,6 +1070,16 @@ async function runInsertParagraphs(doc, args) {
895
1070
  operationId: isFirst && headingFirst ? 'doc.create.heading' : 'doc.create.paragraph',
896
1071
  result,
897
1072
  });
1073
+ // Remember the created block for the post-insert styling pass when this
1074
+ // paragraph carries a runs/marks spec.
1075
+ const spec = args.textSpecs?.[i];
1076
+ if (spec && ((spec.runs && spec.runs.length > 0) || spec.marks != null)) {
1077
+ const created = createdBlockTarget(result);
1078
+ if (created?.nodeId) {
1079
+ styledNodeIds.push(created.nodeId);
1080
+ styledSpecs.push({ ...spec, text });
1081
+ }
1082
+ }
898
1083
  currentPlacement = await advanceAfter(result);
899
1084
  // New-document drafts get a blank paragraph after each item for spacing;
900
1085
  // inserts into an existing document do not (would scatter blank lines).
@@ -904,6 +1089,14 @@ async function runInsertParagraphs(doc, args) {
904
1089
  currentPlacement = await advanceAfter(spacer);
905
1090
  }
906
1091
  }
1092
+ if (styledNodeIds.length > 0) {
1093
+ const styling = await applyItemStyling(doc, styledNodeIds, styledSpecs, args.changeMode);
1094
+ executedOperations.push({
1095
+ operationId: 'doc.format.apply',
1096
+ result: styling,
1097
+ rationale: `Styled ${styledNodeIds.length} inserted paragraph(s)`,
1098
+ });
1099
+ }
907
1100
  const postIdentity = await readDocumentIdentity(doc);
908
1101
  const verification = [revisionVerification(pre.revision, postIdentity.revision, true)];
909
1102
  return {
@@ -956,6 +1149,11 @@ async function runReplaceText(doc, args) {
956
1149
  const selectedTargets = [];
957
1150
  let skippedEdits = [];
958
1151
  let steps;
1152
+ // Blocks whose text this call rewrites, with their pre-mutation run layout:
1153
+ // the engine replace lane renders the replaced range as uniform runs, so a
1154
+ // whole-paragraph find/replace flattens intra-paragraph formatting (bold
1155
+ // lead-ins). Restore it positionally after the mutation.
1156
+ const preserveTargets = [];
959
1157
  if (args.selector) {
960
1158
  const target = selectorToBlockTarget(args.selector, pre);
961
1159
  if (!target) {
@@ -993,6 +1191,13 @@ async function runReplaceText(doc, args) {
993
1191
  },
994
1192
  ];
995
1193
  selectedTargets.push({ selector: args.selector, matched: [target.nodeId] });
1194
+ preserveTargets.push({
1195
+ nodeId: target.nodeId,
1196
+ nodeType: target.nodeType ?? 'paragraph',
1197
+ oldText: target.text,
1198
+ newText: rewrittenText,
1199
+ runs: await captureBlockRuns(doc, target.nodeId, target.text),
1200
+ });
996
1201
  }
997
1202
  else {
998
1203
  const matchingEdits = args.edits.length === 1
@@ -1015,26 +1220,104 @@ async function runReplaceText(doc, args) {
1015
1220
  ],
1016
1221
  };
1017
1222
  }
1018
- steps = matchingEdits.map((edit, index) => ({
1019
- id: `replace-${index + 1}`,
1020
- op: 'text.rewrite',
1021
- where: {
1022
- by: 'select',
1023
- select: {
1024
- type: 'text',
1025
- pattern: edit.find,
1026
- mode: 'contains',
1027
- caseSensitive,
1223
+ // Rewrite affected BODY blocks with block-targeted rewrites. The select
1224
+ // (matched-range) rewrite lane flattens the WHOLE paragraph's runs in
1225
+ // collaborative sessions and leaves format.apply inert on the block
1226
+ // afterwards, so per-block rewrites + the preserve pass below are the
1227
+ // only path that keeps intra-paragraph formatting alive. Bounded; edits
1228
+ // that touch no scanned body block (e.g. header/footer text) fall back
1229
+ // to a select-rewrite step.
1230
+ // Tracked mode keeps the matched-range lane. A block rewrite asks the
1231
+ // engine to delete and re-insert the WHOLE paragraph, so a reviewer sees
1232
+ // the entire block struck and re-added instead of the words that changed,
1233
+ // and each block contributes its own replacement/deletion pair. The
1234
+ // per-block lane exists to keep intra-paragraph formatting alive, which
1235
+ // is a direct-mode concern; under tracked mode the revision itself is the
1236
+ // reviewable unit and must stay scoped to the edit.
1237
+ const scanBlocks = args.changeMode === 'tracked'
1238
+ ? []
1239
+ : pre.blocks.length > 0
1240
+ ? pre.blocks
1241
+ : (await docSnapshot.buildDocumentSnapshot(doc, { includeDomains: ['blocks'] })).blocks;
1242
+ // An edit counts as covered only when EVERY block holding it got a
1243
+ // rewrite target. The per-block lane is bounded (run capture costs a read
1244
+ // each), so a find present in more blocks than the cap must fall through
1245
+ // to the select lane below, which carries `require: 'all'`. Marking such
1246
+ // an edit covered after its first match would replace only the first
1247
+ // PRESERVE_TARGET_CAP blocks and still report success.
1248
+ const blocksHoldingEdit = matchingEdits.map((edit) => scanBlocks.filter((block) => block.nodeId && block.text && textIncludes(block.text, edit.find, caseSensitive))
1249
+ .length);
1250
+ const blocksCoveredForEdit = Array.from({ length: matchingEdits.length }, () => 0);
1251
+ for (const block of scanBlocks) {
1252
+ if (preserveTargets.length >= PRESERVE_TARGET_CAP)
1253
+ break;
1254
+ if (!block.nodeId || !block.text)
1255
+ continue;
1256
+ let newText = block.text;
1257
+ const touched = [];
1258
+ matchingEdits.forEach((edit, editIndex) => {
1259
+ if (textIncludes(newText, edit.find, caseSensitive)) {
1260
+ newText = replaceAllText(newText, edit.find, edit.replace, caseSensitive);
1261
+ touched.push(editIndex);
1262
+ }
1263
+ });
1264
+ if (newText === block.text)
1265
+ continue;
1266
+ preserveTargets.push({
1267
+ nodeId: block.nodeId,
1268
+ nodeType: block.nodeType ?? 'paragraph',
1269
+ oldText: block.text,
1270
+ newText,
1271
+ runs: await captureBlockRuns(doc, block.nodeId, block.text),
1272
+ });
1273
+ for (const editIndex of touched)
1274
+ blocksCoveredForEdit[editIndex] += 1;
1275
+ }
1276
+ const coveredEdits = new Set(matchingEdits
1277
+ .map((_edit, editIndex) => editIndex)
1278
+ .filter((editIndex) => blocksHoldingEdit[editIndex] > 0 && blocksCoveredForEdit[editIndex] >= blocksHoldingEdit[editIndex]));
1279
+ steps = [
1280
+ ...preserveTargets.map((entry, index) => ({
1281
+ id: `replace-block-${index + 1}`,
1282
+ op: 'text.rewrite',
1283
+ where: { by: 'block', nodeType: entry.nodeType, nodeId: entry.nodeId },
1284
+ args: {
1285
+ replacement: { text: entry.newText },
1286
+ style: preserveRewriteStyle(),
1028
1287
  },
1029
- require: 'all',
1030
- },
1031
- args: {
1032
- replacement: { text: edit.replace },
1033
- style: preserveRewriteStyle(),
1034
- },
1035
- }));
1288
+ })),
1289
+ // Edits that touched no scanned body block (header/footer text, or a
1290
+ // doc larger than the scan window) keep the original select-rewrite.
1291
+ ...matchingEdits
1292
+ .map((edit, editIndex) => ({ edit, editIndex }))
1293
+ .filter(({ editIndex }) => !coveredEdits.has(editIndex))
1294
+ .map(({ edit, editIndex }) => ({
1295
+ id: `replace-${editIndex + 1}`,
1296
+ op: 'text.rewrite',
1297
+ where: {
1298
+ by: 'select',
1299
+ select: {
1300
+ type: 'text',
1301
+ pattern: edit.find,
1302
+ mode: 'contains',
1303
+ caseSensitive,
1304
+ },
1305
+ require: 'all',
1306
+ },
1307
+ args: {
1308
+ replacement: { text: edit.replace },
1309
+ style: preserveRewriteStyle(),
1310
+ },
1311
+ })),
1312
+ ];
1036
1313
  }
1037
1314
  const result = await executeMutations(doc, steps, args.changeMode);
1315
+ const preserved = [];
1316
+ for (const entry of preserveTargets) {
1317
+ const restored = await preserveRunPatternAfterRewrite(doc, entry.nodeId, entry.oldText, entry.runs, entry.newText);
1318
+ if (restored)
1319
+ preserved.push({ nodeId: entry.nodeId, ...restored });
1320
+ }
1038
1321
  if (args.selector && selectedTargets[0]) {
1039
1322
  const post = await docSnapshot.buildDocumentSnapshot(doc, { includeDomains: selectorDomains ?? ['blocks'] });
1040
1323
  const blockId = selectedTargets[0].matched[0];
@@ -1063,7 +1346,10 @@ async function runReplaceText(doc, args) {
1063
1346
  preSnapshot: { revision: pre.revision, counts: pre.counts },
1064
1347
  postSnapshot: { revision: post.revision, counts: post.counts },
1065
1348
  selectedTargets,
1066
- executedOperations: [{ operationId: 'doc.mutations.apply', result }],
1349
+ executedOperations: [
1350
+ { operationId: 'doc.mutations.apply', result },
1351
+ ...(preserved.length > 0 ? [{ operationId: 'doc.format.apply', result: { preserved } }] : []),
1352
+ ],
1067
1353
  verification,
1068
1354
  };
1069
1355
  }
@@ -1087,6 +1373,7 @@ async function runReplaceText(doc, args) {
1087
1373
  ...(skippedEditRationale ? { rationale: skippedEditRationale } : {}),
1088
1374
  result,
1089
1375
  },
1376
+ ...(preserved.length > 0 ? [{ operationId: 'doc.format.apply', result: { preserved } }] : []),
1090
1377
  ],
1091
1378
  verification,
1092
1379
  };
@@ -1337,7 +1624,7 @@ async function runDeleteBlocks(doc, args) {
1337
1624
  catch (err) {
1338
1625
  errors$1.push({
1339
1626
  code: 'ACTION_FAILED',
1340
- message: `blocks.delete failed for ${target.nodeType} ${target.nodeId}: ${err instanceof Error ? err.message : String(err)}`,
1627
+ message: `blocks.delete failed for ${target.nodeType} ${target.nodeId}: ${reasonOf(err)}`,
1341
1628
  recovery: { kind: 'reinspect' },
1342
1629
  });
1343
1630
  }
@@ -1386,18 +1673,33 @@ async function runAppendList(doc, args) {
1386
1673
  if (args.items.length === 0) {
1387
1674
  return failedReceipt('append_list', new Error('items must be non-empty'), pre);
1388
1675
  }
1676
+ // The published schema accepts styled objects here, so normalize them
1677
+ // rather than handing the text-only workflow an object it rejects with
1678
+ // INVALID_ARGUMENT. Text goes in; marks come back as a post-insert pass.
1679
+ const specs = args.items.map((item) => (typeof item === 'string' ? { text: item } : item));
1680
+ const anyStyled = specs.some((spec) => spec.marks != null || (spec.runs && spec.runs.length > 0));
1389
1681
  const workflowResult = await listTransform.runSuperdocListTransformWorkflow({
1390
1682
  documentHandle: doc,
1391
1683
  args: {
1392
1684
  action: 'append_new_list',
1393
- items: [...args.items],
1685
+ items: specs.map((spec) => itemSpecText(spec)),
1394
1686
  kind: args.kind ?? 'ordered',
1395
1687
  headingText: args.headingText,
1396
1688
  headingLevel: args.headingLevel,
1397
1689
  changeMode: args.changeMode,
1398
1690
  },
1399
1691
  });
1400
- return receiptFromWorkflowResult(doc, 'append_list', pre, workflowResult);
1692
+ const receipt = await receiptFromWorkflowResult(doc, 'append_list', pre, workflowResult);
1693
+ if (anyStyled) {
1694
+ const insertedNodeIds = Array.isArray(workflowResult.output?.execution?.insertedNodeIds)
1695
+ ? workflowResult.output.execution.insertedNodeIds
1696
+ : [];
1697
+ if (insertedNodeIds.length > 0) {
1698
+ const formattingMatched = await applyItemStyling(doc, insertedNodeIds, specs, args.changeMode);
1699
+ return { ...receipt, formattingMatched };
1700
+ }
1701
+ }
1702
+ return receipt;
1401
1703
  }
1402
1704
  catch (err) {
1403
1705
  return failedReceipt('append_list', err, pre);
@@ -1426,7 +1728,19 @@ async function runInsertListItems(doc, args) {
1426
1728
  changeMode: args.changeMode,
1427
1729
  },
1428
1730
  });
1429
- return receiptFromWorkflowResult(doc, 'add_list_items', pre, workflowResult);
1731
+ const receipt = await receiptFromWorkflowResult(doc, 'add_list_items', pre, workflowResult);
1732
+ // The list workflow is text-only; apply any requested per-run / whole-item
1733
+ // styling as a post-pass on the freshly inserted items (aligned by index).
1734
+ if (args.styled && args.styled.length > 0) {
1735
+ const insertedNodeIds = Array.isArray(workflowResult.output?.execution?.insertedNodeIds)
1736
+ ? workflowResult.output.execution.insertedNodeIds
1737
+ : [];
1738
+ if (insertedNodeIds.length > 0) {
1739
+ const formattingMatched = await applyItemStyling(doc, insertedNodeIds, args.styled, args.changeMode);
1740
+ return { ...receipt, formattingMatched };
1741
+ }
1742
+ }
1743
+ return receipt;
1430
1744
  }
1431
1745
  catch (err) {
1432
1746
  return failedReceipt('add_list_items', err, pre);
@@ -1436,14 +1750,35 @@ async function runAddListItems(doc, args, opts) {
1436
1750
  const pre = await docSnapshot.buildDocumentSnapshot(doc, { includeDomains: ['blocks'] });
1437
1751
  try {
1438
1752
  const needle = args.anchorText?.trim();
1439
- if (!needle)
1440
- return failedReceipt('add_list_items', new Error('anchorText is required'), pre);
1753
+ const anchorNodeId = args.anchorNodeId?.trim();
1754
+ if (!needle && !anchorNodeId) {
1755
+ return failedReceipt('add_list_items', new Error('anchorText or anchorNodeId is required'), pre);
1756
+ }
1441
1757
  if (!args.entries || args.entries.length === 0) {
1442
1758
  return failedReceipt('add_list_items', new Error('entries must be non-empty'), pre);
1443
1759
  }
1444
1760
  const rows = await listBlockRows(doc);
1445
- // The list to extend: a numbered/bulleted item whose text matches anchorText.
1446
- const anchor = rows.find((r) => blockNumbering(r) && (r.textPreview ?? '').toLowerCase().includes(needle.toLowerCase()));
1761
+ // `blocks.list` rows carry only a TRUNCATED textPreview (~78 chars), while
1762
+ // the agent quotes anchorText out of the FULL block text that inspect
1763
+ // reports. Matching the preview alone misses any anchor past the truncation
1764
+ // point — and a missed anchor is not a visible failure: the caller falls
1765
+ // through to the text-only path, which silently discards every run mark the
1766
+ // agent asked for. Match full snapshot text first, preview second.
1767
+ const needleLower = (needle ?? '').toLowerCase();
1768
+ const fullTextById = new Map();
1769
+ for (const block of pre.blocks ?? []) {
1770
+ if (block.nodeId && typeof block.text === 'string')
1771
+ fullTextById.set(block.nodeId, block.text);
1772
+ }
1773
+ const rowMatchesNeedle = (r) => {
1774
+ const rowText = fullTextById.get(r.nodeId ?? '') ?? r.textPreview ?? '';
1775
+ return rowText.toLowerCase().includes(needleLower);
1776
+ };
1777
+ // The list to extend: prefer a direct nodeId anchor (from a resolved
1778
+ // listOrdinal), else a numbered/bulleted item whose text matches anchorText.
1779
+ const anchor = anchorNodeId
1780
+ ? rows.find((r) => r.nodeId === anchorNodeId && blockNumbering(r))
1781
+ : rows.find((r) => blockNumbering(r) && rowMatchesNeedle(r));
1447
1782
  if (!anchor || !anchor.nodeId) {
1448
1783
  // The caller can retry via the ghost/ordinal path (imported list-looking
1449
1784
  // paragraphs carry no numbering, so no NUMBERED anchor matches here).
@@ -1487,6 +1822,27 @@ async function runAddListItems(doc, args, opts) {
1487
1822
  // else: non-numbered paragraph (a continuation) — skip and keep scanning
1488
1823
  }
1489
1824
  }
1825
+ // A new item SHALLOWER than its anchor cannot sit inside a nested run of
1826
+ // sub-items without cutting that run in two: anchored on the middle of a
1827
+ // sub-list, a promoted top-level item lands between two sub-items and
1828
+ // orphans the ones after it. Carry it past every deeper item first, so it
1829
+ // arrives at the end of the group it now belongs to. (The agent quotes
1830
+ // whichever sibling text it noticed; "after this sub-item" and "a top-level
1831
+ // item here" are contradictory instructions, and outline level wins.)
1832
+ const firstEntryLevel = args.entries[0]?.level;
1833
+ const firstRelative = typeof firstEntryLevel === 'number' ? Math.floor(firstEntryLevel) : 0;
1834
+ if (firstRelative < 0) {
1835
+ const firstTargetLevel = Math.max(0, anchorLevel + firstRelative);
1836
+ const startIdx = rows.findIndex((r) => r.nodeId === subtreeEnd.nodeId);
1837
+ for (let i = startIdx + 1; i >= 1 && i < rows.length; i += 1) {
1838
+ const level = levelOfRow(rows[i]);
1839
+ if (level == null)
1840
+ continue; // non-numbered continuation paragraph
1841
+ if (level <= firstTargetLevel)
1842
+ break;
1843
+ subtreeEnd = rows[i];
1844
+ }
1845
+ }
1490
1846
  const attachFn = maybeMethod(doc, ['lists', 'attach']);
1491
1847
  if (!attachFn) {
1492
1848
  throw new errors.SuperDocCliError('doc.lists.attach is not available on the document handle.', {
@@ -1504,7 +1860,10 @@ async function runAddListItems(doc, args, opts) {
1504
1860
  };
1505
1861
  const createdItems = [];
1506
1862
  for (const entry of args.entries) {
1507
- const text = typeof entry?.text === 'string' ? entry.text : '';
1863
+ // Styled items carry ordered `runs`; a plain item is a single run. The
1864
+ // paragraph is created with the concatenated text, then styled per-run.
1865
+ const runs = entry?.runs && entry.runs.length > 0 ? entry.runs : [{ text: entry?.text ?? '' }];
1866
+ const text = itemSpecText(entry);
1508
1867
  // `level` is RELATIVE to the anchor and MAY BE NEGATIVE: positive nests
1509
1868
  // deeper, 0 = same level as the anchor, negative promotes toward the top
1510
1869
  // (e.g. anchor on a sub-item, level:-1 → a top-level sibling). The final
@@ -1530,11 +1889,112 @@ async function runAddListItems(doc, args, opts) {
1530
1889
  result: compactOpResult(attachResult),
1531
1890
  rationale: `level ${targetLevel}`,
1532
1891
  });
1533
- createdItems.push({ nodeId: target.nodeId, targetLevel, text });
1892
+ createdItems.push({ nodeId: target.nodeId, targetLevel, text, runs, itemMarks: entry?.marks });
1534
1893
  // After attach the block is a listItem (same nodeId) — the next placement
1535
1894
  // must address it as such or the create resolver rejects the anchor.
1536
1895
  placement = { kind: 'after', target: { kind: 'block', nodeType: 'listItem', nodeId: target.nodeId } };
1537
1896
  }
1897
+ // A new item is created next to the ANCHOR, so it inherits the anchor's
1898
+ // direct indentation — correct at the same level, wrong at any other one.
1899
+ // `lists.attach` sets the numbering level (`w:ilvl`) but leaves that direct
1900
+ // `w:ind` alone, so a level-changing add renders with a sub-item's indent
1901
+ // under a top-level number: e.g. an item promoted to level 0 kept
1902
+ // left=1800 while its "1./2./3." siblings sit at 1080. Copy the indentation
1903
+ // from an EXISTING sibling at the item's own level. Best-effort: an
1904
+ // unmatched indent is cosmetic and must not fail an item that landed.
1905
+ const createdIds = new Set(createdItems.map((ci) => ci.nodeId));
1906
+ const anchorKind = blockNumbering(anchor)?.kind ?? null;
1907
+ // Search OUTWARD from the anchor, nearest first, and only accept items
1908
+ // of the anchor's own numbering kind: a document holds several lists,
1909
+ // and the first level-0 item in document order usually belongs to a
1910
+ // different one (here: a bullet list far above the ordered list being
1911
+ // extended), whose indentation is unrelated.
1912
+ const findSiblingAt = (level) => {
1913
+ const usable = (r) => !!r?.nodeId &&
1914
+ !createdIds.has(r.nodeId) &&
1915
+ levelOfRow(r) === level &&
1916
+ (blockNumbering(r)?.kind ?? null) === anchorKind;
1917
+ for (let delta = 1; delta <= rows.length; delta += 1) {
1918
+ const before = anchorIdx >= 0 ? rows[anchorIdx - delta] : undefined;
1919
+ if (usable(before))
1920
+ return before;
1921
+ const after = anchorIdx >= 0 ? rows[anchorIdx + delta] : undefined;
1922
+ if (usable(after))
1923
+ return after;
1924
+ }
1925
+ return undefined;
1926
+ };
1927
+ /** The existing item a new one should look like: same level, nearest first. */
1928
+ const lookSourceFor = (targetLevel) => targetLevel === anchorLevel ? anchor : findSiblingAt(targetLevel);
1929
+ // Most Word documents keep a list's look in a paragraph STYLE, not in direct
1930
+ // properties: "MB Agr 2", "Quick 1.", even Heading3 carry the numbering, the
1931
+ // indentation and the face. A created paragraph does not get the sibling's
1932
+ // style — it gets the anchor's `next` style (Heading3 → BodyText), a default
1933
+ // (ListParagraph, whose contextualSpacing changes the spacing), or the
1934
+ // anchor's own style when the new item belongs to a DIFFERENT level (keeping
1935
+ // "MB Agr 3" on an item whose numbering now says level 2). Reconcile it with
1936
+ // the sibling at the item's own level.
1937
+ //
1938
+ // setStyleRef, not setStyle: setStyle clears direct run formatting, which
1939
+ // would wipe the sampled run look applied below.
1940
+ const styledItems = [];
1941
+ const styleSkipped = await bestEffort(async () => {
1942
+ const setStyleRefFn = maybeMethod(doc, ['styles', 'paragraph', 'setStyleRef']);
1943
+ const clearStyleFn = maybeMethod(doc, ['styles', 'paragraph', 'clearStyle']);
1944
+ if (!setStyleRefFn && !clearStyleFn)
1945
+ return;
1946
+ let current = null;
1947
+ for (const ci of createdItems) {
1948
+ const source = lookSourceFor(ci.targetLevel);
1949
+ if (!source)
1950
+ continue;
1951
+ const wanted = source.styleId ?? null;
1952
+ current ??= await listBlockRows(doc);
1953
+ const created = current.find((r) => r.nodeId === ci.nodeId);
1954
+ if ((created?.styleId ?? null) === wanted)
1955
+ continue;
1956
+ const target = { kind: 'block', nodeType: created?.nodeType ?? 'listItem', nodeId: ci.nodeId };
1957
+ if (wanted && setStyleRefFn)
1958
+ await setStyleRefFn({ target, styleId: wanted });
1959
+ else if (!wanted && clearStyleFn)
1960
+ await clearStyleFn({ target });
1961
+ else
1962
+ continue;
1963
+ styledItems.push({ nodeId: ci.nodeId, styleId: wanted });
1964
+ }
1965
+ });
1966
+ const indentedItems = [];
1967
+ const indentSkipped = await bestEffort(async () => {
1968
+ const setIndentFn = maybeMethod(doc, ['format', 'paragraph', 'setIndentation']);
1969
+ const getNodeFn = maybeMethod(doc, ['getNodeById']);
1970
+ if (!setIndentFn || !getNodeFn)
1971
+ return;
1972
+ for (const ci of createdItems) {
1973
+ const sibling = lookSourceFor(ci.targetLevel);
1974
+ if (!sibling?.nodeId)
1975
+ continue;
1976
+ const node = (await getNodeFn({ id: sibling.nodeId }));
1977
+ const indent = paragraphIndentOf(node);
1978
+ if (!indent)
1979
+ continue;
1980
+ // COMPARE rather than assume: a same-level item usually inherits the
1981
+ // anchor's indentation on creation, but not always — one real document
1982
+ // produced items with no `w:ind` at all beside siblings indented 851.
1983
+ const created = paragraphIndentOf(await getNodeFn({ id: ci.nodeId }));
1984
+ if (created && ['left', 'right', 'firstLine', 'hanging'].every((k) => created[k] === indent[k])) {
1985
+ continue;
1986
+ }
1987
+ await setIndentFn({
1988
+ target: { kind: 'block', nodeType: 'listItem', nodeId: ci.nodeId },
1989
+ // The read side reports indentation in POINTS while setIndentation
1990
+ // takes TWIPS — passing the sampled numbers through unconverted
1991
+ // indents by a twentieth of the intended amount, which reads as
1992
+ // "the copy silently did nothing".
1993
+ ...indent,
1994
+ });
1995
+ indentedItems.push({ nodeId: ci.nodeId, from: sibling.nodeId });
1996
+ }
1997
+ });
1538
1998
  // Report WHERE each item actually landed (rendered marker + outline level),
1539
1999
  // so the caller can trust the result instead of re-deriving it and undoing
1540
2000
  // correct work when the level looked ambiguous.
@@ -1543,63 +2003,220 @@ async function runAddListItems(doc, args, opts) {
1543
2003
  try {
1544
2004
  afterRows = await listBlockRows(doc);
1545
2005
  landed = createdItems.map((ci) => {
1546
- const row = afterRows.find((r) => r.nodeId === ci.nodeId);
2006
+ const idx = afterRows.findIndex((r) => r.nodeId === ci.nodeId);
2007
+ const row = idx >= 0 ? afterRows[idx] : undefined;
1547
2008
  const num = row ? blockNumbering(row) : null;
1548
2009
  const p = num?.path;
1549
- return { marker: num?.marker ?? null, level: Array.isArray(p) ? Math.max(0, p.length - 1) : ci.targetLevel };
2010
+ const snippet = (r) => (r?.textPreview ? r.textPreview.slice(0, 60) : null);
2011
+ return {
2012
+ marker: num?.marker ?? null,
2013
+ level: Array.isArray(p) ? Math.max(0, p.length - 1) : ci.targetLevel,
2014
+ // Only a read-back that FOUND the created node counts as verified;
2015
+ // otherwise the level is the requested one, not an observed one.
2016
+ verified: idx >= 0,
2017
+ // Immediate neighboring BLOCKS (may be continuation paragraphs, or —
2018
+ // for multi-entry adds — the other new items) so the caller can
2019
+ // verify PLACEMENT from the receipt instead of re-inspecting a wide
2020
+ // block window.
2021
+ previousBlock: idx > 0 ? snippet(afterRows[idx - 1]) : null,
2022
+ nextBlock: idx >= 0 && idx + 1 < afterRows.length ? snippet(afterRows[idx + 1]) : null,
2023
+ };
1550
2024
  });
1551
2025
  }
1552
2026
  catch {
1553
2027
  // best-effort read-back; the mutation already applied
1554
2028
  }
1555
- // Match the anchor item's inline look (font/size/bold/colour) onto each new
1556
- // item, so a list extended from styled items keeps that styling — the same
1557
- // neighbour-formatting polish insert_paragraphs/insert_heading receive, except
1558
- // the anchor IS the explicit reference here. Best-effort: a format.apply
1559
- // failure must NEVER fail an otherwise-landed add, so the items still exist.
1560
- let formattingMatched = { skipped: 'anchor had no inline look' };
2029
+ // Format each new item in four layers, later layers overriding earlier on
2030
+ // overlapping keys (successive format.apply calls):
2031
+ // 1. BASE — the sampled look of the existing item at the same level, over
2032
+ // the whole item, so an item added with no explicit styling still
2033
+ // matches its siblings' font/size/colour (fixes the plain-insert
2034
+ // mismatch where the new item fell back to style defaults instead of
2035
+ // the list's direct run formatting).
2036
+ // 2. LEAD — the siblings' "bold label: plain sentence" convention over
2037
+ // the new item's own label span, when the item requested no styling of
2038
+ // its own. Without it a plain add reads as the odd one out even though
2039
+ // every other property matches: the agent is only told the item TEXT of
2040
+ // its siblings, never that they open with a bold lead-in.
2041
+ // 3. ITEM — whole-item `marks` the agent supplied (e.g. Arial 16pt on the
2042
+ // entire item).
2043
+ // 4. RUN — per-segment `marks` over each run's char range, so the agent
2044
+ // can replicate an intra-item pattern (e.g. second word italic).
2045
+ // Best-effort: a format.apply failure must NEVER fail an otherwise-landed
2046
+ // add, so the items still exist.
2047
+ let formattingMatched = { skipped: 'no formatting applied' };
2048
+ let leadInMatched = 0;
2049
+ // What the base layer actually asked for, sampled look included — a receipt
2050
+ // reading `base: {}` hides the run sample the row read cannot see.
2051
+ let reportedBase = null;
1561
2052
  try {
1562
- const inline = inlineLookFromRow(anchor);
2053
+ // The BASE layer must come from the row the item will sit beside, not
2054
+ // from the anchor: a promoted or demoted item crosses levels, and the
2055
+ // anchor's font/size/colour/emphasis is then the wrong level's look.
2056
+ // `lookSourceFor(targetLevel)` already picks the destination sibling for
2057
+ // style and indent, so the look follows the same source. Falls back to
2058
+ // the anchor only when the destination level has no sibling to sample.
2059
+ const anchorBase = inlineLookFromRow(anchor);
1563
2060
  const formatFn = maybeMethod(doc, ['format', 'apply']);
1564
- if (Object.keys(inline).length > 0 && formatFn) {
2061
+ if (formatFn) {
2062
+ const applied = [];
2063
+ let redundantLayers = 0;
2064
+ const applyRange = async (blockId, start, end, inline) => {
2065
+ if (Object.keys(inline).length === 0 || end <= start)
2066
+ return;
2067
+ // Styling runs DIRECT even in tracked mode: these ranges only ever
2068
+ // target items created in THIS call, whose tracked insertion already
2069
+ // owns their accept/reject fate. Tracked format.apply DOES land the
2070
+ // styling, but stacks an rPrChange revision on top of the insertion,
2071
+ // so the reviewer sees a redundant "formatting changed" card.
2072
+ const params = { blockId, start, end, inline };
2073
+ try {
2074
+ await formatFn(params);
2075
+ applied.push({ range: [start, end], inline });
2076
+ }
2077
+ catch (err) {
2078
+ // The layers deliberately overlap, so a later layer often requests
2079
+ // formatting a new item ALREADY has (it inherits the anchor's look
2080
+ // on creation) — format.apply rejects that with NO_OP. A redundant
2081
+ // layer means "nothing left to do here", never "stop styling": each
2082
+ // layer must be isolated, or one NO_OP cancels every layer after it
2083
+ // and the agent's explicit per-run marks silently vanish.
2084
+ if (!isNoOp(err))
2085
+ throw err;
2086
+ redundantLayers += 1;
2087
+ }
2088
+ };
1565
2089
  for (const ci of createdItems) {
1566
- const row = afterRows.find((r) => r.nodeId === ci.nodeId);
1567
- const len = (row?.textPreview ?? ci.text ?? '').length;
1568
- const params = {
1569
- blockId: ci.nodeId,
1570
- start: 0,
1571
- end: Math.max(len, 1),
1572
- inline,
1573
- };
1574
- // Mirror scoped delete_text/format_text: changeMode is a MutationOption
1575
- // passed inside the format.apply params, only when tracking is on.
1576
- if (args.changeMode === 'tracked')
1577
- params.changeMode = args.changeMode;
1578
- await formatFn(params);
2090
+ const len = Math.max(ci.text.length, 1);
2091
+ // An item the agent styled itself is left alone by layers 1–2: its
2092
+ // marks describe the whole intent, and sampling would fight them.
2093
+ const unstyled = ci.itemMarks == null && !ci.runs.some((r) => r.marks);
2094
+ const source = unstyled ? lookSourceFor(ci.targetLevel) : undefined;
2095
+ const sourceText = source?.nodeId ? fullTextById.get(source.nodeId) : undefined;
2096
+ const pattern = source?.nodeId && sourceText ? await listItemRunPattern(doc, sourceText, source.nodeId) : null;
2097
+ // 1. sampled base over the whole item (row fields first, run-level
2098
+ // sample second: the row read omits run styling entirely).
2099
+ const destinationBase = source ? inlineLookFromRow(source) : anchorBase;
2100
+ const merged = { ...destinationBase, ...pattern?.body };
2101
+ reportedBase = reportedBase ?? merged;
2102
+ await applyRange(ci.nodeId, 0, len, merged);
2103
+ // 2. the siblings' label look over this item's own label span
2104
+ if (pattern?.lead) {
2105
+ const span = leadInSpanOf(ci.text, pattern.lead.shape);
2106
+ if (span) {
2107
+ await applyRange(ci.nodeId, span.start, span.end, pattern.lead.look);
2108
+ leadInMatched += 1;
2109
+ }
2110
+ }
2111
+ // 3. whole-item marks
2112
+ await applyRange(ci.nodeId, 0, len, marksToInline(ci.itemMarks));
2113
+ // 4. per-run marks over each run's char range, each relative to the
2114
+ // baseline layers 1–3 established, so an unmarked run reads plain
2115
+ // instead of keeping emphasis it inherited.
2116
+ const runBaseline = { ...merged, ...marksToInline(ci.itemMarks) };
2117
+ // Only an EXPLICIT pattern clears inherited marks. A plain item is
2118
+ // stored as one synthesized, unmarked run — clearing there would undo
2119
+ // the very look layer 1 just sampled from the sibling.
2120
+ const explicitRunPattern = ci.runs.length > 1 || ci.runs.some((r) => r.marks != null);
2121
+ let offset = 0;
2122
+ for (const run of ci.runs) {
2123
+ const runLen = run.text.length;
2124
+ const runInline = explicitRunPattern
2125
+ ? runInlineAgainstBaseline(run.marks, runBaseline)
2126
+ : marksToInline(run.marks);
2127
+ if (Object.keys(runInline).length > 0)
2128
+ await applyRange(ci.nodeId, offset, offset + runLen, runInline);
2129
+ offset += runLen;
2130
+ }
1579
2131
  }
1580
- formattingMatched = inline;
2132
+ formattingMatched =
2133
+ applied.length > 0
2134
+ ? {
2135
+ base: reportedBase ?? {},
2136
+ layers: applied.length,
2137
+ ...(leadInMatched > 0 ? { leadInMatched } : {}),
2138
+ ...(redundantLayers > 0 ? { alreadyMatched: redundantLayers } : {}),
2139
+ }
2140
+ : { skipped: 'no formatting applied' };
1581
2141
  }
1582
2142
  }
1583
2143
  catch (err) {
1584
- formattingMatched = { skipped: err instanceof Error ? err.message : String(err) };
1585
- }
2144
+ formattingMatched = { skipped: reasonOf(err) };
2145
+ }
2146
+ // Word paints the LIST NUMBER from the paragraph mark's run properties
2147
+ // (w:pPr/w:rPr), not from the item's text runs — so an item whose text is
2148
+ // styled correctly still shows a default-black "4." next to bold blue
2149
+ // "1./2./3." siblings. Mirror the item's leading run onto the mark, which
2150
+ // is the relationship those siblings already encode. Best-effort and never
2151
+ // fatal: the item and its text styling have already landed.
2152
+ let markStyledItems = 0;
2153
+ const markerSkipped = await bestEffort(async () => {
2154
+ const setMarkFn = maybeMethod(doc, ['format', 'paragraph', 'setMarkRunProps']);
2155
+ const getNodeFn = maybeMethod(doc, ['getNodeById']);
2156
+ if (!setMarkFn)
2157
+ return;
2158
+ for (const ci of createdItems) {
2159
+ // Explicit marks describe the intent — the whole-item ones AND the
2160
+ // leading run's, merged in that order, so a marker never loses the
2161
+ // item's colour just because the first run also asked for bold.
2162
+ //
2163
+ // With no marks at all, COPY the sibling's own paragraph mark instead of
2164
+ // deriving one from the text runs: a marker must not gain bold its
2165
+ // siblings' markers don't have (bullet lists routinely carry a bare mark
2166
+ // next to bold-lead-in text), and must gain it where they do. A
2167
+ // same-level add already inherits the anchor's mark on creation; this
2168
+ // covers the level-CHANGING add, whose inherited mark came from the
2169
+ // wrong level. Reads nothing today: `getNodeById` reports
2170
+ // indent/alignment/spacing but not `markRunProps`, so an unstyled
2171
+ // promotion keeps the plain marker until that read lands.
2172
+ let markProps = markRunPropsFromMarks({ ...ci.itemMarks, ...ci.runs[0]?.marks });
2173
+ if (!markProps && getNodeFn) {
2174
+ const source = lookSourceFor(ci.targetLevel);
2175
+ if (source?.nodeId)
2176
+ markProps = paragraphMarkRunPropsOf(await getNodeFn({ id: source.nodeId }));
2177
+ }
2178
+ if (!markProps)
2179
+ continue;
2180
+ await setMarkFn({
2181
+ target: { kind: 'block', nodeType: 'paragraph', nodeId: ci.nodeId },
2182
+ markRunProps: markProps,
2183
+ });
2184
+ markStyledItems += 1;
2185
+ }
2186
+ });
1586
2187
  const postIdentity = await readDocumentIdentity(doc);
1587
2188
  const verification = [revisionVerification(pre.revision, postIdentity.revision, true)];
1588
2189
  return {
1589
2190
  status: verification.every((v) => v.passed) ? 'ok' : 'failed',
1590
2191
  intent: `add_list_items: ${args.entries.length} into list @ ${anchor.nodeId}`,
2192
+ ...(styledItems.length ? { styleMatched: styledItems.map((i) => i.styleId ?? '(none)') } : {}),
2193
+ ...(styleSkipped ? { styleSkipped } : {}),
2194
+ ...(indentedItems.length ? { indentMatchedFrom: indentedItems.map((i) => i.from) } : {}),
2195
+ ...(indentSkipped ? { indentSkipped } : {}),
2196
+ ...(markStyledItems > 0 ? { markerStyled: markStyledItems } : {}),
2197
+ ...(markerSkipped ? { markerSkipped } : {}),
1591
2198
  ...(landed.length
1592
2199
  ? {
1593
2200
  note: `landed at ${landed
1594
- .map((l) => (l.marker ? `${l.marker} (level ${l.level})` : `level ${l.level}`))
1595
- .join(', ')}`,
2201
+ .map((l) => {
2202
+ const pos = l.marker ? `${l.marker} (level ${l.level})` : `level ${l.level}`;
2203
+ const between = l.previousBlock || l.nextBlock
2204
+ ? ` between ${JSON.stringify(l.previousBlock ?? '(list start)')} and ${JSON.stringify(l.nextBlock ?? '(list end)')}`
2205
+ : '';
2206
+ return pos + between;
2207
+ })
2208
+ .join(', ')}${landed.every((l) => l.verified)
2209
+ ? ' — placement is verified; re-inspect only if this looks wrong'
2210
+ : ' — read-back incomplete; verify with a NARROW inspect window around the anchor'}`,
1596
2211
  addedItems: landed,
1597
2212
  }
1598
2213
  : {}),
1599
2214
  formattingMatched,
1600
2215
  preSnapshot: { revision: pre.revision, counts: pre.counts },
1601
2216
  postSnapshot: postIdentity,
1602
- selectedTargets: [{ selector: { kind: 'textSearch', terms: [needle] }, matched: [anchor.nodeId] }],
2217
+ selectedTargets: [
2218
+ { selector: { kind: 'textSearch', terms: [needle ?? anchor.textPreview ?? ''] }, matched: [anchor.nodeId] },
2219
+ ],
1603
2220
  executedOperations: executed,
1604
2221
  verification,
1605
2222
  };
@@ -1684,6 +2301,13 @@ async function runCreateTable(doc, args) {
1684
2301
  rationale: `Populated ${appliedCells.length} table cells.`,
1685
2302
  });
1686
2303
  }
2304
+ const styledCount = await styleTableCells(doc, appliedCells, args.changeMode);
2305
+ if (styledCount > 0) {
2306
+ executedOperations.push({
2307
+ operationId: 'doc.format.apply',
2308
+ rationale: `Styled ${styledCount} table cell(s).`,
2309
+ });
2310
+ }
1687
2311
  }
1688
2312
  const post = await docSnapshot.buildDocumentSnapshot(doc);
1689
2313
  const verification = evaluateChecks(pre, post, [
@@ -1855,7 +2479,7 @@ async function runResolveComments(doc, args) {
1855
2479
  result = await patchFn({ commentId: c.id, status: newStatus });
1856
2480
  }
1857
2481
  catch (e) {
1858
- const msg = e instanceof Error ? e.message : String(e);
2482
+ const msg = reasonOf(e);
1859
2483
  if (/unknown field|commentId/i.test(msg)) {
1860
2484
  result = await patchFn({ id: c.id, status: newStatus });
1861
2485
  }
@@ -1967,6 +2591,11 @@ async function runRewriteBlock(doc, args) {
1967
2591
  return failedReceipt('rewrite_block', new Error('selector did not resolve to a body block'), pre);
1968
2592
  }
1969
2593
  const normalizedText = normalizeTitleLikeRewriteText(target.text, args.text);
2594
+ // The engine rewrites the whole block as ONE uniform run — losing an
2595
+ // intra-paragraph pattern (bold "(n)" lead-in), or smearing the first
2596
+ // run's marks across the entire text. Capture the current run layout so
2597
+ // it can be re-applied over the unchanged prefix/suffix afterward.
2598
+ const preRuns = await captureBlockRuns(doc, target.nodeId, target.text);
1970
2599
  const steps = [
1971
2600
  {
1972
2601
  id: 'rewrite-block-1',
@@ -1979,6 +2608,7 @@ async function runRewriteBlock(doc, args) {
1979
2608
  },
1980
2609
  ];
1981
2610
  const result = await executeMutations(doc, steps, args.changeMode);
2611
+ const preservation = await preserveRunPatternAfterRewrite(doc, target.nodeId, target.text, preRuns, normalizedText);
1982
2612
  const post = await docSnapshot.buildDocumentSnapshot(doc);
1983
2613
  const rewrittenBlock = findSnapshotTextByNodeId(post, target.nodeId);
1984
2614
  const verification = [
@@ -1996,7 +2626,10 @@ async function runRewriteBlock(doc, args) {
1996
2626
  preSnapshot: { revision: pre.revision, counts: pre.counts },
1997
2627
  postSnapshot: { revision: post.revision, counts: post.counts },
1998
2628
  selectedTargets: [{ selector: args.selector, matched: [target.nodeId] }],
1999
- executedOperations: [{ operationId: 'doc.mutations.apply', result }],
2629
+ executedOperations: [
2630
+ { operationId: 'doc.mutations.apply', result },
2631
+ ...(preservation ? [{ operationId: 'doc.format.apply', result: preservation }] : []),
2632
+ ],
2000
2633
  verification,
2001
2634
  errors: changed && rewritten
2002
2635
  ? undefined
@@ -2014,6 +2647,238 @@ async function runRewriteBlock(doc, args) {
2014
2647
  return failedReceipt('rewrite_block', err, pre);
2015
2648
  }
2016
2649
  }
2650
+ const RUN_BOOL_MARKS = ['bold', 'italic', 'underline', 'strike'];
2651
+ /**
2652
+ * The format.apply inline bag reproducing a SnapshotRun's marks. Boolean marks
2653
+ * listed in `normalize` are always emitted (true OR explicit false) so ranges
2654
+ * that were plain actively CLEAR a mark the engine smeared across the block.
2655
+ */
2656
+ function inlineFromSnapshotRun(run, normalize) {
2657
+ const inline = {};
2658
+ for (const key of RUN_BOOL_MARKS) {
2659
+ if (run[key])
2660
+ inline[key] = true;
2661
+ else if (normalize.has(key))
2662
+ inline[key] = false;
2663
+ }
2664
+ if (run.fontFamily)
2665
+ inline.fontFamily = run.fontFamily;
2666
+ if (run.fontSize)
2667
+ inline.fontSize = run.fontSize;
2668
+ if (run.color)
2669
+ inline.color = run.color;
2670
+ return inline;
2671
+ }
2672
+ /**
2673
+ * format.apply that tolerates the engine's "produced no change" rejection —
2674
+ * for these restore/pattern passes a no-op range (marks already in the
2675
+ * requested state) is success, and throwing would abandon the REMAINING
2676
+ * ranges of the pass.
2677
+ */
2678
+ async function applyInlineQuiet(formatFn, params) {
2679
+ try {
2680
+ await formatFn(params);
2681
+ return true;
2682
+ }
2683
+ catch (err) {
2684
+ const message = err instanceof Error ? err.message : String(err?.message ?? err);
2685
+ if (/produced no change/i.test(message))
2686
+ return false;
2687
+ throw err;
2688
+ }
2689
+ }
2690
+ /** Best-effort read of a block's current run layout (null when unavailable). */
2691
+ async function captureBlockRuns(doc, nodeId, text) {
2692
+ const queryFn = maybeMethod(doc, ['query', 'match']);
2693
+ const trimmed = text.trim();
2694
+ if (!queryFn || !trimmed || text.includes('\n'))
2695
+ return null;
2696
+ try {
2697
+ const runs = await docSnapshot.matchRunsForBlock(queryFn, trimmed, nodeId);
2698
+ return runs.length > 0 ? runs : null;
2699
+ }
2700
+ catch {
2701
+ return null;
2702
+ }
2703
+ }
2704
+ /**
2705
+ * Re-apply a block's pre-rewrite run pattern after a whole-block text.rewrite.
2706
+ * The engine renders the rewritten block as a single uniform run, which both
2707
+ * drops intra-paragraph formatting AND can smear the first run's marks over
2708
+ * everything. Marks are restored positionally over the parts of the text the
2709
+ * rewrite did NOT change (the common prefix and suffix of old vs new text),
2710
+ * and the changed middle takes the marks of the run it continues from —
2711
+ * Word's "typing continues formatting" rule. Best-effort: never fails the
2712
+ * rewrite.
2713
+ */
2714
+ /**
2715
+ * Span-aware run-pattern restore for scoped replaces. The generic
2716
+ * preserveRunPatternAfterRewrite diffs prefix/suffix and treats everything
2717
+ * between the FIRST and LAST change as one modified middle — with two
2718
+ * separated replacements that flattens independently-styled runs between
2719
+ * them. Here the exact spans are known, so every untouched interval keeps its
2720
+ * original run formatting at the offset-mapped position, and each replacement
2721
+ * inherits the marks of the run it starts in.
2722
+ * `spans` are FULL-TEXT coordinates, ascending, non-overlapping.
2723
+ */
2724
+ async function preserveScopedRunPattern(doc, nodeId, oldText, oldRuns, spans) {
2725
+ if (!oldRuns || oldRuns.length === 0 || spans.length === 0)
2726
+ return null;
2727
+ if (!oldRuns.some((run) => Object.keys(inlineFromSnapshotRun(run, new Set())).length > 0))
2728
+ return null;
2729
+ const formatFn = maybeMethod(doc, ['format', 'apply']);
2730
+ if (!formatFn)
2731
+ return null;
2732
+ const normalize = new Set(RUN_BOOL_MARKS.filter((key) => oldRuns.some((run) => run[key])));
2733
+ // Run offsets (captureBlockRuns) are relative to the TRIMMED text; spans are
2734
+ // full-text. Convert spans to trimmed coordinates and clamp.
2735
+ const oldTrimStart = oldText.length - oldText.trimStart().length;
2736
+ const oldTrimLen = oldText.trim().length;
2737
+ const trimSpans = spans
2738
+ .map((s) => ({
2739
+ start: Math.max(0, s.start - oldTrimStart),
2740
+ end: Math.min(oldTrimLen, s.end - oldTrimStart),
2741
+ replaceLen: (s.replace ?? '').length,
2742
+ }))
2743
+ .filter((s) => s.end > s.start || s.replaceLen > 0);
2744
+ if (trimSpans.length === 0)
2745
+ return null;
2746
+ const newText = spans.reduceRight((text, span) => text.slice(0, span.start) + span.replace + text.slice(span.end), oldText);
2747
+ const newTrimStart = newText.length - newText.trimStart().length;
2748
+ // Cumulative old→new offset shift for positions AT OR AFTER a given old offset.
2749
+ const deltaBefore = (oldPos) => {
2750
+ let d = 0;
2751
+ for (const s of trimSpans) {
2752
+ if (s.end <= oldPos)
2753
+ d += s.replaceLen - (s.end - s.start);
2754
+ else
2755
+ break;
2756
+ }
2757
+ return d;
2758
+ };
2759
+ let applied = 0;
2760
+ const applyRange = async (start, end, inline) => {
2761
+ if (end <= start || Object.keys(inline).length === 0)
2762
+ return;
2763
+ // Always direct, even under tracked mode. This pass RESTORES the marks the
2764
+ // rewrite flattened; it is not new authorial formatting, and the text
2765
+ // revision already owns the span's accept/reject fate. Applying it tracked
2766
+ // adds a second reviewable change per rewrite (SD-4309's conformance matrix
2767
+ // counts one per action), and rejecting that change alone would strand the
2768
+ // text in the flattened look the rewrite produced.
2769
+ const changed = await applyInlineQuiet(formatFn, {
2770
+ blockId: nodeId,
2771
+ start: newTrimStart + start,
2772
+ end: newTrimStart + end,
2773
+ inline,
2774
+ });
2775
+ if (changed)
2776
+ applied += 1;
2777
+ };
2778
+ try {
2779
+ // Untouched intervals between/around the spans, in old trimmed coords.
2780
+ const intervals = [];
2781
+ let cursor = 0;
2782
+ for (const s of trimSpans) {
2783
+ if (s.start > cursor)
2784
+ intervals.push({ start: cursor, end: s.start });
2785
+ cursor = Math.max(cursor, s.end);
2786
+ }
2787
+ if (cursor < oldTrimLen)
2788
+ intervals.push({ start: cursor, end: oldTrimLen });
2789
+ for (const run of oldRuns) {
2790
+ const inline = inlineFromSnapshotRun(run, normalize);
2791
+ for (const iv of intervals) {
2792
+ const a = Math.max(run.start, iv.start);
2793
+ const b = Math.min(run.end, iv.end);
2794
+ if (b <= a)
2795
+ continue;
2796
+ const d = deltaBefore(a); // constant across an untouched interval
2797
+ await applyRange(a + d, b + d, inline);
2798
+ }
2799
+ }
2800
+ // Each replacement span inherits the marks of the run it starts in.
2801
+ for (const s of trimSpans) {
2802
+ if (s.replaceLen === 0)
2803
+ continue;
2804
+ const anchorRun = oldRuns.find((run) => s.start >= run.start && s.start < run.end) ?? oldRuns[oldRuns.length - 1];
2805
+ const newStart = s.start + deltaBefore(s.start);
2806
+ await applyRange(newStart, newStart + s.replaceLen, inlineFromSnapshotRun(anchorRun, normalize));
2807
+ }
2808
+ return applied > 0 ? { restoredRunRanges: applied } : null;
2809
+ }
2810
+ catch (err) {
2811
+ return { skipped: reasonOf(err) };
2812
+ }
2813
+ }
2814
+ async function preserveRunPatternAfterRewrite(doc, nodeId, oldText, oldRuns, newText) {
2815
+ // Nothing to restore when the block was uniformly plain; a single STYLED run
2816
+ // still needs restoring (whole-block replaces drop even uniform marks).
2817
+ if (!oldRuns || oldRuns.length === 0)
2818
+ return null;
2819
+ if (!oldRuns.some((run) => Object.keys(inlineFromSnapshotRun(run, new Set())).length > 0))
2820
+ return null;
2821
+ const formatFn = maybeMethod(doc, ['format', 'apply']);
2822
+ if (!formatFn)
2823
+ return null;
2824
+ // Boolean marks any old run carried: plain ranges must explicitly clear
2825
+ // these, because the rewritten single run may have smeared them everywhere.
2826
+ const normalize = new Set(RUN_BOOL_MARKS.filter((key) => oldRuns.some((run) => run[key])));
2827
+ // query.match run offsets are relative to the trimmed text used to match.
2828
+ const oldTrim = oldText.trim();
2829
+ const newTrimStart = newText.length - newText.trimStart().length;
2830
+ const newTrim = newText.trim();
2831
+ let prefix = 0;
2832
+ const maxCommon = Math.min(oldTrim.length, newTrim.length);
2833
+ while (prefix < maxCommon && oldTrim[prefix] === newTrim[prefix])
2834
+ prefix += 1;
2835
+ let suffix = 0;
2836
+ while (suffix < maxCommon - prefix && oldTrim[oldTrim.length - 1 - suffix] === newTrim[newTrim.length - 1 - suffix])
2837
+ suffix += 1;
2838
+ const delta = newTrim.length - oldTrim.length;
2839
+ let applied = 0;
2840
+ const applyRange = async (start, end, inline) => {
2841
+ if (end <= start || Object.keys(inline).length === 0)
2842
+ return;
2843
+ // Always direct, even under tracked mode. This pass RESTORES the marks the
2844
+ // rewrite flattened; it is not new authorial formatting, and the text
2845
+ // revision already owns the span's accept/reject fate. Applying it tracked
2846
+ // adds a second reviewable change per rewrite (SD-4309's conformance matrix
2847
+ // counts one per action), and rejecting that change alone would strand the
2848
+ // text in the flattened look the rewrite produced.
2849
+ const changed = await applyInlineQuiet(formatFn, {
2850
+ blockId: nodeId,
2851
+ start: newTrimStart + start,
2852
+ end: newTrimStart + end,
2853
+ inline,
2854
+ });
2855
+ if (changed)
2856
+ applied += 1;
2857
+ };
2858
+ try {
2859
+ for (const run of oldRuns) {
2860
+ const inline = inlineFromSnapshotRun(run, normalize);
2861
+ // Portion of the run inside the unchanged prefix.
2862
+ await applyRange(run.start, Math.min(run.end, prefix), inline);
2863
+ // Portion inside the unchanged suffix, shifted by the length delta.
2864
+ const sufStart = Math.max(run.start, oldTrim.length - suffix);
2865
+ if (run.end > sufStart)
2866
+ await applyRange(sufStart + delta, run.end + delta, inline);
2867
+ }
2868
+ // The CHANGED middle keeps whatever the engine smeared; give it the marks
2869
+ // of the run the change continues from (the one containing the last
2870
+ // unchanged prefix character).
2871
+ const middleEnd = newTrim.length - suffix;
2872
+ if (middleEnd > prefix) {
2873
+ const anchorRun = oldRuns.find((run) => prefix > run.start && prefix <= run.end) ?? oldRuns[oldRuns.length - 1];
2874
+ await applyRange(prefix, middleEnd, inlineFromSnapshotRun(anchorRun, normalize));
2875
+ }
2876
+ return applied > 0 ? { restoredRunRanges: applied } : null;
2877
+ }
2878
+ catch (err) {
2879
+ return { skipped: reasonOf(err) };
2880
+ }
2881
+ }
2017
2882
  const NAMED_COLORS = {
2018
2883
  black: '000000',
2019
2884
  blue: '0000FF',
@@ -3354,7 +4219,7 @@ async function runInsertTableRow(doc, args) {
3354
4219
  ];
3355
4220
  if (args.dryRun !== true && args.cellTexts && args.cellTexts.length > 0) {
3356
4221
  const insertedRowIndex = position === 'below' ? rowIndex + 1 : rowIndex;
3357
- const cells = args.cellTexts.map((text, columnIndex) => ({ rowIndex: insertedRowIndex, columnIndex, text }));
4222
+ const cells = args.cellTexts.map((cell, columnIndex) => cellTextFromSpec(cell, insertedRowIndex, columnIndex));
3358
4223
  const applied = await applyTableCellTexts(doc, table.nodeId, table.ordinal, cells, args.changeMode);
3359
4224
  if (applied.length > 0) {
3360
4225
  executed.push({
@@ -3362,6 +4227,28 @@ async function runInsertTableRow(doc, args) {
3362
4227
  rationale: `Populated ${applied.length} cell(s) in new row`,
3363
4228
  });
3364
4229
  }
4230
+ // Inherit the neighbouring row's look BEFORE explicit styling, so an
4231
+ // agent-supplied mark still overrides it (same layering as list items).
4232
+ // The neighbour is the TARGET row at its post-insert index: inserting
4233
+ // above shifts it down by one, and sampling `rowIndex` there would read
4234
+ // the new (still unstyled) row itself and copy nothing.
4235
+ const neighbourRowIndex = position === 'above' ? rowIndex + 1 : rowIndex;
4236
+ const match = await matchTableCellsToNeighbour(doc, table.ordinal, applied, (cell) => ({
4237
+ rowIndex: neighbourRowIndex,
4238
+ columnIndex: cell.columnIndex,
4239
+ }));
4240
+ if (match.matched > 0 || match.skipped) {
4241
+ executed.push({
4242
+ operationId: 'doc.format.apply',
4243
+ rationale: match.skipped
4244
+ ? `Matched ${match.matched} cell(s) to row ${neighbourRowIndex}; skipped: ${match.skipped}`
4245
+ : `Matched ${match.matched} cell(s) to row ${neighbourRowIndex}`,
4246
+ });
4247
+ }
4248
+ const styledCount = await styleTableCells(doc, applied, args.changeMode);
4249
+ if (styledCount > 0) {
4250
+ executed.push({ operationId: 'doc.format.apply', rationale: `Styled ${styledCount} cell(s) in new row` });
4251
+ }
3365
4252
  }
3366
4253
  const postIdentity = await readDocumentIdentity(doc);
3367
4254
  const postTable = await getTableShape(doc, table.nodeId);
@@ -3424,11 +4311,45 @@ async function runInsertTableColumn(doc, args) {
3424
4311
  const executed = [
3425
4312
  { operationId: 'doc.tables.insertColumn', result: insertResult },
3426
4313
  ];
3427
- if (args.headerText) {
3428
- const headerColumnIndex = position === 'right' ? columnIndex + 1 : columnIndex;
3429
- const applied = await applyTableCellTexts(doc, table.nodeId, table.ordinal, [{ rowIndex: 0, columnIndex: headerColumnIndex, text: args.headerText }], args.changeMode);
4314
+ const newColumnIndex = position === 'right' ? columnIndex + 1 : columnIndex;
4315
+ // Per-row styled cells for the new column, or a single (optionally styled)
4316
+ // header cell at row 0.
4317
+ let columnCells = [];
4318
+ if (args.cellTexts && args.cellTexts.length > 0) {
4319
+ columnCells = args.cellTexts.map((cell, rowIndex) => cellTextFromSpec(cell, rowIndex, newColumnIndex));
4320
+ }
4321
+ else if (args.headerText) {
4322
+ columnCells = [{ rowIndex: 0, columnIndex: newColumnIndex, text: args.headerText }];
4323
+ }
4324
+ if (columnCells.length > 0) {
4325
+ // forceDirectFill: a new column's cells live in pre-existing rows, which
4326
+ // carry no tracked-insertion marker, so a tracked fill fails closed.
4327
+ const applied = await applyTableCellTexts(doc, table.nodeId, table.ordinal, columnCells, args.changeMode, true);
3430
4328
  if (applied.length > 0) {
3431
- executed.push({ operationId: 'doc.mutations.apply', rationale: 'Populated header cell' });
4329
+ executed.push({
4330
+ operationId: 'doc.mutations.apply',
4331
+ rationale: `Populated ${applied.length} cell(s) in new column`,
4332
+ });
4333
+ }
4334
+ // Inherit the ADJACENT COLUMN's look per row before explicit styling, so a
4335
+ // header cell stays a header cell: a new column's cells are written into
4336
+ // pre-existing rows and arrive with no run properties, which loses the
4337
+ // header row's bold/colour and the body rows' face.
4338
+ const match = await matchTableCellsToNeighbour(doc, table.ordinal, applied, (cell) => ({
4339
+ rowIndex: cell.rowIndex,
4340
+ columnIndex: columnIndex,
4341
+ }));
4342
+ if (match.matched > 0 || match.skipped) {
4343
+ executed.push({
4344
+ operationId: 'doc.format.apply',
4345
+ rationale: match.skipped
4346
+ ? `Matched ${match.matched} cell(s) to column ${columnIndex}; skipped: ${match.skipped}`
4347
+ : `Matched ${match.matched} cell(s) to column ${columnIndex}`,
4348
+ });
4349
+ }
4350
+ const styledCount = await styleTableCells(doc, applied, args.changeMode);
4351
+ if (styledCount > 0) {
4352
+ executed.push({ operationId: 'doc.format.apply', rationale: `Styled ${styledCount} cell(s) in new column` });
3432
4353
  }
3433
4354
  }
3434
4355
  const postIdentity = await readDocumentIdentity(doc);
@@ -3762,24 +4683,70 @@ async function runScopedReplace(doc, args) {
3762
4683
  planned.push(span);
3763
4684
  lastEnd = span.end;
3764
4685
  }
3765
- const steps = planned.map((span, index) => ({
3766
- id: `scoped-replace-${index + 1}`,
3767
- op: 'text.rewrite',
3768
- where: {
3769
- by: 'target',
3770
- target: {
3771
- kind: 'selection',
3772
- start: { kind: 'text', blockId: nodeId, offset: span.start },
3773
- end: { kind: 'text', blockId: nodeId, offset: span.end },
4686
+ // The v2 engine resolves `where` by block/select/ref only — a
4687
+ // selection-targeted step ("by: 'target'") dies in resolveStepRef with
4688
+ // "requires a ref produced by query.match/find".
4689
+ const expected = planned.reduceRight((text, span) => text.slice(0, span.start) + span.replace + text.slice(span.end), block.text);
4690
+ const tracked = (args.changeMode ?? 'direct') === 'tracked';
4691
+ // Tracked mode: redline ONLY the spans. A whole-block rewrite in tracked
4692
+ // mode records the entire paragraph as deleted+reinserted, which buries a
4693
+ // one-word edit in a full-paragraph redline. Each span becomes a
4694
+ // select-scoped rewrite pinned to this block via `within`; duplicate find
4695
+ // strings would re-resolve ambiguously against tracked-visible deleted
4696
+ // text, so those fall back to the whole-block path below.
4697
+ let result = null;
4698
+ // Dedupe on the same basis the selector will match on: under a
4699
+ // case-sensitive request `Foo` and `foo` are distinct targets, and folding
4700
+ // them together would push a legitimately unique pair onto the fallback.
4701
+ const uniqueFinds = new Set(planned.map((s) => (caseSensitive ? s.find : s.find.toLowerCase())));
4702
+ if (tracked && uniqueFinds.size === planned.length) {
4703
+ const spanSteps = planned.map((span, index) => ({
4704
+ id: `scoped-replace-${index + 1}`,
4705
+ op: 'text.rewrite',
4706
+ where: {
4707
+ by: 'select',
4708
+ // The planner honours caseSensitive, so the selector must match on the
4709
+ // same terms. Without it a case-sensitive request can redline a
4710
+ // differently cased occurrence and still verify, because verification
4711
+ // only looks for the replacement text somewhere in the block.
4712
+ select: { type: 'text', pattern: span.find, mode: 'contains', caseSensitive },
4713
+ require: 'first',
4714
+ within: { kind: 'block', nodeType: block.nodeType, nodeId },
3774
4715
  },
3775
- },
3776
- args: { replacement: { text: span.replace } },
3777
- }));
3778
- const result = await executeMutations(doc, steps, args.changeMode);
4716
+ args: { replacement: { text: span.replace } },
4717
+ }));
4718
+ // Select rewrites flatten the block's runs, so the tracked lane needs the
4719
+ // same restoration the whole-block lane below performs. The preserve pass
4720
+ // applies directly, so restoring marks adds no second reviewable change.
4721
+ const trackedPreRuns = await captureBlockRuns(doc, nodeId, block.text);
4722
+ try {
4723
+ result = await executeMutations(doc, spanSteps, args.changeMode);
4724
+ if (result != null)
4725
+ await preserveScopedRunPattern(doc, nodeId, block.text, trackedPreRuns, planned);
4726
+ }
4727
+ catch {
4728
+ result = null; // select lane unavailable (e.g. case mismatch) — fall back
4729
+ }
4730
+ }
4731
+ if (result == null) {
4732
+ // Direct mode (or tracked fallback): rewrite the whole block once, then
4733
+ // restore the intra-paragraph run pattern span-aware — every interval the
4734
+ // spans did NOT touch keeps its original run formatting at the mapped
4735
+ // offsets, and each replacement inherits the marks of the run it starts in.
4736
+ const preRuns = await captureBlockRuns(doc, nodeId, block.text);
4737
+ const steps = [
4738
+ {
4739
+ id: 'scoped-replace-1',
4740
+ op: 'text.rewrite',
4741
+ where: { by: 'block', nodeType: block.nodeType, nodeId },
4742
+ args: { replacement: { text: expected }, style: preserveRewriteStyle() },
4743
+ },
4744
+ ];
4745
+ result = await executeMutations(doc, steps, args.changeMode);
4746
+ await preserveScopedRunPattern(doc, nodeId, block.text, preRuns, planned);
4747
+ }
3779
4748
  const post = await docSnapshot.buildDocumentSnapshot(doc, { includeDomains: ['blocks', 'tables'] });
3780
4749
  const postBlock = findTextTarget(post, nodeId);
3781
- const expected = planned.reduceRight((text, span) => text.slice(0, span.start) + span.replace + text.slice(span.end), block.text);
3782
- const tracked = (args.changeMode ?? 'direct') === 'tracked';
3783
4750
  // In tracked mode the deleted text is still present in the block, so only
3784
4751
  // require the inserted text to be visible.
3785
4752
  const passed = postBlock != null &&
@@ -3823,6 +4790,301 @@ async function runScopedReplace(doc, args) {
3823
4790
  * so both copy the SAME set of run properties in one place. Only emits keys the
3824
4791
  * row actually carries — an empty result means "nothing to match".
3825
4792
  */
4793
+ /** Points → twips: the node read side reports points, mutations take twips. */
4794
+ const TWIPS_PER_POINT = 20;
4795
+ /**
4796
+ * Build `format.paragraph.setMarkRunProps` input from agent run marks.
4797
+ *
4798
+ * The mark's colour is a discriminated model object, not the plain hex string
4799
+ * `format.apply` takes, and the hex is stored without the leading `#`.
4800
+ * Returns null when the marks say nothing about the mark's appearance.
4801
+ */
4802
+ function markRunPropsFromMarks(marks) {
4803
+ if (!marks)
4804
+ return null;
4805
+ const props = {};
4806
+ if (typeof marks.bold === 'boolean')
4807
+ props.bold = marks.bold;
4808
+ if (typeof marks.italic === 'boolean')
4809
+ props.italic = marks.italic;
4810
+ if (typeof marks.fontFamily === 'string' && marks.fontFamily)
4811
+ props.fontFamily = marks.fontFamily;
4812
+ if (typeof marks.fontSize === 'number' && Number.isFinite(marks.fontSize))
4813
+ props.fontSize = marks.fontSize;
4814
+ if (typeof marks.color === 'string' && marks.color) {
4815
+ props.color = { model: 'rgb', value: marks.color.replace(/^#/, '') };
4816
+ }
4817
+ return Object.keys(props).length > 0 ? props : null;
4818
+ }
4819
+ /**
4820
+ * Pull a paragraph's MARK run properties (`w:pPr/w:rPr`) out of a `getNodeById`
4821
+ * payload, ready to hand straight back to `format.paragraph.setMarkRunProps`
4822
+ * (same internal shape, so the values round-trip). Node-shape tolerant like
4823
+ * paragraphIndentOf. Returns null when the paragraph mark carries nothing.
4824
+ */
4825
+ function paragraphMarkRunPropsOf(payload) {
4826
+ const node = isRecord(payload) ? payload.node : null;
4827
+ if (!isRecord(node))
4828
+ return null;
4829
+ const candidates = [isRecord(node.paragraph) ? node.paragraph.props : null, node.props];
4830
+ for (const props of candidates) {
4831
+ const mark = isRecord(props) ? props.markRunProps : null;
4832
+ if (isRecord(mark) && Object.keys(mark).length > 0)
4833
+ return mark;
4834
+ }
4835
+ return null;
4836
+ }
4837
+ /**
4838
+ * Delimiters that separate a list item's lead-in label from its body text.
4839
+ * Spaced dashes are listed separately from bare ones because the label run
4840
+ * usually stops BEFORE the space ("JavaScript / TypeScript" + " — SuperDoc…").
4841
+ */
4842
+ /**
4843
+ * How many blocks the per-block replace lane will capture runs for. Each target
4844
+ * costs a `query.match` read, so the lane is bounded; edits reaching past it
4845
+ * fall through to the select lane rather than being silently truncated.
4846
+ */
4847
+ const PRESERVE_TARGET_CAP = 20;
4848
+ const LEAD_IN_DELIMITERS = [':', ' — ', ' – ', ' - ', '—', '–'];
4849
+ /** A lead-in is a LABEL, not a sentence — longer spans are prose, not a pattern. */
4850
+ const LEAD_IN_MAX_CHARS = 90;
4851
+ /** Sentence characters sampled past the label, to read the body's own look. */
4852
+ const LEAD_IN_BODY_SAMPLE_CHARS = 40;
4853
+ /**
4854
+ * Where an item's lead-in label ends.
4855
+ *
4856
+ * The SIBLINGS' delimiter wins whenever the item uses it: with a dash-style
4857
+ * list, "Rust: Advanced — details" splits at the dash, not at the colon inside
4858
+ * the label. Falling back to the earliest known delimiter is only for items
4859
+ * that do not use the siblings' one at all.
4860
+ */
4861
+ function leadInEndOf(text, preferred) {
4862
+ const at = (delimiter) => {
4863
+ const index = text.indexOf(delimiter);
4864
+ return index > 0 && index <= LEAD_IN_MAX_CHARS ? index : null;
4865
+ };
4866
+ if (preferred) {
4867
+ const preferredAt = at(preferred);
4868
+ if (preferredAt != null)
4869
+ return preferredAt;
4870
+ }
4871
+ const found = LEAD_IN_DELIMITERS.map(at).filter((index) => index != null);
4872
+ return found.length > 0 ? Math.min(...found) : null;
4873
+ }
4874
+ /** Quote characters a defined term is wrapped in ("Deliverables" means …). */
4875
+ const LEAD_IN_QUOTES = ['"', '\u201c', '\u2018', "'"];
4876
+ /**
4877
+ * Delimiters allowed to sit INSIDE the label run. Wider than the set used to
4878
+ * split at a delimiter that follows the label, because a sentence period only
4879
+ * counts as a label boundary when the reference's own emphasised run ends with
4880
+ * it — "1.7. Records. " — which is evidence no bare text scan can provide.
4881
+ */
4882
+ const LEAD_IN_TAIL_DELIMITERS = ['. ', ': ', ...LEAD_IN_DELIMITERS];
4883
+ /**
4884
+ * Read the label shape a reference block uses, from its text and its label run.
4885
+ *
4886
+ * The delimiter has to sit at the label's edge: immediately AFTER it ("Label:
4887
+ * sentence"), or at its own tail ("1.7. Records. " — a numbered clause whose
4888
+ * marker and label share one run). That adjacency is what distinguishes a label
4889
+ * from an ordinary emphasised opening clause.
4890
+ */
4891
+ function leadInShapeOf(blockText, labelText, quote) {
4892
+ if (quote)
4893
+ return { kind: 'quoted', quote };
4894
+ const after = LEAD_IN_DELIMITERS.find((d) => blockText.startsWith(d, labelText.length));
4895
+ if (after)
4896
+ return { kind: 'before', delimiter: after };
4897
+ const trimmed = labelText.trimEnd();
4898
+ const inside = LEAD_IN_TAIL_DELIMITERS.find((d) => trimmed.endsWith(d.trimEnd()) && trimmed.length > d.trimEnd().length);
4899
+ if (inside) {
4900
+ const occurrence = labelText.split(inside).length - 1;
4901
+ if (occurrence > 0)
4902
+ return { kind: 'inside', delimiter: inside, occurrence };
4903
+ }
4904
+ return null;
4905
+ }
4906
+ /** The span of `text` that carries the label look, or null when it has none. */
4907
+ function leadInSpanOf(text, shape) {
4908
+ if (shape.kind === 'quoted') {
4909
+ const opener = text.slice(0, 2).indexOf(shape.quote);
4910
+ if (opener < 0)
4911
+ return null;
4912
+ const close = text.indexOf(shape.quote, opener + 1);
4913
+ if (close < 0 || close - opener - 1 <= 0 || close > LEAD_IN_MAX_CHARS)
4914
+ return null;
4915
+ return { start: opener + 1, end: close };
4916
+ }
4917
+ if (shape.kind === 'inside') {
4918
+ // Mirror the reference's STRUCTURE: "1.7. Records. " ends at its second
4919
+ // ". ", so "1.8. Review. The parties…" must end at its second one too —
4920
+ // stopping at the first would emphasise the number alone.
4921
+ let at = -1;
4922
+ for (let seen = 0; seen < shape.occurrence; seen += 1) {
4923
+ at = text.indexOf(shape.delimiter, at + 1);
4924
+ if (at < 0)
4925
+ return null;
4926
+ }
4927
+ if (at <= 0 || at > LEAD_IN_MAX_CHARS)
4928
+ return null;
4929
+ return { start: 0, end: at + shape.delimiter.length };
4930
+ }
4931
+ const end = leadInEndOf(text, shape.delimiter);
4932
+ return end == null ? null : { start: 0, end };
4933
+ }
4934
+ /**
4935
+ * Cut `text` at `end` without splitting a surrogate pair: half an emoji is not
4936
+ * document text, and `query.match` finds nothing when handed a lone surrogate —
4937
+ * which reads as "the siblings have no pattern" and drops the styling.
4938
+ */
4939
+ function sliceWholeChars(text, end) {
4940
+ if (end >= text.length)
4941
+ return text;
4942
+ const code = text.charCodeAt(end - 1);
4943
+ const splitsPair = code >= 0xd800 && code <= 0xdbff;
4944
+ return text.slice(0, splitsPair ? end - 1 : end);
4945
+ }
4946
+ function matchRunStyle(run) {
4947
+ const styles = asRecord(asRecord(run)?.styles);
4948
+ const effective = asRecord(styles?.effective);
4949
+ const sizePt = asNumber(styles?.fontSizePt);
4950
+ const fontFamily = asString(styles?.fontFamily);
4951
+ const color = asString(styles?.color);
4952
+ return {
4953
+ bold: effective?.bold === true,
4954
+ italic: effective?.italic === true,
4955
+ ...(sizePt != null && Number.isFinite(sizePt) ? { fontSize: sizePt } : {}),
4956
+ ...(fontFamily ? { fontFamily } : {}),
4957
+ ...(color ? { color } : {}),
4958
+ };
4959
+ }
4960
+ /**
4961
+ * A sampled style as a `format.apply` `inline` payload.
4962
+ *
4963
+ * A toggle that is OFF is dropped rather than sent as an explicit `false`: new
4964
+ * text starts unformatted, so "not bold" needs no write — and writing it would
4965
+ * stamp an explicit `w:b w:val="0"` the sampled siblings themselves don't carry.
4966
+ * The exception is a toggle that must be CLEARED because an earlier layer
4967
+ * (`against`) turned it on.
4968
+ */
4969
+ function inlineFromMatchStyle(style, against) {
4970
+ const inline = {};
4971
+ if (style.bold || against?.bold === true)
4972
+ inline.bold = style.bold;
4973
+ if (style.italic || against?.italic === true)
4974
+ inline.italic = style.italic;
4975
+ if (style.fontSize != null)
4976
+ inline.fontSize = style.fontSize;
4977
+ if (style.fontFamily)
4978
+ inline.fontFamily = style.fontFamily;
4979
+ if (style.color)
4980
+ inline.color = style.color;
4981
+ return inline;
4982
+ }
4983
+ /**
4984
+ * Read an existing list item's RUN PATTERN — the body look plus the "bold
4985
+ * label: plain sentence" lead-in convention its siblings share — through
4986
+ * `query.match`, the only read that reports run styling.
4987
+ *
4988
+ * Why the pattern and not just one look: real lists carry the emphasis INSIDE
4989
+ * the item ("Format Painter Feature Request: Joey requested…" — first run bold,
4990
+ * rest plain). Copying a single sampled look either bolds the whole new item or
4991
+ * none of it; both read as "the agent's item doesn't match the list".
4992
+ *
4993
+ * A LABEL is the first run when a delimiter sits immediately after it and its
4994
+ * style differs from the body's; the body look then comes from the run right
4995
+ * AFTER the label (the sentence it introduces), so neither the bold label nor a
4996
+ * later emphasized phrase becomes the whole item's look.
4997
+ *
4998
+ * Only the head of the item is sampled, not all of it: run decomposition
4999
+ * collapses to a single empty-text run once the matched range spans a
5000
+ * HYPERLINK (these items end in "(00:31:34)" timestamp links), and a window
5001
+ * that stops before the links keeps the label/body split readable.
5002
+ */
5003
+ async function listItemRunPattern(doc, text, blockId) {
5004
+ const queryFn = maybeMethod(doc, ['query', 'match']);
5005
+ if (!queryFn || !text.trim())
5006
+ return null;
5007
+ // Window: through the label's delimiter plus enough of the sentence to sample
5008
+ // it, or a plain head when the item carries no label at all.
5009
+ const delimiterAt = leadInEndOf(text);
5010
+ const windowEnd = delimiterAt != null ? delimiterAt + LEAD_IN_BODY_SAMPLE_CHARS : LEAD_IN_BODY_SAMPLE_CHARS;
5011
+ const pattern = sliceWholeChars(text, windowEnd);
5012
+ const result = asRecord(await queryFn({ select: { type: 'text', pattern } }));
5013
+ const items = Array.isArray(result?.items) ? result.items : [];
5014
+ for (const item of items) {
5015
+ const blocks = Array.isArray(asRecord(item)?.blocks) ? asRecord(item).blocks : [];
5016
+ for (const block of blocks) {
5017
+ const rec = asRecord(block);
5018
+ if (asString(rec?.blockId) !== blockId)
5019
+ continue;
5020
+ const runTextOf = (r) => asString(asRecord(r)?.text, '') ?? '';
5021
+ const runs = (Array.isArray(rec?.runs) ? rec.runs : []).filter((r) => runTextOf(r).length > 0);
5022
+ if (runs.length === 0)
5023
+ continue;
5024
+ const blockText = asString(rec?.text, '') ?? '';
5025
+ // A quoted defined term keeps its opening quote in a run of its own —
5026
+ // `"` + **Deliverables** + `" means …` — so the label is the SECOND run
5027
+ // and the quotes stay unemphasised.
5028
+ const opener = runTextOf(runs[0]).trim();
5029
+ const quote = LEAD_IN_QUOTES.find((q) => opener === q);
5030
+ const labelIndex = quote && runs.length > 2 ? 1 : 0;
5031
+ const label = runs[labelIndex];
5032
+ const labelText = runTextOf(label);
5033
+ const leadStyle = matchRunStyle(label);
5034
+ // A source block that is ONLY a short emphasised label (a heading-ish "LLM
5035
+ // Integration", a signature-block name) has no body look to copy — and
5036
+ // copying its label look over a whole new block would emphasise the
5037
+ // entire thing. Treat it as a lead-in whose span the new text decides.
5038
+ if (runs.length === 1) {
5039
+ const labelOnly = labelText.length <= LEAD_IN_MAX_CHARS && (leadStyle.bold || leadStyle.italic);
5040
+ return labelOnly
5041
+ ? { body: {}, lead: { look: inlineFromMatchStyle(leadStyle), shape: { kind: 'before', delimiter: null } } }
5042
+ : { body: inlineFromMatchStyle(leadStyle) };
5043
+ }
5044
+ // The body is the run the label INTRODUCES, not the longest run in the
5045
+ // block: a later emphasised phrase is part of the prose, and sampling it
5046
+ // would stamp its emphasis across the whole new block.
5047
+ const bodyStyle = matchRunStyle(runs[labelIndex + 1]);
5048
+ const differs = ['bold', 'italic', 'fontSize', 'fontFamily', 'color'].some((k) => leadStyle[k] !== bodyStyle[k]);
5049
+ const shape = leadInShapeOf(blockText, labelText, labelIndex > 0 ? quote : undefined);
5050
+ const isLabel = differs && shape != null && labelText.length > 0 && labelText.length <= LEAD_IN_MAX_CHARS;
5051
+ return {
5052
+ body: inlineFromMatchStyle(bodyStyle),
5053
+ ...(isLabel ? { lead: { look: inlineFromMatchStyle(leadStyle, bodyStyle), shape: shape } } : {}),
5054
+ };
5055
+ }
5056
+ }
5057
+ return null;
5058
+ }
5059
+ /**
5060
+ * Pull a paragraph's direct indentation out of a `getNodeById` payload and
5061
+ * convert it to the twips that `format.paragraph.setIndentation` expects.
5062
+ *
5063
+ * The node shape nests the properties under the block kind
5064
+ * (`node.paragraph.props.indent`); older/other shapes put them at
5065
+ * `node.props.indent`. Both are accepted so a shape change degrades to "no
5066
+ * indent copied" rather than a thrown read.
5067
+ */
5068
+ function paragraphIndentOf(payload) {
5069
+ const node = isRecord(payload) ? payload.node : null;
5070
+ if (!isRecord(node))
5071
+ return null;
5072
+ const candidates = [isRecord(node.paragraph) ? node.paragraph.props : null, node.props];
5073
+ for (const props of candidates) {
5074
+ const indent = isRecord(props) ? props.indent : null;
5075
+ if (!isRecord(indent))
5076
+ continue;
5077
+ const twips = {};
5078
+ for (const key of ['left', 'right', 'firstLine', 'hanging']) {
5079
+ const value = indent[key];
5080
+ if (typeof value === 'number' && Number.isFinite(value))
5081
+ twips[key] = Math.round(value * TWIPS_PER_POINT);
5082
+ }
5083
+ if (Object.keys(twips).length > 0)
5084
+ return twips;
5085
+ }
5086
+ return null;
5087
+ }
3826
5088
  function inlineLookFromRow(row) {
3827
5089
  const inline = {};
3828
5090
  if (row.fontFamily)
@@ -3838,6 +5100,229 @@ function inlineLookFromRow(row) {
3838
5100
  inline.italic = true;
3839
5101
  return inline;
3840
5102
  }
5103
+ /**
5104
+ * Build a `doc.format.apply` `inline` payload from agent-supplied run marks.
5105
+ * Same key set + semantics as inlineLookFromRow (so anchor auto-match and
5106
+ * explicit per-run styling stack cleanly), plus underline/strike/highlight.
5107
+ * Booleans are emitted whether true or false so an explicit `false` can CLEAR a
5108
+ * mark the anchor/whole-item default set. Returns {} when nothing is requested.
5109
+ */
5110
+ /**
5111
+ * The inline bag for ONE run of an explicit run pattern, relative to whatever
5112
+ * the item already carries.
5113
+ *
5114
+ * An inserted item inherits its neighbour's look, and the whole-item layer may
5115
+ * add more, so a run that asks for nothing is not automatically plain: without
5116
+ * explicit `false` values a pattern like [plain, bold] renders entirely bold
5117
+ * when the item inherited bold. Boolean marks active in the baseline are
5118
+ * therefore cleared on any run that does not ask for them itself.
5119
+ */
5120
+ function runInlineAgainstBaseline(marks, baseline) {
5121
+ const inline = marksToInline(marks);
5122
+ for (const key of RUN_BOOL_MARKS) {
5123
+ if (baseline[key] === true && inline[key] === undefined)
5124
+ inline[key] = false;
5125
+ }
5126
+ return inline;
5127
+ }
5128
+ function marksToInline(marks) {
5129
+ const inline = {};
5130
+ if (!marks)
5131
+ return inline;
5132
+ if (typeof marks.bold === 'boolean')
5133
+ inline.bold = marks.bold;
5134
+ if (typeof marks.italic === 'boolean')
5135
+ inline.italic = marks.italic;
5136
+ if (typeof marks.underline === 'boolean')
5137
+ inline.underline = marks.underline;
5138
+ if (typeof marks.strikethrough === 'boolean')
5139
+ inline.strike = marks.strikethrough;
5140
+ if (typeof marks.fontFamily === 'string' && marks.fontFamily)
5141
+ inline.fontFamily = marks.fontFamily;
5142
+ if (typeof marks.fontSize === 'number' && Number.isFinite(marks.fontSize))
5143
+ inline.fontSize = marks.fontSize;
5144
+ if (typeof marks.color === 'string' && marks.color)
5145
+ inline.color = marks.color;
5146
+ if (typeof marks.highlight === 'string' && marks.highlight)
5147
+ inline.highlight = marks.highlight;
5148
+ return inline;
5149
+ }
5150
+ /** Parse a raw agent mark bag, keeping only recognized keys with valid types. */
5151
+ function normalizeMarks(raw) {
5152
+ if (!isRecord(raw))
5153
+ return undefined;
5154
+ const m = {};
5155
+ if (typeof raw.bold === 'boolean')
5156
+ m.bold = raw.bold;
5157
+ if (typeof raw.italic === 'boolean')
5158
+ m.italic = raw.italic;
5159
+ if (typeof raw.underline === 'boolean')
5160
+ m.underline = raw.underline;
5161
+ if (typeof raw.strikethrough === 'boolean')
5162
+ m.strikethrough = raw.strikethrough;
5163
+ if (typeof raw.fontFamily === 'string')
5164
+ m.fontFamily = raw.fontFamily;
5165
+ const sz = asNumber(raw.fontSize);
5166
+ if (sz != null && Number.isFinite(sz))
5167
+ m.fontSize = sz;
5168
+ if (typeof raw.color === 'string')
5169
+ m.color = raw.color;
5170
+ if (typeof raw.highlight === 'string')
5171
+ m.highlight = raw.highlight;
5172
+ return Object.keys(m).length > 0 ? m : undefined;
5173
+ }
5174
+ /**
5175
+ * Normalize one agent-supplied list item (string or object) into an
5176
+ * AgentListItemSpec. Accepts `{ text }`, `{ runs:[{text,marks}] }`, an optional
5177
+ * whole-item `marks`, and a relative `level`. Returns null for junk.
5178
+ */
5179
+ function normalizeItemSpec(raw) {
5180
+ if (typeof raw === 'string')
5181
+ return { text: raw, level: 0 };
5182
+ if (!isRecord(raw))
5183
+ return null;
5184
+ const spec = {};
5185
+ const lvl = asNumber(raw.level);
5186
+ spec.level = lvl != null ? Math.floor(lvl) : 0;
5187
+ const itemMarks = normalizeMarks(raw.marks);
5188
+ if (itemMarks)
5189
+ spec.marks = itemMarks;
5190
+ if (Array.isArray(raw.runs)) {
5191
+ const runs = [];
5192
+ for (const r of raw.runs) {
5193
+ if (typeof r === 'string') {
5194
+ runs.push({ text: r });
5195
+ }
5196
+ else if (isRecord(r) && typeof r.text === 'string') {
5197
+ const rm = normalizeMarks(r.marks);
5198
+ runs.push(rm ? { text: r.text, marks: rm } : { text: r.text });
5199
+ }
5200
+ }
5201
+ if (runs.length > 0) {
5202
+ spec.runs = runs;
5203
+ return spec;
5204
+ }
5205
+ }
5206
+ if (typeof raw.text === 'string') {
5207
+ spec.text = raw.text;
5208
+ return spec;
5209
+ }
5210
+ return null;
5211
+ }
5212
+ /** The plain text an item renders as (concatenated runs, or its `text`). */
5213
+ function itemSpecText(spec) {
5214
+ if (spec.runs && spec.runs.length > 0)
5215
+ return spec.runs.map((r) => r.text).join('');
5216
+ return spec.text ?? '';
5217
+ }
5218
+ /**
5219
+ * Resolve a 1-based `listOrdinal` (as reported by inspect) to the nodeId of that
5220
+ * list's last item, so a listOrdinal-addressed add can anchor by nodeId and go
5221
+ * through runAddListItems (which applies numbering + per-run formatting) instead
5222
+ * of the text-only workflow path. Best-effort: undefined if it can't resolve.
5223
+ */
5224
+ async function resolveListAnchorNodeId(doc, listOrdinal) {
5225
+ try {
5226
+ const snap = await docSnapshot.buildDocumentSnapshot(doc, { includeDomains: ['lists'] });
5227
+ const lists = Array.isArray(snap?.lists) ? snap.lists : [];
5228
+ const items = lists[listOrdinal - 1]?.items;
5229
+ if (!Array.isArray(items))
5230
+ return undefined;
5231
+ for (let i = items.length - 1; i >= 0; i -= 1) {
5232
+ const id = items[i]?.nodeId;
5233
+ if (typeof id === 'string' && id)
5234
+ return id;
5235
+ }
5236
+ }
5237
+ catch {
5238
+ // best-effort — fall back to the ghost/workflow path
5239
+ }
5240
+ return undefined;
5241
+ }
5242
+ /**
5243
+ * Apply whole-item + per-run marks to already-created list items, aligned by
5244
+ * index with `specs`. Layered per item (later overrides earlier on overlapping
5245
+ * keys): optional `baseInline` over the whole item, then the item's `marks`,
5246
+ * then each run's `marks` over its char range. This runs as a post-insert pass
5247
+ * (via doc.format.apply on the created nodeId), so it works no matter which
5248
+ * insert path created the item — including the text-only list workflow, which
5249
+ * is the only insert path some runtimes expose. Best-effort: a format failure
5250
+ * never invalidates an otherwise-landed insert.
5251
+ */
5252
+ async function applyItemStyling(doc, nodeIds, specs, changeMode, baseInline) {
5253
+ const formatFn = maybeMethod(doc, ['format', 'apply']);
5254
+ if (!formatFn)
5255
+ return { skipped: 'format.apply unavailable' };
5256
+ let layers = 0;
5257
+ let redundantLayers = 0;
5258
+ const applyRange = async (blockId, start, end, inline) => {
5259
+ if (Object.keys(inline).length === 0 || end <= start)
5260
+ return;
5261
+ const params = { blockId, start, end, inline };
5262
+ if (changeMode === 'tracked')
5263
+ params.changeMode = changeMode;
5264
+ try {
5265
+ await formatFn(params);
5266
+ layers += 1;
5267
+ }
5268
+ catch (err) {
5269
+ // Inserted content often ALREADY carries part of the requested look
5270
+ // (it inherits from its neighbour), and format.apply answers a redundant
5271
+ // range with NO_OP. That means "nothing left to do here", never "stop
5272
+ // styling": isolating it per range keeps later ranges — and later items —
5273
+ // from silently losing their marks. Genuine failures still propagate.
5274
+ if (!isNoOp(err))
5275
+ throw err;
5276
+ redundantLayers += 1;
5277
+ }
5278
+ };
5279
+ try {
5280
+ const seenNodeIds = new Set();
5281
+ for (let i = 0; i < nodeIds.length; i += 1) {
5282
+ const nodeId = nodeIds[i];
5283
+ const spec = specs[i];
5284
+ if (!nodeId || !spec)
5285
+ continue;
5286
+ // A single target should be styled once — merged table cells can resolve
5287
+ // several requested coordinates to the same paragraph nodeId, and styling
5288
+ // it twice would overlap/overwrite.
5289
+ if (seenNodeIds.has(nodeId))
5290
+ continue;
5291
+ seenNodeIds.add(nodeId);
5292
+ const text = itemSpecText(spec);
5293
+ // Run offsets are block-relative to a SINGLE paragraph. A value spanning
5294
+ // multiple paragraphs (newlines) can't be mapped to one nodeId without the
5295
+ // ranges overflowing the block, so skip styling it (text is still applied).
5296
+ if (text.includes('\n'))
5297
+ continue;
5298
+ const runs = spec.runs && spec.runs.length > 0 ? spec.runs : [{ text: spec.text ?? '' }];
5299
+ const len = Math.max(text.length, 1);
5300
+ if (baseInline)
5301
+ ;
5302
+ await applyRange(nodeId, 0, len, marksToInline(spec.marks));
5303
+ // Same baseline reasoning as the list path: an unmarked run must clear
5304
+ // boolean marks the base/whole-item layers put underneath it.
5305
+ const runBaseline = { ...baseInline, ...marksToInline(spec.marks) };
5306
+ const explicitRunPattern = (spec.runs?.length ?? 0) > 1 || (spec.runs ?? []).some((r) => r.marks != null);
5307
+ let offset = 0;
5308
+ for (const run of runs) {
5309
+ const runLen = run.text.length;
5310
+ const runInline = explicitRunPattern
5311
+ ? runInlineAgainstBaseline(run.marks, runBaseline)
5312
+ : marksToInline(run.marks);
5313
+ if (Object.keys(runInline).length > 0)
5314
+ await applyRange(nodeId, offset, offset + runLen, runInline);
5315
+ offset += runLen;
5316
+ }
5317
+ }
5318
+ if (layers > 0)
5319
+ return { layers, ...(redundantLayers > 0 ? { alreadyMatched: redundantLayers } : {}) };
5320
+ return redundantLayers > 0 ? { layers: 0, alreadyMatched: redundantLayers } : { skipped: 'no marks requested' };
5321
+ }
5322
+ catch (err) {
5323
+ return { skipped: reasonOf(err) };
5324
+ }
5325
+ }
3841
5326
  async function listBlockRows(doc) {
3842
5327
  const fn = maybeMethod(doc, ['blocks', 'list']);
3843
5328
  if (!fn)
@@ -3849,6 +5334,11 @@ function blockNumbering(row) {
3849
5334
  const numbering = row?.numbering;
3850
5335
  return numbering && (numbering.marker || numbering.path) ? numbering : null;
3851
5336
  }
5337
+ /** A list row's outline level, or null when the row carries no numbering. */
5338
+ function levelOfRow(row) {
5339
+ const path = blockNumbering(row)?.path;
5340
+ return Array.isArray(path) ? Math.max(0, path.length - 1) : null;
5341
+ }
3852
5342
  /** One real list across a contiguous paragraph range (lists.create fromParagraphs). */
3853
5343
  async function createListFromParagraphRange(createFn, kind, fromNodeId, toNodeId, changeMode) {
3854
5344
  // Dual dialect — see executeCreateParagraph: input key for the CLI
@@ -4140,7 +5630,7 @@ async function runConvertList(doc, args) {
4140
5630
  // level does not exist in the abstract definition"). lists.create
4141
5631
  // converts the paragraph range into a fresh real list instead — the
4142
5632
  // correct result in every one of these cases.
4143
- const message = error instanceof Error ? error.message : String(error);
5633
+ const message = reasonOf(error);
4144
5634
  const recoverable = /numbering metadata|abstract definition|level does not exist/i.test(message);
4145
5635
  if (!recoverable || !createFn) {
4146
5636
  throw error;
@@ -4156,7 +5646,7 @@ async function runConvertList(doc, args) {
4156
5646
  // "Ghost" list items: listItem nodes with no numbering metadata.
4157
5647
  // setType/applyPreset/create all refuse them — detach to plain
4158
5648
  // paragraphs first (nodeIds are stable), then create the real list.
4159
- const second = secondError instanceof Error ? secondError.message : String(secondError);
5649
+ const second = reasonOf(secondError);
4160
5650
  const detachFn = maybeMethod(doc, ['lists', 'detach']);
4161
5651
  if (!/already list items/i.test(second) || !detachFn) {
4162
5652
  throw secondError;
@@ -4408,7 +5898,7 @@ function indentsEqual(a, b) {
4408
5898
  const keys = ['left', 'right', 'firstLine', 'hanging'];
4409
5899
  return keys.every((k) => (a?.[k] ?? 0) === (b?.[k] ?? 0));
4410
5900
  }
4411
- async function matchOneBlock(doc, postBlocks, preIds, created, createdIndex, anchorRow, allowNumberingAttach, isTitle = false) {
5901
+ async function matchOneBlock(doc, postBlocks, preIds, created, createdIndex, anchorRow, allowNumberingAttach, isTitle = false, allowLeadPattern = true, createdText = '') {
4412
5902
  try {
4413
5903
  // Reference: placement anchor first, then nearest same-type sibling
4414
5904
  // (previous preferred), skipping empties and other fresh blocks.
@@ -4453,7 +5943,7 @@ async function matchOneBlock(doc, postBlocks, preIds, created, createdIndex, anc
4453
5943
  }
4454
5944
  catch (err) {
4455
5945
  // style/format matching continues, but the receipt must say so
4456
- applied.numberingSkipped = err instanceof Error ? err.message : String(err);
5946
+ applied.numberingSkipped = reasonOf(err);
4457
5947
  }
4458
5948
  }
4459
5949
  }
@@ -4509,14 +5999,176 @@ async function matchOneBlock(doc, postBlocks, preIds, created, createdIndex, anc
4509
5999
  applied.indent = refIndent;
4510
6000
  }
4511
6001
  }
6002
+ // Lead-marker pattern: manually lettered/numbered sections style the
6003
+ // marker run differently from the body ("(m) " bold, rest plain) — a
6004
+ // per-run pattern blocks.list rows cannot express. When the reference
6005
+ // block opens with such a styled marker and the created text opens with a
6006
+ // similar marker token, reproduce the reference's marker/body split on the
6007
+ // created block. Skipped when the caller supplied explicit runs/marks.
6008
+ if (allowLeadPattern) {
6009
+ const lead = await matchLeadMarkerPattern(doc, reference, created, createdText);
6010
+ if (lead)
6011
+ applied.leadMarker = lead;
6012
+ // matchLeadMarkerPattern only knows a shared literal lead ("WHEREAS") and
6013
+ // numbered tokens ("1.", "(m)"), and it needs the reference split across
6014
+ // two runs. Sample the reference's RUNS for everything else: a quoted
6015
+ // defined term (`"Deliverables" means …`), a "1.7. Records. " clause whose
6016
+ // marker and label share a run, and — the case nothing covered — a
6017
+ // UNIFORM reference, where the whole look transfers (a bold signature-block
6018
+ // name beside which the new one landed plain).
6019
+ else {
6020
+ const runPattern = await matchBlockRunPattern(doc, reference, created, createdText);
6021
+ // Assigning `undefined` would still create the key, and an `applied` with
6022
+ // keys reads as "this block was matched" — hiding a block that got nothing.
6023
+ if (runPattern)
6024
+ applied.runPattern = runPattern;
6025
+ }
6026
+ }
4512
6027
  if (Object.keys(applied).length > 0) {
4513
6028
  return { matchedSibling: reference.nodeId, applied };
4514
6029
  }
4515
6030
  return null;
4516
6031
  }
4517
6032
  catch (err) {
4518
- return { matchedSibling: null, skipped: err instanceof Error ? err.message : String(err) };
6033
+ return { matchedSibling: null, skipped: reasonOf(err) };
6034
+ }
6035
+ }
6036
+ /** A lead-in marker token like "(m)", "1.", "a)", "(iv)" at the start of clause text. */
6037
+ const LEAD_MARKER_PATTERN = /^\(?(?:[a-zA-Z]{1,3}|\d{1,3})[).:\]]/;
6038
+ /**
6039
+ * Reproduce a reference block's lead/body run split on a freshly-created
6040
+ * block. Fires only when the reference genuinely has the pattern — its first
6041
+ * run is a SHORT lead-in styled differently from the body — and the created
6042
+ * text opens with a matching lead of its own. Two lead shapes are recognized:
6043
+ * - literal lead words shared by siblings ("WHEREAS", "NOW, THEREFORE"): the
6044
+ * created text starts with the reference's exact lead text;
6045
+ * - marker tokens ("(m)", "1.", "a)"): the token text differs per clause, so
6046
+ * the created lead is its OWN token matched by shape.
6047
+ * Applies the reference lead's marks to the created lead span and the
6048
+ * reference body's marks to the rest — with explicit false for boolean marks
6049
+ * only one side has, so formatting inherited from a mis-styled anchor is
6050
+ * corrected, not compounded.
6051
+ */
6052
+ async function matchLeadMarkerPattern(doc, reference, created, createdText) {
6053
+ const createdPreview = created.textPreview ?? '';
6054
+ if (!reference.nodeId || !created.nodeId || !createdPreview)
6055
+ return null;
6056
+ const queryFn = maybeMethod(doc, ['query', 'match']);
6057
+ const formatFn = maybeMethod(doc, ['format', 'apply']);
6058
+ if (!queryFn || !formatFn)
6059
+ return null;
6060
+ // A short unique-enough prefix of the reference is all we need: the runs
6061
+ // returned tile the MATCHED range, covering the lead and the body start.
6062
+ // blocks.list truncates textPreview with a trailing ellipsis — strip it, the
6063
+ // remaining prefix IS document text so query.match can find it.
6064
+ const refProbe = (reference.textPreview ?? '')
6065
+ .replace(/…\s*$/, '')
6066
+ .slice(0, 60)
6067
+ .trim();
6068
+ if (!refProbe)
6069
+ return null;
6070
+ const refRuns = await docSnapshot.matchRunsForBlock(queryFn, refProbe, reference.nodeId);
6071
+ if (refRuns.length < 2)
6072
+ return null; // uniform reference — no split to copy
6073
+ const leadRun = refRuns[0];
6074
+ const bodyRun = refRuns[1];
6075
+ // The pattern requires a real FORMATTING transition between lead and body
6076
+ // (bold/italic/underline/strike). Runs split only by font/color/hyperlink
6077
+ // boundaries are ordinary prose — restyling from those would misfire.
6078
+ if (RUN_BOOL_MARKS.every((key) => Boolean(leadRun[key]) === Boolean(bodyRun[key])))
6079
+ return null;
6080
+ const refLead = leadRun.text.trimEnd();
6081
+ // The first run must be a short lead-in (a token or a word or two), not
6082
+ // half the sentence.
6083
+ if (refLead.length === 0 || refLead.length > 40 || leadRun.start !== 0)
6084
+ return null;
6085
+ // The caller knows the text it inserted; the blocks.list preview is truncated.
6086
+ const fullText = createdText || createdPreview.replace(/…\s*$/, '');
6087
+ // The created block's own lead span (ASCII markers only by design).
6088
+ let createdLeadLen = 0;
6089
+ const boundaryAfter = (len) => len >= fullText.length || !/[A-Za-z0-9]/.test(fullText[len]);
6090
+ if (fullText.startsWith(refLead) && boundaryAfter(refLead.length)) {
6091
+ // Literal shared lead word(s) — "WHEREAS, …" after a "WHEREAS, …" sibling.
6092
+ // Exact-case with a token boundary so "Note" never claims "Notebook".
6093
+ createdLeadLen = refLead.length;
6094
+ }
6095
+ else {
6096
+ const refMarker = LEAD_MARKER_PATTERN.exec(refLead);
6097
+ const createdMarker = LEAD_MARKER_PATTERN.exec(fullText);
6098
+ // Marker tokens: the reference's first run must BE the token, and the
6099
+ // created text must open with a token of the same category (letters
6100
+ // with letters, digits with digits — "(m)" doesn't restyle "1.").
6101
+ if (refMarker &&
6102
+ createdMarker &&
6103
+ refMarker[0] === refLead &&
6104
+ /\d/.test(refMarker[0]) === /\d/.test(createdMarker[0])) {
6105
+ createdLeadLen = createdMarker[0].length;
6106
+ }
6107
+ }
6108
+ if (createdLeadLen === 0)
6109
+ return null;
6110
+ const normalize = new Set(RUN_BOOL_MARKS.filter((key) => leadRun[key] !== bodyRun[key]));
6111
+ const markerInline = inlineFromSnapshotRun(leadRun, normalize);
6112
+ const bodyInline = inlineFromSnapshotRun(bodyRun, normalize);
6113
+ if (Object.keys(markerInline).length === 0 && Object.keys(bodyInline).length === 0)
6114
+ return null;
6115
+ let changed = false;
6116
+ if (Object.keys(markerInline).length > 0) {
6117
+ changed =
6118
+ (await applyInlineQuiet(formatFn, {
6119
+ blockId: created.nodeId,
6120
+ start: 0,
6121
+ end: createdLeadLen,
6122
+ inline: markerInline,
6123
+ })) || changed;
6124
+ }
6125
+ if (Object.keys(bodyInline).length > 0 && fullText.length > createdLeadLen) {
6126
+ changed =
6127
+ (await applyInlineQuiet(formatFn, {
6128
+ blockId: created.nodeId,
6129
+ start: createdLeadLen,
6130
+ end: fullText.length,
6131
+ inline: bodyInline,
6132
+ })) || changed;
6133
+ }
6134
+ return { marker: fullText.slice(0, createdLeadLen), markerInline, bodyInline, changed };
6135
+ }
6136
+ /**
6137
+ * Copy a reference block's RUN pattern onto a created block: the body look over
6138
+ * the whole block, then the label look over the new block's own label span.
6139
+ *
6140
+ * Complements matchLeadMarkerPattern, which requires either a shared literal
6141
+ * lead word or a marker token AND a two-run reference. This one reads the
6142
+ * reference's runs (`query.match`, the only read that reports run styling) and
6143
+ * handles the shapes that leaves out — including a reference with a single
6144
+ * uniform run, whose look simply transfers.
6145
+ */
6146
+ async function matchBlockRunPattern(doc, reference, created, createdText) {
6147
+ const formatFn = maybeMethod(doc, ['format', 'apply']);
6148
+ const referenceText = (reference.textPreview ?? '').replace(/…\s*$/, '').trim();
6149
+ const fullText = createdText || (created.textPreview ?? '').replace(/…\s*$/, '');
6150
+ if (!formatFn || !reference.nodeId || !created.nodeId || !referenceText || !fullText)
6151
+ return null;
6152
+ const pattern = await listItemRunPattern(doc, referenceText, reference.nodeId);
6153
+ if (!pattern)
6154
+ return null;
6155
+ const end = Math.max(fullText.length, 1);
6156
+ const applied = {};
6157
+ if (Object.keys(pattern.body).length > 0) {
6158
+ if (await applyInlineQuiet(formatFn, { blockId: created.nodeId, start: 0, end, inline: pattern.body })) {
6159
+ applied.body = pattern.body;
6160
+ }
6161
+ }
6162
+ if (pattern.lead) {
6163
+ const span = leadInSpanOf(fullText, pattern.lead.shape);
6164
+ // A uniform reference has no body look and no delimiter of its own: its look
6165
+ // belongs to the whole created block, not to a label span inside it.
6166
+ const range = span ?? (Object.keys(pattern.body).length === 0 ? { start: 0, end } : null);
6167
+ if (range && (await applyInlineQuiet(formatFn, { blockId: created.nodeId, ...range, inline: pattern.lead.look }))) {
6168
+ applied.lead = { ...pattern.lead.look, range: [range.start, range.end] };
6169
+ }
4519
6170
  }
6171
+ return Object.keys(applied).length > 0 ? { matchedSibling: reference.nodeId, ...applied } : null;
4520
6172
  }
4521
6173
  async function matchInsertedBlockFormatting(doc, preBlocks, receipt, args) {
4522
6174
  if (receipt.status !== 'ok')
@@ -4535,6 +6187,17 @@ async function matchInsertedBlockFormatting(doc, preBlocks, receipt, args) {
4535
6187
  const anchorSelector = placement && isRecord(placement.selector) ? placement.selector : null;
4536
6188
  const anchorId = anchorSelector && typeof anchorSelector.nodeId === 'string' ? anchorSelector.nodeId : null;
4537
6189
  const anchorRow = anchorId ? (postBlocks.find((b) => b.nodeId === anchorId) ?? null) : null;
6190
+ // When the caller styled any entry explicitly (runs/marks), it took
6191
+ // ownership of intra-paragraph formatting — the automatic lead-marker
6192
+ // pattern pass must not fight it.
6193
+ const rawTexts = Array.isArray(args?.texts) ? args.texts : [];
6194
+ const hasExplicitStyling = rawTexts.some((entry) => isRecord(entry) && (Array.isArray(entry.runs) || entry.marks != null));
6195
+ const insertedTexts = rawTexts.map((entry) => {
6196
+ if (typeof entry === 'string')
6197
+ return entry;
6198
+ const spec = normalizeItemSpec(entry);
6199
+ return spec ? itemSpecText(spec) : '';
6200
+ });
4538
6201
  const allApplied = [];
4539
6202
  for (let ci = 0; ci < createdRows.length; ci += 1) {
4540
6203
  const { row: created, index: createdIndex } = createdRows[ci];
@@ -4542,7 +6205,24 @@ async function matchInsertedBlockFormatting(doc, preBlocks, receipt, args) {
4542
6205
  // read like a heading (flush-left), so it must NOT inherit the body
4543
6206
  // sibling's left indent even though it matches the same anchor.
4544
6207
  const isTitle = createdRows.length > 1 && ci === 0;
4545
- const result = await matchOneBlock(doc, postBlocks, preIds, created, createdIndex, anchorRow, createdRows.length === 1, isTitle);
6208
+ const createdText = insertedTexts[ci] ?? '';
6209
+ const match = (anchor) => matchOneBlock(doc, postBlocks, preIds, created, createdIndex, anchor, createdRows.length === 1, isTitle, !hasExplicitStyling,
6210
+ // The texts the caller inserted, in order — the truncated preview
6211
+ // cannot measure a label span or the end of the block.
6212
+ createdText);
6213
+ let result = await match(anchorRow);
6214
+ // A GROUP of paragraphs usually repeats the group before it: a signature
6215
+ // block is name / "By:" / "Name:", and the new name line should look like
6216
+ // the previous NAME line, not like the "Name:" line directly above it.
6217
+ // Only tried when matching against the neighbour found nothing, so this
6218
+ // cannot change a case that already had an answer.
6219
+ const applied = isRecord(result?.applied) ? result.applied : null;
6220
+ if (createdRows.length > 1 && (!applied || Object.keys(applied).length === 0)) {
6221
+ const parallel = postBlocks[createdIndex - createdRows.length];
6222
+ if (parallel && preIds.has(parallel.nodeId) && !parallel.isEmpty) {
6223
+ result = (await match(parallel)) ?? result;
6224
+ }
6225
+ }
4546
6226
  if (result)
4547
6227
  allApplied.push(result);
4548
6228
  }
@@ -4555,7 +6235,7 @@ async function matchInsertedBlockFormatting(doc, preBlocks, receipt, args) {
4555
6235
  // insert because of it — but the receipt says it was skipped.
4556
6236
  return {
4557
6237
  ...receipt,
4558
- contextualFormatting: { skipped: err instanceof Error ? err.message : String(err) },
6238
+ contextualFormatting: { skipped: reasonOf(err) },
4559
6239
  };
4560
6240
  }
4561
6241
  }
@@ -4766,10 +6446,20 @@ async function superdocPerformAction(doc, args) {
4766
6446
  }
4767
6447
  switch (action) {
4768
6448
  case 'insert_paragraphs': {
4769
- // Accept either `texts` (in order) or a single `text` for one paragraph.
4770
- const singleText = asString(args.text);
4771
- const texts = parseStringArray(args.texts) ?? (singleText ? [singleText] : undefined);
4772
- if (!texts || texts.length === 0) {
6449
+ // Accept `texts` (in order) or a single `text`. Each entry may be a
6450
+ // plain string or a {text, runs, marks} spec — styled entries get a
6451
+ // post-insert format.apply pass (mirrors add_list_items / cellTexts).
6452
+ const rawEntries = Array.isArray(args.texts) ? args.texts : args.text != null ? [args.text] : [];
6453
+ const texts = [];
6454
+ const textSpecs = [];
6455
+ for (const raw of rawEntries) {
6456
+ const spec = normalizeItemSpec(raw);
6457
+ if (!spec)
6458
+ continue;
6459
+ texts.push(itemSpecText(spec));
6460
+ textSpecs.push((spec.runs && spec.runs.length > 0) || spec.marks != null ? spec : null);
6461
+ }
6462
+ if (texts.length === 0) {
4773
6463
  throw new errors.SuperDocCliError('insert_paragraphs requires a non-empty "texts" array (or a "text" string for a single paragraph)', {
4774
6464
  code: 'INVALID_ARGUMENT',
4775
6465
  });
@@ -4779,6 +6469,7 @@ async function superdocPerformAction(doc, args) {
4779
6469
  const receipt = await runInsertParagraphs(doc, {
4780
6470
  action,
4781
6471
  texts,
6472
+ textSpecs,
4782
6473
  placement: parsePlacement(args.placement),
4783
6474
  changeMode: parseChangeMode(args.changeMode),
4784
6475
  headingLevel: headingLevel != null && Number.isInteger(headingLevel) && headingLevel >= 1 && headingLevel <= 6
@@ -5105,7 +6796,7 @@ async function superdocPerformAction(doc, args) {
5105
6796
  args.position === 'below'
5106
6797
  ? args.position
5107
6798
  : undefined;
5108
- const cellTexts = parseLooseStringArray(args.cellTexts);
6799
+ const cellTexts = parseCellArray(args.cellTexts);
5109
6800
  return runInsertTableRow(doc, {
5110
6801
  action,
5111
6802
  tableOrdinal: tableOrdinal != null && Number.isInteger(tableOrdinal) && tableOrdinal >= 1 ? tableOrdinal : undefined,
@@ -5126,6 +6817,7 @@ async function superdocPerformAction(doc, args) {
5126
6817
  columnIndex: columnIndex != null && Number.isInteger(columnIndex) && columnIndex >= 0 ? columnIndex : undefined,
5127
6818
  position,
5128
6819
  headerText: asString(args.headerText),
6820
+ cellTexts: parseCellArray(args.cellTexts) ?? undefined,
5129
6821
  changeMode: parseChangeMode(args.changeMode),
5130
6822
  });
5131
6823
  }
@@ -5291,25 +6983,21 @@ async function superdocPerformAction(doc, args) {
5291
6983
  const anchorText = asString(args.anchorText) ?? '';
5292
6984
  const listOrdinalRaw = asNumber(args.listOrdinal);
5293
6985
  const listOrdinal = listOrdinalRaw != null && Number.isInteger(listOrdinalRaw) && listOrdinalRaw >= 1 ? listOrdinalRaw : undefined;
6986
+ // Parse entries + items — either may carry per-run styling (runs/marks).
6987
+ // `items` is an alias for `entries` at the list's base level.
6988
+ const entries = [];
5294
6989
  const rawEntries = Array.isArray(args.entries) ? args.entries : [];
5295
- const entries = rawEntries
5296
- .map((e) => {
5297
- if (typeof e === 'string')
5298
- return { text: e, level: 0 };
5299
- if (isRecord(e) && typeof e.text === 'string') {
5300
- // Preserve NEGATIVE levels (dedent toward the top); runAddListItems
5301
- // clamps the final outline level at 0.
5302
- const lvl = asNumber(e.level);
5303
- return { text: e.text, level: lvl != null ? Math.floor(lvl) : 0 };
6990
+ for (const e of rawEntries) {
6991
+ const spec = normalizeItemSpec(e);
6992
+ if (spec)
6993
+ entries.push(spec);
6994
+ }
6995
+ if (entries.length === 0 && Array.isArray(args.items)) {
6996
+ for (const it of args.items) {
6997
+ const spec = normalizeItemSpec(it);
6998
+ if (spec)
6999
+ entries.push(spec);
5304
7000
  }
5305
- return null;
5306
- })
5307
- .filter((e) => e != null);
5308
- // `items` is a plain-string alias for `entries` at the list's base level.
5309
- const plainItems = parseStringArray(args.items);
5310
- if (entries.length === 0 && plainItems) {
5311
- for (const t of plainItems)
5312
- entries.push({ text: t, level: 0 });
5313
7001
  }
5314
7002
  if (entries.length === 0) {
5315
7003
  throw new errors.SuperDocCliError('add_list_items requires non-empty "entries" or "items"', {
@@ -5322,17 +7010,27 @@ async function superdocPerformAction(doc, args) {
5322
7010
  });
5323
7011
  }
5324
7012
  const changeMode = parseChangeMode(args.changeMode);
5325
- // Preferred path: a real NUMBERED list found by anchorText — attaches each
5326
- // entry at the right nesting level, reusing the list's marker scheme.
7013
+ // Preferred path: attach each entry to a real NUMBERED list (reuses the
7014
+ // list's markers + numbering, and applies anchor + whole-item + per-run
7015
+ // formatting). Locate the list by anchorText, else by resolving
7016
+ // listOrdinal to its last item's nodeId — the latter lets styled inserts
7017
+ // reach this path even when the model addressed the list by ordinal.
7018
+ let anchored = null;
5327
7019
  if (anchorText) {
5328
- const attached = await runAddListItems(doc, { action, anchorText, entries, changeMode }, { fallbackOnMissing: true });
5329
- if (attached)
5330
- return attached;
7020
+ anchored = await runAddListItems(doc, { action, anchorText, entries, changeMode }, { fallbackOnMissing: true });
7021
+ }
7022
+ if (!anchored && listOrdinal != null) {
7023
+ const anchorNodeId = await resolveListAnchorNodeId(doc, listOrdinal);
7024
+ if (anchorNodeId) {
7025
+ anchored = await runAddListItems(doc, { action, anchorNodeId, entries, changeMode }, { fallbackOnMissing: true });
7026
+ }
5331
7027
  }
7028
+ if (anchored)
7029
+ return anchored;
5332
7030
  // Fallback: ghost lists (imported list-looking paragraphs with no
5333
- // numbering) and listOrdinal addressing. Nesting collapses to the base
5334
- // level on this path.
5335
- const flatItems = entries.map((e) => e.text);
7031
+ // numbering) and listOrdinal addressing. This path is text-only, so any
7032
+ // requested per-run styling is dropped here.
7033
+ const flatItems = entries.map(itemSpecText);
5336
7034
  const ghost = await insertListItemsIntoGhostList(doc, {
5337
7035
  items: flatItems,
5338
7036
  anchorText: anchorText || undefined,
@@ -5341,7 +7039,7 @@ async function superdocPerformAction(doc, args) {
5341
7039
  });
5342
7040
  if (ghost)
5343
7041
  return ghost;
5344
- return runInsertListItems(doc, { items: flatItems, listOrdinal, changeMode });
7042
+ return runInsertListItems(doc, { items: flatItems, listOrdinal, styled: entries, changeMode });
5345
7043
  }
5346
7044
  }
5347
7045
  }
@@ -5392,6 +7090,23 @@ function parseEdits(value) {
5392
7090
  }
5393
7091
  return result;
5394
7092
  }
7093
+ /** Parse one cell input: a plain string, or a styled {text, runs, marks} object.
7094
+ * Unparseable input coerces to an empty cell so a grid isn't voided by one bad cell. */
7095
+ function parseCellCandidate(cell) {
7096
+ if (typeof cell === 'string')
7097
+ return cell;
7098
+ if (isRecord(cell)) {
7099
+ const spec = normalizeItemSpec(cell);
7100
+ if (spec)
7101
+ return { text: spec.text, runs: spec.runs, marks: spec.marks };
7102
+ }
7103
+ return '';
7104
+ }
7105
+ function parseCellArray(value) {
7106
+ if (!Array.isArray(value))
7107
+ return undefined;
7108
+ return value.map(parseCellCandidate);
7109
+ }
5395
7110
  function parseCellTexts(value) {
5396
7111
  if (!Array.isArray(value))
5397
7112
  return undefined;
@@ -5399,13 +7114,7 @@ function parseCellTexts(value) {
5399
7114
  for (const row of value) {
5400
7115
  if (!Array.isArray(row))
5401
7116
  return undefined;
5402
- const cells = [];
5403
- for (const cell of row) {
5404
- if (typeof cell !== 'string')
5405
- return undefined;
5406
- cells.push(cell);
5407
- }
5408
- result.push(cells);
7117
+ result.push(row.map(parseCellCandidate));
5409
7118
  }
5410
7119
  return result;
5411
7120
  }