@ansonlai/docx-redline-js 0.7.0 → 0.7.2

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.
@@ -70,11 +70,12 @@ Globally unique literal edits no longer require preliminary extraction:
70
70
  docx-redline apply contract.docx --find "thirty (30) days" --replace "sixty (60) days" --profile agent --output reviewed.docx
71
71
  ```
72
72
 
73
- For a heading followed by a multi-paragraph provision, use a visible text anchor
74
- and directional paragraph scope:
73
+ When global matching is too broad, use a longer, fairly unique nearby phrase and
74
+ a directional paragraph scope. Longer anchors generally have a higher chance of
75
+ being unique:
75
76
 
76
77
  ```bash
77
- docx-redline apply contract.docx --search "Section 4.1" --context-range 1:3 --find "thirty (30) days" --replace "sixty (60) days" --profile agent --output reviewed.docx
78
+ docx-redline apply contract.docx --search "distinctive nearby heading or phrase" --context-range 1:3 --find "thirty (30) days" --replace "sixty (60) days" --profile agent --output reviewed.docx
78
79
  ```
79
80
 
80
81
  `1:3` means strictly the next three physical paragraphs, excluding the heading
@@ -84,10 +85,13 @@ document boundaries. `--around 3` is shorthand for `-3:3`.
84
85
 
85
86
  Every matching search anchor contributes a window. The library unions and
86
87
  deduplicates those windows, then requires exactly one eligible paragraph to
87
- contain the exact `find` literal. Thus a visible section number may appear both
88
- as a heading and in cross-references without the anchor itself deciding what is
89
- edited. `--occurrence` may disambiguate only inside the uniquely selected
90
- paragraph; it never chooses between paragraphs.
88
+ contain the exact `find` literal. A generic or repeated anchor can therefore
89
+ widen the scope until multiple paragraphs contain `find`, producing a safe
90
+ `AMBIGUOUS_TARGET` failure and an extra recovery turn. On success,
91
+ `results[i].change.context.anchorMatchCount > 1` means the anchor was repeated;
92
+ confirm the returned location and excerpts. It is a specificity signal, not an
93
+ automatic failure. `--occurrence` may disambiguate only inside the uniquely
94
+ selected paragraph; it never chooses between paragraphs.
91
95
 
92
96
  ### 3. Refusal Without Output Damage
93
97
 
@@ -106,43 +110,59 @@ overwritten. The source remains immutable unless `--in-place` is explicit.
106
110
 
107
111
  ### 4. Explicit Post-Mutation Change Evidence
108
112
 
109
- Successful localized operations return a compact evidence object:
113
+ Successful localized operations return a compact evidence object inside the
114
+ corresponding operation result:
110
115
 
111
116
  ```json
