@superdoc/sdk 2.12.0-next.1 → 2.12.0-next.11

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
@@ -96,3 +96,18 @@ automatically replay the write.
96
96
  ## License
97
97
 
98
98
  AGPL-3.0. Commercial licenses are available from [SuperDoc](https://www.superdoc.dev).
99
+
100
+ ## Inspecting textbox sections
101
+
102
+ `superdoc_inspect` includes discovered text-bearing textbox stories in `textboxes` when
103
+ block inspection is enabled. Each entry contains its explicit `story` locator,
104
+ its story-local `total`, and normalized `blocks` with text, identities, and list
105
+ markers. The top-level `blocks` array and its ordinals continue to refer to the
106
+ body; textbox block ordinals belong to their containing story.
107
+
108
+ The block offset, limit, text cap, and block filters also apply within each
109
+ textbox. Count-only inspections and inspections excluding the blocks domain do
110
+ not collect textbox text. Check `diagnostics` for incomplete story discovery or
111
+ reads. To edit a discovered heading, use `query.match` with its story locator
112
+ and pass the returned reference to `replace`; a textbox ordinal is not a body
113
+ action selector.
@@ -556,7 +556,12 @@ function mergeIntoAgentAction(tools, actions) {
556
556
  }
557
557
  }
558
558
  const nextActionProp = { ...actionProp, enum: enumValues };
559
- const nextSchema = { ...schema, properties: { ...properties, action: nextActionProp } };
559
+ const hasOpenCustomSchema = actions.some((action) => action.inputSchema.additionalProperties !== false);
560
+ const nextSchema = {
561
+ ...schema,
562
+ additionalProperties: hasOpenCustomSchema ? true : schema.additionalProperties,
563
+ properties: { ...properties, action: nextActionProp },
564
+ };
560
565
  const baseDescription = typeof schemaContainer.description === 'string' ? schemaContainer.description : '';
561
566
  const nextDescription = baseDescription + customActionsDescription(actions);
562
567
  const nextContainer = { ...schemaContainer, description: nextDescription, [schemaKey]: nextSchema };
@@ -553,7 +553,12 @@ function mergeIntoAgentAction(tools, actions) {
553
553
  }
554
554
  }
555
555
  const nextActionProp = { ...actionProp, enum: enumValues };
556
- const nextSchema = { ...schema, properties: { ...properties, action: nextActionProp } };
556
+ const hasOpenCustomSchema = actions.some((action) => action.inputSchema.additionalProperties !== false);
557
+ const nextSchema = {
558
+ ...schema,
559
+ additionalProperties: hasOpenCustomSchema ? true : schema.additionalProperties,
560
+ properties: { ...properties, action: nextActionProp },
561
+ };
557
562
  const baseDescription = typeof schemaContainer.description === 'string' ? schemaContainer.description : '';
558
563
  const nextDescription = baseDescription + customActionsDescription(actions);
559
564
  const nextContainer = { ...schemaContainer, description: nextDescription, [schemaKey]: nextSchema };
