@superdoc/sdk 2.0.0-next.100 → 2.1.0

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/README.md CHANGED
@@ -1,22 +1,22 @@
1
- # @superdoc/sdk
1
+ # @superdoc-dev/sdk
2
2
 
3
3
  Programmatic SDK for deterministic DOCX operations through SuperDoc's Document API.
4
4
 
5
5
  ## Install
6
6
 
7
7
  ```bash
8
- npm install @superdoc/sdk
8
+ npm install @superdoc-dev/sdk
9
9
  ```
10
10
 
11
11
  The package automatically installs a native CLI binary for your platform via optionalDependencies. Supported platforms:
12
12
 
13
13
  | Platform | Package |
14
14
  |----------|---------|
15
- | macOS (Apple Silicon) | `@superdoc/sdk-darwin-arm64` |
16
- | macOS (Intel) | `@superdoc/sdk-darwin-x64` |
17
- | Linux (x64) | `@superdoc/sdk-linux-x64` |
18
- | Linux (ARM64) | `@superdoc/sdk-linux-arm64` |
19
- | Windows (x64) | `@superdoc/sdk-windows-x64` |
15
+ | macOS (Apple Silicon) | `@superdoc-dev/sdk-darwin-arm64` |
16
+ | macOS (Intel) | `@superdoc-dev/sdk-darwin-x64` |
17
+ | Linux (x64) | `@superdoc-dev/sdk-linux-x64` |
18
+ | Linux (ARM64) | `@superdoc-dev/sdk-linux-arm64` |
19
+ | Windows (x64) | `@superdoc-dev/sdk-windows-x64` |
20
20
 
21
21
  ## Quick Start
22
22
 
@@ -24,14 +24,14 @@ Both ESM and CommonJS are supported.
24
24
 
25
25
  ```ts
26
26
  // ESM
27
- import { createSuperDocClient } from '@superdoc/sdk';
27
+ import { createSuperDocClient } from '@superdoc-dev/sdk';
28
28
 
29
29
  // CJS
30
- const { createSuperDocClient } = require('@superdoc/sdk');
30
+ const { createSuperDocClient } = require('@superdoc-dev/sdk');
31
31
  ```
32
32
 
33
33
  ```ts
34
- import { createSuperDocClient } from '@superdoc/sdk';
34
+ import { createSuperDocClient } from '@superdoc-dev/sdk';
35
35
 
36
36
  const client = createSuperDocClient();
37
37
  await client.connect();
@@ -74,7 +74,7 @@ The password is forwarded only for the initial open and is not persisted. If the
74
74
  ### Client
75
75
 
76
76
  ```ts
77
- import { SuperDocClient, createSuperDocClient } from '@superdoc/sdk';
77
+ import { SuperDocClient, createSuperDocClient } from '@superdoc-dev/sdk';
78
78
 
79
79
  const client = createSuperDocClient(options?);
80
80
  await client.connect(); // start the host process
@@ -118,7 +118,7 @@ import {
118
118
  chooseTools,
119
119
  dispatchSuperDocTool,
120
120
  getToolCatalog,
121
- } from '@superdoc/sdk';
121
+ } from '@superdoc-dev/sdk';
122
122
 
123
123
  // Get the full grouped tool set for your AI provider
