@superdoc/sdk 2.22.0-next.9 → 2.23.0-next.1
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 +295 -134
- package/dist/agent/actions.d.ts +3 -0
- package/dist/agent/actions.js +295 -134
- 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/embedded-tools.generated.cjs +7 -7
- package/dist/embedded-tools.generated.js +7 -7
- package/dist/generated/client.d.ts +1538 -494
- package/dist/generated/contract.cjs +1 -1
- package/dist/generated/contract.js +1 -1
- 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/catalog.json +171 -3
- package/tools/generation-receipt.json +24 -24
- package/tools/prompt-templates/system-prompt-core.md +1 -0
- package/tools/system-prompt-mcp.md +1 -0
- package/tools/system-prompt.md +1 -0
- package/tools/tools-policy.json +1 -1
- package/tools/tools.anthropic.json +171 -3
- package/tools/tools.generic.json +171 -3
- package/tools/tools.openai.json +171 -3
- package/tools/tools.vercel.json +171 -3
package/dist/agent/actions.js
CHANGED
|
@@ -162,13 +162,15 @@ function findSnapshotTextByNodeId(snapshot, nodeId) {
|
|
|
162
162
|
}
|
|
163
163
|
function createdBlockTarget(result) {
|
|
164
164
|
const rec = asRecord(result);
|
|
165
|
-
const
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
165
|
+
for (const [key, nodeType] of [
|
|
166
|
+
['paragraph', 'paragraph'],
|
|
167
|
+
['heading', 'heading'],
|
|
168
|
+
['item', 'listItem'],
|
|
169
|
+
]) {
|
|
170
|
+
const block = asRecord(rec?.[key]);
|
|
171
|
+
if (typeof block?.nodeId === 'string' && block.nodeId.length > 0) {
|
|
172
|
+
return { nodeId: block.nodeId, nodeType };
|
|
173
|
+
}
|
|
172
174
|
}
|
|
173
175
|
return null;
|
|
174
176
|
}
|
|
@@ -598,12 +600,10 @@ function normalizeTitleLikeRewriteText(targetText, rewrittenText) {
|
|
|
598
600
|
const displayTitle = toDisplayTitleCase(targetText);
|
|
599
601
|
if (rewrittenText.includes(displayTitle))
|
|
600
602
|
return rewrittenText;
|
|
601
|
-
const
|
|
602
|
-
|
|
603
|
-
const index = lowerRewrite.indexOf(lowerTarget);
|
|
604
|
-
if (index < 0)
|
|
603
|
+
const match = findRanges(rewrittenText, targetText, false)[0];
|
|
604
|
+
if (!match)
|
|
605
605
|
return rewrittenText;
|
|
606
|
-
return `${rewrittenText.slice(0,
|
|
606
|
+
return `${rewrittenText.slice(0, match.start)}${displayTitle}${rewrittenText.slice(match.end)}`;
|
|
607
607
|
}
|
|
608
608
|
async function executeCreateParagraph(doc, text, placement, changeMode) {
|
|
609
609
|
const fn = maybeMethod(doc, ['create', 'paragraph']);
|
|
@@ -972,6 +972,7 @@ async function runReplaceTextUnchecked(doc, args) {
|
|
|
972
972
|
? await buildMutationSnapshot(doc, { includeDomains: selectorDomains ?? ['blocks'] })
|
|
973
973
|
: snapshotFromIdentity(preIdentity);
|
|
974
974
|
try {
|
|
975
|
+
assertExpectedReplacementRevision(args.expectedRevision, pre.revision);
|
|
975
976
|
assertReplacementTextPayloads(args.edits);
|
|
976
977
|
if (args.edits.length === 0) {
|
|
977
978
|
return failedReceipt('replace_text', new Error('edits must be non-empty'), pre);
|
|
@@ -980,7 +981,7 @@ async function runReplaceTextUnchecked(doc, args) {
|
|
|
980
981
|
const selectedTargets = [];
|
|
981
982
|
let skippedEdits = [];
|
|
982
983
|
let steps;
|
|
983
|
-
let applyExpectedRevision;
|
|
984
|
+
let applyExpectedRevision = args.expectedRevision != null ? pre.revision : undefined;
|
|
984
985
|
if (args.selector) {
|
|
985
986
|
const target = selectorToBlockTarget(args.selector, pre);
|
|
986
987
|
if (!target) {
|
|
@@ -1545,7 +1546,7 @@ async function runInsertListItems(doc, args) {
|
|
|
1545
1546
|
}
|
|
1546
1547
|
}
|
|
1547
1548
|
async function runAddListItems(doc, args, opts) {
|
|
1548
|
-
const pre = await buildMutationSnapshot(doc, { includeDomains: ['blocks'] });
|
|
1549
|
+
const pre = await buildMutationSnapshot(doc, { includeDomains: ['blocks', 'lists'] });
|
|
1549
1550
|
try {
|
|
1550
1551
|
const needle = args.anchorText?.trim();
|
|
1551
1552
|
const anchorNodeId = args.anchorNodeId?.trim();
|
|
@@ -1596,25 +1597,47 @@ async function runAddListItems(doc, args, opts) {
|
|
|
1596
1597
|
}
|
|
1597
1598
|
const anchorPath = blockNumbering(anchor)?.path;
|
|
1598
1599
|
const anchorLevel = Array.isArray(anchorPath) ? Math.max(0, anchorPath.length - 1) : 0;
|
|
1600
|
+
const listByNodeId = new Map((pre.lists ?? []).flatMap((list) => list.items.map((item) => [item.nodeId, list.listId])));
|
|
1601
|
+
const anchorListId = listByNodeId.get(anchor.nodeId);
|
|
1602
|
+
// The lists snapshot is paged, so a missing item is unknown, not foreign.
|
|
1603
|
+
const belongsToAnchorList = (row) => {
|
|
1604
|
+
const rowListId = row.nodeId == null ? undefined : listByNodeId.get(row.nodeId);
|
|
1605
|
+
return anchorListId == null || rowListId == null || rowListId === anchorListId;
|
|
1606
|
+
};
|
|
1599
1607
|
// Place new items AFTER the anchor's whole sub-tree — every following item
|
|
1600
1608
|
// whose numbering PATH is nested under the anchor's path (e.g. [12] ⊂ [12,4]).
|
|
1601
1609
|
// Match by path prefix, not merely "deeper level", and SKIP interleaved
|
|
1602
1610
|
// non-numbered paragraphs (list-item continuations) rather than stopping at
|
|
1603
1611
|
// them — real lists wrap sub-items across plain paragraphs. Stop at the first
|
|
1604
1612
|
// numbered item that is NOT a descendant (a sibling/ancestor, or a different
|
|
1605
|
-
// list). Without this, a new same-level item wedges in among the anchor's
|
|
1613
|
+
// unrelated list). Without this, a new same-level item wedges in among the anchor's
|
|
1606
1614
|
// children and steals the ones after it. No descendants → the anchor itself.
|
|
1607
1615
|
let subtreeEnd = anchor;
|
|
1608
1616
|
const anchorPathArr = Array.isArray(anchorPath) ? anchorPath : null;
|
|
1609
1617
|
const anchorIdx = rows.findIndex((r) => r.nodeId === anchor.nodeId);
|
|
1610
1618
|
if (anchorPathArr && anchorIdx >= 0) {
|
|
1611
1619
|
const isDescendant = (p) => Array.isArray(p) && p.length > anchorPathArr.length && anchorPathArr.every((v, k) => p[k] === v);
|
|
1620
|
+
const descendantListIds = new Set();
|
|
1621
|
+
let lastDescendantIdx = anchorIdx;
|
|
1612
1622
|
for (let i = anchorIdx + 1; i < rows.length; i += 1) {
|
|
1613
1623
|
const p = blockNumbering(rows[i])?.path;
|
|
1614
|
-
|
|
1624
|
+
const listId = listByNodeId.get(rows[i].nodeId ?? '');
|
|
1625
|
+
const foreign = !belongsToAnchorList(rows[i]) && listId != null;
|
|
1626
|
+
// A separate numbering definition counts on its own, so its path need
|
|
1627
|
+
// not start with the anchor's path ([1, 2] under [2]); go by depth.
|
|
1628
|
+
const nestedForeign = foreign && Array.isArray(p) && p.length > anchorPathArr.length;
|
|
1629
|
+
if (isDescendant(p) || nestedForeign) {
|
|
1630
|
+
if (foreign && !descendantListIds.has(listId)) {
|
|
1631
|
+
// Nested bullets can use a separate numbering definition. Its first
|
|
1632
|
+
// item must be contiguous; a new list after plain text is unrelated.
|
|
1633
|
+
if (i !== lastDescendantIdx + 1)
|
|
1634
|
+
break;
|
|
1635
|
+
descendantListIds.add(listId);
|
|
1636
|
+
}
|
|
1615
1637
|
subtreeEnd = rows[i]; // a nested child/grandchild of the anchor
|
|
1638
|
+
lastDescendantIdx = i;
|
|
1616
1639
|
}
|
|
1617
|
-
else if (Array.isArray(p)) {
|
|
1640
|
+
else if (Array.isArray(p) || (blockNumbering(rows[i]) && !belongsToAnchorList(rows[i]))) {
|
|
1618
1641
|
break; // a numbered item that is NOT a descendant → sub-tree ended
|
|
1619
1642
|
}
|
|
1620
1643
|
// else: non-numbered paragraph (a continuation) — skip and keep scanning
|
|
@@ -1632,30 +1655,55 @@ async function runAddListItems(doc, args, opts) {
|
|
|
1632
1655
|
if (firstRelative < 0) {
|
|
1633
1656
|
const firstTargetLevel = Math.max(0, anchorLevel + firstRelative);
|
|
1634
1657
|
const startIdx = rows.findIndex((r) => r.nodeId === subtreeEnd.nodeId);
|
|
1658
|
+
const descendantListIds = new Set();
|
|
1659
|
+
let lastDescendantIdx = startIdx;
|
|
1635
1660
|
for (let i = startIdx + 1; i >= 1 && i < rows.length; i += 1) {
|
|
1636
1661
|
const level = levelOfRow(rows[i]);
|
|
1637
1662
|
if (level == null)
|
|
1638
1663
|
continue; // non-numbered continuation paragraph
|
|
1639
1664
|
if (level <= firstTargetLevel)
|
|
1640
1665
|
break;
|
|
1666
|
+
const listId = listByNodeId.get(rows[i].nodeId ?? '');
|
|
1667
|
+
if (!belongsToAnchorList(rows[i]) && listId != null && !descendantListIds.has(listId)) {
|
|
1668
|
+
if (i !== lastDescendantIdx + 1)
|
|
1669
|
+
break;
|
|
1670
|
+
descendantListIds.add(listId);
|
|
1671
|
+
}
|
|
1641
1672
|
subtreeEnd = rows[i];
|
|
1673
|
+
lastDescendantIdx = i;
|
|
1642
1674
|
}
|
|
1643
1675
|
}
|
|
1676
|
+
const anchorKind = blockNumbering(anchor)?.kind ?? null;
|
|
1677
|
+
// Search nearest first within the destination level. A known list may mix
|
|
1678
|
+
// ordered and bullet levels. Keep the numbering-kind fallback only when
|
|
1679
|
+
// the paged snapshot cannot establish that both items share a list.
|
|
1680
|
+
const findSiblingAt = (level) => {
|
|
1681
|
+
const usable = (r) => !!r?.nodeId &&
|
|
1682
|
+
belongsToAnchorList(r) &&
|
|
1683
|
+
levelOfRow(r) === level &&
|
|
1684
|
+
((anchorListId != null && listByNodeId.get(r.nodeId) === anchorListId) ||
|
|
1685
|
+
(blockNumbering(r)?.kind ?? null) === anchorKind);
|
|
1686
|
+
for (let delta = 1; delta <= rows.length; delta += 1) {
|
|
1687
|
+
const before = anchorIdx >= 0 ? rows[anchorIdx - delta] : undefined;
|
|
1688
|
+
if (usable(before))
|
|
1689
|
+
return before;
|
|
1690
|
+
const after = anchorIdx >= 0 ? rows[anchorIdx + delta] : undefined;
|
|
1691
|
+
if (usable(after))
|
|
1692
|
+
return after;
|
|
1693
|
+
}
|
|
1694
|
+
return undefined;
|
|
1695
|
+
};
|
|
1696
|
+
/** The existing item a new one should look like: same level, nearest first. */
|
|
1697
|
+
const formattingSourceForLevel = (targetLevel) => targetLevel === anchorLevel ? anchor : findSiblingAt(targetLevel);
|
|
1644
1698
|
const attachFn = maybeMethod(doc, ['lists', 'attach']);
|
|
1645
|
-
|
|
1646
|
-
|
|
1699
|
+
const insertFn = maybeMethod(doc, ['lists', 'insert']);
|
|
1700
|
+
if (!attachFn || !insertFn) {
|
|
1701
|
+
throw new SuperDocCliError('doc.lists.insert and doc.lists.attach are required on the document handle.', {
|
|
1647
1702
|
code: 'TOOL_DISPATCH_NOT_FOUND',
|
|
1648
1703
|
});
|
|
1649
1704
|
}
|
|
1650
1705
|
const executed = [];
|
|
1651
|
-
let
|
|
1652
|
-
kind: 'after',
|
|
1653
|
-
target: {
|
|
1654
|
-
kind: 'block',
|
|
1655
|
-
nodeType: subtreeEnd.nodeType ?? 'listItem',
|
|
1656
|
-
nodeId: subtreeEnd.nodeId ?? anchor.nodeId,
|
|
1657
|
-
},
|
|
1658
|
-
};
|
|
1706
|
+
let insertionAnchorId = subtreeEnd.nodeId ?? anchor.nodeId;
|
|
1659
1707
|
const createdItems = [];
|
|
1660
1708
|
for (const entry of args.entries) {
|
|
1661
1709
|
// Styled items carry ordered `runs`; a plain item is a single run. The
|
|
@@ -1668,11 +1716,25 @@ async function runAddListItems(doc, args, opts) {
|
|
|
1668
1716
|
// outline level is clamped at 0 (can't go above the root).
|
|
1669
1717
|
const rel = typeof entry?.level === 'number' ? Math.floor(entry.level) : 0;
|
|
1670
1718
|
const targetLevel = Math.max(0, anchorLevel + rel);
|
|
1671
|
-
const
|
|
1719
|
+
const formattingSource = formattingSourceForLevel(targetLevel) ?? anchor;
|
|
1720
|
+
// Placement can follow a child with different direction. Inherit from
|
|
1721
|
+
// the intended list level independently of that insertion point.
|
|
1722
|
+
const created = await insertFn({
|
|
1723
|
+
target: { kind: 'block', nodeType: 'listItem', nodeId: insertionAnchorId },
|
|
1724
|
+
position: 'after',
|
|
1725
|
+
text,
|
|
1726
|
+
...(formattingSource.nodeId !== insertionAnchorId
|
|
1727
|
+
? { inheritFrom: { kind: 'block', nodeType: 'listItem', nodeId: formattingSource.nodeId } }
|
|
1728
|
+
: {}),
|
|
1729
|
+
...(args.changeMode ? { changeMode: args.changeMode } : {}),
|
|
1730
|
+
}, args.changeMode ? { changeMode: args.changeMode } : undefined);
|
|
1672
1731
|
const target = createdBlockTarget(created);
|
|
1673
|
-
|
|
1674
|
-
|
|
1675
|
-
|
|
1732
|
+
if (!target || target.nodeType !== 'listItem') {
|
|
1733
|
+
throw new SuperDocCliError('doc.lists.insert did not return a created list item.', {
|
|
1734
|
+
code: 'ACTION_FAILED',
|
|
1735
|
+
});
|
|
1736
|
+
}
|
|
1737
|
+
executed.push({ operationId: 'doc.lists.insert', result: created });
|
|
1676
1738
|
// changeMode is a MutationOption — pass it in the second options arg, not
|
|
1677
1739
|
// the input (lists.attach reads options.changeMode; input.changeMode is ignored).
|
|
1678
1740
|
const attachResult = await attachFn({
|
|
@@ -1688,53 +1750,10 @@ async function runAddListItems(doc, args, opts) {
|
|
|
1688
1750
|
rationale: `level ${targetLevel}`,
|
|
1689
1751
|
});
|
|
1690
1752
|
createdItems.push({ nodeId: target.nodeId, targetLevel, text, runs, itemMarks: entry?.marks });
|
|
1691
|
-
|
|
1692
|
-
|
|
1693
|
-
|
|
1694
|
-
|
|
1695
|
-
// A new item is created next to the ANCHOR, so it inherits the anchor's
|
|
1696
|
-
// direct indentation — correct at the same level, wrong at any other one.
|
|
1697
|
-
// `lists.attach` sets the numbering level (`w:ilvl`) but leaves that direct
|
|
1698
|
-
// `w:ind` alone, so a level-changing add renders with a sub-item's indent
|
|
1699
|
-
// under a top-level number: e.g. an item promoted to level 0 kept
|
|
1700
|
-
// left=1800 while its "1./2./3." siblings sit at 1080. Copy the indentation
|
|
1701
|
-
// from an EXISTING sibling at the item's own level. Best-effort: an
|
|
1702
|
-
// unmatched indent is cosmetic and must not fail an item that landed.
|
|
1703
|
-
const createdIds = new Set(createdItems.map((ci) => ci.nodeId));
|
|
1704
|
-
const anchorKind = blockNumbering(anchor)?.kind ?? null;
|
|
1705
|
-
// Search OUTWARD from the anchor, nearest first, and only accept items
|
|
1706
|
-
// of the anchor's own numbering kind: a document holds several lists,
|
|
1707
|
-
// and the first level-0 item in document order usually belongs to a
|
|
1708
|
-
// different one (here: a bullet list far above the ordered list being
|
|
1709
|
-
// extended), whose indentation is unrelated.
|
|
1710
|
-
const findSiblingAt = (level) => {
|
|
1711
|
-
const usable = (r) => !!r?.nodeId &&
|
|
1712
|
-
!createdIds.has(r.nodeId) &&
|
|
1713
|
-
levelOfRow(r) === level &&
|
|
1714
|
-
(blockNumbering(r)?.kind ?? null) === anchorKind;
|
|
1715
|
-
for (let delta = 1; delta <= rows.length; delta += 1) {
|
|
1716
|
-
const before = anchorIdx >= 0 ? rows[anchorIdx - delta] : undefined;
|
|
1717
|
-
if (usable(before))
|
|
1718
|
-
return before;
|
|
1719
|
-
const after = anchorIdx >= 0 ? rows[anchorIdx + delta] : undefined;
|
|
1720
|
-
if (usable(after))
|
|
1721
|
-
return after;
|
|
1722
|
-
}
|
|
1723
|
-
return undefined;
|
|
1724
|
-
};
|
|
1725
|
-
/** The existing item a new one should look like: same level, nearest first. */
|
|
1726
|
-
const lookSourceFor = (targetLevel) => targetLevel === anchorLevel ? anchor : findSiblingAt(targetLevel);
|
|
1727
|
-
// Most Word documents keep a list's look in a paragraph STYLE, not in direct
|
|
1728
|
-
// properties: "MB Agr 2", "Quick 1.", even Heading3 carry the numbering, the
|
|
1729
|
-
// indentation and the face. A created paragraph does not get the sibling's
|
|
1730
|
-
// style — it gets the anchor's `next` style (Heading3 → BodyText), a default
|
|
1731
|
-
// (ListParagraph, whose contextualSpacing changes the spacing), or the
|
|
1732
|
-
// anchor's own style when the new item belongs to a DIFFERENT level (keeping
|
|
1733
|
-
// "MB Agr 3" on an item whose numbering now says level 2). Reconcile it with
|
|
1734
|
-
// the sibling at the item's own level.
|
|
1735
|
-
//
|
|
1736
|
-
// setStyleRef, not setStyle: setStyle clears direct run formatting, which
|
|
1737
|
-
// would wipe the sampled run look applied below.
|
|
1753
|
+
insertionAnchorId = target.nodeId;
|
|
1754
|
+
}
|
|
1755
|
+
// Insertion may select a style's `next` style; keep the destination list level's
|
|
1756
|
+
// style instead. setStyleRef preserves the inherited direct run formatting.
|
|
1738
1757
|
const styledItems = [];
|
|
1739
1758
|
const styleSkipped = await bestEffort(async () => {
|
|
1740
1759
|
const setStyleRefFn = maybeMethod(doc, ['styles', 'paragraph', 'setStyleRef']);
|
|
@@ -1743,7 +1762,7 @@ async function runAddListItems(doc, args, opts) {
|
|
|
1743
1762
|
return;
|
|
1744
1763
|
let current = null;
|
|
1745
1764
|
for (const ci of createdItems) {
|
|
1746
|
-
const source =
|
|
1765
|
+
const source = formattingSourceForLevel(ci.targetLevel);
|
|
1747
1766
|
if (!source)
|
|
1748
1767
|
continue;
|
|
1749
1768
|
const wanted = source.styleId ?? null;
|
|
@@ -1761,6 +1780,8 @@ async function runAddListItems(doc, args, opts) {
|
|
|
1761
1780
|
styledItems.push({ nodeId: ci.nodeId, styleId: wanted });
|
|
1762
1781
|
}
|
|
1763
1782
|
});
|
|
1783
|
+
// lists.attach changes numbering level without normalizing direct indentation.
|
|
1784
|
+
// Match the existing destination level when its indentation is available.
|
|
1764
1785
|
const indentedItems = [];
|
|
1765
1786
|
const indentSkipped = await bestEffort(async () => {
|
|
1766
1787
|
const setIndentFn = maybeMethod(doc, ['format', 'paragraph', 'setIndentation']);
|
|
@@ -1768,7 +1789,7 @@ async function runAddListItems(doc, args, opts) {
|
|
|
1768
1789
|
if (!setIndentFn || !getNodeFn)
|
|
1769
1790
|
return;
|
|
1770
1791
|
for (const ci of createdItems) {
|
|
1771
|
-
const sibling =
|
|
1792
|
+
const sibling = formattingSourceForLevel(ci.targetLevel);
|
|
1772
1793
|
if (!sibling?.nodeId)
|
|
1773
1794
|
continue;
|
|
1774
1795
|
const node = (await getNodeFn({ id: sibling.nodeId }));
|
|
@@ -1851,7 +1872,7 @@ async function runAddListItems(doc, args, opts) {
|
|
|
1851
1872
|
// The BASE layer must come from the row the item will sit beside, not
|
|
1852
1873
|
// from the anchor: a promoted or demoted item crosses levels, and the
|
|
1853
1874
|
// anchor's font/size/colour/emphasis is then the wrong level's look.
|
|
1854
|
-
// `
|
|
1875
|
+
// `formattingSourceForLevel(targetLevel)` already picks the destination sibling for
|
|
1855
1876
|
// style and indent, so the look follows the same source. Falls back to
|
|
1856
1877
|
// the anchor only when the destination level has no sibling to sample.
|
|
1857
1878
|
const anchorBase = inlineLookFromRow(anchor);
|
|
@@ -1889,7 +1910,7 @@ async function runAddListItems(doc, args, opts) {
|
|
|
1889
1910
|
// An item the agent styled itself is left alone by layers 1–2: its
|
|
1890
1911
|
// marks describe the whole intent, and sampling would fight them.
|
|
1891
1912
|
const unstyled = ci.itemMarks == null && !ci.runs.some((r) => r.marks);
|
|
1892
|
-
const source = unstyled ?
|
|
1913
|
+
const source = unstyled ? formattingSourceForLevel(ci.targetLevel) : undefined;
|
|
1893
1914
|
const sourceText = source?.nodeId ? fullTextById.get(source.nodeId) : undefined;
|
|
1894
1915
|
const pattern = source?.nodeId && sourceText ? await listItemRunPattern(doc, sourceText, source.nodeId) : null;
|
|
1895
1916
|
// 1. sampled base over the whole item (row fields first, run-level
|
|
@@ -1969,7 +1990,7 @@ async function runAddListItems(doc, args, opts) {
|
|
|
1969
1990
|
// promotion keeps the plain marker until that read lands.
|
|
1970
1991
|
let markProps = markRunPropsFromMarks({ ...ci.itemMarks, ...ci.runs[0]?.marks });
|
|
1971
1992
|
if (!markProps && getNodeFn) {
|
|
1972
|
-
const source =
|
|
1993
|
+
const source = formattingSourceForLevel(ci.targetLevel);
|
|
1973
1994
|
if (source?.nodeId)
|
|
1974
1995
|
markProps = paragraphMarkRunPropsOf(await getNodeFn({ id: source.nodeId }));
|
|
1975
1996
|
}
|
|
@@ -3434,18 +3455,28 @@ function findRanges(haystack, needle, caseSensitive) {
|
|
|
3434
3455
|
const ranges = [];
|
|
3435
3456
|
if (needle.length === 0)
|
|
3436
3457
|
return ranges;
|
|
3437
|
-
|
|
3438
|
-
|
|
3439
|
-
|
|
3440
|
-
|
|
3441
|
-
|
|
3442
|
-
|
|
3443
|
-
|
|
3444
|
-
|
|
3445
|
-
|
|
3458
|
+
if (caseSensitive) {
|
|
3459
|
+
let offset = 0;
|
|
3460
|
+
while (offset < haystack.length) {
|
|
3461
|
+
const idx = haystack.indexOf(needle, offset);
|
|
3462
|
+
if (idx < 0)
|
|
3463
|
+
break;
|
|
3464
|
+
ranges.push({ start: idx, end: idx + needle.length });
|
|
3465
|
+
offset = idx + needle.length;
|
|
3466
|
+
}
|
|
3467
|
+
return ranges;
|
|
3468
|
+
}
|
|
3469
|
+
// Match case-insensitively on the original string. Lowercasing a copy first
|
|
3470
|
+
// changes its length for characters such as "İ", which shifts every later
|
|
3471
|
+
// offset onto the wrong text.
|
|
3472
|
+
for (const match of haystack.matchAll(caseInsensitivePattern(needle))) {
|
|
3473
|
+
ranges.push({ start: match.index, end: match.index + match[0].length });
|
|
3446
3474
|
}
|
|
3447
3475
|
return ranges;
|
|
3448
3476
|
}
|
|
3477
|
+
function caseInsensitivePattern(needle) {
|
|
3478
|
+
return new RegExp(needle.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'), 'giu');
|
|
3479
|
+
}
|
|
3449
3480
|
async function runApplyLetterSpacing(doc, args) {
|
|
3450
3481
|
const pre = await buildMutationSnapshot(doc, { includeDomains: ['blocks'] });
|
|
3451
3482
|
try {
|
|
@@ -4276,29 +4307,64 @@ function trackedChangeRefIds(result) {
|
|
|
4276
4307
|
return entityId ? [entityId] : [];
|
|
4277
4308
|
}));
|
|
4278
4309
|
}
|
|
4279
|
-
function parseScopedReplaceArgs(args, changeMode) {
|
|
4280
|
-
if (args.action !== 'replace_text')
|
|
4281
|
-
return null;
|
|
4310
|
+
function parseScopedReplaceArgs(args, edits, changeMode, expectedRevision) {
|
|
4282
4311
|
if (!isRecord(args.selector))
|
|
4283
4312
|
return null;
|
|
4284
|
-
if (!Array.isArray(args.edits) || args.edits.length === 0)
|
|
4285
|
-
return null;
|
|
4286
|
-
const edits = [];
|
|
4287
|
-
for (const entry of args.edits) {
|
|
4288
|
-
if (!isRecord(entry) || typeof entry.find !== 'string' || entry.find.length === 0)
|
|
4289
|
-
return null;
|
|
4290
|
-
edits.push({ find: entry.find, replace: typeof entry.replace === 'string' ? entry.replace : '' });
|
|
4291
|
-
}
|
|
4292
4313
|
return {
|
|
4293
4314
|
edits,
|
|
4294
4315
|
selector: args.selector,
|
|
4295
4316
|
caseSensitive: args.caseSensitive === true,
|
|
4317
|
+
expectedRevision,
|
|
4296
4318
|
changeMode,
|
|
4297
4319
|
};
|
|
4298
4320
|
}
|
|
4321
|
+
function assertExpectedReplacementRevision(expected, actual) {
|
|
4322
|
+
if (expected != null && expected !== actual) {
|
|
4323
|
+
throw new SuperDocCliError('replace_text: expectedRevision is stale. Inspect this live document again before editing.', {
|
|
4324
|
+
code: 'REVISION_CONFLICT',
|
|
4325
|
+
});
|
|
4326
|
+
}
|
|
4327
|
+
}
|
|
4328
|
+
// Ranges of the block text that may show a body field's result. Field
|
|
4329
|
+
// results carry no offsets, so every place the result text occurs counts,
|
|
4330
|
+
// overlapping ones included. A field whose result text is not reported (some
|
|
4331
|
+
// imported fields expose an empty result while displaying cached text) covers
|
|
4332
|
+
// the whole block. Returns null when the field catalog cannot be read.
|
|
4333
|
+
async function fieldResultRanges(doc, nodeId, blockText) {
|
|
4334
|
+
const fn = maybeMethod(doc, ['fields', 'list']);
|
|
4335
|
+
if (!fn)
|
|
4336
|
+
return null;
|
|
4337
|
+
const results = [];
|
|
4338
|
+
const limit = 500;
|
|
4339
|
+
for (let offset = 0; offset < 100_000; offset += limit) {
|
|
4340
|
+
const page = await fn({ limit, offset });
|
|
4341
|
+
const items = isRecord(page) && Array.isArray(page.items) ? page.items.filter(isRecord) : [];
|
|
4342
|
+
for (const item of items) {
|
|
4343
|
+
const address = isRecord(item.address) ? item.address : null;
|
|
4344
|
+
const inBody = typeof address?.storyId !== 'string' || address.storyId.startsWith('main:');
|
|
4345
|
+
if (address?.blockId !== nodeId || !inBody)
|
|
4346
|
+
continue;
|
|
4347
|
+
if (typeof item.resolvedText !== 'string' || item.resolvedText.length === 0) {
|
|
4348
|
+
return [{ start: 0, end: blockText.length }];
|
|
4349
|
+
}
|
|
4350
|
+
results.push(item.resolvedText);
|
|
4351
|
+
}
|
|
4352
|
+
const total = isRecord(page) && typeof page.total === 'number' ? page.total : null;
|
|
4353
|
+
if (items.length < limit || (total != null && offset + limit >= total))
|
|
4354
|
+
break;
|
|
4355
|
+
}
|
|
4356
|
+
const ranges = [];
|
|
4357
|
+
for (const result of results) {
|
|
4358
|
+
for (let at = blockText.indexOf(result); at >= 0; at = blockText.indexOf(result, at + 1)) {
|
|
4359
|
+
ranges.push({ start: at, end: at + result.length });
|
|
4360
|
+
}
|
|
4361
|
+
}
|
|
4362
|
+
return ranges;
|
|
4363
|
+
}
|
|
4299
4364
|
async function runScopedReplaceUnchecked(doc, args) {
|
|
4300
4365
|
assertReplacementTextPayloads(args.edits);
|
|
4301
4366
|
const pre = await buildMutationSnapshot(doc, { includeDomains: ['blocks', 'tables'] });
|
|
4367
|
+
assertExpectedReplacementRevision(args.expectedRevision, pre.revision);
|
|
4302
4368
|
const matched = resolveSnapshotSelector(pre, args.selector);
|
|
4303
4369
|
if (matched.length !== 1) {
|
|
4304
4370
|
const failure = describeSelectorFailure(args.selector, matched, pre, args.edits.map((e) => e.find));
|
|
@@ -4318,13 +4384,24 @@ async function runScopedReplaceUnchecked(doc, args) {
|
|
|
4318
4384
|
};
|
|
4319
4385
|
}
|
|
4320
4386
|
const caseSensitive = args.caseSensitive === true;
|
|
4387
|
+
const hasExplicitOccurrence = args.edits.some((edit) => edit.occurrence != null);
|
|
4321
4388
|
const perEdit = [];
|
|
4322
4389
|
const allSpans = [];
|
|
4323
4390
|
for (const edit of args.edits) {
|
|
4324
|
-
const
|
|
4325
|
-
|
|
4391
|
+
const matches = findRanges(block.text, edit.find, caseSensitive);
|
|
4392
|
+
const spans = edit.occurrence == null ? matches : matches.slice(edit.occurrence - 1, edit.occurrence);
|
|
4393
|
+
if (hasExplicitOccurrence && spans.length === 0) {
|
|
4394
|
+
return failedReceipt('replace_text', new SuperDocCliError(edit.occurrence == null
|
|
4395
|
+
? `replace_text: ${JSON.stringify(edit.find)} not found in the selected block. Inspect and choose a valid target.`
|
|
4396
|
+
: `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);
|
|
4397
|
+
}
|
|
4398
|
+
perEdit.push({
|
|
4399
|
+
find: edit.find,
|
|
4400
|
+
occurrences: spans.length,
|
|
4401
|
+
...(edit.occurrence != null ? { occurrence: edit.occurrence } : {}),
|
|
4402
|
+
});
|
|
4326
4403
|
for (const span of spans)
|
|
4327
|
-
allSpans.push({ ...span, replace: edit.replace
|
|
4404
|
+
allSpans.push({ ...span, replace: edit.replace, find: edit.find });
|
|
4328
4405
|
}
|
|
4329
4406
|
const appliedEdits = perEdit.filter((e) => e.occurrences > 0);
|
|
4330
4407
|
const skippedEdits = perEdit.filter((e) => e.occurrences === 0);
|
|
@@ -4352,45 +4429,82 @@ async function runScopedReplaceUnchecked(doc, args) {
|
|
|
4352
4429
|
let lastEnd = -1;
|
|
4353
4430
|
for (const span of allSpans) {
|
|
4354
4431
|
if (span.start < lastEnd) {
|
|
4432
|
+
if (hasExplicitOccurrence) {
|
|
4433
|
+
return failedReceipt('replace_text', new SuperDocCliError('replace_text: explicit occurrence targets overlap. Choose non-overlapping edits in the pre-edit block.', { code: 'INVALID_ARGUMENT' }), pre);
|
|
4434
|
+
}
|
|
4355
4435
|
droppedOverlaps.push(span);
|
|
4356
4436
|
continue;
|
|
4357
4437
|
}
|
|
4358
4438
|
planned.push(span);
|
|
4359
4439
|
lastEnd = span.end;
|
|
4360
4440
|
}
|
|
4361
|
-
// The
|
|
4362
|
-
//
|
|
4363
|
-
// "requires a ref produced by query.match/find".
|
|
4441
|
+
// The block text once every planned span is replaced: the whole-block
|
|
4442
|
+
// rewrite writes it, and exact-target writes must produce it.
|
|
4364
4443
|
const expected = planned.reduceRight((text, span) => text.slice(0, span.start) + span.replace + text.slice(span.end), block.text);
|
|
4365
4444
|
const tracked = (args.changeMode ?? 'direct') === 'tracked';
|
|
4366
4445
|
// Unique tracked finds can be resolved within the block. Repeated finds
|
|
4367
4446
|
// use one whole-block rewrite, whose engine diff preserves unchanged runs.
|
|
4368
|
-
|
|
4369
|
-
//
|
|
4370
|
-
//
|
|
4371
|
-
// them together would push a legitimately unique pair onto the fallback.
|
|
4447
|
+
// Dedupe on the same basis the planner matched on: under a case-sensitive
|
|
4448
|
+
// request `Foo` and `foo` are distinct targets, and folding them together
|
|
4449
|
+
// would push a legitimately unique pair onto the fallback.
|
|
4372
4450
|
const uniqueFinds = new Set(planned.map((s) => (caseSensitive ? s.find : s.find.toLowerCase())));
|
|
4373
|
-
|
|
4374
|
-
|
|
4451
|
+
// Explicit occurrences and unique tracked finds are written at the offsets
|
|
4452
|
+
// matched above. A whole-block diff could redline a different identical
|
|
4453
|
+
// occurrence while producing the same final text, and the engine's own text
|
|
4454
|
+
// search would match again with its own case rules.
|
|
4455
|
+
// A direct deletion through an exact target that touches a field deletes
|
|
4456
|
+
// the whole field. Such deletions keep the whole-block rewrite, whose
|
|
4457
|
+
// planned text is exact for explicit occurrences too; every other span keeps
|
|
4458
|
+
// its exact target and with it the chosen occurrence and its formatting.
|
|
4459
|
+
const deletions = tracked ? [] : planned.filter((span) => span.replace.length === 0);
|
|
4460
|
+
const fieldRanges = deletions.length > 0 ? await fieldResultRanges(doc, nodeId, block.text) : [];
|
|
4461
|
+
const fieldDeletionRisk = fieldRanges == null ||
|
|
4462
|
+
deletions.some((span) => fieldRanges.some((range) => span.start < range.end && span.end > range.start));
|
|
4463
|
+
const exactTargets = !fieldDeletionRisk && (hasExplicitOccurrence || (tracked && uniqueFinds.size === planned.length));
|
|
4464
|
+
let result = null;
|
|
4465
|
+
let wroteExactTargets = false;
|
|
4466
|
+
if (exactTargets) {
|
|
4467
|
+
// A span already equal to its replacement has nothing to write, and the
|
|
4468
|
+
// engine refuses a no-op target, which would sink its sibling edits.
|
|
4469
|
+
const targetedSpans = [...planned]
|
|
4470
|
+
.reverse()
|
|
4471
|
+
.filter((span) => block.text.slice(span.start, span.end) !== span.replace);
|
|
4472
|
+
const steps = targetedSpans.map((span, index) => ({
|
|
4375
4473
|
id: `scoped-replace-${index + 1}`,
|
|
4376
4474
|
op: 'text.rewrite',
|
|
4377
4475
|
where: {
|
|
4378
|
-
by: '
|
|
4379
|
-
|
|
4380
|
-
|
|
4381
|
-
|
|
4382
|
-
|
|
4383
|
-
|
|
4384
|
-
require: 'first',
|
|
4385
|
-
within: { kind: 'block', nodeType: block.nodeType, nodeId },
|
|
4476
|
+
by: 'target',
|
|
4477
|
+
target: {
|
|
4478
|
+
kind: 'selection',
|
|
4479
|
+
start: { kind: 'text', blockId: nodeId, offset: span.start },
|
|
4480
|
+
end: { kind: 'text', blockId: nodeId, offset: span.end },
|
|
4481
|
+
},
|
|
4386
4482
|
},
|
|
4387
4483
|
args: { replacement: { text: span.replace }, style: preserveRewriteStyle() },
|
|
4388
4484
|
}));
|
|
4389
|
-
|
|
4390
|
-
result =
|
|
4485
|
+
if (steps.length === 0) {
|
|
4486
|
+
result = { noop: true };
|
|
4391
4487
|
}
|
|
4392
|
-
|
|
4393
|
-
|
|
4488
|
+
else {
|
|
4489
|
+
// The engine resolves these offsets against its own paragraph text. Check
|
|
4490
|
+
// that each one still covers the text matched in the snapshot, so a
|
|
4491
|
+
// projection mismatch never rewrites other text.
|
|
4492
|
+
const preview = await previewMutations(doc, steps, args.changeMode, pre.revision);
|
|
4493
|
+
const previewSteps = Array.isArray(preview.steps) ? preview.steps.filter(isRecord) : [];
|
|
4494
|
+
const aligned = preview.valid === true &&
|
|
4495
|
+
steps.every((step, index) => {
|
|
4496
|
+
const resolutions = previewSteps.find((candidate) => candidate.stepId === step.id)?.resolutions;
|
|
4497
|
+
const resolved = Array.isArray(resolutions) && isRecord(resolutions[0]) ? resolutions[0].text : undefined;
|
|
4498
|
+
const span = targetedSpans[index];
|
|
4499
|
+
return resolved === block.text.slice(span.start, span.end);
|
|
4500
|
+
});
|
|
4501
|
+
if (aligned) {
|
|
4502
|
+
result = await executeMutations(doc, steps, args.changeMode, pre.revision);
|
|
4503
|
+
wroteExactTargets = true;
|
|
4504
|
+
}
|
|
4505
|
+
else if (hasExplicitOccurrence) {
|
|
4506
|
+
return failedReceipt('replace_text', new 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);
|
|
4507
|
+
}
|
|
4394
4508
|
}
|
|
4395
4509
|
}
|
|
4396
4510
|
if (result == null) {
|
|
@@ -4406,12 +4520,34 @@ async function runScopedReplaceUnchecked(doc, args) {
|
|
|
4406
4520
|
];
|
|
4407
4521
|
result = await executeMutations(doc, steps, args.changeMode, pre.revision);
|
|
4408
4522
|
}
|
|
4409
|
-
|
|
4410
|
-
|
|
4411
|
-
//
|
|
4412
|
-
//
|
|
4523
|
+
let post = await buildMutationSnapshot(doc, { includeDomains: ['blocks', 'tables'] });
|
|
4524
|
+
let postBlock = findTextTarget(post, nodeId);
|
|
4525
|
+
// Exact-target edits must leave exactly the planned visible text. If the
|
|
4526
|
+
// engine changed more than the requested spans, undo the edit, which is one
|
|
4527
|
+
// atomic history step. Only do so while the document is still at the
|
|
4528
|
+
// revision this plan produced, so the undo cannot reach any other change.
|
|
4529
|
+
let rolledBack = false;
|
|
4530
|
+
const appliedRevision = isRecord(result) && isRecord(result.revision) ? result.revision.after : undefined;
|
|
4531
|
+
if (wroteExactTargets &&
|
|
4532
|
+
postBlock != null &&
|
|
4533
|
+
postBlock.text !== expected &&
|
|
4534
|
+
postBlock.text !== block.text &&
|
|
4535
|
+
typeof appliedRevision === 'string' &&
|
|
4536
|
+
appliedRevision === post.revision) {
|
|
4537
|
+
const undoFn = maybeMethod(doc, ['history', 'undo']);
|
|
4538
|
+
if (undoFn) {
|
|
4539
|
+
await undoFn({});
|
|
4540
|
+
post = await buildMutationSnapshot(doc, { includeDomains: ['blocks', 'tables'] });
|
|
4541
|
+
postBlock = findTextTarget(post, nodeId);
|
|
4542
|
+
rolledBack = postBlock?.text === block.text;
|
|
4543
|
+
}
|
|
4544
|
+
}
|
|
4545
|
+
// Block text is the visible (final) text. Exact-offset edits must produce
|
|
4546
|
+
// exactly the planned text; tracked edits that fell back to a whole-block
|
|
4547
|
+
// rewrite only need each inserted text to be visible.
|
|
4413
4548
|
const passed = postBlock != null &&
|
|
4414
|
-
|
|
4549
|
+
!rolledBack &&
|
|
4550
|
+
(tracked && !hasExplicitOccurrence && !wroteExactTargets
|
|
4415
4551
|
? planned.every((span) => span.replace.length === 0 || postBlock.text.includes(span.replace))
|
|
4416
4552
|
: postBlock.text === expected);
|
|
4417
4553
|
// Partially-applied requests must NOT read as success: a model that asked
|
|
@@ -4427,6 +4563,16 @@ async function runScopedReplaceUnchecked(doc, args) {
|
|
|
4427
4563
|
recovery: { kind: 'retry' },
|
|
4428
4564
|
}
|
|
4429
4565
|
: {}),
|
|
4566
|
+
...(rolledBack
|
|
4567
|
+
? {
|
|
4568
|
+
errors: [
|
|
4569
|
+
{
|
|
4570
|
+
code: 'ACTION_FAILED',
|
|
4571
|
+
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.',
|
|
4572
|
+
},
|
|
4573
|
+
],
|
|
4574
|
+
}
|
|
4575
|
+
: {}),
|
|
4430
4576
|
preSnapshot: { revision: pre.revision },
|
|
4431
4577
|
postSnapshot: { revision: post.revision },
|
|
4432
4578
|
selectedBlock: { nodeId, nodeType: block.nodeType },
|
|
@@ -6212,12 +6358,21 @@ async function dispatchSuperdocPerformAction(doc, args) {
|
|
|
6212
6358
|
code: 'INVALID_ARGUMENT',
|
|
6213
6359
|
});
|
|
6214
6360
|
}
|
|
6361
|
+
const expectedRevision = args.expectedRevision;
|
|
6362
|
+
if (expectedRevision !== undefined && (typeof expectedRevision !== 'string' || expectedRevision.length === 0)) {
|
|
6363
|
+
throw new SuperDocCliError('replace_text: expectedRevision must be a non-empty string from this live document handle', { code: 'INVALID_ARGUMENT' });
|
|
6364
|
+
}
|
|
6365
|
+
if (edits.some((edit) => edit.occurrence != null) && !isRecord(args.selector)) {
|
|
6366
|
+
throw new SuperDocCliError('replace_text: explicit occurrence requires a selector resolving to one block', {
|
|
6367
|
+
code: 'INVALID_ARGUMENT',
|
|
6368
|
+
});
|
|
6369
|
+
}
|
|
6215
6370
|
const changeMode = parseReplaceTextChangeMode(args.changeMode);
|
|
6216
6371
|
// Selector-scoped replaces take the span-targeted path: per-find
|
|
6217
6372
|
// text.rewrite spans inside the one selected block, so tabs/breaks/
|
|
6218
6373
|
// images outside the spans survive and the receipt reports per-edit
|
|
6219
6374
|
// applied/skipped truth.
|
|
6220
|
-
const scoped = parseScopedReplaceArgs(args, changeMode);
|
|
6375
|
+
const scoped = parseScopedReplaceArgs(args, edits, changeMode, expectedRevision);
|
|
6221
6376
|
if (scoped) {
|
|
6222
6377
|
return runScopedReplace(doc, scoped);
|
|
6223
6378
|
}
|
|
@@ -6230,6 +6385,7 @@ async function dispatchSuperdocPerformAction(doc, args) {
|
|
|
6230
6385
|
edits,
|
|
6231
6386
|
selector,
|
|
6232
6387
|
caseSensitive: args.caseSensitive === true,
|
|
6388
|
+
expectedRevision,
|
|
6233
6389
|
changeMode,
|
|
6234
6390
|
});
|
|
6235
6391
|
}
|
|
@@ -6911,7 +7067,12 @@ function parseEdits(value) {
|
|
|
6911
7067
|
const replace = asString(entry.replace) ?? '';
|
|
6912
7068
|
if (!find)
|
|
6913
7069
|
return undefined;
|
|
6914
|
-
|
|
7070
|
+
const occurrence = entry.occurrence;
|
|
7071
|
+
if (occurrence !== undefined &&
|
|
7072
|
+
(typeof occurrence !== 'number' || !Number.isSafeInteger(occurrence) || occurrence < 1)) {
|
|
7073
|
+
throw new SuperDocCliError('replace_text: occurrence must be a positive safe integer (one-based within the selected block)', { code: 'INVALID_ARGUMENT' });
|
|
7074
|
+
}
|
|
7075
|
+
result.push({ find, replace, ...(occurrence !== undefined ? { occurrence } : {}) });
|
|
6915
7076
|
}
|
|
6916
7077
|
return result;
|
|
6917
7078
|
}
|