@superdoc/sdk 2.22.0-next.11 → 2.22.0-next.13

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.
@@ -14,10 +14,10 @@
14
14
  "operations": ["create.heading"]
15
15
  },
16
16
  "replace_text": {
17
- "hint": "edits[{find,replace}], optional selector to scope replacements to one inspected block, caseSensitive?, changeMode? — for EDITING existing wording only; the replaced span is rewritten as PLAIN text (intra-span bold/italic is dropped), so keep find/replace spans MINIMAL (the words that change, not the whole paragraph) and NEVER use it to append a new sentence/clause/paragraph (use insert_paragraphs)",
17
+ "hint": "edits[{find,replace,occurrence?}], optional selector to scope replacements to one inspected block, caseSensitive?, expectedRevision?, changeMode? — for EDITING existing wording only; keep find/replace spans MINIMAL (the words that change, not the whole paragraph). When the find appears more than once in the selected block and only one should change, set that edit's occurrence (1-based match number within the block; requires selector; this is not selector.occurrence, which picks a block). NEVER use it to append a new sentence/clause/paragraph (use insert_paragraphs)",
18
18
  "group": "Text/structure actions",
19
19
  "groupOrder": 2,
20
- "args": ["edits", "selector", "caseSensitive", "changeMode"],
20
+ "args": ["edits", "selector", "caseSensitive", "expectedRevision", "changeMode"],
21
21
  "operations": ["replace"]
22
22
  },