124
124
  const { tools, meta } = await chooseTools({
@@ -11,6 +11,7 @@ const ACTION_NAMES = [
11
11
  'insert_heading',
12
12
  'replace_text',
13
13
  'delete_text',
14
+ 'delete_blocks',
14
15
  'append_list',
15
16
  'create_table',
16
17
  'comment_paragraphs',
@@ -59,7 +60,8 @@ const ACTION_HINTS = {
59
60
  insert_paragraphs: 'texts[] (or text for a single paragraph), placement?, headingLevel? (first item as heading 1-6), changeMode?',
60
61
  insert_heading: 'text, level, placement?, changeMode?',
61
62
  replace_text: 'edits[{find,replace}], optional selector to scope replacements to one inspected block, caseSensitive?, changeMode?',
62
- delete_text: 'finds[], optional selector to scope deletions to ONE inspected block (required to delete stray whitespace — an unscoped whitespace find matches document-wide), caseSensitive?, changeMode?',
63
+ delete_text: "finds[], optional selector to scope deletions to ONE inspected block (required to delete stray whitespace — an unscoped whitespace find matches document-wide), caseSensitive?, changeMode? — deletes TEXT ONLY, leaving the block (and a list item's bullet/number) in place. To remove a whole list item, paragraph or heading use delete_blocks",
64
+ delete_blocks: 'selectors[] (each resolving to ONE inspected block: list item, paragraph or heading), changeMode? — THE way to DELETE a whole LIST ITEM, paragraph or heading, bullet/number included. Use for "remove the first item under Article II" / "delete that clause". delete_text only strikes the text and leaves an empty numbered item behind. Deletes several blocks in ONE call; use delete_table for a whole table and delete_text to clear a table cell (a cell must keep a paragraph)',
63
65
  append_list: 'items[], kind?: ordered|bullet, headingText?, headingLevel?, placement? {at:"after"|"before",selector} builds the list at that block instead of document end',
64
66
  create_table: 'rows, columns, cellTexts?, placement?, changeMode? — changeMode:"tracked" makes the table insertion itself a tracked change',
65
67
  rewrite_block: 'selector, text, changeMode?',
@@ -106,6 +108,7 @@ const ACTION_GROUPS = [
106
108
  'insert_heading',
107
109
  'replace_text',
108
110
  'delete_text',
111
+ 'delete_blocks',
109
112
  'append_list',
110
113
  'create_table',
111
114
  'rewrite_block',
@@ -167,6 +170,7 @@ const ACTION_ARGS = {
167
170
  insert_heading: ['text', 'level', 'placement', 'changeMode'],
168
171
  replace_text: ['edits', 'selector', 'caseSensitive', 'changeMode'],
169
172
  delete_text: ['finds', 'selector', 'caseSensitive', 'changeMode'],
173
+ delete_blocks: ['selectors', 'selector', 'changeMode'],
170
174
  append_list: ['items', 'kind', 'headingText', 'headingLevel', 'placement', 'changeMode'],
171
175
  create_table: ['rows', 'columns', 'cellTexts', 'placement', 'changeMode'],
172
176
  comment_paragraphs: ['commentText', 'scope', 'excludeBlockQuotes'],
@@ -1225,6 +1229,157 @@ async function runDeleteText(doc, args) {
1225
1229
  return failedReceipt('delete_text', err, pre);
1226
1230
  }
1227
1231
  }
1232
+ /** Block node types `doc.blocks.delete` removes as a whole paragraph-shaped block. */
1233
+ const DELETABLE_BLOCK_NODE_TYPES = new Set(['paragraph', 'heading', 'listItem']);
1234
+ /**
1235
+ * delete_blocks — remove ENTIRE blocks (list items, paragraphs, headings) in
1236
+ * ONE call. Wraps doc.blocks.delete, which removes the block NODE, its
1237
+ * paragraph properties (`w:pPr`, including a list item's `w:numPr`) included.
1238
+ *
1239
+ * delete_text only strikes the RUNS. Routing "delete this list item"
1240
+ * through it leaves the numbered paragraph behind, so accepting the tracked
1241
+ * change yields an empty `1.` and the following items never renumber. Whole-
1242
+ * block removal has to go through blocks.delete, and in tracked mode that
1243
+ * records ONE structural revision whose acceptance removes the paragraph and
1244
+ * whose rejection restores it intact.
1245
+ */
1246
+ async function runDeleteBlocks(doc, args) {
1247
+ const domains = new Set(['blocks', 'trackedChanges']);
1248
+ for (const selector of args.selectors) {
1249
+ for (const domain of snapshotDomainsForSelector(selector))
1250
+ domains.add(domain);
1251
+ }
1252
+ const pre = await docSnapshot.buildDocumentSnapshot(doc, { includeDomains: [...domains] });
1253
+ try {
1254
+ if (args.selectors.length === 0) {
1255
+ return failedReceipt('delete_blocks', new Error('selectors must be non-empty'), pre);
1256
+ }
1257
+ const deleteFn = maybeMethod(doc, ['blocks', 'delete']);
1258
+ if (!deleteFn) {
1259
+ throw new errors.SuperDocCliError('doc.blocks.delete is not available on the document handle.', {
1260
+ code: 'TOOL_DISPATCH_NOT_FOUND',
1261
+ });
1262
+ }
1263
+ // Resolve EVERY selector against the same pre-snapshot before deleting
1264
+ // anything. nodeIds are stable paragraph ids, so a target resolved up front
1265
+ // stays addressable after a sibling is removed — whereas an ordinal or
1266
+ // text-search selector re-resolved mid-run would drift onto the wrong block.
1267
+ const targets = [];
1268
+ const unresolved = [];
1269
+ const wrongShape = [];
1270
+ const seen = new Set();
1271
+ for (const selector of args.selectors) {
1272
+ // A tableCell selector resolves to the FIRST PARAGRAPH inside the cell,
1273
+ // so it would otherwise pass the paragraph-shape gate below. Removing it
1274
+ // is not a whole-block delete: a `<w:tc>` must keep at least one
1275
+ // paragraph, so deleting the only one leaves invalid OOXML, and in
1276
+ // tracked mode the compiler can only strike the runs anyway. Refuse the
1277
+ // shape rather than half-honour it.
1278
+ if (selector.kind === 'tableCell') {
1279
+ wrongShape.push({ selector, nodeType: 'tableCell' });
1280
+ continue;
1281
+ }
1282
+ const target = selectorToBlockTarget(selector, pre);
1283
+ if (!target) {
1284
+ unresolved.push(selector);
1285
+ continue;
1286
+ }
1287
+ if (!DELETABLE_BLOCK_NODE_TYPES.has(target.nodeType)) {
1288
+ wrongShape.push({ selector, nodeType: target.nodeType });
1289
+ continue;
1290
+ }
1291
+ if (seen.has(target.nodeId))
1292
+ continue;
1293
+ seen.add(target.nodeId);
1294
+ targets.push({ selector, nodeId: target.nodeId, nodeType: target.nodeType });
1295
+ }
1296
+ const errors$1 = [];
1297
+ for (const selector of unresolved) {
1298
+ errors$1.push({
1299
+ code: 'ACTION_FAILED',
1300
+ message: `selector did not resolve to a unique body block: ${JSON.stringify(selector)}`,
1301
+ recovery: { kind: 'reinspect' },
1302
+ });
1303
+ }
1304
+ for (const entry of wrongShape) {
1305
+ errors$1.push({
1306
+ code: 'INVALID_ARGUMENT',
1307
+ message: `delete_blocks removes paragraph-shaped blocks (list items, paragraphs, headings); ` +
1308
+ `${JSON.stringify(entry.selector)} resolved to a "${entry.nodeType}"` +
1309
+ (entry.nodeType === 'table'
1310
+ ? ' — use delete_table for a whole table.'
1311
+ : entry.nodeType === 'tableCell'
1312
+ ? ' — a cell must keep a paragraph; use delete_text to clear it, or delete_table_row/delete_table_column.'
1313
+ : '.'),
1314
+ recovery: { kind: 'reinspect' },
1315
+ });
1316
+ }
1317
+ if (targets.length === 0) {
1318
+ return {
1319
+ status: 'failed',
1320
+ intent: 'delete_blocks',
1321
+ preSnapshot: { revision: pre.revision, counts: pre.counts },
1322
+ selectedTargets: [],
1323
+ executedOperations: [],
1324
+ verification: [],
1325
+ errors: errors$1,
1326
+ };
1327
+ }
1328
+ const changeMode = parseChangeMode(args.changeMode);
1329
+ const executedOperations = [];
1330
+ const deleted = [];
1331
+ for (const target of targets) {
1332
+ try {
1333
+ const result = await deleteFn({ target: { kind: 'block', nodeType: target.nodeType, nodeId: target.nodeId } }, changeMode ? { changeMode } : undefined);
1334
+ executedOperations.push({ operationId: 'doc.blocks.delete', result: compactOpResult(result) });
1335
+ deleted.push({ nodeId: target.nodeId, nodeType: target.nodeType });
1336
+ }
1337
+ catch (err) {
1338
+ errors$1.push({
1339
+ code: 'ACTION_FAILED',
1340
+ message: `blocks.delete failed for ${target.nodeType} ${target.nodeId}: ${err instanceof Error ? err.message : String(err)}`,
1341
+ recovery: { kind: 'reinspect' },
1342
+ });
1343
+ }
1344
+ }
1345
+ const post = await docSnapshot.buildDocumentSnapshot(doc, { includeDomains: [...domains] });
1346
+ // Tracked deletions leave the block in place until the revision is decided,
1347
+ // so the block count cannot move — count the structural revisions instead.
1348
+ // Direct deletions must show one fewer block of each deleted node type.
1349
+ const checks = changeMode === 'tracked'
1350
+ ? [{ kind: 'tracked-change-count-delta', delta: deleted.length }]
1351
+ : [...new Set(deleted.map((entry) => entry.nodeType))].map((nodeType) => ({
1352
+ kind: 'block-count-delta',
1353
+ nodeType,
1354
+ delta: -deleted.filter((entry) => entry.nodeType === nodeType).length,
1355
+ }));
1356
+ const verification = evaluateChecks(pre, post, checks);
1357
+ // Compare against the DEDUPED target list: two selectors naming the same
1358
+ // block are one deletion, and counting the raw selectors would report a
1359
+ // fully applied call as `partial` with no error to explain it. Selectors
1360
+ // that never became a target (unresolved or wrong-shape) already recorded
1361
+ // an error, so the `errors.length` term still catches a short request.
1362
+ const allApplied = deleted.length === targets.length && errors$1.length === 0;
1363
+ const verified = verification.every((v) => v.passed);
1364
+ return {
1365
+ status: !verified || deleted.length === 0 ? 'failed' : allApplied ? 'ok' : 'partial',
1366
+ intent: `delete_blocks: ${deleted.length} block(s)`,
1367
+ preSnapshot: { revision: pre.revision, counts: pre.counts },
1368
+ postSnapshot: { revision: post.revision, counts: post.counts },
1369
+ selectedTargets: targets.map((target) => ({ selector: target.selector, matched: [target.nodeId] })),
1370
+ executedOperations,
1371
+ verification,
1372
+ deletedBlocks: deleted,
1373
+ ...(errors$1.length ? { errors: errors$1 } : {}),
1374
+ ...(errors$1.length
1375
+ ? { nextStep: 'Re-inspect the document and retry the selectors that did not resolve to one block.' }
1376
+ : {}),
1377
+ };
1378
+ }
1379
+ catch (err) {
1380
+ return failedReceipt('delete_blocks', err, pre);
1381
+ }
1382
+ }
1228
1383
  async function runAppendList(doc, args) {
1229
1384
  const pre = await docSnapshot.buildDocumentSnapshot(doc);
1230
1385
  try {
@@ -4692,6 +4847,33 @@ async function superdocPerformAction(doc, args) {
4692
4847
  changeMode: parseChangeMode(args.changeMode),
4693
4848
  });
4694
4849
  }
4850
+ case 'delete_blocks': {
4851
+ // Accept a single `selector` too: the model reaches for the singular form
4852
+ // by analogy with delete_text/rewrite_block, and rejecting it would cost a
4853
+ // turn for no reason.
4854
+ const rawSelectors = Array.isArray(args.selectors)
4855
+ ? args.selectors
4856
+ : args.selectors != null
4857
+ ? [args.selectors]
4858
+ : args.selector != null
4859
+ ? [args.selector]
4860
+ : [];
4861
+ if (rawSelectors.length === 0) {
4862
+ throw new errors.SuperDocCliError('delete_blocks requires a non-empty "selectors" array, each entry resolving to one block (e.g. {kind:"nodeId",nodeId:"…"})', { code: 'INVALID_ARGUMENT' });
4863
+ }
4864
+ // EVERY entry has to parse. Dropping the unparseable ones would delete the
4865
+ // rest and report success, so the model would never learn that part of its
4866
+ // request went nowhere — the same false-success loop this action ends.
4867
+ const selectors = [];
4868
+ for (const [index, raw] of rawSelectors.entries()) {
4869
+ const parsed = parseSelector(raw);
4870
+ if (!parsed) {
4871
+ throw new errors.SuperDocCliError(`delete_blocks selectors[${index}] is not a valid selector (e.g. {kind:"nodeId",nodeId:"…"}); no blocks were deleted`, { code: 'INVALID_ARGUMENT' });
4872
+ }
4873
+ selectors.push(parsed);
4874
+ }
4875
+ return runDeleteBlocks(doc, { action, selectors, changeMode: parseChangeMode(args.changeMode) });
4876
+ }
4695
4877
  case 'append_list': {
4696
4878
  const items = parseStringArray(args.items);
4697
4879
  if (!items || items.length === 0) {
@@ -20,7 +20,7 @@
20
20
  import type { BoundDocApi } from '../generated/client.js';
21
21
  import type { AgentReceipt } from './runtime.js';
22
22
  import type { AgentChangeMode, AgentSelector } from './ir.js';
23
- export type ActionName = 'insert_paragraphs' | 'insert_heading' | 'replace_text' | 'delete_text' | 'append_list' | 'create_table' | 'comment_paragraphs' | 'add_comments' | 'resolve_comments' | 'reply_to_comment' | 'rewrite_block' | 'accept_tracked_changes' | 'reject_tracked_changes' | 'normalize_body_font_size' | 'set_font_family' | 'apply_letter_spacing' | 'fill_placeholders' | 'move_range' | 'insert_toc' | 'insert_table_row' | 'insert_table_column' | 'delete_table_row' | 'delete_table_column' | 'split_table' | 'convert_list' | 'split_list' | 'undo_changes' | 'redo_changes' | 'attach_numbering' | 'add_list_items' | 'format_text' | 'apply_style' | 'format_paragraph' | 'move_text' | 'style_table' | 'move_table' | 'delete_table' | 'set_paragraph_spacing' | 'insert_page_break' | 'add_hyperlink';
23
+ export type ActionName = 'insert_paragraphs' | 'insert_heading' | 'replace_text' | 'delete_text' | 'delete_blocks' | 'append_list' | 'create_table' | 'comment_paragraphs' | 'add_comments' | 'resolve_comments' | 'reply_to_comment' | 'rewrite_block' | 'accept_tracked_changes' | 'reject_tracked_changes' | 'normalize_body_font_size' | 'set_font_family' | 'apply_letter_spacing' | 'fill_placeholders' | 'move_range' | 'insert_toc' | 'insert_table_row' | 'insert_table_column' | 'delete_table_row' | 'delete_table_column' | 'split_table' | 'convert_list' | 'split_list' | 'undo_changes' | 'redo_changes' | 'attach_numbering' | 'add_list_items' | 'format_text' | 'apply_style' | 'format_paragraph' | 'move_text' | 'style_table' | 'move_table' | 'delete_table' | 'set_paragraph_spacing' | 'insert_page_break' | 'add_hyperlink';
24
24
  export type ActionPlacement = {
25
25
  at: 'document_end';
26
26
  } | {
@@ -32,7 +32,7 @@ export type ActionPlacement = {
32
32
  at: 'before';
33
33
  selector: AgentSelector;
34
34
  };
35
- export type ActionArgs = InsertParagraphsArgs | InsertHeadingArgs | ReplaceTextArgs | DeleteTextArgs | AppendListArgs | AddListItemsArgs | ConvertListArgs | AttachNumberingArgs | SplitListArgs | CreateTableArgs | CommentParagraphsArgs | AddCommentsArgs | ResolveCommentsArgs | ReplyToCommentArgs | RewriteBlockArgs | FormatTextArgs | FormatParagraphArgs | ApplyStyleArgs | MoveTextArgs | UndoChangesArgs | RedoChangesArgs | AcceptTrackedChangesArgs | RejectTrackedChangesArgs | NormalizeBodyFontSizeArgs | SetFontFamilyArgs | ApplyLetterSpacingArgs | FillPlaceholdersArgs | MoveRangeArgs | InsertTocArgs | StyleTableArgs | MoveTableArgs | DeleteTableArgs | SetParagraphSpacingArgs | InsertPageBreakArgs | AddHyperlinkArgs | InsertTableRowArgs | InsertTableColumnArgs | DeleteTableRowArgs | DeleteTableColumnArgs | SplitTableArgs;
35
+ export type ActionArgs = InsertParagraphsArgs | InsertHeadingArgs | ReplaceTextArgs | DeleteTextArgs | DeleteBlocksArgs | AppendListArgs | AddListItemsArgs | ConvertListArgs | AttachNumberingArgs | SplitListArgs | CreateTableArgs | CommentParagraphsArgs | AddCommentsArgs | ResolveCommentsArgs | ReplyToCommentArgs | RewriteBlockArgs | FormatTextArgs | FormatParagraphArgs | ApplyStyleArgs | MoveTextArgs | UndoChangesArgs | RedoChangesArgs | AcceptTrackedChangesArgs | RejectTrackedChangesArgs | NormalizeBodyFontSizeArgs | SetFontFamilyArgs | ApplyLetterSpacingArgs | FillPlaceholdersArgs | MoveRangeArgs | InsertTocArgs | StyleTableArgs | MoveTableArgs | DeleteTableArgs | SetParagraphSpacingArgs | InsertPageBreakArgs | AddHyperlinkArgs | InsertTableRowArgs | InsertTableColumnArgs | DeleteTableRowArgs | DeleteTableColumnArgs | SplitTableArgs;
36
36
  export type InsertParagraphsArgs = {
37
37
  action: 'insert_paragraphs';
38
38
  texts?: readonly string[];
@@ -66,6 +66,12 @@ export type DeleteTextArgs = {
66
66
  caseSensitive?: boolean;
67
67
  changeMode?: AgentChangeMode;
68
68
  };
69
+ export type DeleteBlocksArgs = {
70
+ action: 'delete_blocks';
71
+ /** One or more selectors, each resolving to exactly ONE body block. */
72
+ selectors: readonly AgentSelector[];
73
+ changeMode?: AgentChangeMode;
74
+ };
69
75
  export type AppendListArgs = {
70
76
  action: 'append_list';
71
77
  items: readonly string[];
@@ -8,6 +8,7 @@ const ACTION_NAMES = [
8
8
  'insert_heading',
9
9
  'replace_text',
10
10
  'delete_text',
11
+ 'delete_blocks',
11
12
  'append_list',
12
13
  'create_table',
13
14
  'comment_paragraphs',
@@ -56,7 +57,8 @@ export const ACTION_HINTS = {
56
57
  insert_paragraphs: 'texts[] (or text for a single paragraph), placement?, headingLevel? (first item as heading 1-6), changeMode?',
57
58
  insert_heading: 'text, level, placement?, changeMode?',
58
59
  replace_text: 'edits[{find,replace}], optional selector to scope replacements to one inspected block, caseSensitive?, changeMode?',
59
- delete_text: 'finds[], optional selector to scope deletions to ONE inspected block (required to delete stray whitespace — an unscoped whitespace find matches document-wide), caseSensitive?, changeMode?',
60
+ delete_text: "finds[], optional selector to scope deletions to ONE inspected block (required to delete stray whitespace — an unscoped whitespace find matches document-wide), caseSensitive?, changeMode? — deletes TEXT ONLY, leaving the block (and a list item's bullet/number) in place. To remove a whole list item, paragraph or heading use delete_blocks",
61
+ delete_blocks: 'selectors[] (each resolving to ONE inspected block: list item, paragraph or heading), changeMode? — THE way to DELETE a whole LIST ITEM, paragraph or heading, bullet/number included. Use for "remove the first item under Article II" / "delete that clause". delete_text only strikes the text and leaves an empty numbered item behind. Deletes several blocks in ONE call; use delete_table for a whole table and delete_text to clear a table cell (a cell must keep a paragraph)',
60
62
  append_list: 'items[], kind?: ordered|bullet, headingText?, headingLevel?, placement? {at:"after"|"before",selector} builds the list at that block instead of document end',
61
63
  create_table: 'rows, columns, cellTexts?, placement?, changeMode? — changeMode:"tracked" makes the table insertion itself a tracked change',
62
64
  rewrite_block: 'selector, text, changeMode?',
@@ -103,6 +105,7 @@ export const ACTION_GROUPS = [
103
105
  'insert_heading',
104
106
  'replace_text',
105
107
  'delete_text',
108
+ 'delete_blocks',
106
109
  'append_list',
107
110
  'create_table',
108
111
  'rewrite_block',
@@ -164,6 +167,7 @@ export const ACTION_ARGS = {
164
167
  insert_heading: ['text', 'level', 'placement', 'changeMode'],
165
168
  replace_text: ['edits', 'selector', 'caseSensitive', 'changeMode'],
166
169
  delete_text: ['finds', 'selector', 'caseSensitive', 'changeMode'],
170
+ delete_blocks: ['selectors', 'selector', 'changeMode'],
167
171
  append_list: ['items', 'kind', 'headingText', 'headingLevel', 'placement', 'changeMode'],
168
172
  create_table: ['rows', 'columns', 'cellTexts', 'placement', 'changeMode'],
169
173
  comment_paragraphs: ['commentText', 'scope', 'excludeBlockQuotes'],
@@ -1222,6 +1226,157 @@ async function runDeleteText(doc, args) {
1222
1226
  return failedReceipt('delete_text', err, pre);
1223
1227
  }
1224
1228
  }
1229
+ /** Block node types `doc.blocks.delete` removes as a whole paragraph-shaped block. */
1230
+ const DELETABLE_BLOCK_NODE_TYPES = new Set(['paragraph', 'heading', 'listItem']);
1231
+ /**
1232
+ * delete_blocks — remove ENTIRE blocks (list items, paragraphs, headings) in
1233
+ * ONE call. Wraps doc.blocks.delete, which removes the block NODE, its
1234
+ * paragraph properties (`w:pPr`, including a list item's `w:numPr`) included.
1235
+ *
1236
+ * delete_text only strikes the RUNS. Routing "delete this list item"
1237
+ * through it leaves the numbered paragraph behind, so accepting the tracked
1238
+ * change yields an empty `1.` and the following items never renumber. Whole-
1239
+ * block removal has to go through blocks.delete, and in tracked mode that
1240
+ * records ONE structural revision whose acceptance removes the paragraph and
1241
+ * whose rejection restores it intact.
1242
+ */
1243
+ async function runDeleteBlocks(doc, args) {
1244
+ const domains = new Set(['blocks', 'trackedChanges']);
1245
+ for (const selector of args.selectors) {
1246
+ for (const domain of snapshotDomainsForSelector(selector))
1247
+ domains.add(domain);
1248
+ }
1249
+ const pre = await buildDocumentSnapshot(doc, { includeDomains: [...domains] });
1250
+ try {
1251
+ if (args.selectors.length === 0) {
1252
+ return failedReceipt('delete_blocks', new Error('selectors must be non-empty'), pre);
1253
+ }
1254
+ const deleteFn = maybeMethod(doc, ['blocks', 'delete']);
1255
+ if (!deleteFn) {
1256
+ throw new SuperDocCliError('doc.blocks.delete is not available on the document handle.', {
1257
+ code: 'TOOL_DISPATCH_NOT_FOUND',
1258
+ });
1259
+ }
1260
+ // Resolve EVERY selector against the same pre-snapshot before deleting
1261
+ // anything. nodeIds are stable paragraph ids, so a target resolved up front
1262
+ // stays addressable after a sibling is removed — whereas an ordinal or
1263
+ // text-search selector re-resolved mid-run would drift onto the wrong block.
1264
+ const targets = [];
1265
+ const unresolved = [];
1266
+ const wrongShape = [];
1267
+ const seen = new Set();
1268
+ for (const selector of args.selectors) {
1269
+ // A tableCell selector resolves to the FIRST PARAGRAPH inside the cell,
1270
+ // so it would otherwise pass the paragraph-shape gate below. Removing it
1271
+ // is not a whole-block delete: a `<w:tc>` must keep at least one
1272
+ // paragraph, so deleting the only one leaves invalid OOXML, and in
1273
+ // tracked mode the compiler can only strike the runs anyway. Refuse the
1274
+ // shape rather than half-honour it.
1275
+ if (selector.kind === 'tableCell') {
1276
+ wrongShape.push({ selector, nodeType: 'tableCell' });
1277
+ continue;
1278
+ }
1279
+ const target = selectorToBlockTarget(selector, pre);
1280
+ if (!target) {
1281
+ unresolved.push(selector);
1282
+ continue;
1283
+ }
1284
+ if (!DELETABLE_BLOCK_NODE_TYPES.has(target.nodeType)) {
1285
+ wrongShape.push({ selector, nodeType: target.nodeType });
1286
+ continue;
1287
+ }
1288
+ if (seen.has(target.nodeId))
1289
+ continue;
1290
+ seen.add(target.nodeId);
1291
+ targets.push({ selector, nodeId: target.nodeId, nodeType: target.nodeType });
1292
+ }
1293
+ const errors = [];
1294
+ for (const selector of unresolved) {
1295
+ errors.push({
1296
+ code: 'ACTION_FAILED',
1297
+ message: `selector did not resolve to a unique body block: ${JSON.stringify(selector)}`,
1298
+ recovery: { kind: 'reinspect' },
1299
+ });
1300
+ }
1301
+ for (const entry of wrongShape) {
1302
+ errors.push({
1303
+ code: 'INVALID_ARGUMENT',
1304
+ message: `delete_blocks removes paragraph-shaped blocks (list items, paragraphs, headings); ` +
1305
+ `${JSON.stringify(entry.selector)} resolved to a "${entry.nodeType}"` +
1306
+ (entry.nodeType === 'table'
1307
+ ? ' — use delete_table for a whole table.'
1308
+ : entry.nodeType === 'tableCell'
1309
+ ? ' — a cell must keep a paragraph; use delete_text to clear it, or delete_table_row/delete_table_column.'
1310
+ : '.'),
1311
+ recovery: { kind: 'reinspect' },
1312
+ });
1313
+ }
1314
+ if (targets.length === 0) {
1315
+ return {
1316
+ status: 'failed',
1317
+ intent: 'delete_blocks',
1318
+ preSnapshot: { revision: pre.revision, counts: pre.counts },
1319
+ selectedTargets: [],
1320
+ executedOperations: [],
1321
+ verification: [],
1322
+ errors,
1323
+ };
1324
+ }
1325
+ const changeMode = parseChangeMode(args.changeMode);
1326
+ const executedOperations = [];
1327
+ const deleted = [];
1328
+ for (const target of targets) {
1329
+ try {
1330
+ const result = await deleteFn({ target: { kind: 'block', nodeType: target.nodeType, nodeId: target.nodeId } }, changeMode ? { changeMode } : undefined);
1331
+ executedOperations.push({ operationId: 'doc.blocks.delete', result: compactOpResult(result) });
1332
+ deleted.push({ nodeId: target.nodeId, nodeType: target.nodeType });
1333
+ }
1334
+ catch (err) {
1335
+ errors.push({
1336
+ code: 'ACTION_FAILED',
1337
+ message: `blocks.delete failed for ${target.nodeType} ${target.nodeId}: ${err instanceof Error ? err.message : String(err)}`,
1338
+ recovery: { kind: 'reinspect' },
1339
+ });
1340
+ }
1341
+ }
1342
+ const post = await buildDocumentSnapshot(doc, { includeDomains: [...domains] });
1343
+ // Tracked deletions leave the block in place until the revision is decided,
1344
+ // so the block count cannot move — count the structural revisions instead.
1345
+ // Direct deletions must show one fewer block of each deleted node type.
1346
+ const checks = changeMode === 'tracked'
1347
+ ? [{ kind: 'tracked-change-count-delta', delta: deleted.length }]
1348
+ : [...new Set(deleted.map((entry) => entry.nodeType))].map((nodeType) => ({
1349
+ kind: 'block-count-delta',
1350
+ nodeType,
1351
+ delta: -deleted.filter((entry) => entry.nodeType === nodeType).length,
1352
+ }));
1353
+ const verification = evaluateChecks(pre, post, checks);
1354
+ // Compare against the DEDUPED target list: two selectors naming the same
1355
+ // block are one deletion, and counting the raw selectors would report a
1356
+ // fully applied call as `partial` with no error to explain it. Selectors
1357
+ // that never became a target (unresolved or wrong-shape) already recorded
1358
+ // an error, so the `errors.length` term still catches a short request.
1359
+ const allApplied = deleted.length === targets.length && errors.length === 0;
1360
+ const verified = verification.every((v) => v.passed);
1361
+ return {
1362
+ status: !verified || deleted.length === 0 ? 'failed' : allApplied ? 'ok' : 'partial',
1363
+ intent: `delete_blocks: ${deleted.length} block(s)`,
1364
+ preSnapshot: { revision: pre.revision, counts: pre.counts },
1365
+ postSnapshot: { revision: post.revision, counts: post.counts },
1366
+ selectedTargets: targets.map((target) => ({ selector: target.selector, matched: [target.nodeId] })),
1367
+ executedOperations,
1368
+ verification,
1369
+ deletedBlocks: deleted,
1370
+ ...(errors.length ? { errors } : {}),
1371
+ ...(errors.length
1372
+ ? { nextStep: 'Re-inspect the document and retry the selectors that did not resolve to one block.' }
1373
+ : {}),
1374
+ };
1375
+ }
1376
+ catch (err) {
1377
+ return failedReceipt('delete_blocks', err, pre);
1378
+ }
1379
+ }
1225
1380
  async function runAppendList(doc, args) {
1226
1381
  const pre = await buildDocumentSnapshot(doc);
1227
1382
  try {
@@ -4689,6 +4844,33 @@ export async function superdocPerformAction(doc, args) {
4689
4844
  changeMode: parseChangeMode(args.changeMode),
4690
4845
  });
4691
4846
  }
4847
+ case 'delete_blocks': {
4848
+ // Accept a single `selector` too: the model reaches for the singular form
4849
+ // by analogy with delete_text/rewrite_block, and rejecting it would cost a
4850
+ // turn for no reason.
4851
+ const rawSelectors = Array.isArray(args.selectors)
4852
+ ? args.selectors
4853
+ : args.selectors != null
4854
+ ? [args.selectors]
4855
+ : args.selector != null
4856
+ ? [args.selector]
4857
+ : [];
4858
+ if (rawSelectors.length === 0) {
4859
+ throw new SuperDocCliError('delete_blocks requires a non-empty "selectors" array, each entry resolving to one block (e.g. {kind:"nodeId",nodeId:"…"})', { code: 'INVALID_ARGUMENT' });
4860
+ }
4861
+ // EVERY entry has to parse. Dropping the unparseable ones would delete the
4862
+ // rest and report success, so the model would never learn that part of its
4863
+ // request went nowhere — the same false-success loop this action ends.
4864
+ const selectors = [];
4865
+ for (const [index, raw] of rawSelectors.entries()) {
4866
+ const parsed = parseSelector(raw);
4867
+ if (!parsed) {
4868
+ throw new SuperDocCliError(`delete_blocks selectors[${index}] is not a valid selector (e.g. {kind:"nodeId",nodeId:"…"}); no blocks were deleted`, { code: 'INVALID_ARGUMENT' });
4869
+ }
4870
+ selectors.push(parsed);
4871
+ }
4872
+ return runDeleteBlocks(doc, { action, selectors, changeMode: parseChangeMode(args.changeMode) });
4873
+ }
4692
4874
  case 'append_list': {
4693
4875
  const items = parseStringArray(args.items);
4694
4876
  if (!items || items.length === 0) {