@superdoc/sdk 2.9.1-next.2 → 2.10.0-next.10

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 (57) hide show
  1. package/README.md +45 -0
  2. package/dist/action-primitives/doc-index.cjs +6 -4
  3. package/dist/action-primitives/doc-index.js +6 -4
  4. package/dist/action-primitives/engine.cjs +6 -2
  5. package/dist/action-primitives/engine.js +6 -2
  6. package/dist/action-primitives/receipt.d.ts +4 -0
  7. package/dist/action-primitives/tools/structure-insert.d.ts +1 -1
  8. package/dist/agent/actions.cjs +384 -373
  9. package/dist/agent/actions.d.ts +5 -0
  10. package/dist/agent/actions.js +385 -374
  11. package/dist/agent/catalog.cjs +15 -0
  12. package/dist/agent/catalog.js +15 -0
  13. package/dist/agent/doc-snapshot.cjs +205 -86
  14. package/dist/agent/doc-snapshot.d.ts +11 -0
  15. package/dist/agent/doc-snapshot.js +203 -86
  16. package/dist/agent/execution-context.cjs +385 -0
  17. package/dist/agent/execution-context.d.ts +97 -0
  18. package/dist/agent/execution-context.js +376 -0
  19. package/dist/agent/runtime.cjs +123 -117
  20. package/dist/agent/runtime.d.ts +3 -0
  21. package/dist/agent/runtime.js +124 -118
  22. package/dist/agent/v2-preset-compat.cjs +5 -1
  23. package/dist/agent/v2-preset-compat.js +4 -1
  24. package/dist/embedded-tools.generated.cjs +5 -5
  25. package/dist/embedded-tools.generated.js +5 -5
  26. package/dist/generated/client.cjs +2 -0
  27. package/dist/generated/client.d.ts +85 -0
  28. package/dist/generated/client.js +2 -0
  29. package/dist/generated/contract.cjs +1796 -1297
  30. package/dist/generated/contract.js +1797 -1297
  31. package/dist/index.cjs +23 -0
  32. package/dist/index.d.ts +7 -2
  33. package/dist/index.js +23 -0
  34. package/dist/presets/core.cjs +1 -1
  35. package/dist/presets/core.js +1 -1
  36. package/dist/runtime/document-evidence.cjs +40 -0
  37. package/dist/runtime/document-evidence.d.ts +13 -0
  38. package/dist/runtime/document-evidence.js +30 -0
  39. package/dist/runtime/document-rpc.cjs +27 -0
  40. package/dist/runtime/document-rpc.d.ts +2 -0
  41. package/dist/runtime/document-rpc.js +25 -0
  42. package/dist/runtime/host.cjs +65 -4
  43. package/dist/runtime/host.d.ts +3 -0
  44. package/dist/runtime/host.js +66 -5
  45. package/dist/runtime/process.cjs +38 -0
  46. package/dist/runtime/process.d.ts +16 -0
  47. package/dist/runtime/process.js +38 -0
  48. package/dist/runtime/sdk-version.generated.cjs +1 -1
  49. package/dist/runtime/sdk-version.generated.d.ts +1 -1
  50. package/dist/runtime/sdk-version.generated.js +1 -1
  51. package/package.json +10 -8
  52. package/tools/catalog.json +89 -0
  53. package/tools/tools-policy.json +1 -1
  54. package/tools/tools.anthropic.json +89 -0
  55. package/tools/tools.generic.json +89 -0
  56. package/tools/tools.openai.json +89 -0
  57. package/tools/tools.vercel.json +89 -0
@@ -1,8 +1,9 @@
1
+ import { ExecutionContext, evaluateFactChecks } from './execution-context.js';
1
2
  import { SuperDocCliError } from '../runtime/errors.js';
2
3
  import { runSuperdocListTransformWorkflow } from '../action-primitives/tools/list-transform.js';
3
4
  import { runSuperdocStructureInsertWorkflow } from '../action-primitives/tools/structure-insert.js';
4
5
  import { runSuperdocTextTransformWorkflow } from '../action-primitives/tools/text-transform.js';
