@superdoc/sdk 2.22.0-next.12 → 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.
- package/dist/agent/action-descriptors.json +2 -2
- package/dist/agent/actions.cjs +196 -57
- package/dist/agent/actions.d.ts +3 -0
- package/dist/agent/actions.js +196 -57
- package/dist/agent/catalog.cjs +15 -1
- package/dist/agent/catalog.js +15 -1
- package/dist/embedded-prompts.generated.cjs +2 -2
- package/dist/embedded-prompts.generated.js +2 -2
- package/dist/index.d.ts +1 -1
- package/dist/prompts/mcp-prompt.md +2 -0
- package/dist/prompts/system-prompt.md +1 -1
- package/dist/runtime/sdk-version.generated.cjs +1 -1
- package/dist/runtime/sdk-version.generated.d.ts +1 -1
- package/dist/runtime/sdk-version.generated.js +1 -1
- package/package.json +6 -6
- package/tools/generation-receipt.json +7 -7
|
@@ -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;
|
|
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": {
|
package/dist/agent/actions.cjs
CHANGED
|
@@ -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
|
|
605
|
-
|
|
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,
|
|
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
|
-
|
|
3441
|
-
|
|
3442
|
-
|
|
3443
|
-
|
|
3444
|
-
|
|
3445
|
-
|
|
3446
|
-
|
|
3447
|
-
|
|
3448
|
-
|
|
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
|
|
4328
|
-
|
|
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
|
|
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
|
|
4365
|
-
//
|
|
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
|
-
|
|
4372
|
-
//
|
|
4373
|
-
//
|
|
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
|
-
|
|
4377
|
-
|
|
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: '
|
|
4382
|
-
|
|
4383
|
-
|
|
4384
|
-
|
|
4385
|
-
|
|
4386
|
-
|
|
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
|
-
|
|
4393
|
-
result =
|
|
4466
|
+
if (steps.length === 0) {
|
|
4467
|
+
result = { noop: true };
|
|
4394
4468
|
}
|
|
4395
|
-
|
|
4396
|
-
|
|
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
|
-
|
|
4413
|
-
|
|
4414
|
-
//
|
|
4415
|
-
//
|
|
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
|
-
|
|
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
|
-
|
|
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
|
}
|
package/dist/agent/actions.d.ts
CHANGED
|
@@ -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 = {
|