@@ -72,8 +72,8 @@ const ACTION_HINTS = {
72
72
  add_comments: 'commentText, selector (one block) OR selectors[] to comment MANY blocks in ONE call (same text) — use selectors[] for "comment every heading/section/clause"; NEVER emit multiple add_comments calls, batch the targets into selectors[]',
73
73
  resolve_comments: 'anchorText? (resolve only comments anchored on text containing this; omit to resolve ALL open comments), reopen? (true = reopen resolved comments instead) — THE way to resolve (or reopen) comments. Use for "resolve the comment(s)" / "mark comments resolved".',
74
74
  reply_to_comment: 'commentText (the reply body), anchorText (text the target comment is anchored on / mentions) OR commentId — THE way to REPLY to an existing comment thread. Use for "reply to the comment about X" / "respond to Reviewer\'s comment". Adds a threaded reply, not a new top-level comment.',
75
- accept_tracked_changes: 'author?, changeType?: insert|delete|replacement|format — e.g. changeType:"format" accepts ONLY formatting revisions (bold/italic/color), leaving text edits pending',
76
- reject_tracked_changes: 'author?, changeType?: insert|delete|replacement|format',
75
+ accept_tracked_changes: 'id? (string or string[]) OR author?/changeType?: insert|delete|replacement|format — a string[] decides that exact set atomically; e.g. changeType:"format" accepts ONLY formatting revisions (bold/italic/color), leaving text edits pending',
76
+ reject_tracked_changes: 'id? (string or string[]) OR author?/changeType?: insert|delete|replacement|format — a string[] decides that exact set atomically',
77
77
  normalize_body_font_size: 'fontSize, changeMode?',
78
78
  set_font_family: 'fontFamily (e.g. "Arial"), selector? (one block) OR targetText/targetTexts[] (occurrences) — omit both to set the WHOLE body font, caseSensitive?, changeMode? — THE way to change the typeface. Use for "change the font to X" / "set the heading font to Y".',
79
79
  format_text: "bold?/italic?/underline?/strike? booleans, highlight? color name, color?, fontSize?, applied to EVERY occurrence of targetText (or targetTexts[] for several phrases in one call, or a selector'd block), caseSensitive?, changeMode? — THE way to bold/italicize/underline/highlight text, tracked-safe",
@@ -183,8 +183,8 @@ const ACTION_ARGS = {
183
183
  resolve_comments: ['anchorText', 'reopen'],
184
184
  reply_to_comment: ['commentText', 'anchorText', 'commentId'],
185
185
  rewrite_block: ['text', 'selector', 'changeMode', 'evidence'],
186
- accept_tracked_changes: ['author', 'changeType'],
187
- reject_tracked_changes: ['author', 'changeType'],
186
+ accept_tracked_changes: ['id', 'author', 'changeType'],
187
+ reject_tracked_changes: ['id', 'author', 'changeType'],
188
188
  normalize_body_font_size: ['fontSize', 'changeMode'],
189
189
  set_font_family: ['fontFamily', 'selector', 'targetText', 'targetTexts', 'caseSensitive', 'changeMode'],
190
190
  apply_letter_spacing: ['selector', 'letterSpacing', 'changeMode'],
@@ -2872,17 +2872,28 @@ function normalizeTableColor(raw) {
2872
2872
  return normalized ? `#${normalized}` : null;
2873
2873
  }
2874
2874
  async function runAcceptTrackedChanges(doc, args) {
2875
- return runTrackedChangeDecision(doc, 'accept_tracked_changes', 'accept', args.author, args.changeType);
2875
+ return runTrackedChangeDecision(doc, 'accept_tracked_changes', 'accept', Array.isArray(args.id) ? args.id : typeof args.id === 'string' ? [args.id] : undefined, args.author, args.changeType);
2876
2876
  }
2877
2877
  async function runRejectTrackedChanges(doc, args) {
2878
- return runTrackedChangeDecision(doc, 'reject_tracked_changes', 'reject', args.author, args.changeType);
2878
+ return runTrackedChangeDecision(doc, 'reject_tracked_changes', 'reject', Array.isArray(args.id) ? args.id : typeof args.id === 'string' ? [args.id] : undefined, args.author, args.changeType);
2879
2879
  }
2880
- async function runTrackedChangeDecision(doc, intentLabel, decision, author, changeType) {
2880
+ async function runTrackedChangeDecision(doc, intentLabel, decision, exactIds, author, changeType) {
2881
2881
  const pre = await docSnapshot.buildDocumentSnapshot(doc);
2882
2882
  try {
2883
- const listFn = maybeMethod(doc, ['trackChanges', 'list']);
2884
2883
  const decideFn = maybeMethod(doc, ['trackChanges', 'decide']);
2885
- if (!listFn || !decideFn) {
2884
+ if (!decideFn) {
2885
+ throw new errors.SuperDocCliError('doc.trackChanges.list / decide are not available on the document handle.', {
2886
+ code: 'TOOL_DISPATCH_NOT_FOUND',
2887
+ });
2888
+ }
2889
+ // AIDEV-NOTE: Exact `id` must go to decide({ kind: 'id'|'ids' }) once.
2890
+ // Listing then filtering treats a stale id as an ok no-op, which looks
2891
+ // like success and invites a follow-up with no selector (accept/reject all).
2892
+ if (exactIds && exactIds.length > 0) {
2893
+ return decideExactTrackedChanges(doc, intentLabel, decision, decideFn, exactIds, pre);
2894
+ }
2895
+ const listFn = maybeMethod(doc, ['trackChanges', 'list']);
2896
+ if (!listFn) {
2886
2897
  throw new errors.SuperDocCliError('doc.trackChanges.list / decide are not available on the document handle.', {
2887
2898
  code: 'TOOL_DISPATCH_NOT_FOUND',
2888
2899
  });
@@ -2963,6 +2974,53 @@ async function runTrackedChangeDecision(doc, intentLabel, decision, author, chan
2963
2974
  return failedReceipt(intentLabel, err, pre);
2964
2975
  }
2965
2976
  }
2977
+ function trackedChangeDecideFailure(result) {
2978
+ const rec = asRecord(result);
2979
+ if (!rec || rec.success !== false)
2980
+ return null;
2981
+ const failure = asRecord(rec.failure);
2982
+ return {
2983
+ code: asString(failure?.code, 'ACTION_FAILED') || 'ACTION_FAILED',
2984
+ message: asString(failure?.message, 'tracked-change decision failed') || 'tracked-change decision failed',
2985
+ };
2986
+ }
2987
+ async function decideExactTrackedChanges(doc, intentLabel, decision, decideFn, exactIds, pre) {
2988
+ const target = exactIds.length === 1 ? { kind: 'id', id: exactIds[0] } : { kind: 'ids', ids: [...exactIds] };
2989
+ const result = await decideFn({ decision, target });
2990
+ const failure = trackedChangeDecideFailure(result);
2991
+ const selectedTargets = exactIds.map((id) => ({
2992
+ selector: { kind: 'entity', entityType: 'trackedChange', entityId: id },
2993
+ matched: failure ? [] : [id],
2994
+ }));
2995
+ if (failure) {
2996
+ return {
2997
+ status: 'failed',
2998
+ intent: intentLabel,
2999
+ preSnapshot: { revision: pre.revision, counts: pre.counts },
3000
+ selectedTargets,
3001
+ executedOperations: [{ operationId: 'doc.trackChanges.decide', result }],
3002
+ verification: [],
3003
+ errors: [
3004
+ {
3005
+ code: failure.code,
3006
+ message: failure.message,
3007
+ recovery: { kind: 'reinspect' },
3008
+ },
3009
+ ],
3010
+ };
3011
+ }
3012
+ const post = await docSnapshot.buildDocumentSnapshot(doc);
3013
+ const verification = evaluateChecks(pre, post, [{ kind: 'revision-changed' }]);
3014
+ return {
3015
+ status: verification.every((v) => v.passed) ? 'ok' : 'failed',
3016
+ intent: intentLabel,
3017
+ preSnapshot: { revision: pre.revision, counts: pre.counts },
3018
+ postSnapshot: { revision: post.revision, counts: post.counts },
3019
+ selectedTargets,
3020
+ executedOperations: [{ operationId: 'doc.trackChanges.decide', result }],
3021
+ verification,
3022
+ };
3023
+ }
2966
3024
  async function listAllTrackedChanges(listFn) {
2967
3025
  const PAGE = 250;
2968
3026
  let offset = 0;
@@ -6660,13 +6718,11 @@ async function dispatchSuperdocPerformAction(doc, args) {
6660
6718
  }
6661
6719
  case 'accept_tracked_changes':
6662
6720
  return runAcceptTrackedChanges(doc, {
6663
- author: asString(args.author),
6664
- changeType: parseTrackedChangeKind(args.changeType),
6721
+ ...parseTrackedChangeDecisionArgs(action, args),
6665
6722
  });
6666
6723
  case 'reject_tracked_changes':
6667
6724
  return runRejectTrackedChanges(doc, {
6668
- author: asString(args.author),
6669
- changeType: parseTrackedChangeKind(args.changeType),
6725
+ ...parseTrackedChangeDecisionArgs(action, args),
6670
6726
  });
6671
6727
  case 'normalize_body_font_size': {
6672
6728
  const fontSize = asNumber(args.fontSize);
@@ -7036,6 +7092,62 @@ async function dispatchSuperdocPerformAction(doc, args) {
7036
7092
  }
7037
7093
  }
7038
7094
  }
7095
+ function parseTrackedChangeDecisionArgs(action, args) {
7096
+ const hasId = args.id !== undefined;
7097
+ const hasAuthor = args.author !== undefined;
7098
+ const hasChangeType = args.changeType !== undefined;
7099
+ const id = hasId ? parseExactTrackedChangeIdList(args.id) : undefined;
7100
+ const author = asString(args.author)?.trim();
7101
+ const changeType = parseTrackedChangeKind(args.changeType);
7102
+ if (hasId && !id) {
7103
+ throw new errors.SuperDocCliError(`${action} requires "id" to be a non-empty string or array of non-empty strings`, {
7104
+ code: 'INVALID_ARGUMENT',
7105
+ });
7106
+ }
7107
+ if (hasAuthor && !author) {
7108
+ throw new errors.SuperDocCliError(`${action} requires "author" to be a non-empty string when provided`, {
7109
+ code: 'INVALID_ARGUMENT',
7110
+ });
7111
+ }
7112
+ if (hasChangeType && !changeType) {
7113
+ throw new errors.SuperDocCliError(`${action} requires "changeType" to be insert, delete, replacement, or format when provided`, {
7114
+ code: 'INVALID_ARGUMENT',
7115
+ });
7116
+ }
7117
+ if (id && (hasAuthor || hasChangeType)) {
7118
+ throw new errors.SuperDocCliError(`${action} cannot combine "id" with "author" or "changeType"`, {
7119
+ code: 'INVALID_ARGUMENT',
7120
+ });
7121
+ }
7122
+ if (id)
7123
+ return { id };
7124
+ return {
7125
+ ...(author ? { author } : {}),
7126
+ ...(changeType ? { changeType } : {}),
7127
+ };
7128
+ }
7129
+ function parseExactTrackedChangeIdList(value) {
7130
+ if (typeof value === 'string') {
7131
+ const trimmed = value.trim();
7132
+ return trimmed.length > 0 ? [trimmed] : undefined;
7133
+ }
7134
+ if (!Array.isArray(value))
7135
+ return undefined;
7136
+ const ids = [];
7137
+ const seen = new Set();
7138
+ for (const entry of value) {
7139
+ if (typeof entry !== 'string')
7140
+ return undefined;
7141
+ const trimmed = entry.trim();
7142
+ if (trimmed.length === 0)
7143
+ return undefined;
7144
+ if (seen.has(trimmed))
7145
+ continue;
7146
+ seen.add(trimmed);
7147
+ ids.push(trimmed);
7148
+ }
7149
+ return ids.length > 0 ? ids : undefined;
7150
+ }
7039
7151
  async function superdocPerformAction(doc, args) {
7040
7152
  try {
7041
7153
  return await dispatchSuperdocPerformAction(doc, args);
@@ -224,17 +224,23 @@ export type RewriteBlockArgs = {
224
224
  changeMode?: AgentChangeMode;
225
225
  };
226
226
  export type TrackedChangeKind = 'insert' | 'delete' | 'replacement' | 'format';
227
- export type AcceptTrackedChangesArgs = {
228
- action: 'accept_tracked_changes';
227
+ type TrackedChangeDecisionSelector = {
228
+ /** One change, or an atomic set. */
229
+ id: string | readonly string[];
230
+ author?: never;
231
+ changeType?: never;
232
+ } | {
233
+ id?: never;
229
234
  author?: string;
230
235
  /** Restrict the decision to one kind of change, e.g. 'format' = formatting-only revisions. */
231
236
  changeType?: TrackedChangeKind;
232
237
  };
238
+ export type AcceptTrackedChangesArgs = {
239
+ action: 'accept_tracked_changes';
240
+ } & TrackedChangeDecisionSelector;
233
241
  export type RejectTrackedChangesArgs = {
234
242
  action: 'reject_tracked_changes';
235
- author?: string;
236
- changeType?: TrackedChangeKind;
237
- };
243
+ } & TrackedChangeDecisionSelector;
238
244
  export type NormalizeBodyFontSizeArgs = {
239
245
  action: 'normalize_body_font_size';
240
246
  fontSize: number;
@@ -479,3 +485,4 @@ export type AddHyperlinkArgs = {
479
485
  };
480
486
  export declare function superdocPerformAction(doc: BoundDocApi, args: unknown): Promise<AgentReceipt>;
481
487
  export declare const ACTION_NAMES_LIST: readonly ActionName[];
488
+ export {};
@@ -69,8 +69,8 @@ export const ACTION_HINTS = {
69
69
  add_comments: 'commentText, selector (one block) OR selectors[] to comment MANY blocks in ONE call (same text) — use selectors[] for "comment every heading/section/clause"; NEVER emit multiple add_comments calls, batch the targets into selectors[]',
70
70
  resolve_comments: 'anchorText? (resolve only comments anchored on text containing this; omit to resolve ALL open comments), reopen? (true = reopen resolved comments instead) — THE way to resolve (or reopen) comments. Use for "resolve the comment(s)" / "mark comments resolved".',
71
71
  reply_to_comment: 'commentText (the reply body), anchorText (text the target comment is anchored on / mentions) OR commentId — THE way to REPLY to an existing comment thread. Use for "reply to the comment about X" / "respond to Reviewer\'s comment". Adds a threaded reply, not a new top-level comment.',
72
- accept_tracked_changes: 'author?, changeType?: insert|delete|replacement|format — e.g. changeType:"format" accepts ONLY formatting revisions (bold/italic/color), leaving text edits pending',
73
- reject_tracked_changes: 'author?, changeType?: insert|delete|replacement|format',
72
+ accept_tracked_changes: 'id? (string or string[]) OR author?/changeType?: insert|delete|replacement|format — a string[] decides that exact set atomically; e.g. changeType:"format" accepts ONLY formatting revisions (bold/italic/color), leaving text edits pending',
73
+ reject_tracked_changes: 'id? (string or string[]) OR author?/changeType?: insert|delete|replacement|format — a string[] decides that exact set atomically',
74
74
  normalize_body_font_size: 'fontSize, changeMode?',
75
75
  set_font_family: 'fontFamily (e.g. "Arial"), selector? (one block) OR targetText/targetTexts[] (occurrences) — omit both to set the WHOLE body font, caseSensitive?, changeMode? — THE way to change the typeface. Use for "change the font to X" / "set the heading font to Y".',
76
76
  format_text: "bold?/italic?/underline?/strike? booleans, highlight? color name, color?, fontSize?, applied to EVERY occurrence of targetText (or targetTexts[] for several phrases in one call, or a selector'd block), caseSensitive?, changeMode? — THE way to bold/italicize/underline/highlight text, tracked-safe",
@@ -180,8 +180,8 @@ export const ACTION_ARGS = {
180
180
  resolve_comments: ['anchorText', 'reopen'],
181
181
  reply_to_comment: ['commentText', 'anchorText', 'commentId'],
182
182
  rewrite_block: ['text', 'selector', 'changeMode', 'evidence'],
183
- accept_tracked_changes: ['author', 'changeType'],
184
- reject_tracked_changes: ['author', 'changeType'],
183
+ accept_tracked_changes: ['id', 'author', 'changeType'],
184
+ reject_tracked_changes: ['id', 'author', 'changeType'],
185
185
  normalize_body_font_size: ['fontSize', 'changeMode'],
186
186
  set_font_family: ['fontFamily', 'selector', 'targetText', 'targetTexts', 'caseSensitive', 'changeMode'],
187
187
  apply_letter_spacing: ['selector', 'letterSpacing', 'changeMode'],
@@ -2869,17 +2869,28 @@ function normalizeTableColor(raw) {
2869
2869
  return normalized ? `#${normalized}` : null;
2870
2870
  }
2871
2871
  async function runAcceptTrackedChanges(doc, args) {
2872
- return runTrackedChangeDecision(doc, 'accept_tracked_changes', 'accept', args.author, args.changeType);
2872
+ return runTrackedChangeDecision(doc, 'accept_tracked_changes', 'accept', Array.isArray(args.id) ? args.id : typeof args.id === 'string' ? [args.id] : undefined, args.author, args.changeType);
2873
2873
  }
2874
2874
  async function runRejectTrackedChanges(doc, args) {
2875
- return runTrackedChangeDecision(doc, 'reject_tracked_changes', 'reject', args.author, args.changeType);
2875
+ return runTrackedChangeDecision(doc, 'reject_tracked_changes', 'reject', Array.isArray(args.id) ? args.id : typeof args.id === 'string' ? [args.id] : undefined, args.author, args.changeType);
2876
2876
  }
2877
- async function runTrackedChangeDecision(doc, intentLabel, decision, author, changeType) {
2877
+ async function runTrackedChangeDecision(doc, intentLabel, decision, exactIds, author, changeType) {
2878
2878
  const pre = await buildDocumentSnapshot(doc);
2879
2879
  try {
2880
- const listFn = maybeMethod(doc, ['trackChanges', 'list']);
2881
2880
  const decideFn = maybeMethod(doc, ['trackChanges', 'decide']);
2882
- if (!listFn || !decideFn) {
2881
+ if (!decideFn) {
2882
+ throw new SuperDocCliError('doc.trackChanges.list / decide are not available on the document handle.', {
2883
+ code: 'TOOL_DISPATCH_NOT_FOUND',
2884
+ });
2885
+ }
2886
+ // AIDEV-NOTE: Exact `id` must go to decide({ kind: 'id'|'ids' }) once.
2887
+ // Listing then filtering treats a stale id as an ok no-op, which looks
2888
+ // like success and invites a follow-up with no selector (accept/reject all).
2889
+ if (exactIds && exactIds.length > 0) {
2890
+ return decideExactTrackedChanges(doc, intentLabel, decision, decideFn, exactIds, pre);
2891
+ }
2892
+ const listFn = maybeMethod(doc, ['trackChanges', 'list']);
2893
+ if (!listFn) {
2883
2894
  throw new SuperDocCliError('doc.trackChanges.list / decide are not available on the document handle.', {
2884
2895
  code: 'TOOL_DISPATCH_NOT_FOUND',
2885
2896
  });
@@ -2960,6 +2971,53 @@ async function runTrackedChangeDecision(doc, intentLabel, decision, author, chan
2960
2971
  return failedReceipt(intentLabel, err, pre);
2961
2972
  }
2962
2973
  }
2974
+ function trackedChangeDecideFailure(result) {
2975
+ const rec = asRecord(result);
2976
+ if (!rec || rec.success !== false)
2977
+ return null;
2978
+ const failure = asRecord(rec.failure);
2979
+ return {
2980
+ code: asString(failure?.code, 'ACTION_FAILED') || 'ACTION_FAILED',
2981
+ message: asString(failure?.message, 'tracked-change decision failed') || 'tracked-change decision failed',
2982
+ };
2983
+ }
2984
+ async function decideExactTrackedChanges(doc, intentLabel, decision, decideFn, exactIds, pre) {
2985
+ const target = exactIds.length === 1 ? { kind: 'id', id: exactIds[0] } : { kind: 'ids', ids: [...exactIds] };
2986
+ const result = await decideFn({ decision, target });
2987
+ const failure = trackedChangeDecideFailure(result);
2988
+ const selectedTargets = exactIds.map((id) => ({
2989
+ selector: { kind: 'entity', entityType: 'trackedChange', entityId: id },
2990
+ matched: failure ? [] : [id],
2991
+ }));
2992
+ if (failure) {
2993
+ return {
2994
+ status: 'failed',
2995
+ intent: intentLabel,
2996
+ preSnapshot: { revision: pre.revision, counts: pre.counts },
2997
+ selectedTargets,
2998
+ executedOperations: [{ operationId: 'doc.trackChanges.decide', result }],
2999
+ verification: [],
3000
+ errors: [
3001
+ {
3002
+ code: failure.code,
3003
+ message: failure.message,
3004
+ recovery: { kind: 'reinspect' },
3005
+ },
3006
+ ],
3007
+ };
3008
+ }
3009
+ const post = await buildDocumentSnapshot(doc);
3010
+ const verification = evaluateChecks(pre, post, [{ kind: 'revision-changed' }]);
3011
+ return {
3012
+ status: verification.every((v) => v.passed) ? 'ok' : 'failed',
3013
+ intent: intentLabel,
3014
+ preSnapshot: { revision: pre.revision, counts: pre.counts },
3015
+ postSnapshot: { revision: post.revision, counts: post.counts },
3016
+ selectedTargets,
3017
+ executedOperations: [{ operationId: 'doc.trackChanges.decide', result }],
3018
+ verification,
3019
+ };
3020
+ }
2963
3021
  async function listAllTrackedChanges(listFn) {
2964
3022
  const PAGE = 250;
2965
3023
  let offset = 0;
@@ -6658,14 +6716,12 @@ async function dispatchSuperdocPerformAction(doc, args) {
6658
6716
  case 'accept_tracked_changes':
6659
6717
  return runAcceptTrackedChanges(doc, {
6660
6718
  action,
6661
- author: asString(args.author),
6662
- changeType: parseTrackedChangeKind(args.changeType),
6719
+ ...parseTrackedChangeDecisionArgs(action, args),
6663
6720
  });
6664
6721
  case 'reject_tracked_changes':
6665
6722
  return runRejectTrackedChanges(doc, {
6666
6723
  action,
6667
- author: asString(args.author),
6668
- changeType: parseTrackedChangeKind(args.changeType),
6724
+ ...parseTrackedChangeDecisionArgs(action, args),
6669
6725
  });
6670
6726
  case 'normalize_body_font_size': {
6671
6727
  const fontSize = asNumber(args.fontSize);
@@ -7035,6 +7091,62 @@ async function dispatchSuperdocPerformAction(doc, args) {
7035
7091
  }
7036
7092
  }
7037
7093
  }
7094
+ function parseTrackedChangeDecisionArgs(action, args) {
7095
+ const hasId = args.id !== undefined;
7096
+ const hasAuthor = args.author !== undefined;
7097
+ const hasChangeType = args.changeType !== undefined;
7098
+ const id = hasId ? parseExactTrackedChangeIdList(args.id) : undefined;
7099
+ const author = asString(args.author)?.trim();
7100
+ const changeType = parseTrackedChangeKind(args.changeType);
7101
+ if (hasId && !id) {
7102
+ throw new SuperDocCliError(`${action} requires "id" to be a non-empty string or array of non-empty strings`, {
7103
+ code: 'INVALID_ARGUMENT',
7104
+ });
7105
+ }
7106
+ if (hasAuthor && !author) {
7107
+ throw new SuperDocCliError(`${action} requires "author" to be a non-empty string when provided`, {
7108
+ code: 'INVALID_ARGUMENT',
7109
+ });
7110
+ }
7111
+ if (hasChangeType && !changeType) {
7112
+ throw new SuperDocCliError(`${action} requires "changeType" to be insert, delete, replacement, or format when provided`, {
7113
+ code: 'INVALID_ARGUMENT',
7114
+ });
7115
+ }
7116
+ if (id && (hasAuthor || hasChangeType)) {
7117
+ throw new SuperDocCliError(`${action} cannot combine "id" with "author" or "changeType"`, {
7118
+ code: 'INVALID_ARGUMENT',
7119
+ });
7120
+ }
7121
+ if (id)
7122
+ return { id };
7123
+ return {
7124
+ ...(author ? { author } : {}),
7125
+ ...(changeType ? { changeType } : {}),
7126
+ };
7127
+ }
7128
+ function parseExactTrackedChangeIdList(value) {
7129
+ if (typeof value === 'string') {
7130
+ const trimmed = value.trim();
7131
+ return trimmed.length > 0 ? [trimmed] : undefined;
7132
+ }
7133
+ if (!Array.isArray(value))
7134
+ return undefined;
7135
+ const ids = [];
7136
+ const seen = new Set();
7137
+ for (const entry of value) {
7138
+ if (typeof entry !== 'string')
7139
+ return undefined;
7140
+ const trimmed = entry.trim();
7141
+ if (trimmed.length === 0)
7142
+ return undefined;
7143
+ if (seen.has(trimmed))
7144
+ continue;
7145
+ seen.add(trimmed);
7146
+ ids.push(trimmed);
7147
+ }
7148
+ return ids.length > 0 ? ids : undefined;
7149
+ }
7038
7150
  export async function superdocPerformAction(doc, args) {
7039
7151
  try {
7040
7152
  return await dispatchSuperdocPerformAction(doc, args);
@@ -158,6 +158,12 @@ const ACTION_ARG_SCHEMA = {
158
158
  commentText: { type: 'string' },
159
159
  scope: { type: 'string', enum: ['all', 'body'] },
160
160
  excludeBlockQuotes: { type: 'boolean' },
161
+ id: {
162
+ oneOf: [
163
+ { type: 'string', minLength: 1 },
164
+ { type: 'array', minItems: 1, items: { type: 'string', minLength: 1 } },
165
+ ],
166
+ },
161
167
  author: { type: 'string' },
162
168
  changeType: { type: 'string', enum: ['insert', 'delete', 'replacement', 'format'] },
163
169
  fontSize: { type: 'number' },
@@ -316,7 +322,7 @@ function buildPerformActionDefinition(includedActions) {
316
322
  description: buildActionDescription(included.size === actions.ACTION_NAMES_LIST.length ? undefined : included),
317
323
  inputSchema: {
318
324
  type: 'object',
319
- additionalProperties: true,
325
+ additionalProperties: false,
320
326
  required: ['action'],
321
327
  properties: {
322
328
  action: {
@@ -155,6 +155,12 @@ export const ACTION_ARG_SCHEMA = {
155
155
  commentText: { type: 'string' },
156
156
  scope: { type: 'string', enum: ['all', 'body'] },
157
157
  excludeBlockQuotes: { type: 'boolean' },
158
+ id: {
159
+ oneOf: [
160
+ { type: 'string', minLength: 1 },
161
+ { type: 'array', minItems: 1, items: { type: 'string', minLength: 1 } },
162
+ ],
163
+ },
158
164
  author: { type: 'string' },
159
165
  changeType: { type: 'string', enum: ['insert', 'delete', 'replacement', 'format'] },
160
166
  fontSize: { type: 'number' },
@@ -313,7 +319,7 @@ export function buildPerformActionDefinition(includedActions) {
313
319
  description: buildActionDescription(included.size === ACTION_NAMES_LIST.length ? undefined : included),
314
320
  inputSchema: {
315
321
  type: 'object',
316
- additionalProperties: true,
322
+ additionalProperties: false,
317
323
  required: ['action'],
318
324
  properties: {
319
325
  action: {
@@ -141,6 +141,33 @@ function populateTableCellsFromExtract(tables, extractRaw, tableOrdinalBase = 0)
141
141
  table.cells = [...cellMap.values()].sort((left, right) => left.rowIndex - right.rowIndex || left.columnIndex - right.columnIndex);
142
142
  }
143
143
  }
144
+ function normalizeSnapshotBlock(value, fallbackOrdinal, blockTextLimit) {
145
+ const rec = asRecord(value) ?? {};
146
+ const fullText = asString(rec.text);
147
+ const text = truncateBlockText(fullText, blockTextLimit);
148
+ const textPreview = typeof rec.textPreview === 'string' ? truncateBlockText(rec.textPreview, blockTextLimit) : null;
149
+ const block = {
150
+ // doc-api block.ordinal is 0-based; the model-facing convention is
151
+ // 1-based (matches paragraphOrdinal/tableOrdinal and ordinal selectors).
152
+ ordinal: asNumber(rec.ordinal, fallbackOrdinal) + 1,
153
+ nodeId: asString(rec.nodeId),
154
+ nodeType: asString(rec.nodeType, 'paragraph'),
155
+ text,
156
+ textPreview,
157
+ styleId: typeof rec.styleId === 'string' ? rec.styleId : null,
158
+ headingLevel: typeof rec.headingLevel === 'number' ? rec.headingLevel : undefined,
159
+ ...(asRecord(rec.numbering)
160
+ ? {
161
+ numbering: {
162
+ marker: asString(asRecord(rec.numbering).marker) || null,
163
+ path: Array.isArray(asRecord(rec.numbering).path) ? asRecord(rec.numbering).path : null,
164
+ kind: asString(asRecord(rec.numbering).kind) || null,
165
+ },
166
+ }
167
+ : {}),
168
+ };
169
+ return block;
170
+ }
144
171
  /**
145
172
  * Build a deterministic snapshot of a document. The snapshot uses only
146
173
  * read-mode operations from the generated contract — it never mutates state,
@@ -259,30 +286,8 @@ async function buildDocumentSnapshot(doc, options = {}) {
259
286
  const rawBlocks = Array.isArray(blocksRec?.blocks) ? blocksRec.blocks : [];
260
287
  const fullBlockText = new Map();
261
288
  const normalizedBlocks = rawBlocks.map((b, index) => {
262
- const rec = asRecord(b) ?? {};
263
- const fullText = asString(rec.text);
264
- const text = truncateBlockText(fullText, blockTextLimit);
265
- const textPreview = typeof rec.textPreview === 'string' ? truncateBlockText(rec.textPreview, blockTextLimit) : null;
266
- const block = {
267
- // doc-api block.ordinal is 0-based; the model-facing convention is
268
- // 1-based (matches paragraphOrdinal/tableOrdinal and ordinal selectors).
269
- ordinal: asNumber(rec.ordinal, blockOffset + index) + 1,
270
- nodeId: asString(rec.nodeId),
271
- nodeType: asString(rec.nodeType, 'paragraph'),
272
- text,
273
- textPreview,
274
- styleId: typeof rec.styleId === 'string' ? rec.styleId : null,
275
- headingLevel: typeof rec.headingLevel === 'number' ? rec.headingLevel : undefined,
276
- ...(asRecord(rec.numbering)
277
- ? {
278
- numbering: {
279
- marker: asString(asRecord(rec.numbering).marker) || null,
280
- path: Array.isArray(asRecord(rec.numbering).path) ? asRecord(rec.numbering).path : null,
281
- kind: asString(asRecord(rec.numbering).kind) || null,
282
- },
283
- }
284
- : {}),
285
- };
289
+ const fullText = asString(asRecord(b)?.text);
290
+ const block = normalizeSnapshotBlock(b, blockOffset + index, blockTextLimit);
286
291
  fullBlockText.set(block, fullText);
287
292
  return block;
288
293
  });
@@ -1003,4 +1008,5 @@ exports.MutationSnapshotError = MutationSnapshotError;
1003
1008
  exports.buildDocumentSnapshot = buildDocumentSnapshot;
1004
1009
  exports.buildMutationSnapshot = buildMutationSnapshot;
1005
1010
  exports.matchRunsForBlock = matchRunsForBlock;
1011
+ exports.normalizeSnapshotBlock = normalizeSnapshotBlock;
1006
1012
  exports.resolveSnapshotSelector = resolveSnapshotSelector;
@@ -306,6 +306,7 @@ type SnapshotOptions = {
306
306
  /** Max blocks to enrich with runs when includeBlockRuns is set (default 30). */
307
307
  blockRunsLimit?: number;
308
308
  };
309
+ export declare function normalizeSnapshotBlock(value: unknown, fallbackOrdinal: number, blockTextLimit: number | null): SnapshotBlock;
309
310
  /**
310
311
  * Build a deterministic snapshot of a document. The snapshot uses only
311
312
  * read-mode operations from the generated contract — it never mutates state,
@@ -139,6 +139,33 @@ function populateTableCellsFromExtract(tables, extractRaw, tableOrdinalBase = 0)
139
139
  table.cells = [...cellMap.values()].sort((left, right) => left.rowIndex - right.rowIndex || left.columnIndex - right.columnIndex);
140
140
  }
141
141
  }
142
+ export function normalizeSnapshotBlock(value, fallbackOrdinal, blockTextLimit) {
143
+ const rec = asRecord(value) ?? {};
144
+ const fullText = asString(rec.text);
145
+ const text = truncateBlockText(fullText, blockTextLimit);
146
+ const textPreview = typeof rec.textPreview === 'string' ? truncateBlockText(rec.textPreview, blockTextLimit) : null;
147
+ const block = {
148
+ // doc-api block.ordinal is 0-based; the model-facing convention is
149
+ // 1-based (matches paragraphOrdinal/tableOrdinal and ordinal selectors).
150
+ ordinal: asNumber(rec.ordinal, fallbackOrdinal) + 1,
151
+ nodeId: asString(rec.nodeId),
152
+ nodeType: asString(rec.nodeType, 'paragraph'),
153
+ text,
154
+ textPreview,
155
+ styleId: typeof rec.styleId === 'string' ? rec.styleId : null,
156
+ headingLevel: typeof rec.headingLevel === 'number' ? rec.headingLevel : undefined,
157
+ ...(asRecord(rec.numbering)
158
+ ? {
159
+ numbering: {
160
+ marker: asString(asRecord(rec.numbering).marker) || null,
161
+ path: Array.isArray(asRecord(rec.numbering).path) ? asRecord(rec.numbering).path : null,
162
+ kind: asString(asRecord(rec.numbering).kind) || null,
163
+ },
164
+ }
165
+ : {}),
166
+ };
167
+ return block;
168
+ }
142
169
  /**
143
170
  * Build a deterministic snapshot of a document. The snapshot uses only
144
171
  * read-mode operations from the generated contract — it never mutates state,
@@ -257,30 +284,8 @@ export async function buildDocumentSnapshot(doc, options = {}) {
257
284
  const rawBlocks = Array.isArray(blocksRec?.blocks) ? blocksRec.blocks : [];
258
285
  const fullBlockText = new Map();
259
286
  const normalizedBlocks = rawBlocks.map((b, index) => {
260
- const rec = asRecord(b) ?? {};
261
- const fullText = asString(rec.text);
262
- const text = truncateBlockText(fullText, blockTextLimit);
263
- const textPreview = typeof rec.textPreview === 'string' ? truncateBlockText(rec.textPreview, blockTextLimit) : null;
264
- const block = {
265
- // doc-api block.ordinal is 0-based; the model-facing convention is
266
- // 1-based (matches paragraphOrdinal/tableOrdinal and ordinal selectors).
267
- ordinal: asNumber(rec.ordinal, blockOffset + index) + 1,
268
- nodeId: asString(rec.nodeId),
269
- nodeType: asString(rec.nodeType, 'paragraph'),
270
- text,
271
- textPreview,
272
- styleId: typeof rec.styleId === 'string' ? rec.styleId : null,
273
- headingLevel: typeof rec.headingLevel === 'number' ? rec.headingLevel : undefined,
274
- ...(asRecord(rec.numbering)
275
- ? {
276
- numbering: {
277
- marker: asString(asRecord(rec.numbering).marker) || null,
278
- path: Array.isArray(asRecord(rec.numbering).path) ? asRecord(rec.numbering).path : null,
279
- kind: asString(asRecord(rec.numbering).kind) || null,
280
- },
281
- }
282
- : {}),
283
- };
287
+ const fullText = asString(asRecord(b)?.text);
288
+ const block = normalizeSnapshotBlock(b, blockOffset + index, blockTextLimit);
284
289
  fullBlockText.set(block, fullText);
285
290
  return block;
286
291
  });