5
- import { buildDocumentSnapshot, matchRunsForBlock, resolveSnapshotSelector, } from './doc-snapshot.js';
6
+ import { buildDocumentSnapshot, buildMutationSnapshot, matchRunsForBlock, MutationSnapshotError, resolveSnapshotSelector, } from './doc-snapshot.js';
6
7
  const ACTION_NAMES = [
7
8
  'insert_paragraphs',
8
9
  'insert_heading',
@@ -167,18 +168,18 @@ export const ACTION_GROUPS = [
167
168
  * action at compile time.
168
169
  */
169
170
  export const ACTION_ARGS = {
170
- insert_paragraphs: ['text', 'texts', 'headingLevel', 'placement', 'changeMode'],
171
+ insert_paragraphs: ['text', 'texts', 'headingLevel', 'placement', 'changeMode', 'evidence'],
171
172
  insert_heading: ['text', 'level', 'placement', 'changeMode'],
172
173
  replace_text: ['edits', 'selector', 'caseSensitive', 'changeMode'],
173
174
  delete_text: ['finds', 'selector', 'caseSensitive', 'changeMode'],
174
175
  delete_blocks: ['selectors', 'selector', 'changeMode'],
175
176
  append_list: ['items', 'kind', 'headingText', 'headingLevel', 'placement', 'changeMode'],
176
- create_table: ['rows', 'columns', 'cellTexts', 'placement', 'changeMode'],
177
+ create_table: ['rows', 'columns', 'cellTexts', 'placement', 'changeMode', 'evidence'],
177
178
  comment_paragraphs: ['commentText', 'scope', 'excludeBlockQuotes'],
178
179
  add_comments: ['commentText', 'selector', 'selectors'],
179
180
  resolve_comments: ['anchorText', 'reopen'],
180
181
  reply_to_comment: ['commentText', 'anchorText', 'commentId'],
181
- rewrite_block: ['text', 'selector', 'changeMode'],
182
+ rewrite_block: ['text', 'selector', 'changeMode', 'evidence'],
182
183
  accept_tracked_changes: ['author', 'changeType'],
183
184
  reject_tracked_changes: ['author', 'changeType'],
184
185
  normalize_body_font_size: ['fontSize', 'changeMode'],
@@ -213,7 +214,7 @@ export const ACTION_ARGS = {
213
214
  'changeMode',
214
215
  ],
215
216
  apply_style: ['selector', 'styleId', 'headingLevel', 'likeText'],
216
- format_paragraph: ['selector', 'alignment', 'changeMode'],
217
+ format_paragraph: ['selector', 'alignment', 'changeMode', 'evidence'],
217
218
  move_text: ['text', 'afterText', 'changeMode'],
218
219
  style_table: ['tableOrdinal', 'accentColor'],
219
220
  move_table: ['tableOrdinal', 'placement'],
@@ -316,10 +317,6 @@ function findSnapshotTextByNodeId(snapshot, nodeId) {
316
317
  }
317
318
  return null;
318
319
  }
319
- function lastBlock(snapshot) {
320
- const block = snapshot.blocks[snapshot.blocks.length - 1];
321
- return block ? { nodeId: block.nodeId, nodeType: block.nodeType } : null;
322
- }
323
320
  function createdBlockTarget(result) {
324
321
  const rec = asRecord(result);
325
322
  const paragraph = asRecord(rec?.paragraph);
@@ -511,6 +508,8 @@ function revisionVerification(preRevision, postRevision, expectChanged) {
511
508
  }
512
509
  function failedReceipt(intent, err, preSnapshot) {
513
510
  const message = reasonOf(err);
511
+ const snapshotError = err instanceof MutationSnapshotError ? err : null;
512
+ const errorCode = snapshotError?.code ?? asString(asRecord(err)?.code) ?? 'ACTION_FAILED';
514
513
  return {
515
514
  status: 'failed',
516
515
  intent,
@@ -520,7 +519,13 @@ function failedReceipt(intent, err, preSnapshot) {
520
519
  selectedTargets: [],
521
520
  executedOperations: [],
522
521
  verification: [],
523
- errors: [{ code: 'ACTION_FAILED', message }],
522
+ errors: [
523
+ {
524
+ code: errorCode,
525
+ message,
526
+ ...(snapshotError || errorCode === 'REVISION_CONFLICT' ? { recovery: { kind: 'reinspect' } } : {}),
527
+ },
528
+ ],
524
529
  };
525
530
  }
526
531
  async function receiptFromWorkflowResult(doc, intent, pre, workflowResult, selectedTargets = [], checks = [{ kind: 'revision-changed' }]) {
@@ -564,14 +569,16 @@ async function receiptFromWorkflowResult(doc, intent, pre, workflowResult, selec
564
569
  }
565
570
  function buildFullBlockTextTarget(snapshot, blockId) {
566
571
  const block = snapshot.blocks.find((entry) => entry.nodeId === blockId);
567
- if (!block)
572
+ const cell = block ? null : snapshot.tables.flatMap((table) => table.cells).find((entry) => entry.nodeId === blockId);
573
+ const text = block?.text ?? cell?.text;
574
+ if (text == null)
568
575
  return null;
569
576
  return {
570
577
  kind: 'text',
571
578
  blockId,
572
579
  range: {
573
580
  start: 0,
574
- end: block.text.length,
581
+ end: text.length,
575
582
  },
576
583
  };
577
584
  }
@@ -624,8 +631,8 @@ function evaluateChecks(pre, post, checks) {
624
631
  else if (check.kind === 'comment-count-delta') {
625
632
  results.push({
626
633
  check,
627
- passed: post.comments.length - pre.comments.length === check.delta,
628
- detail: `pre=${pre.comments.length} post=${post.comments.length}`,
634
+ passed: post.counts.comments - pre.counts.comments === check.delta,
635
+ detail: `pre=${pre.counts.comments} post=${post.counts.comments}`,
629
636
  });
630
637
  }
631
638
  else if (check.kind === 'tracked-change-count-delta') {
@@ -772,13 +779,28 @@ async function executeCreateHeading(doc, text, level, placement, changeMode) {
772
779
  // Dual dialect — see executeCreateParagraph.
773
780
  return fn(params, changeMode ? { changeMode } : undefined);
774
781
  }
775
- async function executeMutations(doc, steps, changeMode) {
782
+ async function executeMutations(doc, steps, changeMode, expectedRevision) {
776
783
  const fn = maybeMethod(doc, ['mutations', 'apply']);
777
784
  if (!fn)
778
785
  throw new SuperDocCliError('doc.mutations.apply is not available on the document handle.', {
779
786
  code: 'TOOL_DISPATCH_NOT_FOUND',
780
787
  });
781
- return fn({ atomic: true, changeMode: changeMode ?? 'direct', steps });
788
+ return fn({
789
+ atomic: true,
790
+ changeMode: changeMode ?? 'direct',
791
+ steps,
792
+ ...(expectedRevision ? { expectedRevision } : {}),
793
+ });
794
+ }
795
+ async function previewMutations(doc, steps, changeMode, expectedRevision) {
796
+ const fn = maybeMethod(doc, ['mutations', 'preview']);
797
+ if (!fn) {
798
+ throw new SuperDocCliError('doc.mutations.preview is not available on the document handle.', {
799
+ code: 'TOOL_DISPATCH_NOT_FOUND',
800
+ });
801
+ }
802
+ const result = await fn({ atomic: true, changeMode: changeMode ?? 'direct', expectedRevision, steps });
803
+ return isRecord(result) ? result : {};
782
804
  }
783
805
  async function executeCreateTable(doc, args, placement) {
784
806
  const fn = maybeMethod(doc, ['create', 'table']);
@@ -832,7 +854,7 @@ forceDirectFill = false) {
832
854
  });
833
855
  }
834
856
  const extracted = asRecord(await extractFn({}));
835
- const blocks = (Array.isArray(extracted?.blocks) ? extracted?.blocks : [])
857
+ const blocks = (Array.isArray(extracted?.blocks) ? extracted.blocks : [])
836
858
  .map((block) => asRecord(block))
837
859
  .filter((block) => block != null)
838
860
  .flatMap((block) => {
@@ -1021,97 +1043,74 @@ async function styleTableCells(doc, cells, changeMode) {
1021
1043
  await applyItemStyling(doc, nodeIds, specs, changeMode);
1022
1044
  return styled.length;
1023
1045
  }
1024
- async function runInsertParagraphs(doc, args) {
1025
- const pre = await buildDocumentSnapshot(doc, { includeDomains: ['blocks'] });
1046
+ async function runInsertParagraphs(doc, args, formatArgs) {
1047
+ const context = new ExecutionContext(doc, { evidence: args.evidence, selectors: placementSelectors(args.placement) });
1026
1048
  try {
1027
- // `texts` is the canonical input; a single `text` is normalized to one item
1028
- // upstream in the dispatcher, but tolerate it here too.
1029
- const texts = args.texts ?? (args.text ? [args.text] : []);
1030
- const placement = resolvePlacement(args.placement, pre);
1031
- const executedOperations = [];
1032
- // Blank-line spacing between drafted paragraphs is only desirable when
1033
- // creating a NEW document (a blank doc has ~one empty block). Inserting into
1034
- // an existing doc must NOT scatter blank paragraphs through it.
1035
- const isNewDocument = (pre.counts?.blocks ?? 0) <= 1;
1036
- // For the first item: respect the requested placement and headingLevel.
1037
- // For subsequent items: append after the previously inserted block by
1038
- // using `documentEnd` (the SDK keeps blocks contiguous) so order is
1039
- // preserved.
1049
+ const pre = await context.start();
1050
+ const placement = await resolveFactPlacement(args.placement, pre);
1051
+ const isNewDocument = (await pre.count()) <= 1;
1052
+ const anchor = placement.kind === 'before' || placement.kind === 'after'
1053
+ ? await pre.block(placement.target.nodeId)
1054
+ : await pre.target({
1055
+ kind: 'placement',
1056
+ at: placement.kind === 'documentStart' ? 'document_start' : 'document_end',
1057
+ });
1058
+ const preRows = pre.complete ? await listBlockRows(doc) : anchor ? [anchor] : [];
1040
1059
  let currentPlacement = placement;
1041
- let headingFirst = false;
1042
- if (typeof args.headingLevel === 'number' && args.headingLevel >= 1 && args.headingLevel <= 6) {
1043
- headingFirst = true;
1044
- }
1045
- // Advance the insertion point to sit right after the block just created
1046
- // (prefer the receipt's created id; fall back to the document's last block).
1047
- const advanceAfter = async (result) => {
1048
- const created = createdBlockTarget(result);
1049
- if (created) {
1050
- return { kind: 'after', target: { kind: 'block', nodeType: created.nodeType, nodeId: created.nodeId } };
1060
+ const nodeIds = [];
1061
+ const specs = [];
1062
+ const advance = async (result) => {
1063
+ const target = createdBlockTarget(result);
1064
+ if (target) {
1065
+ currentPlacement = {
1066
+ kind: 'after',
1067
+ target: { kind: 'block', nodeType: target.nodeType, nodeId: target.nodeId },
1068
+ };
1069
+ return target.nodeId;
1051
1070
  }
1052
- const mid = await buildDocumentSnapshot(doc, { includeDomains: ['blocks'] });
1053
- const last = lastBlock(mid);
1054
- return last
1055
- ? { kind: 'after', target: { kind: 'block', nodeType: last.nodeType, nodeId: last.nodeId } }
1056
- : { kind: 'documentEnd' };
1071
+ const current = await context.finishRevision();
1072
+ const last = await current.target({ kind: 'placement', at: 'document_end' });
1073
+ if (!last)
1074
+ throw new Error('Created paragraph identity is unavailable.');
1075
+ currentPlacement = { kind: 'after', target: { kind: 'block', nodeType: last.nodeType, nodeId: last.nodeId } };
1076
+ return last.nodeId;
1057
1077
  };
1058
- const styledNodeIds = [];
1059
- const styledSpecs = [];
1060
- for (let i = 0; i < texts.length; i += 1) {
1061
- const text = texts[i];
1062
- const isFirst = i === 0;
1063
- const result = isFirst && headingFirst
1064
- ? await executeCreateHeading(doc, text, args.headingLevel, currentPlacement, args.changeMode)
1065
- : await executeCreateParagraph(doc, text, currentPlacement, args.changeMode);
1066
- executedOperations.push({
1067
- operationId: isFirst && headingFirst ? 'doc.create.heading' : 'doc.create.paragraph',
1068
- result,
1069
- });
1070
- // Remember the created block for the post-insert styling pass when this
1071
- // paragraph carries a runs/marks spec.
1072
- const spec = args.textSpecs?.[i];
1073
- if (spec && ((spec.runs && spec.runs.length > 0) || spec.marks != null)) {
1074
- const created = createdBlockTarget(result);
1075
- if (created?.nodeId) {
1076
- styledNodeIds.push(created.nodeId);
1077
- styledSpecs.push({ ...spec, text });
1078
- }
1079
- }
1080
- currentPlacement = await advanceAfter(result);
1081
- // New-document drafts get a blank paragraph after each item for spacing;
1082
- // inserts into an existing document do not (would scatter blank lines).
1083
- if (isNewDocument) {
1084
- const spacer = await executeCreateParagraph(doc, '', currentPlacement, args.changeMode);
1085
- executedOperations.push({ operationId: 'doc.create.paragraph', result: spacer, rationale: 'spacing' });
1086
- currentPlacement = await advanceAfter(spacer);
1078
+ const texts = args.texts ?? (args.text ? [args.text] : []);
1079
+ for (let i = 0; i < texts.length; i++) {
1080
+ const result = i === 0 && args.headingLevel
1081
+ ? await executeCreateHeading(context.document, texts[i], args.headingLevel, currentPlacement, args.changeMode)
1082
+ : await executeCreateParagraph(context.document, texts[i], currentPlacement, args.changeMode);
1083
+ const id = await advance(result);
1084
+ nodeIds.push(id);
1085
+ specs.push({ ...args.textSpecs?.[i], text: texts[i] });
1086
+ if (isNewDocument)
1087
+ await advance(await executeCreateParagraph(context.document, '', currentPlacement, args.changeMode));
1088
+ }
1089
+ if (specs.some((spec) => spec.runs?.length || spec.marks))
1090
+ await applyItemStyling(context.document, nodeIds, specs, args.changeMode);
1091
+ let post = await context.finishRevision();
1092
+ let formatted;
1093
+ if (formatArgs) {
1094
+ let localRows;
1095
+ if (!pre.complete) {
1096
+ const created = await post.page({ nodeIds, limit: Math.max(nodeIds.length, 1) });
1097
+ localRows = [...(anchor ? [anchor] : []), ...created].sort((a, b) => a.ordinal - b.ordinal);
1087
1098
  }
1099
+ const count = context.executedOperations.length;
1100
+ formatted = await matchInsertedBlockFormatting(context.document, preRows, context.receipt('insert_paragraphs', []), formatArgs, localRows);
1101
+ if (context.executedOperations.length !== count)
1102
+ post = await context.finishRevision();
1088
1103
  }
1089
- if (styledNodeIds.length > 0) {
1090
- const styling = await applyItemStyling(doc, styledNodeIds, styledSpecs, args.changeMode);
1091
- executedOperations.push({
1092
- operationId: 'doc.format.apply',
1093
- result: styling,
1094
- rationale: `Styled ${styledNodeIds.length} inserted paragraph(s)`,
1095
- });
1096
- }
1097
- const postIdentity = await readDocumentIdentity(doc);
1098
- const verification = [revisionVerification(pre.revision, postIdentity.revision, true)];
1099
- return {
1100
- status: verification.every((v) => v.passed) ? 'ok' : 'failed',
1101
- intent: 'insert_paragraphs',
1102
- preSnapshot: { revision: pre.revision, counts: pre.counts },
1103
- postSnapshot: postIdentity,
1104
- selectedTargets: [],
1105
- executedOperations,
1106
- verification,
1107
- };
1104
+ return context.receipt('insert_paragraphs', await evaluateFactChecks(pre, post, [{ kind: 'revision-changed' }]), formatted?.contextualFormatting ? { contextualFormatting: formatted.contextualFormatting } : {});
1108
1105
  }
1109
- catch (err) {
1110
- return failedReceipt('insert_paragraphs', err, pre);
1106
+ catch (error) {
1107
+ return context.failure('insert_paragraphs', error);
1111
1108
  }
1112
1109
  }
1113
1110
  async function runInsertHeading(doc, args) {
1114
- const pre = await buildDocumentSnapshot(doc, { includeDomains: ['blocks'] });
1111
+ const pre = args.placement?.at === 'before' || args.placement?.at === 'after'
1112
+ ? await buildMutationSnapshot(doc, { includeDomains: ['blocks'] })
1113
+ : await buildDocumentSnapshot(doc, { includeDomains: ['blocks'] });
1115
1114
  try {
1116
1115
  const placement = resolvePlacement(args.placement, pre);
1117
1116
  const result = await executeCreateHeading(doc, args.text, args.level, placement, args.changeMode);
@@ -1133,10 +1132,10 @@ async function runInsertHeading(doc, args) {
1133
1132
  }
1134
1133
  async function runReplaceText(doc, args) {
1135
1134
  const selectorDomains = args.selector ? snapshotDomainsForSelector(args.selector) : null;
1136
- const requiresBlockSnapshot = args.selector != null || args.edits.length > 1;
1135
+ const requiresBlockSnapshot = args.selector != null;
1137
1136
  const preIdentity = requiresBlockSnapshot ? null : await readDocumentIdentity(doc);
1138
1137
  const pre = requiresBlockSnapshot
1139
- ? await buildDocumentSnapshot(doc, { includeDomains: selectorDomains ?? ['blocks'] })
1138
+ ? await buildMutationSnapshot(doc, { includeDomains: selectorDomains ?? ['blocks'] })
1140
1139
  : snapshotFromIdentity(preIdentity);
1141
1140
  try {
1142
1141
  if (args.edits.length === 0) {
@@ -1146,6 +1145,7 @@ async function runReplaceText(doc, args) {
1146
1145
  const selectedTargets = [];
1147
1146
  let skippedEdits = [];
1148
1147
  let steps;
1148
+ let applyExpectedRevision;
1149
1149
  // Blocks whose text this call rewrites, with their pre-mutation run layout:
1150
1150
  // the engine replace lane renders the replaced range as uniform runs, so a
1151
1151
  // whole-paragraph find/replace flattens intra-paragraph formatting (bold
@@ -1187,6 +1187,7 @@ async function runReplaceText(doc, args) {
1187
1187
  },
1188
1188
  },
1189
1189
  ];
1190
+ applyExpectedRevision = pre.revision;
1190
1191
  selectedTargets.push({ selector: args.selector, matched: [target.nodeId] });
1191
1192
  preserveTargets.push({
1192
1193
  nodeId: target.nodeId,
@@ -1197,98 +1198,9 @@ async function runReplaceText(doc, args) {
1197
1198
  });
1198
1199
  }
1199
1200
  else {
1200
- const matchingEdits = args.edits.length === 1
1201
- ? args.edits
1202
- : args.edits.filter((edit) => pre.blocks.some((block) => textIncludes(block.text, edit.find, caseSensitive)));
1203
- skippedEdits = args.edits.filter((edit) => !matchingEdits.includes(edit));
1204
- if (matchingEdits.length === 0) {
1205
- return {
1206
- status: 'failed',
1207
- intent: 'replace_text',
1208
- preSnapshot: { revision: pre.revision, counts: pre.counts },
1209
- selectedTargets: [],
1210
- executedOperations: [],
1211
- verification: [],
1212
- errors: [
1213
- {
1214
- code: 'ACTION_FAILED',
1215
- message: 'none of the requested text replacements matched the current document',
1216
- },
1217
- ],
1218
- };
1219
- }
1220
- // Rewrite affected BODY blocks with block-targeted rewrites. The select
1221
- // (matched-range) rewrite lane flattens the WHOLE paragraph's runs in
1222
- // collaborative sessions and leaves format.apply inert on the block
1223
- // afterwards, so per-block rewrites + the preserve pass below are the
1224
- // only path that keeps intra-paragraph formatting alive. Bounded; edits
1225
- // that touch no scanned body block (e.g. header/footer text) fall back
1226
- // to a select-rewrite step.
1227
- // Tracked mode keeps the matched-range lane. A block rewrite asks the
1228
- // engine to delete and re-insert the WHOLE paragraph, so a reviewer sees
1229
- // the entire block struck and re-added instead of the words that changed,
1230
- // and each block contributes its own replacement/deletion pair. The
1231
- // per-block lane exists to keep intra-paragraph formatting alive, which
1232
- // is a direct-mode concern; under tracked mode the revision itself is the
1233
- // reviewable unit and must stay scoped to the edit.
1234
- const scanBlocks = args.changeMode === 'tracked'
1235
- ? []
1236
- : pre.blocks.length > 0
1237
- ? pre.blocks
1238
- : (await buildDocumentSnapshot(doc, { includeDomains: ['blocks'] })).blocks;
1239
- // An edit counts as covered only when EVERY block holding it got a
1240
- // rewrite target. The per-block lane is bounded (run capture costs a read
1241
- // each), so a find present in more blocks than the cap must fall through
1242
- // to the select lane below, which carries `require: 'all'`. Marking such
1243
- // an edit covered after its first match would replace only the first
1244
- // PRESERVE_TARGET_CAP blocks and still report success.
1245
- const blocksHoldingEdit = matchingEdits.map((edit) => scanBlocks.filter((block) => block.nodeId && block.text && textIncludes(block.text, edit.find, caseSensitive))
1246
- .length);
1247
- const blocksCoveredForEdit = Array.from({ length: matchingEdits.length }, () => 0);
1248
- for (const block of scanBlocks) {
1249
- if (preserveTargets.length >= PRESERVE_TARGET_CAP)
1250
- break;
1251
- if (!block.nodeId || !block.text)
1252
- continue;
1253
- let newText = block.text;
1254
- const touched = [];
1255
- matchingEdits.forEach((edit, editIndex) => {
1256
- if (textIncludes(newText, edit.find, caseSensitive)) {
1257
- newText = replaceAllText(newText, edit.find, edit.replace, caseSensitive);
1258
- touched.push(editIndex);
1259
- }
1260
- });
1261
- if (newText === block.text)
1262
- continue;
1263
- preserveTargets.push({
1264
- nodeId: block.nodeId,
1265
- nodeType: block.nodeType ?? 'paragraph',
1266
- oldText: block.text,
1267
- newText,
1268
- runs: await captureBlockRuns(doc, block.nodeId, block.text),
1269
- });
1270
- for (const editIndex of touched)
1271
- blocksCoveredForEdit[editIndex] += 1;
1272
- }
1273
- const coveredEdits = new Set(matchingEdits
1274
- .map((_edit, editIndex) => editIndex)
1275
- .filter((editIndex) => blocksHoldingEdit[editIndex] > 0 && blocksCoveredForEdit[editIndex] >= blocksHoldingEdit[editIndex]));
1276
- steps = [
1277
- ...preserveTargets.map((entry, index) => ({
1278
- id: `replace-block-${index + 1}`,
1279
- op: 'text.rewrite',
1280
- where: { by: 'block', nodeType: entry.nodeType, nodeId: entry.nodeId },
1281
- args: {
1282
- replacement: { text: entry.newText },
1283
- style: preserveRewriteStyle(),
1284
- },
1285
- })),
1286
- // Edits that touched no scanned body block (header/footer text, or a
1287
- // doc larger than the scan window) keep the original select-rewrite.
1288
- ...matchingEdits
1289
- .map((edit, editIndex) => ({ edit, editIndex }))
1290
- .filter(({ editIndex }) => !coveredEdits.has(editIndex))
1291
- .map(({ edit, editIndex }) => ({
1201
+ let candidates = args.edits.map((edit, editIndex) => ({
1202
+ edit,
1203
+ step: {
1292
1204
  id: `replace-${editIndex + 1}`,
1293
1205
  op: 'text.rewrite',
1294
1206
  where: {
@@ -1305,10 +1217,42 @@ async function runReplaceText(doc, args) {
1305
1217
  replacement: { text: edit.replace },
1306
1218
  style: preserveRewriteStyle(),
1307
1219
  },
1308
- })),
1309
- ];
1220
+ },
1221
+ }));
1222
+ if (candidates.length > 1) {
1223
+ applyExpectedRevision = pre.revision;
1224
+ while (candidates.length > 0) {
1225
+ const preview = await previewMutations(doc, candidates.map((candidate) => candidate.step), args.changeMode, pre.revision);
1226
+ if (preview.valid === true)
1227
+ break;
1228
+ const failures = Array.isArray(preview.failures) ? preview.failures.filter(isRecord) : [];
1229
+ const absent = failures.find((failure) => failure.code === 'MATCH_NOT_FOUND' && typeof failure.stepId === 'string');
1230
+ if (!absent) {
1231
+ throw new Error(typeof failures[0]?.message === 'string' ? failures[0].message : 'replacement plan could not be prepared');
1232
+ }
1233
+ const absentIndex = candidates.findIndex((candidate) => candidate.step.id === absent.stepId);
1234
+ if (absentIndex < 0)
1235
+ throw new Error('replacement preview returned an unknown step id');
1236
+ skippedEdits.push({ find: candidates[absentIndex].edit.find });
1237
+ candidates = candidates.filter((_candidate, index) => index !== absentIndex);
1238
+ }
1239
+ }
1240
+ if (candidates.length === 0) {
1241
+ return {
1242
+ status: 'failed',
1243
+ intent: 'replace_text',
1244
+ preSnapshot: { revision: pre.revision, counts: pre.counts },
1245
+ selectedTargets: [],
1246
+ executedOperations: [],
1247
+ verification: [],
1248
+ errors: [
1249
+ { code: 'ACTION_FAILED', message: 'none of the requested text replacements matched the current document' },
1250
+ ],
1251
+ };
1252
+ }
1253
+ steps = candidates.map((candidate) => candidate.step);
1310
1254
  }
1311
- const result = await executeMutations(doc, steps, args.changeMode);
1255
+ const result = await executeMutations(doc, steps, args.changeMode, applyExpectedRevision);
1312
1256
  const preserved = [];
1313
1257
  for (const entry of preserveTargets) {
1314
1258
  const restored = await preserveRunPatternAfterRewrite(doc, entry.nodeId, entry.oldText, entry.runs, entry.newText);
@@ -1316,7 +1260,7 @@ async function runReplaceText(doc, args) {
1316
1260
  preserved.push({ nodeId: entry.nodeId, ...restored });
1317
1261
  }
1318
1262
  if (args.selector && selectedTargets[0]) {
1319
- const post = await buildDocumentSnapshot(doc, { includeDomains: selectorDomains ?? ['blocks'] });
1263
+ const post = await buildMutationSnapshot(doc, { includeDomains: selectorDomains ?? ['blocks'] });
1320
1264
  const blockId = selectedTargets[0].matched[0];
1321
1265
  const postTarget = findSnapshotTextByNodeId(post, blockId);
1322
1266
  const preTarget = findSnapshotTextByNodeId(pre, blockId);
@@ -1385,7 +1329,7 @@ async function runDeleteText(doc, args) {
1385
1329
  // path). Rewriting the block minus the finds keeps the deletion local — without
1386
1330
  // this a short/whitespace find matches document-wide and blows the target cap.
1387
1331
  if (args.selector) {
1388
- const scopedPre = await buildDocumentSnapshot(doc, { includeDomains: snapshotDomainsForSelector(args.selector) });
1332
+ const scopedPre = await buildMutationSnapshot(doc, { includeDomains: snapshotDomainsForSelector(args.selector) });
1389
1333
  try {
1390
1334
  if (args.finds.length === 0) {
1391
1335
  return failedReceipt('delete_text', new Error('finds must be non-empty'), scopedPre);
@@ -1428,7 +1372,7 @@ async function runDeleteText(doc, args) {
1428
1372
  args: { replacement: { text: rewritten }, style: preserveRewriteStyle() },
1429
1373
  },
1430
1374
  ];
1431
- const scopedResult = await executeMutations(doc, scopedSteps, args.changeMode);
1375
+ const scopedResult = await executeMutations(doc, scopedSteps, args.changeMode, scopedPre.revision);
1432
1376
  const scopedPost = await buildDocumentSnapshot(doc, { includeDomains: ['blocks'] });
1433
1377
  const verification = evaluateChecks(scopedPre, scopedPost, [{ kind: 'revision-changed' }]);
1434
1378
  return {
@@ -1490,7 +1434,7 @@ async function runDeleteText(doc, args) {
1490
1434
  },
1491
1435
  args: {},
1492
1436
  }));
1493
- const result = await executeMutations(doc, steps, args.changeMode);
1437
+ const result = await executeMutations(doc, steps, args.changeMode, pre.revision);
1494
1438
  const revision = asRecord(asRecord(result)?.revision);
1495
1439
  const postIdentity = args.changeMode === 'tracked'
1496
1440
  ? await readDocumentIdentity(doc)
@@ -1533,7 +1477,7 @@ async function runDeleteBlocks(doc, args) {
1533
1477
  for (const domain of snapshotDomainsForSelector(selector))
1534
1478
  domains.add(domain);
1535
1479
  }
1536
- const pre = await buildDocumentSnapshot(doc, { includeDomains: [...domains] });
1480
+ const pre = await buildMutationSnapshot(doc, { includeDomains: [...domains] });
1537
1481
  try {
1538
1482
  if (args.selectors.length === 0) {
1539
1483
  return failedReceipt('delete_blocks', new Error('selectors must be non-empty'), pre);
@@ -1626,7 +1570,7 @@ async function runDeleteBlocks(doc, args) {
1626
1570
  });
1627
1571
  }
1628
1572
  }
1629
- const post = await buildDocumentSnapshot(doc, { includeDomains: [...domains] });
1573
+ const post = await buildMutationSnapshot(doc, { includeDomains: [...domains] });
1630
1574
  // Tracked deletions leave the block in place until the revision is decided,
1631
1575
  // so the block count cannot move — count the structural revisions instead.
1632
1576
  // Direct deletions must show one fewer block of each deleted node type.
@@ -1744,7 +1688,7 @@ async function runInsertListItems(doc, args) {
1744
1688
  }
1745
1689
  }
1746
1690
  async function runAddListItems(doc, args, opts) {
1747
- const pre = await buildDocumentSnapshot(doc, { includeDomains: ['blocks'] });
1691
+ const pre = await buildMutationSnapshot(doc, { includeDomains: ['blocks'] });
1748
1692
  try {
1749
1693
  const needle = args.anchorText?.trim();
1750
1694
  const anchorNodeId = args.anchorNodeId?.trim();
@@ -2277,56 +2221,79 @@ async function runSplitList(doc, args) {
2277
2221
  return failedReceipt('split_list', err, pre);
2278
2222
  }
2279
2223
  }
2224
+ async function resolveFactPlacement(placement, facts) {
2225
+ if (!placement || placement.at === 'document_end')
2226
+ return { kind: 'documentEnd' };
2227
+ if (placement.at === 'document_start')
2228
+ return { kind: 'documentStart' };
2229
+ const target = await facts.target(placement.selector);
2230
+ if (!target)
2231
+ throw new SuperDocCliError('placement selector did not resolve to a unique body block', {
2232
+ code: 'INVALID_ARGUMENT',
2233
+ });
2234
+ return { kind: placement.at, target: { kind: 'block', nodeType: target.nodeType, nodeId: target.nodeId } };
2235
+ }
2236
+ function placementSelectors(placement) {
2237
+ return placement?.at === 'before' || placement?.at === 'after' ? [placement.selector] : [];
2238
+ }
2280
2239
  async function runCreateTable(doc, args) {
2281
- const pre = await buildDocumentSnapshot(doc);
2240
+ const checks = [
2241
+ { kind: 'revision-changed' },
2242
+ { kind: 'block-count-delta', nodeType: 'table', delta: 1 },
2243
+ ];
2244
+ const context = new ExecutionContext(doc, {
2245
+ evidence: args.evidence,
2246
+ selectors: placementSelectors(args.placement),
2247
+ checks,
2248
+ });
2282
2249
  try {
2283
- if (!Number.isInteger(args.rows) || args.rows < 1 || !Number.isInteger(args.columns) || args.columns < 1) {
2284
- return failedReceipt('create_table', new Error('rows and columns must be positive integers'), pre);
2285
- }
2286
- const placement = resolvePlacement(args.placement, pre);
2287
- const insertedTableOrdinal = estimateInsertedTableOrdinal(pre, placement);
2288
- const result = await executeCreateTable(doc, args, placement);
2289
- const executedOperations = [
2290
- { operationId: 'doc.create.table', result },
2291
- ];
2292
- const createdTableNodeId = asString(asRecord(asRecord(result)?.table)?.nodeId);
2293
- if (createdTableNodeId && args.cellTexts) {
2294
- const appliedCells = await applyTableCellTexts(doc, createdTableNodeId, insertedTableOrdinal, flattenCellTexts(args.cellTexts), args.changeMode);
2295
- if (appliedCells.length > 0) {
2296
- executedOperations.push({
2297
- operationId: 'doc.mutations.apply',
2298
- rationale: `Populated ${appliedCells.length} table cells.`,
2299
- });
2250
+ if (!Number.isInteger(args.rows) || args.rows < 1 || !Number.isInteger(args.columns) || args.columns < 1)
2251
+ throw new Error('rows and columns must be positive integers');
2252
+ const pre = await context.start();
2253
+ const placement = await resolveFactPlacement(args.placement, pre);
2254
+ const result = await executeCreateTable(context.document, args, placement);
2255
+ const tableNodeId = asString(asRecord(asRecord(result)?.table)?.nodeId);
2256
+ if (args.cellTexts && tableNodeId) {
2257
+ const cells = flattenCellTexts(args.cellTexts).filter((cell) => cell.text.trim());
2258
+ if (pre.complete) {
2259
+ const applied = await applyTableCellTexts(context.document, tableNodeId, estimateInsertedTableOrdinal(pre.complete, placement), cells, args.changeMode);
2260
+ await styleTableCells(context.document, applied, args.changeMode);
2300
2261
  }
2301
- const styledCount = await styleTableCells(doc, appliedCells, args.changeMode);
2302
- if (styledCount > 0) {
2303
- executedOperations.push({
2304
- operationId: 'doc.format.apply',
2305
- rationale: `Styled ${styledCount} table cell(s).`,
2306
- });
2262
+ else {
2263
+ for (const cell of cells)
2264
+ await context.document.tables.setCellText({
2265
+ target: { kind: 'block', nodeType: 'table', nodeId: tableNodeId },
2266
+ rowIndex: cell.rowIndex,
2267
+ columnIndex: cell.columnIndex,
2268
+ text: cell.text,
2269
+ changeMode: args.changeMode,
2270
+ });
2271
+ const styled = cells.filter((cell) => cell.runs?.length || cell.marks);
2272
+ if (styled.length) {
2273
+ const identities = await doc.tables.getCells({ nodeId: tableNodeId });
2274
+ const resolved = styled.map((cell) => {
2275
+ const identity = identities.cells.find((item) => item.rowIndex === cell.rowIndex &&
2276
+ item.columnIndex <= cell.columnIndex &&
2277
+ item.columnIndex + item.colspan > cell.columnIndex);
2278
+ if (!identity?.firstParagraphNodeId)
2279
+ throw new Error('Required cell paragraph identity is unavailable.');
2280
+ return { ...cell, nodeId: identity.firstParagraphNodeId };
2281
+ });
2282
+ const styling = await applyItemStyling(context.document, resolved.map((cell) => cell.nodeId), resolved, args.changeMode);
2283
+ if (styling.skipped)
2284
+ throw new Error(`Requested cell styling failed: ${styling.skipped}`);
2285
+ }
2307
2286
  }
2308
2287
  }
2309
- const post = await buildDocumentSnapshot(doc);
2310
- const verification = evaluateChecks(pre, post, [
2311
- { kind: 'revision-changed' },
2312
- { kind: 'block-count-delta', nodeType: 'table', delta: 1 },
2313
- ]);
2314
- return {
2315
- status: verification.every((v) => v.passed) ? 'ok' : 'failed',
2316
- intent: 'create_table',
2317
- preSnapshot: { revision: pre.revision, counts: pre.counts },
2318
- postSnapshot: { revision: post.revision, counts: post.counts },
2319
- selectedTargets: [],
2320
- executedOperations,
2321
- verification,
2322
- };
2288
+ const post = await context.finish();
2289
+ return context.receipt('create_table', await evaluateFactChecks(pre, post, checks));
2323
2290
  }
2324
- catch (err) {
2325
- return failedReceipt('create_table', err, pre);
2291
+ catch (error) {
2292
+ return context.failure('create_table', error);
2326
2293
  }
2327
2294
  }
2328
2295
  async function runCommentParagraphs(doc, args) {
2329
- const pre = await buildDocumentSnapshot(doc);
2296
+ const pre = await buildMutationSnapshot(doc, { includeDomains: ['blocks'] });
2330
2297
  try {
2331
2298
  const blocks = pre.blocks.filter((b) => {
2332
2299
  if (args.scope === 'all') {
@@ -2368,13 +2335,18 @@ async function runCommentParagraphs(doc, args) {
2368
2335
  }
2369
2336
  }
2370
2337
  async function runAddComments(doc, args) {
2371
- const pre = await buildDocumentSnapshot(doc);
2338
+ const selectorList = args.selectors?.length ? args.selectors : args.selector ? [args.selector] : [];
2339
+ const domains = new Set(['blocks']);
2340
+ for (const selector of selectorList) {
2341
+ for (const domain of snapshotDomainsForSelector(selector))
2342
+ domains.add(domain);
2343
+ }
2344
+ const pre = await buildMutationSnapshot(doc, { includeDomains: [...domains] });
2372
2345
  try {
2373
2346
  // Batch form: `selectors` comments many blocks in ONE action call so the
2374
2347
  // model never fans out N concurrent add_comments tool calls (which race the
2375
2348
  // shared document and the comment-count verification). A single `selector`
2376
2349
  // is the one-target shorthand. Comments are applied sequentially.
2377
- const selectorList = args.selectors?.length ? args.selectors : args.selector ? [args.selector] : [];
2378
2350
  if (!selectorList.length) {
2379
2351
  return failedReceipt('add_comments', new Error('add_comments requires a "selector" or a non-empty "selectors" array'), pre);
2380
2352
  }
@@ -2388,7 +2360,21 @@ async function runAddComments(doc, args) {
2388
2360
  unresolved.push(sel);
2389
2361
  }
2390
2362
  if (!resolved.length) {
2391
- return failedReceipt('add_comments', new Error('no selector resolved to a body block'), pre);
2363
+ return {
2364
+ status: 'failed',
2365
+ intent: 'add_comments',
2366
+ preSnapshot: { revision: pre.revision, counts: pre.counts },
2367
+ selectedTargets: unresolved.map((selector) => ({ selector, matched: [] })),
2368
+ executedOperations: [],
2369
+ verification: [],
2370
+ errors: [
2371
+ {
2372
+ code: 'TARGET_NOT_FOUND',
2373
+ message: 'none of the requested comment selectors resolved to a block',
2374
+ recovery: { kind: 'reinspect' },
2375
+ },
2376
+ ],
2377
+ };
2392
2378
  }
2393
2379
  const executed = [];
2394
2380
  for (const { commentText, nodeId } of resolved.map((r) => ({ commentText: args.commentText, nodeId: r.nodeId }))) {
@@ -2397,15 +2383,20 @@ async function runAddComments(doc, args) {
2397
2383
  }
2398
2384
  const post = await buildDocumentSnapshot(doc);
2399
2385
  const verification = evaluateChecks(pre, post, [{ kind: 'comment-count-delta', delta: resolved.length }]);
2386
+ const verified = verification.every((v) => v.passed);
2387
+ const status = !verified ? 'failed' : unresolved.length > 0 ? 'partial' : 'ok';
2400
2388
  return {
2401
- status: verification.every((v) => v.passed) ? 'ok' : 'failed',
2389
+ status,
2402
2390
  intent: 'add_comments',
2403
2391
  ...(unresolved.length
2404
2392
  ? { note: `${unresolved.length} selector(s) did not resolve to a block and were skipped` }
2405
2393
  : {}),
2406
2394
  preSnapshot: { revision: pre.revision, counts: pre.counts },
2407
2395
  postSnapshot: { revision: post.revision, counts: post.counts },
2408
- selectedTargets: resolved.map((r) => ({ selector: r.selector, matched: [r.nodeId] })),
2396
+ selectedTargets: [
2397
+ ...resolved.map((r) => ({ selector: r.selector, matched: [r.nodeId] })),
2398
+ ...unresolved.map((selector) => ({ selector, matched: [] })),
2399
+ ],
2409
2400
  executedOperations: executed,
2410
2401
  verification,
2411
2402
  };
@@ -2581,67 +2572,39 @@ async function runReplyToComment(doc, args) {
2581
2572
  }
2582
2573
  }
2583
2574
  async function runRewriteBlock(doc, args) {
2584
- const pre = await buildDocumentSnapshot(doc);
2575
+ const context = new ExecutionContext(doc, { evidence: args.evidence, selectors: [args.selector] });
2585
2576
  try {
2586
- const target = selectorToBlockTarget(args.selector, pre);
2587
- if (!target) {
2588
- return failedReceipt('rewrite_block', new Error('selector did not resolve to a body block'), pre);
2589
- }
2590
- const normalizedText = normalizeTitleLikeRewriteText(target.text, args.text);
2591
- // The engine rewrites the whole block as ONE uniform run — losing an
2592
- // intra-paragraph pattern (bold "(n)" lead-in), or smearing the first
2593
- // run's marks across the entire text. Capture the current run layout so
2594
- // it can be re-applied over the unchanged prefix/suffix afterward.
2595
- const preRuns = await captureBlockRuns(doc, target.nodeId, target.text);
2596
- const steps = [
2577
+ const pre = await context.start();
2578
+ const target = await pre.target(args.selector);
2579
+ if (!target)
2580
+ throw new Error('selector did not resolve to a body block');
2581
+ context.selectedTargets.push({ selector: args.selector, matched: [target.nodeId] });
2582
+ const text = normalizeTitleLikeRewriteText(target.text, args.text);
2583
+ const runs = await captureBlockRuns(doc, target.nodeId, target.text);
2584
+ await executeMutations(context.document, [
2597
2585
  {
2598
2586
  id: 'rewrite-block-1',
2599
2587
  op: 'text.rewrite',
2600
2588
  where: { by: 'block', nodeType: target.nodeType, nodeId: target.nodeId },
2601
- args: {
2602
- replacement: { text: normalizedText },
2603
- style: preserveRewriteStyle(),
2604
- },
2589
+ args: { replacement: { text }, style: preserveRewriteStyle() },
2605
2590
  },
2606
- ];
2607
- const result = await executeMutations(doc, steps, args.changeMode);
2608
- const preservation = await preserveRunPatternAfterRewrite(doc, target.nodeId, target.text, preRuns, normalizedText);
2609
- const post = await buildDocumentSnapshot(doc);
2610
- const rewrittenBlock = findSnapshotTextByNodeId(post, target.nodeId);
2591
+ ], args.changeMode, pre.revision);
2592
+ await preserveRunPatternAfterRewrite(context.document, target.nodeId, target.text, runs, text);
2593
+ const post = await context.finish();
2594
+ const rewritten = await post.block(target.nodeId);
2611
2595
  const verification = [
2612
2596
  revisionVerification(pre.revision, post.revision, true),
2613
2597
  {
2614
- check: { kind: 'block-text-contains', nodeId: target.nodeId, text: normalizedText },
2615
- passed: !!rewrittenBlock && verifyRewrittenBlockText(rewrittenBlock.text, normalizedText, args.changeMode),
2598
+ check: { kind: 'block-text-contains', nodeId: target.nodeId, text },
2599
+ passed: !!rewritten && verifyRewrittenBlockText(rewritten.text, text, args.changeMode),
2616
2600
  },
2617
2601
  ];
2618
- const changed = pre.revision !== post.revision;
2619
- const rewritten = !!rewrittenBlock && verifyRewrittenBlockText(rewrittenBlock.text, args.text, args.changeMode);
2620
- return {
2621
- status: changed && rewritten ? 'ok' : 'failed',
2622
- intent: 'rewrite_block',
2623
- preSnapshot: { revision: pre.revision, counts: pre.counts },
2624
- postSnapshot: { revision: post.revision, counts: post.counts },
2625
- selectedTargets: [{ selector: args.selector, matched: [target.nodeId] }],
2626
- executedOperations: [
2627
- { operationId: 'doc.mutations.apply', result },
2628
- ...(preservation ? [{ operationId: 'doc.format.apply', result: preservation }] : []),
2629
- ],
2630
- verification,
2631
- errors: changed && rewritten
2632
- ? undefined
2633
- : [
2634
- {
2635
- code: 'ACTION_FAILED',
2636
- message: changed
2637
- ? 'rewrite_block did not produce the requested rewritten text for the selected block'
2638
- : 'rewrite_block produced no change for the selected block; keep the same target and provide a changed rewrite',
2639
- },
2640
- ],
2641
- };
2602
+ return context.receipt('rewrite_block', verification, verification.every((check) => check.passed)
2603
+ ? {}
2604
+ : { errors: [{ code: 'ACTION_FAILED', message: 'rewrite_block did not produce the requested changed text' }] });
2642
2605
  }
2643
- catch (err) {
2644
- return failedReceipt('rewrite_block', err, pre);
2606
+ catch (error) {
2607
+ return context.failure('rewrite_block', error);
2645
2608
  }
2646
2609
  }
2647
2610
  const RUN_BOOL_MARKS = ['bold', 'italic', 'underline', 'strike'];
@@ -3015,7 +2978,7 @@ async function listAllTrackedChanges(listFn) {
3015
2978
  }
3016
2979
  }
3017
2980
  async function runNormalizeBodyFontSize(doc, args) {
3018
- const pre = await buildDocumentSnapshot(doc);
2981
+ const pre = await buildMutationSnapshot(doc, { includeDomains: ['blocks'] });
3019
2982
  try {
3020
2983
  if (!Number.isFinite(args.fontSize) || args.fontSize <= 0) {
3021
2984
  return failedReceipt('normalize_body_font_size', new Error('fontSize must be a positive number'), pre);
@@ -3032,7 +2995,7 @@ async function runNormalizeBodyFontSize(doc, args) {
3032
2995
  where: { by: 'block', nodeType: block.nodeType, nodeId: block.nodeId },
3033
2996
  args: { inline: { fontSize: args.fontSize }, scope: 'block' },
3034
2997
  }));
3035
- const result = await executeMutations(doc, steps, args.changeMode);
2998
+ const result = await executeMutations(doc, steps, args.changeMode, pre.revision);
3036
2999
  const post = await buildDocumentSnapshot(doc);
3037
3000
  const verification = evaluateChecks(pre, post, [{ kind: 'revision-changed' }]);
3038
3001
  return {
@@ -3063,7 +3026,7 @@ async function runNormalizeBodyFontSize(doc, args) {
3063
3026
  * tracked-safe (changeMode).
3064
3027
  */
3065
3028
  async function runSetFontFamily(doc, args) {
3066
- const pre = await buildDocumentSnapshot(doc);
3029
+ const pre = await buildMutationSnapshot(doc, { includeDomains: ['blocks', 'tables'] });
3067
3030
  try {
3068
3031
  const fontFamily = args.fontFamily.trim();
3069
3032
  if (fontFamily.length === 0) {
@@ -3190,7 +3153,7 @@ async function runSetFontFamily(doc, args) {
3190
3153
  * the recreated block inside an adjacent table cell.
3191
3154
  */
3192
3155
  async function runApplyStyle(doc, args) {
3193
- const pre = await buildDocumentSnapshot(doc);
3156
+ const pre = await buildMutationSnapshot(doc, { includeDomains: ['blocks'] });
3194
3157
  try {
3195
3158
  const target = selectorToBlockTarget(args.selector, pre);
3196
3159
  if (!target) {
@@ -3292,43 +3255,30 @@ async function runApplyStyle(doc, args) {
3292
3255
  * escape to superdoc_execute_code and apply the alignment untracked (no pPrChange).
3293
3256
  */
3294
3257
  async function runFormatParagraph(doc, args) {
3295
- const pre = await buildDocumentSnapshot(doc);
3258
+ const context = new ExecutionContext(doc, { evidence: args.evidence, selectors: [args.selector] });
3296
3259
  try {
3297
- const target = selectorToBlockTarget(args.selector, pre);
3298
- if (!target) {
3299
- return failedReceipt('format_paragraph', new Error('selector did not resolve to a unique body block'), pre);
3300
- }
3301
3260
  const alignment = typeof args.alignment === 'string' ? args.alignment.trim().toLowerCase() : '';
3302
- const normalizedAlignment = alignment === 'both' ? 'justify' : alignment;
3303
- if (!['left', 'center', 'right', 'justify'].includes(normalizedAlignment)) {
3304
- return failedReceipt('format_paragraph', new Error('format_paragraph requires alignment: left | center | right | justify'), pre);
3305
- }
3306
- const steps = [
3261
+ const normalized = alignment === 'both' ? 'justify' : alignment;
3262
+ if (!['left', 'center', 'right', 'justify'].includes(normalized))
3263
+ throw new Error('format_paragraph requires alignment: left | center | right | justify');
3264
+ const pre = await context.start();
3265
+ const target = await pre.target(args.selector);
3266
+ if (!target)
3267
+ throw new Error('selector did not resolve to a unique body block');
3268
+ context.selectedTargets.push({ selector: args.selector, matched: [target.nodeId] });
3269
+ await executeMutations(context.document, [
3307
3270
  {
3308
3271
  id: 'format-paragraph-1',
3309
3272
  op: 'format.apply',
3310
3273
  where: { by: 'block', nodeType: target.nodeType, nodeId: target.nodeId },
3311
- args: { alignment: normalizedAlignment, scope: 'block' },
3274
+ args: { alignment: normalized, scope: 'block' },
3312
3275
  },
3313
- ];
3314
- const result = await executeMutations(doc, steps, args.changeMode);
3315
- const post = await buildDocumentSnapshot(doc);
3316
- const verification = evaluateChecks(pre, post, [{ kind: 'revision-changed' }]);
3317
- return {
3318
- status: verification.every((v) => v.passed) ? 'ok' : 'failed',
3319
- intent: `format_paragraph: ${normalizedAlignment}`,
3320
- preSnapshot: { revision: pre.revision, counts: pre.counts },
3321
- postSnapshot: { revision: post.revision, counts: post.counts },
3322
- selectedTargets: [{ selector: args.selector, matched: [target.nodeId] }],
3323
- applied: { alignment: normalizedAlignment },
3324
- executedOperations: [
3325
- { operationId: 'doc.mutations.apply', result: compactOpResult(result), rationale: `align ${target.nodeId}` },
3326
- ],
3327
- verification,
3328
- };
3276
+ ], args.changeMode, pre.revision);
3277
+ const post = await context.finish();
3278
+ return context.receipt(`format_paragraph: ${normalized}`, await evaluateFactChecks(pre, post, [{ kind: 'revision-changed' }]), { applied: { alignment: normalized } });
3329
3279
  }
3330
- catch (err) {
3331
- return failedReceipt('format_paragraph', err, pre);
3280
+ catch (error) {
3281
+ return context.failure('format_paragraph', error);
3332
3282
  }
3333
3283
  }
3334
3284
  /**
@@ -3346,7 +3296,7 @@ async function runFormatParagraph(doc, args) {
3346
3296
  * still anchor on the (now tracked-deleted) source span and land right after it.
3347
3297
  */
3348
3298
  async function runMoveText(doc, args) {
3349
- const pre = await buildDocumentSnapshot(doc);
3299
+ const pre = await buildMutationSnapshot(doc, { includeDomains: ['blocks', 'tables'] });
3350
3300
  try {
3351
3301
  if (!args.text || args.text.length === 0) {
3352
3302
  return failedReceipt('move_text', new Error('text (the exact source span to move) is required'), pre);
@@ -3463,7 +3413,7 @@ async function runMoveText(doc, args) {
3463
3413
  * instead of inserting blank paragraphs.
3464
3414
  */
3465
3415
  async function runSetParagraphSpacing(doc, args) {
3466
- const pre = await buildDocumentSnapshot(doc, { includeDomains: ['blocks'] });
3416
+ const pre = await buildMutationSnapshot(doc, { includeDomains: ['blocks'] });
3467
3417
  try {
3468
3418
  const target = selectorToBlockTarget(args.selector, pre);
3469
3419
  if (!target) {
@@ -3520,7 +3470,7 @@ async function runSetParagraphSpacing(doc, args) {
3520
3470
  * THE way to "start X on a new page" instead of padding with empty paragraphs.
3521
3471
  */
3522
3472
  async function runInsertPageBreak(doc, args) {
3523
- const pre = await buildDocumentSnapshot(doc, { includeDomains: ['blocks'] });
3473
+ const pre = await buildMutationSnapshot(doc, { includeDomains: ['blocks'] });
3524
3474
  try {
3525
3475
  const target = selectorToBlockTarget(args.selector, pre);
3526
3476
  if (!target) {
@@ -3557,7 +3507,7 @@ async function runInsertPageBreak(doc, args) {
3557
3507
  * Finds the text in the body and applies a link over its range.
3558
3508
  */
3559
3509
  async function runAddHyperlink(doc, args) {
3560
- const pre = await buildDocumentSnapshot(doc, { includeDomains: ['blocks'] });
3510
+ const pre = await buildMutationSnapshot(doc, { includeDomains: ['blocks'] });
3561
3511
  try {
3562
3512
  const needle = args.text;
3563
3513
  const url = args.url;
@@ -3615,7 +3565,7 @@ async function runAddHyperlink(doc, args) {
3615
3565
  * dialect, and silently apply the formatting untracked.
3616
3566
  */
3617
3567
  async function runFormatText(doc, args) {
3618
- const pre = await buildDocumentSnapshot(doc);
3568
+ const pre = await buildMutationSnapshot(doc, { includeDomains: ['blocks', 'tables'] });
3619
3569
  try {
3620
3570
  const inline = {};
3621
3571
  if (args.bold === true)
@@ -3776,7 +3726,7 @@ function findRanges(haystack, needle, caseSensitive) {
3776
3726
  return ranges;
3777
3727
  }
3778
3728
  async function runApplyLetterSpacing(doc, args) {
3779
- const pre = await buildDocumentSnapshot(doc);
3729
+ const pre = await buildMutationSnapshot(doc, { includeDomains: ['blocks'] });
3780
3730
  try {
3781
3731
  if (!Number.isFinite(args.letterSpacing)) {
3782
3732
  return failedReceipt('apply_letter_spacing', new Error('letterSpacing must be a finite number'), pre);
@@ -3796,7 +3746,7 @@ async function runApplyLetterSpacing(doc, args) {
3796
3746
  args: { inline: { letterSpacing: args.letterSpacing }, scope: 'block' },
3797
3747
  },
3798
3748
  ];
3799
- const result = await executeMutations(doc, steps, args.changeMode);
3749
+ const result = await executeMutations(doc, steps, args.changeMode, pre.revision);
3800
3750
  const post = await buildDocumentSnapshot(doc);
3801
3751
  const verification = evaluateChecks(pre, post, [{ kind: 'revision-changed' }]);
3802
3752
  return {
@@ -3954,7 +3904,10 @@ async function runMoveRange(doc, args) {
3954
3904
  }
3955
3905
  }
3956
3906
  async function runInsertToc(doc, args) {
3957
- const pre = await buildDocumentSnapshot(doc);
3907
+ const placement = args.placement ?? { at: 'document_start' };
3908
+ const pre = placement.at === 'before' || placement.at === 'after'
3909
+ ? await buildMutationSnapshot(doc)
3910
+ : await buildDocumentSnapshot(doc);
3958
3911
  try {
3959
3912
  const tocFn = maybeMethod(doc, ['create', 'tableOfContents']);
3960
3913
  if (!tocFn) {
@@ -3962,11 +3915,11 @@ async function runInsertToc(doc, args) {
3962
3915
  code: 'TOOL_DISPATCH_NOT_FOUND',
3963
3916
  });
3964
3917
  }
3965
- const placement = resolvePlacement(args.placement ?? { at: 'document_start' }, pre);
3918
+ const resolvedPlacement = resolvePlacement(placement, pre);
3966
3919
  const executed = [];
3967
- let tocPlacement = placement;
3920
+ let tocPlacement = resolvedPlacement;
3968
3921
  if (args.title) {
3969
- const headingResult = await executeCreateHeading(doc, args.title, 1, placement, args.changeMode);
3922
+ const headingResult = await executeCreateHeading(doc, args.title, 1, resolvedPlacement, args.changeMode);
3970
3923
  executed.push({ operationId: 'doc.create.heading', result: headingResult });
3971
3924
  const headingNodeId = asString(asRecord(asRecord(headingResult)?.heading)?.nodeId);
3972
3925
  if (headingNodeId) {
@@ -4093,7 +4046,7 @@ async function runStyleTable(doc, args) {
4093
4046
  * should reach for instead of delete-and-recreate or insert/undo churn.
4094
4047
  */
4095
4048
  async function runMoveTable(doc, args) {
4096
- const pre = await buildDocumentSnapshot(doc);
4049
+ const pre = await buildMutationSnapshot(doc);
4097
4050
  try {
4098
4051
  const tableOrdinal = args.tableOrdinal ?? 1;
4099
4052
  const table = await resolveTableContextQuick(doc, tableOrdinal);
@@ -4619,8 +4572,8 @@ function parseScopedReplaceArgs(args) {
4619
4572
  changeMode: args.changeMode === 'tracked' ? 'tracked' : 'direct',
4620
4573
  };
4621
4574
  }
4622
- async function runScopedReplace(doc, args) {
4623
- const pre = await buildDocumentSnapshot(doc, { includeDomains: ['blocks', 'tables'] });
4575
+ async function runScopedReplaceUnchecked(doc, args) {
4576
+ const pre = await buildMutationSnapshot(doc, { includeDomains: ['blocks', 'tables'] });
4624
4577
  const matched = resolveSnapshotSelector(pre, args.selector);
4625
4578
  if (matched.length !== 1) {
4626
4579
  const failure = describeSelectorFailure(args.selector, matched, pre, args.edits.map((e) => e.find));
@@ -4717,7 +4670,7 @@ async function runScopedReplace(doc, args) {
4717
4670
  // applies directly, so restoring marks adds no second reviewable change.
4718
4671
  const trackedPreRuns = await captureBlockRuns(doc, nodeId, block.text);
4719
4672
  try {
4720
- result = await executeMutations(doc, spanSteps, args.changeMode);
4673
+ result = await executeMutations(doc, spanSteps, args.changeMode, pre.revision);
4721
4674
  if (result != null)
4722
4675
  await preserveScopedRunPattern(doc, nodeId, block.text, trackedPreRuns, planned);
4723
4676
  }
@@ -4739,10 +4692,10 @@ async function runScopedReplace(doc, args) {
4739
4692
  args: { replacement: { text: expected }, style: preserveRewriteStyle() },
4740
4693
  },
4741
4694
  ];
4742
- result = await executeMutations(doc, steps, args.changeMode);
4695
+ result = await executeMutations(doc, steps, args.changeMode, pre.revision);
4743
4696
  await preserveScopedRunPattern(doc, nodeId, block.text, preRuns, planned);
4744
4697
  }
4745
- const post = await buildDocumentSnapshot(doc, { includeDomains: ['blocks', 'tables'] });
4698
+ const post = await buildMutationSnapshot(doc, { includeDomains: ['blocks', 'tables'] });
4746
4699
  const postBlock = findTextTarget(post, nodeId);
4747
4700
  // In tracked mode the deleted text is still present in the block, so only
4748
4701
  // require the inserted text to be visible.
@@ -4781,6 +4734,14 @@ async function runScopedReplace(doc, args) {
4781
4734
  ],
4782
4735
  };
4783
4736
  }
4737
+ async function runScopedReplace(doc, args) {
4738
+ try {
4739
+ return await runScopedReplaceUnchecked(doc, args);
4740
+ }
4741
+ catch (error) {
4742
+ return failedReceipt('replace_text', error);
4743
+ }
4744
+ }
4784
4745
  /**
4785
4746
  * Build a `doc.format.apply` `inline` payload from a block row's sampled look.
4786
4747
  * Shared by apply_style (likeText copy) and add_list_items (anchor auto-match)
@@ -4836,12 +4797,6 @@ function paragraphMarkRunPropsOf(payload) {
4836
4797
  * Spaced dashes are listed separately from bare ones because the label run
4837
4798
  * usually stops BEFORE the space ("JavaScript / TypeScript" + " — SuperDoc…").
4838
4799
  */
4839
- /**
4840
- * How many blocks the per-block replace lane will capture runs for. Each target
4841
- * costs a `query.match` read, so the lane is bounded; edits reaching past it
4842
- * fall through to the select lane rather than being silently truncated.
4843
- */
4844
- const PRESERVE_TARGET_CAP = 20;
4845
4800
  const LEAD_IN_DELIMITERS = [':', ' — ', ' – ', ' - ', '—', '–'];
4846
4801
  /** A lead-in is a LABEL, not a sentence — longer spans are prose, not a pattern. */
4847
4802
  const LEAD_IN_MAX_CHARS = 90;
@@ -5324,8 +5279,18 @@ async function listBlockRows(doc) {
5324
5279
  const fn = maybeMethod(doc, ['blocks', 'list']);
5325
5280
  if (!fn)
5326
5281
  return [];
5327
- const raw = (await fn({}));
5328
- return Array.isArray(raw?.blocks) ? raw.blocks : [];
5282
+ const rows = [];
5283
+ const pageSize = 250;
5284
+ let offset = 0;
5285
+ while (true) {
5286
+ const raw = (await fn({ offset, limit: pageSize }));
5287
+ const page = Array.isArray(raw?.blocks) ? raw.blocks : [];
5288
+ rows.push(...page);
5289
+ offset += page.length;
5290
+ const total = typeof raw?.total === 'number' && Number.isFinite(raw.total) ? raw.total : rows.length;
5291
+ if (page.length === 0 || offset >= total)
5292
+ return rows;
5293
+ }
5329
5294
  }
5330
5295
  function blockNumbering(row) {
5331
5296
  const numbering = row?.numbering;
@@ -5491,7 +5456,7 @@ async function convertNumberedRange(doc, fromMarker, toMarker, kind, tracked) {
5491
5456
  };
5492
5457
  }
5493
5458
  async function convertParagraphRange(doc, fromText, toText, kind, tracked) {
5494
- const pre = await buildDocumentSnapshot(doc, { includeDomains: ['blocks'] });
5459
+ const pre = await buildMutationSnapshot(doc, { includeDomains: ['blocks'] });
5495
5460
  const blocks = pre.blocks ?? [];
5496
5461
  const findBlock = (needle) => blocks.findIndex((b) => b.nodeType === 'paragraph' && (b.text ?? '').toLowerCase().includes(needle.toLowerCase()));
5497
5462
  const fromIdx = findBlock(fromText);
@@ -5535,7 +5500,7 @@ async function convertParagraphRange(doc, fromText, toText, kind, tracked) {
5535
5500
  };
5536
5501
  }
5537
5502
  await createListFromParagraphRange(createFn, kind, range[0].nodeId, range[range.length - 1].nodeId, tracked ? 'tracked' : undefined);
5538
- const post = await buildDocumentSnapshot(doc, { includeDomains: ['blocks', 'lists'] });
5503
+ const post = await buildMutationSnapshot(doc, { includeDomains: ['blocks', 'lists'] });
5539
5504
  const postList = (post.lists ?? []).find((l) => l.items.some((it) => it.nodeId === range[0].nodeId));
5540
5505
  const allInOneList = postList != null && range.every((b) => postList.items.some((it) => it.nodeId === b.nodeId));
5541
5506
  return {
@@ -5804,7 +5769,7 @@ async function appendListAtPlacement(doc, args) {
5804
5769
  const selector = placement && isRecord(placement.selector) ? placement.selector : null;
5805
5770
  if (items.length === 0 || !placement || (at !== 'after' && at !== 'before') || !selector)
5806
5771
  return null;
5807
- const pre = await buildDocumentSnapshot(doc, { includeDomains: ['blocks'] });
5772
+ const pre = await buildMutationSnapshot(doc, { includeDomains: ['blocks'] });
5808
5773
  const blocks = pre.blocks ?? [];
5809
5774
  // Resolve through the SHARED selector resolver so the full selector
5810
5775
  // vocabulary works (nodeId / textSearch / ordinal / relative / ref) and
@@ -5865,7 +5830,7 @@ async function appendListAtPlacement(doc, args) {
5865
5830
  const listIds = typeof args.headingText === 'string' && args.headingText.length > 0 ? createdIds.slice(1) : createdIds;
5866
5831
  await createListFromParagraphRange(listsCreateFn, kind, listIds[0], listIds[listIds.length - 1], parseChangeMode(args.changeMode));
5867
5832
  // Verify both promises: the items form one list AND it sits at the anchor.
5868
- const post = await buildDocumentSnapshot(doc, { includeDomains: ['blocks', 'lists'] });
5833
+ const post = await buildMutationSnapshot(doc, { includeDomains: ['blocks', 'lists'] });
5869
5834
  const postBlocks = post.blocks ?? [];
5870
5835
  const postList = (post.lists ?? []).find((l) => l.items.some((it) => it.nodeId === listIds[0]));
5871
5836
  const listOk = postList != null && listIds.every((id) => postList.items.some((it) => it.nodeId === id));
@@ -5969,8 +5934,9 @@ async function matchOneBlock(doc, postBlocks, preIds, created, createdIndex, anc
5969
5934
  const formatApplyFn = maybeMethod(doc, ['format', 'apply']);
5970
5935
  if (formatApplyFn) {
5971
5936
  const textLength = (created.textPreview ?? '').length;
5972
- const post = await buildDocumentSnapshot(doc, { includeDomains: ['blocks'] });
5973
- const full = post.blocks.find((b) => b.nodeId === created.nodeId)?.text ?? '';
5937
+ const full = createdText ||
5938
+ (await doc.blocks.list({ nodeIds: [created.nodeId], limit: 1, includeText: true })).blocks.find((block) => block.nodeId === created.nodeId)?.text ||
5939
+ '';
5974
5940
  await formatApplyFn({
5975
5941
  blockId: created.nodeId,
5976
5942
  start: 0,
@@ -6167,11 +6133,11 @@ async function matchBlockRunPattern(doc, reference, created, createdText) {
6167
6133
  }
6168
6134
  return Object.keys(applied).length > 0 ? { matchedSibling: reference.nodeId, ...applied } : null;
6169
6135
  }
6170
- async function matchInsertedBlockFormatting(doc, preBlocks, receipt, args) {
6136
+ async function matchInsertedBlockFormatting(doc, preBlocks, receipt, args, availableRows) {
6171
6137
  if (receipt.status !== 'ok')
6172
6138
  return receipt;
6173
6139
  try {
6174
- const postBlocks = await listBlockRows(doc);
6140
+ const postBlocks = availableRows ?? (await listBlockRows(doc));
6175
6141
  const preIds = new Set(preBlocks.map((b) => b.nodeId));
6176
6142
  const createdRows = postBlocks
6177
6143
  .map((b, i) => ({ row: b, index: i }))
@@ -6428,7 +6394,7 @@ async function runAttachNumbering(doc, args) {
6428
6394
  verification: [{ check: { kind: 'marker-rendered' }, passed: !!newMarker }],
6429
6395
  };
6430
6396
  }
6431
- export async function superdocPerformAction(doc, args) {
6397
+ async function dispatchSuperdocPerformAction(doc, args) {
6432
6398
  if (!isRecord(args)) {
6433
6399
  throw new SuperDocCliError('superdoc_perform_action arguments must be an object', {
6434
6400
  code: 'INVALID_ARGUMENT',
@@ -6462,9 +6428,9 @@ export async function superdocPerformAction(doc, args) {
6462
6428
  });
6463
6429
  }
6464
6430
  const headingLevel = asNumber(args.headingLevel);
6465
- const preRows = await listBlockRows(doc);
6466
- const receipt = await runInsertParagraphs(doc, {
6431
+ return runInsertParagraphs(doc, {
6467
6432
  action,
6433
+ evidence: args.evidence,
6468
6434
  texts,
6469
6435
  textSpecs,
6470
6436
  placement: parsePlacement(args.placement),
@@ -6472,8 +6438,7 @@ export async function superdocPerformAction(doc, args) {
6472
6438
  headingLevel: headingLevel != null && Number.isInteger(headingLevel) && headingLevel >= 1 && headingLevel <= 6
6473
6439
  ? headingLevel
6474
6440
  : undefined,
6475
- });
6476
- return matchInsertedBlockFormatting(doc, preRows, receipt, args);
6441
+ }, args);
6477
6442
  }
6478
6443
  case 'insert_heading': {
6479
6444
  const text = asString(args.text);
@@ -6597,6 +6562,7 @@ export async function superdocPerformAction(doc, args) {
6597
6562
  }
6598
6563
  return runCreateTable(doc, {
6599
6564
  action,
6565
+ evidence: args.evidence,
6600
6566
  rows,
6601
6567
  columns,
6602
6568
  cellTexts: parseCellTexts(args.cellTexts),
@@ -6620,16 +6586,41 @@ export async function superdocPerformAction(doc, args) {
6620
6586
  }
6621
6587
  case 'add_comments': {
6622
6588
  const commentText = asString(args.commentText);
6623
- const selector = parseSelector(args.selector);
6624
- const selectors = Array.isArray(args.selectors)
6625
- ? args.selectors.map((s) => parseSelector(s)).filter((s) => Boolean(s))
6626
- : undefined;
6627
- if (!commentText || (!selector && !(selectors && selectors.length))) {
6589
+ if (!commentText) {
6628
6590
  throw new SuperDocCliError('add_comments requires "commentText" and a "selector" or non-empty "selectors" array', {
6629
6591
  code: 'INVALID_ARGUMENT',
6630
6592
  });
6631
6593
  }
6632
- return runAddComments(doc, { action, commentText, selector: selector ?? undefined, selectors });
6594
+ const hasSelector = args.selector != null;
6595
+ const hasSelectors = args.selectors != null;
6596
+ if (hasSelector && hasSelectors) {
6597
+ throw new SuperDocCliError('add_comments requires either "selector" or "selectors", not both', {
6598
+ code: 'INVALID_ARGUMENT',
6599
+ });
6600
+ }
6601
+ if (hasSelectors) {
6602
+ if (!Array.isArray(args.selectors) || args.selectors.length === 0) {
6603
+ throw new SuperDocCliError('add_comments requires "selectors" to be a non-empty array', {
6604
+ code: 'INVALID_ARGUMENT',
6605
+ });
6606
+ }
6607
+ // Parse the complete batch before mutation. Filtering an invalid entry
6608
+ // would make the receipt report success for only part of the request.
6609
+ const selectors = [];
6610
+ for (const [index, raw] of args.selectors.entries()) {
6611
+ const parsed = parseSelector(raw);
6612
+ if (!parsed) {
6613
+ throw new SuperDocCliError(`add_comments selectors[${index}] is not a valid selector; no comments were added`, { code: 'INVALID_ARGUMENT' });
6614
+ }
6615
+ selectors.push(parsed);
6616
+ }
6617
+ return runAddComments(doc, { action, commentText, selectors });
6618
+ }
6619
+ const selector = parseSelector(args.selector);
6620
+ if (!selector) {
6621
+ throw new SuperDocCliError('add_comments requires "selector" to be valid or "selectors" to be a non-empty array', { code: 'INVALID_ARGUMENT' });
6622
+ }
6623
+ return runAddComments(doc, { action, commentText, selector });
6633
6624
  }
6634
6625
  case 'resolve_comments':
6635
6626
  return runResolveComments(doc, {
@@ -6658,6 +6649,7 @@ export async function superdocPerformAction(doc, args) {
6658
6649
  }
6659
6650
  return runRewriteBlock(doc, {
6660
6651
  action,
6652
+ evidence: args.evidence,
6661
6653
  text,
6662
6654
  selector,
6663
6655
  changeMode: parseChangeMode(args.changeMode),
@@ -6911,6 +6903,7 @@ export async function superdocPerformAction(doc, args) {
6911
6903
  }
6912
6904
  return runFormatParagraph(doc, {
6913
6905
  action,
6906
+ evidence: args.evidence,
6914
6907
  selector,
6915
6908
  alignment: asString(args.alignment) ?? '',
6916
6909
  changeMode: parseChangeMode(args.changeMode),
@@ -7042,6 +7035,24 @@ export async function superdocPerformAction(doc, args) {
7042
7035
  }
7043
7036
  }
7044
7037
  }
7038
+ export async function superdocPerformAction(doc, args) {
7039
+ try {
7040
+ return await dispatchSuperdocPerformAction(doc, args);
7041
+ }
7042
+ catch (error) {
7043
+ if (!(error instanceof MutationSnapshotError))
7044
+ throw error;
7045
+ const action = isRecord(args) && isActionName(args.action) ? args.action : 'superdoc_perform_action';
7046
+ return {
7047
+ status: 'failed',
7048
+ intent: action,
7049
+ selectedTargets: [],
7050
+ executedOperations: [],
7051
+ verification: [],
7052
+ errors: [{ code: error.code, message: error.message, recovery: { kind: 'reinspect' } }],
7053
+ };
7054
+ }
7055
+ }
7045
7056
  function parseChangeMode(value) {
7046
7057
  if (value === 'direct' || value === 'tracked')
7047
7058
  return value;