112
117
  {
113
- "change": {
114
- "kind": "localized_replacement",
115
- "committed": true,
116
- "finalDisposition": "applied",
117
- "target": {
118
- "paragraphId": "1A2B3C4D",
119
- "humanReference": "Section 4.1 — Termination"
120
- },
121
- "context": {
122
- "search": "Section 4.1",
123
- "range": "1:3",
124
- "anchorMatchCount": 2
125
- },
126
- "replacements": [
127
- {
128
- "find": "thirty (30) days",
129
- "replace": "sixty (60) days",
130
- "beforeExcerpt": "...thirty (30) days...",
131
- "afterExcerpt": "...sixty (60) days..."
118
+ "status": "ok",
119
+ "written": true,
120
+ "completion": true,
121
+ "results": [
122
+ {
123
+ "index": 1,
124
+ "status": "applied",
125
+ "change": {
126
+ "kind": "localized_replacement",
127
+ "committed": true,
128
+ "finalDisposition": "applied",
129
+ "target": {
130
+ "paragraphId": "1A2B3C4D",
131
+ "humanReference": "Relevant heading or text lead"
132
+ },
133
+ "context": {
134
+ "search": "distinctive nearby heading or phrase",
135
+ "range": "1:3",
136
+ "anchorMatchCount": 1
137
+ },
138
+ "replacements": [
139
+ {
140
+ "find": "thirty (30) days",
141
+ "replace": "sixty (60) days",
142
+ "beforeExcerpt": "...thirty (30) days...",
143
+ "afterExcerpt": "...sixty (60) days..."
144
+ }
145
+ ],
146
+ "verification": {
147
+ "acceptedViewMatchesCompiledText": true
148
+ }
132
149
  }
133
- ],
134
- "verification": {
135
- "acceptedViewMatchesCompiledText": true
136
150
  }
137
- }
151
+ ]
138
152
  }
139
153
  ```
140
154
 
141
- Evidence is derived from the resolved source and actual post-mutation accepted
142
- view rather than simply echoing the request. Excerpts are deterministically
143
- bounded to 120 characters. Agent completion additionally requires committed,
144
- applied evidence and positive accepted-view verification. Atomic rollback marks
145
- previously attempted evidence `committed: false` and `rolled_back`.
155
+ The excerpts come from the resolved batch-start source and compiled desired
156
+ text; the complete actual post-mutation accepted view is then compared with that
157
+ desired text. Positive verification therefore authenticates the reported after
158
+ state without returning the full paragraph. Excerpts are deterministically
159
+ bounded to 120 characters.
160
+
161
+ Report success only when `completion: true`, `written: true`, the output path is
162
+ non-null, every operation succeeded, and every localized result has
163
+ `change.committed: true`, `change.finalDisposition: "applied"`, and
164
+ `change.verification.acceptedViewMatchesCompiledText: true`. Atomic rollback
165
+ marks previously attempted evidence `committed: false` and `rolled_back`.
146
166
 
147
167
  ### 5. Agent Guidance and Development Wrapper
148
168
 
@@ -116,7 +116,7 @@
116
116
  "allOf": [ { "$ref": "#/$defs/base" }, { "type": "object", "required": ["modified"], "properties": { "type": { "enum": ["redline", "replace", "format", "list-change", "table-reconciliation", "insert"] }, "modified": { "type": "string" }, "structuredContent": { "type": "boolean" }, "targetEnd": { "$ref": "#/$defs/target" }, "targetEndRef": { "type": ["integer", "string", "null"] }, "anchor": { "$ref": "#/$defs/rejectedTextInsertionAnchor" } } } ]
117
117
  },
118
118
  {
119
- "allOf": [ { "$ref": "#/$defs/base" }, { "type": "object", "required": ["modified"], "properties": { "type": { "const": "restore" }, "modified": { "oneOf": [ { "type": "string", "minLength": 1 }, { "type": "array", "minItems": 1, "items": { "type": "string", "minLength": 1 } } ] }, "targetEnd": { "$ref": "#/$defs/target" }, "targetEndRef": { "type": ["integer", "string", "null"] } } } ]
119
+ "allOf": [ { "$ref": "#/$defs/base" }, { "type": "object", "properties": { "type": { "const": "restore" }, "modified": { "oneOf": [ { "type": "string", "minLength": 1 }, { "type": "array", "minItems": 1, "items": { "type": "string", "minLength": 1 } } ] }, "replacements": { "type": "array", "minItems": 1, "items": { "$ref": "#/$defs/localizedReplacement" } }, "targetEnd": { "$ref": "#/$defs/target" }, "targetEndRef": { "type": ["integer", "string", "null"] } }, "oneOf": [ { "required": ["modified"], "not": { "required": ["replacements"] } }, { "required": ["replacements"], "not": { "required": ["modified"] } }, { "not": { "anyOf": [ { "required": ["modified"] }, { "required": ["replacements"] } ] } } ] } ]
120
120
  },
121
121
  {
122
122
  "allOf": [ { "$ref": "#/$defs/base" }, { "type": "object", "properties": { "type": { "const": "delete" }, "modified": { "const": "" } } } ]
@@ -0,0 +1,75 @@
1
+ # Agent Surface Simplification Replay
2
+
3
+ **Date:** 2026-09-14
4
+ **Runtime:** Node.js 24.11.1 on Windows x64
5
+ **Engine:** Current post-0.7.1 source for every policy
6
+ **Command:** `npm run benchmark:agent-surface`
7
+ **Configuration:** 7 measured iterations and 1 warmup per scenario/policy
8
+
9
+ ## Scope
10
+
11
+ This repository-controlled replay compares three workflow policies without
12
+ changing the underlying engine:
13
+
14
+ 1. `v0.6.2-extract-full`: extraction followed by complete modified text.
15
+ 2. `v0.7.1-speculation-led`: speculative literal edits with extraction/apply
16
+ recovery, and extraction for semantic or batch work.
17
+ 3. `simplified-extract-localized`: extraction followed by a compact localized
18
+ operation against `paragraphId` plus `fingerprint`.
19
+
20
+ The six wholly synthetic scenarios cover a long literal edit, semantic drafting,
21
+ repeated visible text, rejected-view restoration, an NBSP-bearing target, and an
22
+ independent two-target batch. Every output is re-extracted and compared with its
23
+ predetermined accepted-view text. Benchmark-only verification calls are not
24
+ counted as workflow calls.
25
+
26
+ ## Aggregate Results
27
+
28
+ | Policy | Correct runs | Total calls | Calls/run | Failed calls | Edit request bytes | Median local ms |
29
+ |---|---:|---:|---:|---:|---:|---:|
30
+ | v0.6.2 extract/full | 42/42 | 84 | 2.00 | 0 | 27,034 | 3.86 |
31
+ | v0.7.1 speculation-led | 42/42 | 77 | 1.83 | 7 | 8,988 | 3.86 |
32
+ | simplified extract/localized | 42/42 | 84 | 2.00 | 0 | 10,661 | 3.77 |
33
+
34
+ The simplified policy reduced generated edit-request bytes by 60.6% relative to
35
+ the complete-paragraph baseline while preserving a fixed two-call workflow and
36
+ zero failed calls. On the long-paragraph case, its median edit request was 193
37
+ bytes rather than 2,104 bytes, a 90.8% reduction.
38
+
39
+ The speculation-led policy saved seven total calls because two unique literal
40
+ scenarios completed in one call. The repeated-text scenario cost three calls and
41
+ one failure on every iteration. This demonstrates both the legitimate one-turn
42
+ benefit and the recovery cost that the simplified default is intended to avoid.
43
+
44
+ The localized form is not always smaller for a short paragraph. In the NBSP
45
+ scenario it used 344 bytes versus 343 bytes for complete modified text. The
46
+ benefit is strongest when the inspected source is long relative to the changed
47
+ span.
48
+
49
+ ## Interpretation
50
+
51
+ The repository evidence supports the surface decision:
52
+
53
+ - keep speculative apply as an available expert/harness capability;
54
+ - do not make ordinary agents classify whether to use it;
55
+ - lead with deterministic extraction and one apply;
56
+ - retain localized replacements after inspection to avoid long output payloads;
57
+ - use compact `paragraphId` plus `fingerprint` targets for localized requests,
58
+ while retaining extracted text locally for drafting; and
59
+ - keep restoration as a request/hint-driven branch.
60
+
61
+ Local execution time is effectively neutral at this scale and remains far below
62
+ agent deliberation or provider latency. No performance claim should be based on
63
+ the small local timing differences in this report.
64
+
65
+ ## Limitations
66
+
67
+ This is a deterministic workflow replay, not a model evaluation. It cannot
68
+ measure reasoning tokens, provider tokenization, transport latency, stochastic
69
+ instruction following, or subjective drafting quality. Repeated external agent
70
+ runs using the same model, prompt, document, and engine are still required before
71
+ claiming end-to-end latency improvement.
72
+
73
+ The machine-readable output is written to
74
+ `tmp/benchmarks/agent-surface-simplification-latest.json` and is intentionally
75
+ not committed.
package/node/cli-help.js CHANGED
@@ -12,7 +12,7 @@ const COMPACT = option('compact', '--compact', 'Emit one-line JSON to reduce pro
12
12
  const INSPECTION_OPTIONS = Object.freeze([
13
13
  HELP,
14
14
  option('search', '--search <text>', 'Case-insensitive substring search.'),
15
- option('revised', '--revised', 'Select paragraphs containing tracked revisions.'),
15
+ option('revised', '--revised', 'Select paragraphs containing tracked revisions; extract identifies authors and fully hidden deleted/inserted paragraphs.'),
16
16
  option('table', '--table', 'Select paragraphs inside tables.'),
17
17
  option('body', '--body', 'Select paragraphs outside tables.'),
18
18
  option('nonEmpty', '--non-empty', 'Exclude empty paragraphs; this is not a narrow document scope.'),
@@ -56,15 +56,14 @@ const APPLY_EXAMPLES = Object.freeze([
56
56
  }
57
57
  },
58
58
  {
59
- description: 'Counterpropose a wholly foreign-deleted paragraph found through --view rejected.',
59
+ description: 'Restore one wholly foreign-deleted paragraph verbatim from a strong rejected-view target.',
60
60
  operation: {
61
61
  type: 'restore',
62
62
  target: {
63
63
  exactText: 'Deleted source paragraph.',
64
64
  paragraphId: '3A2B3C4D',
65
65
  revisionView: 'rejected'
66
- },
67
- modified: 'Restored and revised paragraph.'
66
+ }
68
67
  }
69
68
  }
70
69
  ]);
@@ -133,11 +132,12 @@ export const CLI_COMMAND_HELP = Object.freeze({
133
132
  option('expectedRevision', '--expected-revision <token|json>', 'Reject a stale package revision.'),
134
133
  option('target', '--target <text>', 'Inline one-operation target text.'),
135
134
  option('targetId', '--target-id <paragraphId>', 'Strong inline target using a paragraphId from fresh inspection.'),
135
+ option('restore', '--restore', 'Restore one rejected-view paragraph identified by --target-id; omit --modified for verbatim restoration.'),
136
136
  option('modified', '--modified <text>', 'Complete desired accepted-view target content.'),
137
- option('find', '--find <exact text>', 'Exact case-sensitive source span for one localized replacement.'),
137
+ option('find', '--find <exact text>', 'Case-sensitive source span; exact match first, then ASCII-space/NBSP equivalence.'),
138
138
  option('replace', '--replace <text>', 'Replacement text for --find; an empty string deletes the span.'),
139
139
  option('occurrence', '--occurrence <N>', 'Positive 1-based --find occurrence within the uniquely resolved target paragraph.'),
140
- option('search', '--search <text>', 'Case-insensitive context anchor for speculative find/replace; all matching anchors are considered.'),
140
+ option('search', '--search <text>', 'Case-insensitive context anchor for speculative find/replace; prefer a longer, fairly unique phrase because all matches contribute scope.'),
141
141
  option('contextRange', '--context-range <START:END>', 'Signed inclusive paragraph offsets (-20..20) from each --search anchor; 1:3 means strictly the next three paragraphs.'),
142
142
  option('around', '--around <N>', 'With --search, shorthand for symmetric --context-range -N:N (0-20).'),
143
143
  option('comment', '--comment <text>', 'Create an inline comment operation.'),
@@ -154,7 +154,9 @@ export const CLI_COMMAND_HELP = Object.freeze({
154
154
  notes: [
155
155
  'modified is complete desired accepted-view content, not only inserted words.',
156
156
  'Use either modified or find/replace. A strong accepted-view target is preferred when already known.',
157
+ '--restore requires --target-id from rejected-view extraction; omit modified for verbatim restore or use one inline find/replace.',
157
158
  'Without a strong target, find/replace may resolve globally or within --search plus a directional context range; it fails unless exactly one paragraph is eligible.',
159
+ 'Generic or repeated search anchors widen the unioned scope and may cause AMBIGUOUS_TARGET; on success, anchorMatchCount greater than 1 signals that the returned location and excerpts should be confirmed.',
158
160
  '--occurrence disambiguates repeated text only within one uniquely selected paragraph, never between paragraphs.',
159
161
  'Inline find/replace accepts one replacement; use operations JSON for multiple simultaneous replacements.',
160
162
  'Use a structured JSON file or serializer-backed stdin; never interpolate legal text through raw shell quoting.',
package/node/cli.js CHANGED
@@ -7,6 +7,7 @@ import { validateRedlineOoxml } from '../core/redline-validation.js';
7
7
  import { configureLogger } from '../adapters/logger.js';
8
8
  import { isExistingRevisionsPolicy } from '../services/document-operation-contract.js';
9
9
  import { normalizeErrorWithRecovery } from '../services/error-recovery.js';
10
+ import { localizedReplacementOffsets } from '../services/localized-replacement-compiler.js';
10
11
  import { buildCliHelp, CLI_COMMANDS, commandOptionKeys } from './cli-help.js';
11
12
 
12
13
  const suffixes = { apply: 'redlined', accept: 'accepted', reject: 'rejected', 'delete-comments': 'comments-removed' };
@@ -31,6 +32,8 @@ const CLI_CAPABILITIES = [
31
32
  'localized-replacements-v1',
32
33
  'speculative-search-apply-v1',
33
34
  'localized-change-summary-v1',
35
+ 'localized-space-equivalence-v1',
36
+ 'restore-shortcuts-v1',
34
37
  'deduplicated-cli-receipts',
35
38
  'compact-cli-json-v1'
36
39
  ];
@@ -241,21 +244,27 @@ function resolveSpeculativePatchTarget(document, flags) {
241
244
  }
242
245
 
243
246
  const find = String(flags.find);
244
- const matches = eligible.filter(paragraph => paragraph.exactText.includes(find));
247
+ const patchCandidates = eligible.map(paragraph => ({
248
+ paragraph,
249
+ match: localizedReplacementOffsets(paragraph.exactText, find)
250
+ })).filter(candidate => candidate.match.offsets.length > 0);
251
+ const exactCandidates = patchCandidates.filter(candidate => candidate.match.matchMode === 'exact');
252
+ const matches = (exactCandidates.length > 0 ? exactCandidates : patchCandidates)
253
+ .map(candidate => candidate.paragraph);
245
254
  const context = {
246
255
  ...(search ? { search, range: range.text, anchorMatchCount: anchors.length } : {})
247
256
  };
248
257
  if (matches.length === 0) {
249
258
  throw speculativeError('PATCH_SOURCE_NOT_FOUND', search
250
- ? `No paragraph in the contextual range contains the exact patch source: "${find}".`
251
- : `No paragraph contains the exact patch source: "${find}".`, {
259
+ ? `No paragraph in the contextual range contains the patch source: "${find}".`
260
+ : `No paragraph contains the patch source: "${find}".`, {
252
261
  context,
253
262
  ...(anchors.length ? { candidates: anchors.map(speculativeCandidate) } : {}),
254
263
  recovery: { action: 'reinspect', requiresReinspection: true, sameArgumentsSafe: false }
255
264
  });
256
265
  }
257
266
  if (matches.length > 1) {
258
- throw speculativeError('AMBIGUOUS_TARGET', `The exact patch source matched ${matches.length} eligible paragraphs.`, {
267
+ throw speculativeError('AMBIGUOUS_TARGET', `The patch source matched ${matches.length} eligible paragraphs.`, {
259
268
  context,
260
269
  candidates: matches.map(speculativeCandidate),
261
270
  recovery: { action: 'choose_candidate', requiresReinspection: false, sameArgumentsSafe: false }
@@ -278,6 +287,7 @@ function resolveSpeculativePatchTarget(document, flags) {
278
287
  }
279
288
 
280
289
  async function readOperations(file, flags = {}, stdin = process.stdin, document = null) {
290
+ const inlineRestore = flags?.restore !== undefined;
281
291
  const hasInlineTarget = flags?.target !== undefined
282
292
  || flags?.targetId !== undefined
283
293
  || flags?.targetRef !== undefined;
@@ -287,8 +297,8 @@ async function readOperations(file, flags = {}, stdin = process.stdin, document
287
297
  const hasSpeculativeScope = flags?.search !== undefined
288
298
  || flags?.contextRange !== undefined
289
299
  || flags?.around !== undefined;
290
- if (file && (hasInlinePatch || hasSpeculativeScope)) {
291
- throw Object.assign(new Error('Use --operations or inline speculative find/replace options, not both.'), {
300
+ if (file && (hasInlinePatch || hasSpeculativeScope || inlineRestore)) {
301
+ throw Object.assign(new Error('Use --operations or inline restore/find/replace options, not both.'), {
292
302
  code: 'INVALID_OPERATION'
293
303
  });
294
304
  }
@@ -297,7 +307,12 @@ async function readOperations(file, flags = {}, stdin = process.stdin, document
297
307
  code: 'INVALID_OPERATION'
298
308
  });
299
309
  }
300
- if (!file && (hasInlineTarget || hasInlinePatch || hasSpeculativeScope)) {
310
+ if (!file && (hasInlineTarget || hasInlinePatch || hasSpeculativeScope || inlineRestore)) {
311
+ if (inlineRestore && flags.restore !== true) {
312
+ throw Object.assign(new Error('--restore is a boolean flag and does not take a value.'), {
313
+ code: 'INVALID_OPERATION'
314
+ });
315
+ }
301
316
  if (hasSpeculativeScope && !hasInlinePatch) {
302
317
  throw Object.assign(new Error('--search, --context-range, and --around are only valid with inline --find/--replace.'), {
303
318
  code: 'INVALID_OPERATION'
@@ -319,9 +334,22 @@ async function readOperations(file, flags = {}, stdin = process.stdin, document
319
334
  let target = targetId
320
335
  ? { ...(targetText == null ? {} : { exactText: targetText }), paragraphId: targetId }
321
336
  : targetText;
337
+ if (inlineRestore) {
338
+ if (!targetId) {
339
+ throw Object.assign(new Error('Inline --restore requires --target-id from rejected-view extraction.'), {
340
+ code: 'INVALID_OPERATION'
341
+ });
342
+ }
343
+ target = { ...target, revisionView: 'rejected' };
344
+ }
322
345
  let speculativeContext = null;
323
346
  let op;
324
347
  if (flags.comment) {
348
+ if (inlineRestore) {
349
+ throw Object.assign(new Error('Use --restore or --comment, not both.'), {
350
+ code: 'INVALID_OPERATION'
351
+ });
352
+ }
325
353
  if (hasInlinePatch) {
326
354
  throw Object.assign(new Error('Inline comments cannot be combined with --find/--replace.'), {
327
355
  code: 'INVALID_OPERATION'
@@ -364,7 +392,7 @@ async function readOperations(file, flags = {}, stdin = process.stdin, document
364
392
  speculativeContext = resolution.context;
365
393
  }
366
394
  op = {
367
- type: 'redline',
395
+ type: inlineRestore ? 'restore' : 'redline',
368
396
  ...(target == null ? {} : { target }),
369
397
  ...(targetRef == null ? {} : { targetRef }),
370
398
  replacements: [{
@@ -376,6 +404,14 @@ async function readOperations(file, flags = {}, stdin = process.stdin, document
376
404
  ...(flags.author ? { author: String(flags.author) } : {}),
377
405
  ...(flags.existingRevisions ? { existingRevisions: String(flags.existingRevisions) } : {})
378
406
  };
407
+ } else if (inlineRestore) {
408
+ op = {
409
+ type: 'restore',
410
+ target,
411
+ ...(flags.modified === undefined ? {} : { modified: String(flags.modified) }),
412
+ ...(flags.author ? { author: String(flags.author) } : {}),
413
+ ...(flags.existingRevisions ? { existingRevisions: String(flags.existingRevisions) } : {})
414
+ };
379
415
  } else {
380
416
  op = {
381
417
  type: 'replace',
@@ -430,8 +466,14 @@ function compactExtractParagraph(paragraph) {
430
466
  const {
431
467
  humanReference, provision, nearestHeading, index, ref, paragraphId,
432
468
  fingerprint, revisionView, exactText, inTable, list, selectionRole,
433
- contextFor
469
+ contextFor, hasRevisions, revisionAuthors, segments
434
470
  } = paragraph;
471
+ const hiddenRevisionContent = exactText.length === 0 && Array.isArray(segments)
472
+ && segments.some(segment => segment.text.length > 0 && (
473
+ revisionView === 'rejected'
474
+ ? segment.acceptedStart !== null
475
+ : segment.rejectedStart !== null
476
+ ));
435
477
  return {
436
478
  humanReference,
437
479
  provision,
@@ -444,6 +486,12 @@ function compactExtractParagraph(paragraph) {
444
486
  exactText,
445
487
  inTable,
446
488
  list,
489
+ ...(hasRevisions ? {
490
+ hasRevisions: true,
491
+ revisionAuthors,
492
+ ...(hiddenRevisionContent && revisionView === 'accepted' ? { deleted: true } : {}),
493
+ ...(hiddenRevisionContent && revisionView === 'rejected' ? { inserted: true } : {})
494
+ } : {}),
447
495
  ...(selectionRole ? { selectionRole } : {}),
448
496
  ...(Array.isArray(contextFor) ? { contextFor } : {})
449
497
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ansonlai/docx-redline-js",
3
- "version": "0.7.0",
3
+ "version": "0.7.2",
4
4
  "description": "Host-independent OOXML reconciliation engine for .docx manipulation with track changes",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -47,6 +47,7 @@
47
47
  "!scripts/benchmark-agent-workflow.mjs",
48
48
  "!scripts/benchmark-agent-cli-discovery.mjs",
49
49
  "!scripts/benchmark-localized-turn-reduction.mjs",
50
+ "!scripts/benchmark-agent-surface-simplification.mjs",
50
51
  "!scripts/lib/agent-performance-cases.mjs",
51
52
  "docs/AGENT_FAST_START.md",
52
53
  "docs/AGENT_KNOWLEDGE_BASE.md",
@@ -58,6 +59,7 @@
58
59
  "docs/validation-reports/2026-09-12-agent-cli-discovery-baseline.md",
59
60
  "docs/validation-reports/2026-09-13-agent-cli-efficiency-rollout.md",
60
61
  "docs/validation-reports/2026-09-14-localized-patching-rollout.md",
62
+ "docs/validation-reports/2026-09-14-agent-surface-simplification.md",
61
63
  "index.js",
62
64
  "index.d.ts",
63
65
  "dist/",
@@ -97,6 +99,7 @@
97
99
  "benchmark:agent": "node scripts/benchmark-agent-workflow.mjs",
98
100
  "benchmark:agent-cli": "node scripts/benchmark-agent-cli-discovery.mjs",
99
101
  "benchmark:localized": "node scripts/benchmark-localized-turn-reduction.mjs",
102
+ "benchmark:agent-surface": "node scripts/benchmark-agent-surface-simplification.mjs",
100
103
  "benchmark:targeting": "node scripts/benchmark-targeting-hot-paths.mjs",
101
104
  "benchmark:tests": "node scripts/benchmark-test-runner.mjs",
102
105
  "profile:routes": "node scripts/profile-route-selection.mjs",
@@ -324,9 +324,8 @@ export function inspectDocumentParts(parts, options = {}) {
324
324
  if (options.range && (index < rangeStart || index > rangeEnd)) continue;
325
325
  if (Array.isArray(options.indexes) && !options.indexes.includes(index)) continue;
326
326
  const pNode = paragraphNodes[i];
327
- if (options.table && !hasAncestor(pNode, 'tc')) continue;
328
- if (options.body && hasAncestor(pNode, 'tc')) continue;
329
- if (options.revised && revisionAuthors(pNode).length === 0) continue;
327
+ if (options.inTable != null && hasAncestor(pNode, 'tc') !== !!options.inTable) continue;
328
+ if (options.revisedOnly && revisionAuthors(pNode).length === 0) continue;
330
329
  const altText = extractCanonicalParagraphText(pNode, { revisionView: alternateView });
331
330
  if (options.skipEmpty && altText.length === 0) continue;
332
331
  if (altText.toLowerCase().includes(needle)) {
@@ -160,7 +160,10 @@ export async function applyOperationToDocumentXml(documentXml, op, author, runti
160
160
  };
161
161
  }
162
162
 
163
- if (Array.isArray(operation.replacements)) {
163
+ if (
164
+ Array.isArray(operation.replacements)
165
+ || (operation.operationKind === 'restore' && op?.modified === undefined)
166
+ ) {
164
167
  const compilation = compileOperationBatch(session.document, [op], {
165
168
  strictTargets: options.strictTargets !== false,
166
169
  onInfo: options.onInfo,
@@ -415,7 +418,14 @@ export async function applyOperationToDocumentXml(documentXml, op, author, runti
415
418
  }
416
419
  if (operation._localizedReplacementCompilation) {
417
420
  let outputText = null;
418
- if (operation._compiledSourceId && session.sourceTargetRegistry) {
421
+ if (operation.operationKind === 'restore') {
422
+ const restoredParagraph = operationOptions._mutationLiveNodes.find(node => node?.localName === 'p');
423
+ if (restoredParagraph) {
424
+ outputText = extractCanonicalParagraphText(restoredParagraph, {
425
+ revisionView: 'accepted'
426
+ });
427
+ }
428
+ } else if (operation._compiledSourceId && session.sourceTargetRegistry) {
419
429
  const bound = session.sourceTargetRegistry.resolve(
420
430
  operation._compiledSourceId,
421
431
  session.document
@@ -307,19 +307,66 @@ export function validateDocumentOperation(operation) {
307
307
  }
308
308
 
309
309
  if (normalized.operationKind === 'restore') {
310
+ const hasModified = operation.modified !== undefined;
311
+ const hasReplacements = operation.replacements !== undefined;
310
312
  const validSingle = nonEmptyString(normalized.modified);
311
313
  const validRange = Array.isArray(normalized.modified)
312
314
  && normalized.modified.length > 0
313
315
  && normalized.modified.every(nonEmptyString);
314
- if (!validSingle && !validRange) {
316
+ if (hasModified && hasReplacements) {
315
317
  return {
316
318
  valid: false,
317
319
  error: {
318
320
  code: 'INVALID_OPERATION',
319
- message: 'Restore operations require a non-empty string or non-empty string array in "modified".'
321
+ message: 'Restore operations accept modified or replacements, not both.'
320
322
  }
321
323
  };
322
324
  }
325
+ if (hasModified && !validSingle && !validRange) {
326
+ return {
327
+ valid: false,
328
+ error: {
329
+ code: 'INVALID_OPERATION',
330
+ message: 'Restore modified must be a non-empty string or non-empty string array.'
331
+ }
332
+ };
333
+ }
334
+ if (!hasModified) {
335
+ if (
336
+ target.revisionView !== 'rejected'
337
+ || (!target.paragraphId && !target.fingerprint)
338
+ || target.captureRef
339
+ || target.occurrence != null
340
+ || normalized.captureKey
341
+ || normalized.targetEndDescriptor
342
+ || normalized.targetEndRef != null
343
+ || /\r|\n/.test(target.text || '')
344
+ ) {
345
+ return {
346
+ valid: false,
347
+ error: {
348
+ code: 'INVALID_OPERATION',
349
+ message: 'Verbatim and localized restore require one strong batch-start rejected-view paragraph target without captures, target occurrence, or a target range.'
350
+ }
351
+ };
352
+ }
353
+ }
354
+ if (hasReplacements) {
355
+ const replacementValidation = validateExactReplacementRequests(normalized.replacements);
356
+ if (!replacementValidation.ok) {
357
+ return { valid: false, error: replacementValidation.error };
358
+ }
359
+ if (replacementValidation.replacements.some(replacement => /\r|\n/.test(replacement.replace))) {
360
+ return {
361
+ valid: false,
362
+ error: {
363
+ code: 'INVALID_OPERATION',
364
+ message: 'Localized restore cannot insert paragraph breaks.'
365
+ }
366
+ };
367
+ }
368
+ normalized.replacements = replacementValidation.replacements;
369
+ }
323
370
  if (normalized.generateRedlines === false) {
324
371
  return {
325
372
  valid: false,