@superdoc/sdk 2.10.0-next.4 → 2.10.0-next.6

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.
@@ -2,7 +2,7 @@ import { SuperDocCliError } from '../runtime/errors.js';
2
2
  import { runSuperdocListTransformWorkflow } from '../action-primitives/tools/list-transform.js';
3
3
  import { runSuperdocStructureInsertWorkflow } from '../action-primitives/tools/structure-insert.js';
4
4
  import { runSuperdocTextTransformWorkflow } from '../action-primitives/tools/text-transform.js';
5
- import { buildDocumentSnapshot, matchRunsForBlock, resolveSnapshotSelector, } from './doc-snapshot.js';
5
+ import { buildDocumentSnapshot, buildMutationSnapshot, matchRunsForBlock, MutationSnapshotError, resolveSnapshotSelector, } from './doc-snapshot.js';
6
6
  const ACTION_NAMES = [
7
7
  'insert_paragraphs',
8
8
  'insert_heading',
@@ -511,6 +511,8 @@ function revisionVerification(preRevision, postRevision, expectChanged) {
511
511
  }
512
512
  function failedReceipt(intent, err, preSnapshot) {
513
513
  const message = reasonOf(err);
514
+ const snapshotError = err instanceof MutationSnapshotError ? err : null;
515
+ const errorCode = snapshotError?.code ?? asString(asRecord(err)?.code) ?? 'ACTION_FAILED';
514
516
  return {
515
517
  status: 'failed',
516
518
  intent,
@@ -520,7 +522,13 @@ function failedReceipt(intent, err, preSnapshot) {
520
522
  selectedTargets: [],
521
523
  executedOperations: [],
522
524
  verification: [],
523
- errors: [{ code: 'ACTION_FAILED', message }],
525
+ errors: [
526
+ {
527
+ code: errorCode,
528
+ message,
529
+ ...(snapshotError || errorCode === 'REVISION_CONFLICT' ? { recovery: { kind: 'reinspect' } } : {}),
530
+ },
531
+ ],
524
532
  };
525
533
  }
526
534
  async function receiptFromWorkflowResult(doc, intent, pre, workflowResult, selectedTargets = [], checks = [{ kind: 'revision-changed' }]) {
@@ -564,14 +572,16 @@ async function receiptFromWorkflowResult(doc, intent, pre, workflowResult, selec
564
572
  }
565
573
  function buildFullBlockTextTarget(snapshot, blockId) {
566
574
  const block = snapshot.blocks.find((entry) => entry.nodeId === blockId);
567
- if (!block)
575
+ const cell = block ? null : snapshot.tables.flatMap((table) => table.cells).find((entry) => entry.nodeId === blockId);
576
+ const text = block?.text ?? cell?.text;
577
+ if (text == null)
568
578
  return null;
569
579
  return {
570
580
  kind: 'text',
571
581
  blockId,
572
582
  range: {
573
583
  start: 0,
574
- end: block.text.length,
584
+ end: text.length,
575
585
  },
576
586
  };
577
587
  }
@@ -624,8 +634,8 @@ function evaluateChecks(pre, post, checks) {
624
634
  else if (check.kind === 'comment-count-delta') {
625
635
  results.push({
626
636
  check,
627
- passed: post.comments.length - pre.comments.length === check.delta,
628
- detail: `pre=${pre.comments.length} post=${post.comments.length}`,
637
+ passed: post.counts.comments - pre.counts.comments === check.delta,
638
+ detail: `pre=${pre.counts.comments} post=${post.counts.comments}`,
629
639
  });
630
640
  }
631
641
  else if (check.kind === 'tracked-change-count-delta') {
@@ -1037,7 +1047,9 @@ async function styleTableCells(doc, cells, changeMode) {
1037
1047
  return styled.length;
1038
1048
  }
1039
1049
  async function runInsertParagraphs(doc, args) {
1040
- const pre = await buildDocumentSnapshot(doc, { includeDomains: ['blocks'] });
1050
+ const pre = args.placement?.at === 'before' || args.placement?.at === 'after'
1051
+ ? await buildMutationSnapshot(doc, { includeDomains: ['blocks'] })
1052
+ : await buildDocumentSnapshot(doc, { includeDomains: ['blocks'] });
1041
1053
  try {
1042
1054
  // `texts` is the canonical input; a single `text` is normalized to one item
1043
1055
  // upstream in the dispatcher, but tolerate it here too.
@@ -1064,7 +1076,7 @@ async function runInsertParagraphs(doc, args) {
1064
1076
  if (created) {
1065
1077
  return { kind: 'after', target: { kind: 'block', nodeType: created.nodeType, nodeId: created.nodeId } };
1066
1078
  }
1067
- const mid = await buildDocumentSnapshot(doc, { includeDomains: ['blocks'] });
1079
+ const mid = await buildMutationSnapshot(doc, { includeDomains: ['blocks'] });
1068
1080
  const last = lastBlock(mid);
1069
1081
  return last
1070
1082
  ? { kind: 'after', target: { kind: 'block', nodeType: last.nodeType, nodeId: last.nodeId } }
@@ -1126,7 +1138,9 @@ async function runInsertParagraphs(doc, args) {
1126
1138
  }
1127
1139
  }
1128
1140
  async function runInsertHeading(doc, args) {
1129
- const pre = await buildDocumentSnapshot(doc, { includeDomains: ['blocks'] });
1141
+ const pre = args.placement?.at === 'before' || args.placement?.at === 'after'
1142
+ ? await buildMutationSnapshot(doc, { includeDomains: ['blocks'] })
1143
+ : await buildDocumentSnapshot(doc, { includeDomains: ['blocks'] });
1130
1144
  try {
1131
1145
  const placement = resolvePlacement(args.placement, pre);
1132
1146
  const result = await executeCreateHeading(doc, args.text, args.level, placement, args.changeMode);
@@ -1151,7 +1165,7 @@ async function runReplaceText(doc, args) {
1151
1165
  const requiresBlockSnapshot = args.selector != null;
1152
1166
  const preIdentity = requiresBlockSnapshot ? null : await readDocumentIdentity(doc);
1153
1167
  const pre = requiresBlockSnapshot
1154
- ? await buildDocumentSnapshot(doc, { includeDomains: selectorDomains ?? ['blocks'] })
1168
+ ? await buildMutationSnapshot(doc, { includeDomains: selectorDomains ?? ['blocks'] })
1155
1169
  : snapshotFromIdentity(preIdentity);
1156
1170
  try {
1157
1171
  if (args.edits.length === 0) {
@@ -1203,6 +1217,7 @@ async function runReplaceText(doc, args) {
1203
1217
  },
1204
1218
  },
1205
1219
  ];
1220
+ applyExpectedRevision = pre.revision;
1206
1221
  selectedTargets.push({ selector: args.selector, matched: [target.nodeId] });
1207
1222
  preserveTargets.push({
1208
1223
  nodeId: target.nodeId,
@@ -1275,7 +1290,7 @@ async function runReplaceText(doc, args) {
1275
1290
  preserved.push({ nodeId: entry.nodeId, ...restored });
1276
1291
  }
1277
1292
  if (args.selector && selectedTargets[0]) {
1278
- const post = await buildDocumentSnapshot(doc, { includeDomains: selectorDomains ?? ['blocks'] });
1293
+ const post = await buildMutationSnapshot(doc, { includeDomains: selectorDomains ?? ['blocks'] });
1279
1294
  const blockId = selectedTargets[0].matched[0];
1280
1295
  const postTarget = findSnapshotTextByNodeId(post, blockId);
1281
1296
  const preTarget = findSnapshotTextByNodeId(pre, blockId);
@@ -1344,7 +1359,7 @@ async function runDeleteText(doc, args) {
1344
1359
  // path). Rewriting the block minus the finds keeps the deletion local — without
1345
1360
  // this a short/whitespace find matches document-wide and blows the target cap.
1346
1361
  if (args.selector) {
1347
- const scopedPre = await buildDocumentSnapshot(doc, { includeDomains: snapshotDomainsForSelector(args.selector) });
1362
+ const scopedPre = await buildMutationSnapshot(doc, { includeDomains: snapshotDomainsForSelector(args.selector) });
1348
1363
  try {
1349
1364
  if (args.finds.length === 0) {
1350
1365
  return failedReceipt('delete_text', new Error('finds must be non-empty'), scopedPre);
@@ -1387,7 +1402,7 @@ async function runDeleteText(doc, args) {
1387
1402
  args: { replacement: { text: rewritten }, style: preserveRewriteStyle() },
1388
1403
  },
1389
1404
  ];
1390
- const scopedResult = await executeMutations(doc, scopedSteps, args.changeMode);
1405
+ const scopedResult = await executeMutations(doc, scopedSteps, args.changeMode, scopedPre.revision);
1391
1406
  const scopedPost = await buildDocumentSnapshot(doc, { includeDomains: ['blocks'] });
1392
1407
  const verification = evaluateChecks(scopedPre, scopedPost, [{ kind: 'revision-changed' }]);
1393
1408
  return {
@@ -1449,7 +1464,7 @@ async function runDeleteText(doc, args) {
1449
1464
  },
1450
1465
  args: {},
1451
1466
  }));
1452
- const result = await executeMutations(doc, steps, args.changeMode);
1467
+ const result = await executeMutations(doc, steps, args.changeMode, pre.revision);
1453
1468
  const revision = asRecord(asRecord(result)?.revision);
1454
1469
  const postIdentity = args.changeMode === 'tracked'
1455
1470
  ? await readDocumentIdentity(doc)
@@ -1492,7 +1507,7 @@ async function runDeleteBlocks(doc, args) {
1492
1507
  for (const domain of snapshotDomainsForSelector(selector))
1493
1508
  domains.add(domain);
1494
1509
  }
1495
- const pre = await buildDocumentSnapshot(doc, { includeDomains: [...domains] });
1510
+ const pre = await buildMutationSnapshot(doc, { includeDomains: [...domains] });
1496
1511
  try {
1497
1512
  if (args.selectors.length === 0) {
1498
1513
  return failedReceipt('delete_blocks', new Error('selectors must be non-empty'), pre);
@@ -1585,7 +1600,7 @@ async function runDeleteBlocks(doc, args) {
1585
1600
  });
1586
1601
  }
1587
1602
  }
1588
- const post = await buildDocumentSnapshot(doc, { includeDomains: [...domains] });
1603
+ const post = await buildMutationSnapshot(doc, { includeDomains: [...domains] });
1589
1604
  // Tracked deletions leave the block in place until the revision is decided,
1590
1605
  // so the block count cannot move — count the structural revisions instead.
1591
1606
  // Direct deletions must show one fewer block of each deleted node type.
@@ -1703,7 +1718,7 @@ async function runInsertListItems(doc, args) {
1703
1718
  }
1704
1719
  }
1705
1720
  async function runAddListItems(doc, args, opts) {
1706
- const pre = await buildDocumentSnapshot(doc, { includeDomains: ['blocks'] });
1721
+ const pre = await buildMutationSnapshot(doc, { includeDomains: ['blocks'] });
1707
1722
  try {
1708
1723
  const needle = args.anchorText?.trim();
1709
1724
  const anchorNodeId = args.anchorNodeId?.trim();
@@ -2237,7 +2252,7 @@ async function runSplitList(doc, args) {
2237
2252
  }
2238
2253
  }
2239
2254
  async function runCreateTable(doc, args) {
2240
- const pre = await buildDocumentSnapshot(doc);
2255
+ const pre = await buildMutationSnapshot(doc, { includeDomains: ['blocks', 'tables'] });
2241
2256
  try {
2242
2257
  if (!Number.isInteger(args.rows) || args.rows < 1 || !Number.isInteger(args.columns) || args.columns < 1) {
2243
2258
  return failedReceipt('create_table', new Error('rows and columns must be positive integers'), pre);
@@ -2265,7 +2280,7 @@ async function runCreateTable(doc, args) {
2265
2280
  });
2266
2281
  }
2267
2282
  }
2268
- const post = await buildDocumentSnapshot(doc);
2283
+ const post = await buildMutationSnapshot(doc, { includeDomains: ['blocks', 'tables'] });
2269
2284
  const verification = evaluateChecks(pre, post, [
2270
2285
  { kind: 'revision-changed' },
2271
2286
  { kind: 'block-count-delta', nodeType: 'table', delta: 1 },
@@ -2285,7 +2300,7 @@ async function runCreateTable(doc, args) {
2285
2300
  }
2286
2301
  }
2287
2302
  async function runCommentParagraphs(doc, args) {
2288
- const pre = await buildDocumentSnapshot(doc);
2303
+ const pre = await buildMutationSnapshot(doc, { includeDomains: ['blocks'] });
2289
2304
  try {
2290
2305
  const blocks = pre.blocks.filter((b) => {
2291
2306
  if (args.scope === 'all') {
@@ -2327,13 +2342,18 @@ async function runCommentParagraphs(doc, args) {
2327
2342
  }
2328
2343
  }
2329
2344
  async function runAddComments(doc, args) {
2330
- const pre = await buildDocumentSnapshot(doc);
2345
+ const selectorList = args.selectors?.length ? args.selectors : args.selector ? [args.selector] : [];
2346
+ const domains = new Set(['blocks']);
2347
+ for (const selector of selectorList) {
2348
+ for (const domain of snapshotDomainsForSelector(selector))
2349
+ domains.add(domain);
2350
+ }
2351
+ const pre = await buildMutationSnapshot(doc, { includeDomains: [...domains] });
2331
2352
  try {
2332
2353
  // Batch form: `selectors` comments many blocks in ONE action call so the
2333
2354
  // model never fans out N concurrent add_comments tool calls (which race the
2334
2355
  // shared document and the comment-count verification). A single `selector`
2335
2356
  // is the one-target shorthand. Comments are applied sequentially.
2336
- const selectorList = args.selectors?.length ? args.selectors : args.selector ? [args.selector] : [];
2337
2357
  if (!selectorList.length) {
2338
2358
  return failedReceipt('add_comments', new Error('add_comments requires a "selector" or a non-empty "selectors" array'), pre);
2339
2359
  }
@@ -2347,7 +2367,21 @@ async function runAddComments(doc, args) {
2347
2367
  unresolved.push(sel);
2348
2368
  }
2349
2369
  if (!resolved.length) {
2350
- return failedReceipt('add_comments', new Error('no selector resolved to a body block'), pre);
2370
+ return {
2371
+ status: 'failed',
2372
+ intent: 'add_comments',
2373
+ preSnapshot: { revision: pre.revision, counts: pre.counts },
2374
+ selectedTargets: unresolved.map((selector) => ({ selector, matched: [] })),
2375
+ executedOperations: [],
2376
+ verification: [],
2377
+ errors: [
2378
+ {
2379
+ code: 'TARGET_NOT_FOUND',
2380
+ message: 'none of the requested comment selectors resolved to a block',
2381
+ recovery: { kind: 'reinspect' },
2382
+ },
2383
+ ],
2384
+ };
2351
2385
  }
2352
2386
  const executed = [];
2353
2387
  for (const { commentText, nodeId } of resolved.map((r) => ({ commentText: args.commentText, nodeId: r.nodeId }))) {
@@ -2356,15 +2390,20 @@ async function runAddComments(doc, args) {
2356
2390
  }
2357
2391
  const post = await buildDocumentSnapshot(doc);
2358
2392
  const verification = evaluateChecks(pre, post, [{ kind: 'comment-count-delta', delta: resolved.length }]);
2393
+ const verified = verification.every((v) => v.passed);
2394
+ const status = !verified ? 'failed' : unresolved.length > 0 ? 'partial' : 'ok';
2359
2395
  return {
2360
- status: verification.every((v) => v.passed) ? 'ok' : 'failed',
2396
+ status,
2361
2397
  intent: 'add_comments',
2362
2398
  ...(unresolved.length
2363
2399
  ? { note: `${unresolved.length} selector(s) did not resolve to a block and were skipped` }
2364
2400
  : {}),
2365
2401
  preSnapshot: { revision: pre.revision, counts: pre.counts },
2366
2402
  postSnapshot: { revision: post.revision, counts: post.counts },
2367
- selectedTargets: resolved.map((r) => ({ selector: r.selector, matched: [r.nodeId] })),
2403
+ selectedTargets: [
2404
+ ...resolved.map((r) => ({ selector: r.selector, matched: [r.nodeId] })),
2405
+ ...unresolved.map((selector) => ({ selector, matched: [] })),
2406
+ ],
2368
2407
  executedOperations: executed,
2369
2408
  verification,
2370
2409
  };
@@ -2540,7 +2579,7 @@ async function runReplyToComment(doc, args) {
2540
2579
  }
2541
2580
  }
2542
2581
  async function runRewriteBlock(doc, args) {
2543
- const pre = await buildDocumentSnapshot(doc);
2582
+ const pre = await buildMutationSnapshot(doc, { includeDomains: ['blocks'] });
2544
2583
  try {
2545
2584
  const target = selectorToBlockTarget(args.selector, pre);
2546
2585
  if (!target) {
@@ -2563,9 +2602,9 @@ async function runRewriteBlock(doc, args) {
2563
2602
  },
2564
2603
  },
2565
2604
  ];
2566
- const result = await executeMutations(doc, steps, args.changeMode);
2605
+ const result = await executeMutations(doc, steps, args.changeMode, pre.revision);
2567
2606
  const preservation = await preserveRunPatternAfterRewrite(doc, target.nodeId, target.text, preRuns, normalizedText);
2568
- const post = await buildDocumentSnapshot(doc);
2607
+ const post = await buildMutationSnapshot(doc, { includeDomains: ['blocks'] });
2569
2608
  const rewrittenBlock = findSnapshotTextByNodeId(post, target.nodeId);
2570
2609
  const verification = [
2571
2610
  revisionVerification(pre.revision, post.revision, true),
@@ -2974,7 +3013,7 @@ async function listAllTrackedChanges(listFn) {
2974
3013
  }
2975
3014
  }
2976
3015
  async function runNormalizeBodyFontSize(doc, args) {
2977
- const pre = await buildDocumentSnapshot(doc);
3016
+ const pre = await buildMutationSnapshot(doc, { includeDomains: ['blocks'] });
2978
3017
  try {
2979
3018
  if (!Number.isFinite(args.fontSize) || args.fontSize <= 0) {
2980
3019
  return failedReceipt('normalize_body_font_size', new Error('fontSize must be a positive number'), pre);
@@ -2991,7 +3030,7 @@ async function runNormalizeBodyFontSize(doc, args) {
2991
3030
  where: { by: 'block', nodeType: block.nodeType, nodeId: block.nodeId },
2992
3031
  args: { inline: { fontSize: args.fontSize }, scope: 'block' },
2993
3032
  }));
2994
- const result = await executeMutations(doc, steps, args.changeMode);
3033
+ const result = await executeMutations(doc, steps, args.changeMode, pre.revision);
2995
3034
  const post = await buildDocumentSnapshot(doc);
2996
3035
  const verification = evaluateChecks(pre, post, [{ kind: 'revision-changed' }]);
2997
3036
  return {
@@ -3022,7 +3061,7 @@ async function runNormalizeBodyFontSize(doc, args) {
3022
3061
  * tracked-safe (changeMode).
3023
3062
  */
3024
3063
  async function runSetFontFamily(doc, args) {
3025
- const pre = await buildDocumentSnapshot(doc);
3064
+ const pre = await buildMutationSnapshot(doc, { includeDomains: ['blocks', 'tables'] });
3026
3065
  try {
3027
3066
  const fontFamily = args.fontFamily.trim();
3028
3067
  if (fontFamily.length === 0) {
@@ -3149,7 +3188,7 @@ async function runSetFontFamily(doc, args) {
3149
3188
  * the recreated block inside an adjacent table cell.
3150
3189
  */
3151
3190
  async function runApplyStyle(doc, args) {
3152
- const pre = await buildDocumentSnapshot(doc);
3191
+ const pre = await buildMutationSnapshot(doc, { includeDomains: ['blocks'] });
3153
3192
  try {
3154
3193
  const target = selectorToBlockTarget(args.selector, pre);
3155
3194
  if (!target) {
@@ -3251,7 +3290,7 @@ async function runApplyStyle(doc, args) {
3251
3290
  * escape to superdoc_execute_code and apply the alignment untracked (no pPrChange).
3252
3291
  */
3253
3292
  async function runFormatParagraph(doc, args) {
3254
- const pre = await buildDocumentSnapshot(doc);
3293
+ const pre = await buildMutationSnapshot(doc, { includeDomains: ['blocks'] });
3255
3294
  try {
3256
3295
  const target = selectorToBlockTarget(args.selector, pre);
3257
3296
  if (!target) {
@@ -3270,7 +3309,7 @@ async function runFormatParagraph(doc, args) {
3270
3309
  args: { alignment: normalizedAlignment, scope: 'block' },
3271
3310
  },
3272
3311
  ];
3273
- const result = await executeMutations(doc, steps, args.changeMode);
3312
+ const result = await executeMutations(doc, steps, args.changeMode, pre.revision);
3274
3313
  const post = await buildDocumentSnapshot(doc);
3275
3314
  const verification = evaluateChecks(pre, post, [{ kind: 'revision-changed' }]);
3276
3315
  return {
@@ -3305,7 +3344,7 @@ async function runFormatParagraph(doc, args) {
3305
3344
  * still anchor on the (now tracked-deleted) source span and land right after it.
3306
3345
  */
3307
3346
  async function runMoveText(doc, args) {
3308
- const pre = await buildDocumentSnapshot(doc);
3347
+ const pre = await buildMutationSnapshot(doc, { includeDomains: ['blocks', 'tables'] });
3309
3348
  try {
3310
3349
  if (!args.text || args.text.length === 0) {
3311
3350
  return failedReceipt('move_text', new Error('text (the exact source span to move) is required'), pre);
@@ -3422,7 +3461,7 @@ async function runMoveText(doc, args) {
3422
3461
  * instead of inserting blank paragraphs.
3423
3462
  */
3424
3463
  async function runSetParagraphSpacing(doc, args) {
3425
- const pre = await buildDocumentSnapshot(doc, { includeDomains: ['blocks'] });
3464
+ const pre = await buildMutationSnapshot(doc, { includeDomains: ['blocks'] });
3426
3465
  try {
3427
3466
  const target = selectorToBlockTarget(args.selector, pre);
3428
3467
  if (!target) {
@@ -3479,7 +3518,7 @@ async function runSetParagraphSpacing(doc, args) {
3479
3518
  * THE way to "start X on a new page" instead of padding with empty paragraphs.
3480
3519
  */
3481
3520
  async function runInsertPageBreak(doc, args) {
3482
- const pre = await buildDocumentSnapshot(doc, { includeDomains: ['blocks'] });
3521
+ const pre = await buildMutationSnapshot(doc, { includeDomains: ['blocks'] });
3483
3522
  try {
3484
3523
  const target = selectorToBlockTarget(args.selector, pre);
3485
3524
  if (!target) {
@@ -3516,7 +3555,7 @@ async function runInsertPageBreak(doc, args) {
3516
3555
  * Finds the text in the body and applies a link over its range.
3517
3556
  */
3518
3557
  async function runAddHyperlink(doc, args) {
3519
- const pre = await buildDocumentSnapshot(doc, { includeDomains: ['blocks'] });
3558
+ const pre = await buildMutationSnapshot(doc, { includeDomains: ['blocks'] });
3520
3559
  try {
3521
3560
  const needle = args.text;
3522
3561
  const url = args.url;
@@ -3574,7 +3613,7 @@ async function runAddHyperlink(doc, args) {
3574
3613
  * dialect, and silently apply the formatting untracked.
3575
3614
  */
3576
3615
  async function runFormatText(doc, args) {
3577
- const pre = await buildDocumentSnapshot(doc);
3616
+ const pre = await buildMutationSnapshot(doc, { includeDomains: ['blocks', 'tables'] });
3578
3617
  try {
3579
3618
  const inline = {};
3580
3619
  if (args.bold === true)
@@ -3735,7 +3774,7 @@ function findRanges(haystack, needle, caseSensitive) {
3735
3774
  return ranges;
3736
3775
  }
3737
3776
  async function runApplyLetterSpacing(doc, args) {
3738
- const pre = await buildDocumentSnapshot(doc);
3777
+ const pre = await buildMutationSnapshot(doc, { includeDomains: ['blocks'] });
3739
3778
  try {
3740
3779
  if (!Number.isFinite(args.letterSpacing)) {
3741
3780
  return failedReceipt('apply_letter_spacing', new Error('letterSpacing must be a finite number'), pre);
@@ -3755,7 +3794,7 @@ async function runApplyLetterSpacing(doc, args) {
3755
3794
  args: { inline: { letterSpacing: args.letterSpacing }, scope: 'block' },
3756
3795
  },
3757
3796
  ];
3758
- const result = await executeMutations(doc, steps, args.changeMode);
3797
+ const result = await executeMutations(doc, steps, args.changeMode, pre.revision);
3759
3798
  const post = await buildDocumentSnapshot(doc);
3760
3799
  const verification = evaluateChecks(pre, post, [{ kind: 'revision-changed' }]);
3761
3800
  return {
@@ -3913,7 +3952,10 @@ async function runMoveRange(doc, args) {
3913
3952
  }
3914
3953
  }
3915
3954
  async function runInsertToc(doc, args) {
3916
- const pre = await buildDocumentSnapshot(doc);
3955
+ const placement = args.placement ?? { at: 'document_start' };
3956
+ const pre = placement.at === 'before' || placement.at === 'after'
3957
+ ? await buildMutationSnapshot(doc)
3958
+ : await buildDocumentSnapshot(doc);
3917
3959
  try {
3918
3960
  const tocFn = maybeMethod(doc, ['create', 'tableOfContents']);
3919
3961
  if (!tocFn) {
@@ -3921,11 +3963,11 @@ async function runInsertToc(doc, args) {
3921
3963
  code: 'TOOL_DISPATCH_NOT_FOUND',
3922
3964
  });
3923
3965
  }
3924
- const placement = resolvePlacement(args.placement ?? { at: 'document_start' }, pre);
3966
+ const resolvedPlacement = resolvePlacement(placement, pre);
3925
3967
  const executed = [];
3926
- let tocPlacement = placement;
3968
+ let tocPlacement = resolvedPlacement;
3927
3969
  if (args.title) {
3928
- const headingResult = await executeCreateHeading(doc, args.title, 1, placement, args.changeMode);
3970
+ const headingResult = await executeCreateHeading(doc, args.title, 1, resolvedPlacement, args.changeMode);
3929
3971
  executed.push({ operationId: 'doc.create.heading', result: headingResult });
3930
3972
  const headingNodeId = asString(asRecord(asRecord(headingResult)?.heading)?.nodeId);
3931
3973
  if (headingNodeId) {
@@ -4052,7 +4094,7 @@ async function runStyleTable(doc, args) {
4052
4094
  * should reach for instead of delete-and-recreate or insert/undo churn.
4053
4095
  */
4054
4096
  async function runMoveTable(doc, args) {
4055
- const pre = await buildDocumentSnapshot(doc);
4097
+ const pre = await buildMutationSnapshot(doc);
4056
4098
  try {
4057
4099
  const tableOrdinal = args.tableOrdinal ?? 1;
4058
4100
  const table = await resolveTableContextQuick(doc, tableOrdinal);
@@ -4578,8 +4620,8 @@ function parseScopedReplaceArgs(args) {
4578
4620
  changeMode: args.changeMode === 'tracked' ? 'tracked' : 'direct',
4579
4621
  };
4580
4622
  }
4581
- async function runScopedReplace(doc, args) {
4582
- const pre = await buildDocumentSnapshot(doc, { includeDomains: ['blocks', 'tables'] });
4623
+ async function runScopedReplaceUnchecked(doc, args) {
4624
+ const pre = await buildMutationSnapshot(doc, { includeDomains: ['blocks', 'tables'] });
4583
4625
  const matched = resolveSnapshotSelector(pre, args.selector);
4584
4626
  if (matched.length !== 1) {
4585
4627
  const failure = describeSelectorFailure(args.selector, matched, pre, args.edits.map((e) => e.find));
@@ -4676,7 +4718,7 @@ async function runScopedReplace(doc, args) {
4676
4718
  // applies directly, so restoring marks adds no second reviewable change.
4677
4719
  const trackedPreRuns = await captureBlockRuns(doc, nodeId, block.text);
4678
4720
  try {
4679
- result = await executeMutations(doc, spanSteps, args.changeMode);
4721
+ result = await executeMutations(doc, spanSteps, args.changeMode, pre.revision);
4680
4722
  if (result != null)
4681
4723
  await preserveScopedRunPattern(doc, nodeId, block.text, trackedPreRuns, planned);
4682
4724
  }
@@ -4698,10 +4740,10 @@ async function runScopedReplace(doc, args) {
4698
4740
  args: { replacement: { text: expected }, style: preserveRewriteStyle() },
4699
4741
  },
4700
4742
  ];
4701
- result = await executeMutations(doc, steps, args.changeMode);
4743
+ result = await executeMutations(doc, steps, args.changeMode, pre.revision);
4702
4744
  await preserveScopedRunPattern(doc, nodeId, block.text, preRuns, planned);
4703
4745
  }
4704
- const post = await buildDocumentSnapshot(doc, { includeDomains: ['blocks', 'tables'] });
4746
+ const post = await buildMutationSnapshot(doc, { includeDomains: ['blocks', 'tables'] });
4705
4747
  const postBlock = findTextTarget(post, nodeId);
4706
4748
  // In tracked mode the deleted text is still present in the block, so only
4707
4749
  // require the inserted text to be visible.
@@ -4740,6 +4782,14 @@ async function runScopedReplace(doc, args) {
4740
4782
  ],
4741
4783
  };
4742
4784
  }
4785
+ async function runScopedReplace(doc, args) {
4786
+ try {
4787
+ return await runScopedReplaceUnchecked(doc, args);
4788
+ }
4789
+ catch (error) {
4790
+ return failedReceipt('replace_text', error);
4791
+ }
4792
+ }
4743
4793
  /**
4744
4794
  * Build a `doc.format.apply` `inline` payload from a block row's sampled look.
4745
4795
  * Shared by apply_style (likeText copy) and add_list_items (anchor auto-match)
@@ -5277,8 +5327,18 @@ async function listBlockRows(doc) {
5277
5327
  const fn = maybeMethod(doc, ['blocks', 'list']);
5278
5328
  if (!fn)
5279
5329
  return [];
5280
- const raw = (await fn({}));
5281
- return Array.isArray(raw?.blocks) ? raw.blocks : [];
5330
+ const rows = [];
5331
+ const pageSize = 250;
5332
+ let offset = 0;
5333
+ while (true) {
5334
+ const raw = (await fn({ offset, limit: pageSize }));
5335
+ const page = Array.isArray(raw?.blocks) ? raw.blocks : [];
5336
+ rows.push(...page);
5337
+ offset += page.length;
5338
+ const total = typeof raw?.total === 'number' && Number.isFinite(raw.total) ? raw.total : rows.length;
5339
+ if (page.length === 0 || offset >= total)
5340
+ return rows;
5341
+ }
5282
5342
  }
5283
5343
  function blockNumbering(row) {
5284
5344
  const numbering = row?.numbering;
@@ -5444,7 +5504,7 @@ async function convertNumberedRange(doc, fromMarker, toMarker, kind, tracked) {
5444
5504
  };
5445
5505
  }
5446
5506
  async function convertParagraphRange(doc, fromText, toText, kind, tracked) {
5447
- const pre = await buildDocumentSnapshot(doc, { includeDomains: ['blocks'] });
5507
+ const pre = await buildMutationSnapshot(doc, { includeDomains: ['blocks'] });
5448
5508
  const blocks = pre.blocks ?? [];
5449
5509
  const findBlock = (needle) => blocks.findIndex((b) => b.nodeType === 'paragraph' && (b.text ?? '').toLowerCase().includes(needle.toLowerCase()));
5450
5510
  const fromIdx = findBlock(fromText);
@@ -5488,7 +5548,7 @@ async function convertParagraphRange(doc, fromText, toText, kind, tracked) {
5488
5548
  };
5489
5549
  }
5490
5550
  await createListFromParagraphRange(createFn, kind, range[0].nodeId, range[range.length - 1].nodeId, tracked ? 'tracked' : undefined);
5491
- const post = await buildDocumentSnapshot(doc, { includeDomains: ['blocks', 'lists'] });
5551
+ const post = await buildMutationSnapshot(doc, { includeDomains: ['blocks', 'lists'] });
5492
5552
  const postList = (post.lists ?? []).find((l) => l.items.some((it) => it.nodeId === range[0].nodeId));
5493
5553
  const allInOneList = postList != null && range.every((b) => postList.items.some((it) => it.nodeId === b.nodeId));
5494
5554
  return {
@@ -5757,7 +5817,7 @@ async function appendListAtPlacement(doc, args) {
5757
5817
  const selector = placement && isRecord(placement.selector) ? placement.selector : null;
5758
5818
  if (items.length === 0 || !placement || (at !== 'after' && at !== 'before') || !selector)
5759
5819
  return null;
5760
- const pre = await buildDocumentSnapshot(doc, { includeDomains: ['blocks'] });
5820
+ const pre = await buildMutationSnapshot(doc, { includeDomains: ['blocks'] });
5761
5821
  const blocks = pre.blocks ?? [];
5762
5822
  // Resolve through the SHARED selector resolver so the full selector
5763
5823
  // vocabulary works (nodeId / textSearch / ordinal / relative / ref) and
@@ -5818,7 +5878,7 @@ async function appendListAtPlacement(doc, args) {
5818
5878
  const listIds = typeof args.headingText === 'string' && args.headingText.length > 0 ? createdIds.slice(1) : createdIds;
5819
5879
  await createListFromParagraphRange(listsCreateFn, kind, listIds[0], listIds[listIds.length - 1], parseChangeMode(args.changeMode));
5820
5880
  // Verify both promises: the items form one list AND it sits at the anchor.
5821
- const post = await buildDocumentSnapshot(doc, { includeDomains: ['blocks', 'lists'] });
5881
+ const post = await buildMutationSnapshot(doc, { includeDomains: ['blocks', 'lists'] });
5822
5882
  const postBlocks = post.blocks ?? [];
5823
5883
  const postList = (post.lists ?? []).find((l) => l.items.some((it) => it.nodeId === listIds[0]));
5824
5884
  const listOk = postList != null && listIds.every((id) => postList.items.some((it) => it.nodeId === id));
@@ -6381,7 +6441,7 @@ async function runAttachNumbering(doc, args) {
6381
6441
  verification: [{ check: { kind: 'marker-rendered' }, passed: !!newMarker }],
6382
6442
  };
6383
6443
  }
6384
- export async function superdocPerformAction(doc, args) {
6444
+ async function dispatchSuperdocPerformAction(doc, args) {
6385
6445
  if (!isRecord(args)) {
6386
6446
  throw new SuperDocCliError('superdoc_perform_action arguments must be an object', {
6387
6447
  code: 'INVALID_ARGUMENT',
@@ -6995,6 +7055,24 @@ export async function superdocPerformAction(doc, args) {
6995
7055
  }
6996
7056
  }
6997
7057
  }
7058
+ export async function superdocPerformAction(doc, args) {
7059
+ try {
7060
+ return await dispatchSuperdocPerformAction(doc, args);
7061
+ }
7062
+ catch (error) {
7063
+ if (!(error instanceof MutationSnapshotError))
7064
+ throw error;
7065
+ const action = isRecord(args) && isActionName(args.action) ? args.action : 'superdoc_perform_action';
7066
+ return {
7067
+ status: 'failed',
7068
+ intent: action,
7069
+ selectedTargets: [],
7070
+ executedOperations: [],
7071
+ verification: [],
7072
+ errors: [{ code: error.code, message: error.message, recovery: { kind: 'reinspect' } }],
7073
+ };
7074
+ }
7075
+ }
6998
7076
  function parseChangeMode(value) {
6999
7077
  if (value === 'direct' || value === 'tracked')
7000
7078
  return value;