23
23
  "delete_text": {
@@ -601,12 +601,10 @@ function normalizeTitleLikeRewriteText(targetText, rewrittenText) {
601
601
  const displayTitle = toDisplayTitleCase(targetText);
602
602
  if (rewrittenText.includes(displayTitle))
603
603
  return rewrittenText;
604
- const lowerTarget = targetText.toLocaleLowerCase();
605
- const lowerRewrite = rewrittenText.toLocaleLowerCase();
606
- const index = lowerRewrite.indexOf(lowerTarget);
607
- if (index < 0)
604
+ const match = findRanges(rewrittenText, targetText, false)[0];
605
+ if (!match)
608
606
  return rewrittenText;
609
- return `${rewrittenText.slice(0, index)}${displayTitle}${rewrittenText.slice(index + targetText.length)}`;
607
+ return `${rewrittenText.slice(0, match.start)}${displayTitle}${rewrittenText.slice(match.end)}`;
610
608
  }
611
609
  async function executeCreateParagraph(doc, text, placement, changeMode) {
612
610
  const fn = maybeMethod(doc, ['create', 'paragraph']);
@@ -975,6 +973,7 @@ async function runReplaceTextUnchecked(doc, args) {
975
973
  ? await docSnapshot.buildMutationSnapshot(doc, { includeDomains: selectorDomains ?? ['blocks'] })
976
974
  : snapshotFromIdentity(preIdentity);
977
975
  try {
976
+ assertExpectedReplacementRevision(args.expectedRevision, pre.revision);
978
977
  assertReplacementTextPayloads(args.edits);
979
978
  if (args.edits.length === 0) {
980
979
  return failedReceipt('replace_text', new Error('edits must be non-empty'), pre);
@@ -983,7 +982,7 @@ async function runReplaceTextUnchecked(doc, args) {
983
982
  const selectedTargets = [];
984
983
  let skippedEdits = [];
985
984
  let steps;
986
- let applyExpectedRevision;
985
+ let applyExpectedRevision = args.expectedRevision != null ? pre.revision : undefined;
987
986
  if (args.selector) {
988
987
  const target = selectorToBlockTarget(args.selector, pre);
989
988
  if (!target) {
@@ -3437,18 +3436,28 @@ function findRanges(haystack, needle, caseSensitive) {
3437
3436
  const ranges = [];
3438
3437
  if (needle.length === 0)
3439
3438
  return ranges;
3440
- const source = caseSensitive ? haystack : haystack.toLocaleLowerCase();
3441
- const target = caseSensitive ? needle : needle.toLocaleLowerCase();
3442
- let offset = 0;
3443
- while (offset < source.length) {
3444
- const idx = source.indexOf(target, offset);
3445
- if (idx < 0)
3446
- break;
3447
- ranges.push({ start: idx, end: idx + target.length });
3448
- offset = idx + target.length;
3439
+ if (caseSensitive) {
3440
+ let offset = 0;
3441
+ while (offset < haystack.length) {
3442
+ const idx = haystack.indexOf(needle, offset);
3443
+ if (idx < 0)
3444
+ break;
3445
+ ranges.push({ start: idx, end: idx + needle.length });
3446
+ offset = idx + needle.length;
3447
+ }
3448
+ return ranges;
3449
+ }
3450
+ // Match case-insensitively on the original string. Lowercasing a copy first
3451
+ // changes its length for characters such as "İ", which shifts every later
3452
+ // offset onto the wrong text.
3453
+ for (const match of haystack.matchAll(caseInsensitivePattern(needle))) {
3454
+ ranges.push({ start: match.index, end: match.index + match[0].length });
3449
3455
  }
3450
3456
  return ranges;
3451
3457
  }
3458
+ function caseInsensitivePattern(needle) {
3459
+ return new RegExp(needle.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'), 'giu');
3460
+ }
3452
3461
  async function runApplyLetterSpacing(doc, args) {
3453
3462
  const pre = await docSnapshot.buildMutationSnapshot(doc, { includeDomains: ['blocks'] });
3454
3463
  try {
@@ -4279,29 +4288,64 @@ function trackedChangeRefIds(result) {
4279
4288
  return entityId ? [entityId] : [];
4280
4289
  }));
4281
4290
  }
4282
- function parseScopedReplaceArgs(args, changeMode) {
4283
- if (args.action !== 'replace_text')
4284
- return null;
4291
+ function parseScopedReplaceArgs(args, edits, changeMode, expectedRevision) {
4285
4292
  if (!isRecord(args.selector))
4286
4293
  return null;
4287
- if (!Array.isArray(args.edits) || args.edits.length === 0)
4288
- return null;
4289
- const edits = [];
4290
- for (const entry of args.edits) {
4291
- if (!isRecord(entry) || typeof entry.find !== 'string' || entry.find.length === 0)
4292
- return null;
4293
- edits.push({ find: entry.find, replace: typeof entry.replace === 'string' ? entry.replace : '' });
4294
- }
4295
4294
  return {
4296
4295
  edits,
4297
4296
  selector: args.selector,
4298
4297
  caseSensitive: args.caseSensitive === true,
4298
+ expectedRevision,
4299
4299
  changeMode,
4300
4300
  };
4301
4301
  }
4302
+ function assertExpectedReplacementRevision(expected, actual) {
4303
+ if (expected != null && expected !== actual) {
4304
+ throw new errors.SuperDocCliError('replace_text: expectedRevision is stale. Inspect this live document again before editing.', {
4305
+ code: 'REVISION_CONFLICT',
4306
+ });
4307
+ }
4308
+ }
4309
+ // Ranges of the block text that may show a body field's result. Field
4310
+ // results carry no offsets, so every place the result text occurs counts,
4311
+ // overlapping ones included. A field whose result text is not reported (some
4312
+ // imported fields expose an empty result while displaying cached text) covers
4313
+ // the whole block. Returns null when the field catalog cannot be read.
4314
+ async function fieldResultRanges(doc, nodeId, blockText) {
4315
+ const fn = maybeMethod(doc, ['fields', 'list']);
4316
+ if (!fn)
4317
+ return null;
4318
+ const results = [];
4319
+ const limit = 500;
4320
+ for (let offset = 0; offset < 100_000; offset += limit) {
4321
+ const page = await fn({ limit, offset });
4322
+ const items = isRecord(page) && Array.isArray(page.items) ? page.items.filter(isRecord) : [];
4323
+ for (const item of items) {
4324
+ const address = isRecord(item.address) ? item.address : null;
4325
+ const inBody = typeof address?.storyId !== 'string' || address.storyId.startsWith('main:');
4326
+ if (address?.blockId !== nodeId || !inBody)
4327
+ continue;
4328
+ if (typeof item.resolvedText !== 'string' || item.resolvedText.length === 0) {
4329
+ return [{ start: 0, end: blockText.length }];
4330
+ }
4331
+ results.push(item.resolvedText);
4332
+ }
4333
+ const total = isRecord(page) && typeof page.total === 'number' ? page.total : null;
4334
+ if (items.length < limit || (total != null && offset + limit >= total))
4335
+ break;
4336
+ }
4337
+ const ranges = [];
4338
+ for (const result of results) {
4339
+ for (let at = blockText.indexOf(result); at >= 0; at = blockText.indexOf(result, at + 1)) {
4340
+ ranges.push({ start: at, end: at + result.length });
4341
+ }
4342
+ }
4343
+ return ranges;
4344
+ }
4302
4345
  async function runScopedReplaceUnchecked(doc, args) {
4303
4346
  assertReplacementTextPayloads(args.edits);
4304
4347
  const pre = await docSnapshot.buildMutationSnapshot(doc, { includeDomains: ['blocks', 'tables'] });
4348
+ assertExpectedReplacementRevision(args.expectedRevision, pre.revision);
4305
4349
  const matched = docSnapshot.resolveSnapshotSelector(pre, args.selector);
4306
4350
  if (matched.length !== 1) {
4307
4351
  const failure = describeSelectorFailure(args.selector, matched, pre, args.edits.map((e) => e.find));
@@ -4321,13 +4365,24 @@ async function runScopedReplaceUnchecked(doc, args) {
4321
4365
  };
4322
4366
  }
4323
4367
  const caseSensitive = args.caseSensitive === true;
4368
+ const hasExplicitOccurrence = args.edits.some((edit) => edit.occurrence != null);
4324
4369
  const perEdit = [];
4325
4370
  const allSpans = [];
4326
4371
  for (const edit of args.edits) {
4327
- const spans = findRanges(block.text, edit.find, caseSensitive);
4328
- perEdit.push({ find: edit.find, occurrences: spans.length });
4372
+ const matches = findRanges(block.text, edit.find, caseSensitive);
4373
+ const spans = edit.occurrence == null ? matches : matches.slice(edit.occurrence - 1, edit.occurrence);
4374
+ if (hasExplicitOccurrence && spans.length === 0) {
4375
+ return failedReceipt('replace_text', new errors.SuperDocCliError(edit.occurrence == null
4376
+ ? `replace_text: ${JSON.stringify(edit.find)} not found in the selected block. Inspect and choose a valid target.`
4377
+ : `replace_text: ${JSON.stringify(edit.find)} occurrence ${edit.occurrence} not found in the selected block (${matches.length} matches). Inspect and choose a valid target.`, { code: 'MATCH_NOT_FOUND' }), pre);
4378
+ }
4379
+ perEdit.push({
4380
+ find: edit.find,
4381
+ occurrences: spans.length,
4382
+ ...(edit.occurrence != null ? { occurrence: edit.occurrence } : {}),
4383
+ });
4329
4384
  for (const span of spans)
4330
- allSpans.push({ ...span, replace: edit.replace ?? '', find: edit.find });
4385
+ allSpans.push({ ...span, replace: edit.replace, find: edit.find });
4331
4386
  }
4332
4387
  const appliedEdits = perEdit.filter((e) => e.occurrences > 0);
4333
4388
  const skippedEdits = perEdit.filter((e) => e.occurrences === 0);
@@ -4355,45 +4410,82 @@ async function runScopedReplaceUnchecked(doc, args) {
4355
4410
  let lastEnd = -1;
4356
4411
  for (const span of allSpans) {
4357
4412
  if (span.start < lastEnd) {
4413
+ if (hasExplicitOccurrence) {
4414
+ return failedReceipt('replace_text', new errors.SuperDocCliError('replace_text: explicit occurrence targets overlap. Choose non-overlapping edits in the pre-edit block.', { code: 'INVALID_ARGUMENT' }), pre);
4415
+ }
4358
4416
  droppedOverlaps.push(span);
4359
4417
  continue;
4360
4418
  }
4361
4419
  planned.push(span);
4362
4420
  lastEnd = span.end;
4363
4421
  }
4364
- // The v2 engine resolves `where` by block/select/ref only — a
4365
- // selection-targeted step ("by: 'target'") dies in resolveStepRef with
4366
- // "requires a ref produced by query.match/find".
4422
+ // The block text once every planned span is replaced: the whole-block
4423
+ // rewrite writes it, and exact-target writes must produce it.
4367
4424
  const expected = planned.reduceRight((text, span) => text.slice(0, span.start) + span.replace + text.slice(span.end), block.text);
4368
4425
  const tracked = (args.changeMode ?? 'direct') === 'tracked';
4369
4426
  // Unique tracked finds can be resolved within the block. Repeated finds
4370
4427
  // use one whole-block rewrite, whose engine diff preserves unchanged runs.
4371
- let result = null;
4372
- // Dedupe on the same basis the selector will match on: under a
4373
- // case-sensitive request `Foo` and `foo` are distinct targets, and folding
4374
- // them together would push a legitimately unique pair onto the fallback.
4428
+ // Dedupe on the same basis the planner matched on: under a case-sensitive
4429
+ // request `Foo` and `foo` are distinct targets, and folding them together
4430
+ // would push a legitimately unique pair onto the fallback.
4375
4431
  const uniqueFinds = new Set(planned.map((s) => (caseSensitive ? s.find : s.find.toLowerCase())));
4376
- if (tracked && uniqueFinds.size === planned.length) {
4377
- const spanSteps = planned.map((span, index) => ({
4432
+ // Explicit occurrences and unique tracked finds are written at the offsets
4433
+ // matched above. A whole-block diff could redline a different identical
4434
+ // occurrence while producing the same final text, and the engine's own text
4435
+ // search would match again with its own case rules.
4436
+ // A direct deletion through an exact target that touches a field deletes
4437
+ // the whole field. Such deletions keep the whole-block rewrite, whose
4438
+ // planned text is exact for explicit occurrences too; every other span keeps
4439
+ // its exact target and with it the chosen occurrence and its formatting.
4440
+ const deletions = tracked ? [] : planned.filter((span) => span.replace.length === 0);
4441
+ const fieldRanges = deletions.length > 0 ? await fieldResultRanges(doc, nodeId, block.text) : [];
4442
+ const fieldDeletionRisk = fieldRanges == null ||
4443
+ deletions.some((span) => fieldRanges.some((range) => span.start < range.end && span.end > range.start));
4444
+ const exactTargets = !fieldDeletionRisk && (hasExplicitOccurrence || (tracked && uniqueFinds.size === planned.length));
4445
+ let result = null;
4446
+ let wroteExactTargets = false;
4447
+ if (exactTargets) {
4448
+ // A span already equal to its replacement has nothing to write, and the
4449
+ // engine refuses a no-op target, which would sink its sibling edits.
4450
+ const targetedSpans = [...planned]
4451
+ .reverse()
4452
+ .filter((span) => block.text.slice(span.start, span.end) !== span.replace);
4453
+ const steps = targetedSpans.map((span, index) => ({
4378
4454
  id: `scoped-replace-${index + 1}`,
4379
4455
  op: 'text.rewrite',
4380
4456
  where: {
4381
- by: 'select',
4382
- // The planner honours caseSensitive, so the selector must match on the
4383
- // same terms. Without it a case-sensitive request can redline a
4384
- // differently cased occurrence and still verify, because verification
4385
- // only looks for the replacement text somewhere in the block.
4386
- select: { type: 'text', pattern: span.find, mode: 'contains', caseSensitive },
4387
- require: 'first',
4388
- within: { kind: 'block', nodeType: block.nodeType, nodeId },
4457
+ by: 'target',
4458
+ target: {
4459
+ kind: 'selection',
4460
+ start: { kind: 'text', blockId: nodeId, offset: span.start },
4461
+ end: { kind: 'text', blockId: nodeId, offset: span.end },
4462
+ },
4389
4463
  },
4390
4464
  args: { replacement: { text: span.replace }, style: preserveRewriteStyle() },
4391
4465
  }));
4392
- try {
4393
- result = await executeMutations(doc, spanSteps, args.changeMode, pre.revision);
4466
+ if (steps.length === 0) {
4467
+ result = { noop: true };
4394
4468
  }
4395
- catch {
4396
- result = null; // select lane unavailable (e.g. case mismatch) — fall back
4469
+ else {
4470
+ // The engine resolves these offsets against its own paragraph text. Check
4471
+ // that each one still covers the text matched in the snapshot, so a
4472
+ // projection mismatch never rewrites other text.
4473
+ const preview = await previewMutations(doc, steps, args.changeMode, pre.revision);
4474
+ const previewSteps = Array.isArray(preview.steps) ? preview.steps.filter(isRecord) : [];
4475
+ const aligned = preview.valid === true &&
4476
+ steps.every((step, index) => {
4477
+ const resolutions = previewSteps.find((candidate) => candidate.stepId === step.id)?.resolutions;
4478
+ const resolved = Array.isArray(resolutions) && isRecord(resolutions[0]) ? resolutions[0].text : undefined;
4479
+ const span = targetedSpans[index];
4480
+ return resolved === block.text.slice(span.start, span.end);
4481
+ });
4482
+ if (aligned) {
4483
+ result = await executeMutations(doc, steps, args.changeMode, pre.revision);
4484
+ wroteExactTargets = true;
4485
+ }
4486
+ else if (hasExplicitOccurrence) {
4487
+ return failedReceipt('replace_text', new errors.SuperDocCliError('replace_text: the chosen occurrence does not resolve to the same text in the document. Inspect again before editing.', { code: 'TARGET_NOT_FOUND' }), pre);
4488
+ }
4397
4489
  }
4398
4490
  }
4399
4491
  if (result == null) {
@@ -4409,12 +4501,34 @@ async function runScopedReplaceUnchecked(doc, args) {
4409
4501
  ];
4410
4502
  result = await executeMutations(doc, steps, args.changeMode, pre.revision);
4411
4503
  }
4412
- const post = await docSnapshot.buildMutationSnapshot(doc, { includeDomains: ['blocks', 'tables'] });
4413
- const postBlock = findTextTarget(post, nodeId);
4414
- // In tracked mode the deleted text is still present in the block, so only
4415
- // require the inserted text to be visible.
4504
+ let post = await docSnapshot.buildMutationSnapshot(doc, { includeDomains: ['blocks', 'tables'] });
4505
+ let postBlock = findTextTarget(post, nodeId);
4506
+ // Exact-target edits must leave exactly the planned visible text. If the
4507
+ // engine changed more than the requested spans, undo the edit, which is one
4508
+ // atomic history step. Only do so while the document is still at the
4509
+ // revision this plan produced, so the undo cannot reach any other change.
4510
+ let rolledBack = false;
4511
+ const appliedRevision = isRecord(result) && isRecord(result.revision) ? result.revision.after : undefined;
4512
+ if (wroteExactTargets &&
4513
+ postBlock != null &&
4514
+ postBlock.text !== expected &&
4515
+ postBlock.text !== block.text &&
4516
+ typeof appliedRevision === 'string' &&
4517
+ appliedRevision === post.revision) {
4518
+ const undoFn = maybeMethod(doc, ['history', 'undo']);
4519
+ if (undoFn) {
4520
+ await undoFn({});
4521
+ post = await docSnapshot.buildMutationSnapshot(doc, { includeDomains: ['blocks', 'tables'] });
4522
+ postBlock = findTextTarget(post, nodeId);
4523
+ rolledBack = postBlock?.text === block.text;
4524
+ }
4525
+ }
4526
+ // Block text is the visible (final) text. Exact-offset edits must produce
4527
+ // exactly the planned text; tracked edits that fell back to a whole-block
4528
+ // rewrite only need each inserted text to be visible.
4416
4529
  const passed = postBlock != null &&
4417
- (tracked
4530
+ !rolledBack &&
4531
+ (tracked && !hasExplicitOccurrence && !wroteExactTargets
4418
4532
  ? planned.every((span) => span.replace.length === 0 || postBlock.text.includes(span.replace))
4419
4533
  : postBlock.text === expected);
4420
4534
  // Partially-applied requests must NOT read as success: a model that asked
@@ -4430,6 +4544,16 @@ async function runScopedReplaceUnchecked(doc, args) {
4430
4544
  recovery: { kind: 'retry' },
4431
4545
  }
4432
4546
  : {}),
4547
+ ...(rolledBack
4548
+ ? {
4549
+ errors: [
4550
+ {
4551
+ code: 'ACTION_FAILED',
4552
+ message: 'replace_text: the engine changed more than the requested text, so the edit was undone. Inspect the block and retry with a larger find or rewrite the whole block.',
4553
+ },
4554
+ ],
4555
+ }
4556
+ : {}),
4433
4557
  preSnapshot: { revision: pre.revision },
4434
4558
  postSnapshot: { revision: post.revision },
4435
4559
  selectedBlock: { nodeId, nodeType: block.nodeType },
@@ -6215,12 +6339,21 @@ async function dispatchSuperdocPerformAction(doc, args) {
6215
6339
  code: 'INVALID_ARGUMENT',
6216
6340
  });
6217
6341
  }
6342
+ const expectedRevision = args.expectedRevision;
6343
+ if (expectedRevision !== undefined && (typeof expectedRevision !== 'string' || expectedRevision.length === 0)) {
6344
+ throw new errors.SuperDocCliError('replace_text: expectedRevision must be a non-empty string from this live document handle', { code: 'INVALID_ARGUMENT' });
6345
+ }
6346
+ if (edits.some((edit) => edit.occurrence != null) && !isRecord(args.selector)) {
6347
+ throw new errors.SuperDocCliError('replace_text: explicit occurrence requires a selector resolving to one block', {
6348
+ code: 'INVALID_ARGUMENT',
6349
+ });
6350
+ }
6218
6351
  const changeMode = parseReplaceTextChangeMode(args.changeMode);
6219
6352
  // Selector-scoped replaces take the span-targeted path: per-find
6220
6353
  // text.rewrite spans inside the one selected block, so tabs/breaks/
6221
6354
  // images outside the spans survive and the receipt reports per-edit
6222
6355
  // applied/skipped truth.
6223
- const scoped = parseScopedReplaceArgs(args, changeMode);
6356
+ const scoped = parseScopedReplaceArgs(args, edits, changeMode, expectedRevision);
6224
6357
  if (scoped) {
6225
6358
  return runScopedReplace(doc, scoped);
6226
6359
  }
@@ -6233,6 +6366,7 @@ async function dispatchSuperdocPerformAction(doc, args) {
6233
6366
  edits,
6234
6367
  selector,
6235
6368
  caseSensitive: args.caseSensitive === true,
6369
+ expectedRevision,
6236
6370
  changeMode,
6237
6371
  });
6238
6372
  }
@@ -6912,7 +7046,12 @@ function parseEdits(value) {
6912
7046
  const replace = asString(entry.replace) ?? '';
6913
7047
  if (!find)
6914
7048
  return undefined;
6915
- result.push({ find, replace });
7049
+ const occurrence = entry.occurrence;
7050
+ if (occurrence !== undefined &&
7051
+ (typeof occurrence !== 'number' || !Number.isSafeInteger(occurrence) || occurrence < 1)) {
7052
+ throw new errors.SuperDocCliError('replace_text: occurrence must be a positive safe integer (one-based within the selected block)', { code: 'INVALID_ARGUMENT' });
7053
+ }
7054
+ result.push({ find, replace, ...(occurrence !== undefined ? { occurrence } : {}) });
6916
7055
  }
6917
7056
  return result;
6918
7057
  }
@@ -63,9 +63,12 @@ export type ReplaceTextArgs = {
63
63
  edits: ReadonlyArray<{
64
64
  find: string;
65
65
  replace: string;
66
+ occurrence?: number;
66
67
  }>;
67
68
  selector?: AgentSelector;
68
69
  caseSensitive?: boolean;
70
+ /** Opaque observation token from the same live document handle; refuses stale edits. */
71
+ expectedRevision?: string;
69
72
  changeMode?: AgentChangeMode;
70
73
  };
71
74
  export type DeleteTextArgs = {