@molecule/api-ai-tools 1.2.1 → 1.3.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
@@ -3,7 +3,7 @@ AUTO-GENERATED — DO NOT EDIT THIS FILE.
3
3
  Generated by `mlcl sync-docs` from the package's src/index.ts JSDoc + mlcl/registry.json.
4
4
  Edits here are overwritten on the next commit (molecule's pre-commit hook regenerates).
5
5
  To change this document, edit the module-level JSDoc in src/index.ts.
6
- Generated: 2026-09-22T21:57:22.227Z
6
+ Generated: 2026-09-24T01:21:52.537Z
7
7
  -->
8
8
 
9
9
  # @molecule/api-ai-tools
@@ -957,6 +957,15 @@ Peer dependencies:
957
957
  (`MAX_BATCH_READ_FILES` / `MAX_BATCH_READ_BYTES`, which report where they
958
958
  stopped). Each entry carries its own content or its own error, so one bad
959
959
  path never fails the batch.
960
+ - **`edit_file` shows the result, so the model need not re-read.** A
961
+ successful edit returns `excerpt`: each edited region as it now reads, with
962
+ 1-based line numbers (`N: text`) and a few lines of context, capped at
963
+ 60 lines in total (what did not fit is named by line range),
964
+ plus `totalLines`. `write_file` returns `totalLines`, and `excerpt` only
965
+ for a small file (up to 40 lines) — a big file is never echoed back.
966
+ Line numbers count the way `read_file` does, so they are valid offsets. The
967
+ excerpt is redacted like a read. The existing fields (`ok`,
968
+ `replacementsApplied`, `alreadyApplied`, `syntaxError`) are unchanged.
960
969
  - **A parameter under another name is not a broken tool.** Every tool's input
961
970
  is normalized first (`cmd` → `command`, `file` → `path`, camelCase ⇄
962
971
  snake_case), and a handler that throws returns an error naming the missing
@@ -0,0 +1,77 @@
1
+ /**
2
+ * What `edit_file` / `write_file` hand back so the model can see the result of
3
+ * a change without reading the file again.
4
+ *
5
+ * Measured on X0 benchmark runs: 7–31 `read_file` calls per build re-read a
6
+ * file the executor had just written or edited — to check the result, or to
7
+ * find line numbers for the next edit. Each is a full model round-trip. The
8
+ * edited region, numbered, answers both questions in the edit's own result.
9
+ *
10
+ * @module
11
+ */
12
+ /** Lines of unchanged context shown above and below each edited region. */
13
+ export declare const EDIT_EXCERPT_CONTEXT_LINES = 3;
14
+ /** Most lines an edit excerpt shows in total, across all regions. */
15
+ export declare const EDIT_EXCERPT_MAX_LINES = 60;
16
+ /** Longest single line an excerpt shows before cutting it (minified files). */
17
+ export declare const EDIT_EXCERPT_MAX_LINE_CHARS = 240;
18
+ /** A `write_file` result echoes the file back only when it has at most this many lines… */
19
+ export declare const WRITE_ECHO_MAX_LINES = 40;
20
+ /** …and at most this many characters. */
21
+ export declare const WRITE_ECHO_MAX_CHARS = 3000;
22
+ /** A half-open character range `[start, end)` in a string. */
23
+ export interface CharSpan {
24
+ /** First changed character. */
25
+ start: number;
26
+ /** One past the last changed character (equal to `start` for a pure deletion). */
27
+ end: number;
28
+ }
29
+ /**
30
+ * Number of lines in a file, counted the way `read_file` counts them
31
+ * (`split('\n').length`), so a line number from an excerpt is a valid
32
+ * `read_file` offset. An empty file has 0 lines.
33
+ *
34
+ * @param content - The file text.
35
+ * @returns The line count.
36
+ */
37
+ export declare function countLines(content: string): number;
38
+ /**
39
+ * The region of `after` that differs from `before`, found by trimming the
40
+ * common prefix and suffix. Exact for a single replacement, whatever matched it
41
+ * (verbatim or whitespace-tolerant), which is how `edit_file` uses it.
42
+ *
43
+ * @param before - Text before the change.
44
+ * @param after - Text after the change.
45
+ * @returns The changed span in `after`, or null when the texts are equal.
46
+ */
47
+ export declare function changedSpan(before: string, after: string): CharSpan | null;
48
+ /**
49
+ * Fold one more change into the spans already recorded, keeping every span
50
+ * expressed in the coordinates of the NEWEST text. Spans after the change shift
51
+ * by its length delta; spans it overlaps merge into it.
52
+ *
53
+ * @param spans - Spans recorded so far, in `before` coordinates.
54
+ * @param before - Text before this change.
55
+ * @param after - Text after this change.
56
+ * @returns The spans in `after` coordinates, sorted by start.
57
+ */
58
+ export declare function addChangeSpan(spans: CharSpan[], before: string, after: string): CharSpan[];
59
+ /**
60
+ * The edited regions of a file as they now read — numbered, with a few lines of
61
+ * context, capped at {@link EDIT_EXCERPT_MAX_LINES} in total. What does not fit
62
+ * is named by line range so the model can `read_file` exactly that window.
63
+ *
64
+ * @param content - The file's text after the edit.
65
+ * @param spans - Changed character spans in `content` (from {@link addChangeSpan}).
66
+ * @returns The excerpt text, or an empty string when there is nothing to show.
67
+ */
68
+ export declare function renderEditExcerpt(content: string, spans: CharSpan[]): string;
69
+ /**
70
+ * A small file, numbered, for a `write_file` result — or null when the file is
71
+ * too big to echo (the model has what it just wrote; only line numbers are new).
72
+ *
73
+ * @param content - The file's text as written.
74
+ * @returns The numbered text, or null when the file is over the echo limits.
75
+ */
76
+ export declare function renderSmallFile(content: string): string | null;
77
+ //# sourceMappingURL=edit-excerpt.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"edit-excerpt.d.ts","sourceRoot":"","sources":["../src/edit-excerpt.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;GAUG;AAEH,2EAA2E;AAC3E,eAAO,MAAM,0BAA0B,IAAI,CAAA;AAE3C,qEAAqE;AACrE,eAAO,MAAM,sBAAsB,KAAK,CAAA;AAExC,+EAA+E;AAC/E,eAAO,MAAM,2BAA2B,MAAM,CAAA;AAE9C,2FAA2F;AAC3F,eAAO,MAAM,oBAAoB,KAAK,CAAA;AAEtC,yCAAyC;AACzC,eAAO,MAAM,oBAAoB,OAAO,CAAA;AAExC,8DAA8D;AAC9D,MAAM,WAAW,QAAQ;IACvB,+BAA+B;IAC/B,KAAK,EAAE,MAAM,CAAA;IACb,kFAAkF;IAClF,GAAG,EAAE,MAAM,CAAA;CACZ;AAED;;;;;;;GAOG;AACH,wBAAgB,UAAU,CAAC,OAAO,EAAE,MAAM,GAAG,MAAM,CAElD;AAED;;;;;;;;GAQG;AACH,wBAAgB,WAAW,CAAC,MAAM,EAAE,MAAM,EAAE,KAAK,EAAE,MAAM,GAAG,QAAQ,GAAG,IAAI,CAY1E;AAED;;;;;;;;;GASG;AACH,wBAAgB,aAAa,CAAC,KAAK,EAAE,QAAQ,EAAE,EAAE,MAAM,EAAE,MAAM,EAAE,KAAK,EAAE,MAAM,GAAG,QAAQ,EAAE,CAmB1F;AA2CD;;;;;;;;GAQG;AACH,wBAAgB,iBAAiB,CAAC,OAAO,EAAE,MAAM,EAAE,KAAK,EAAE,QAAQ,EAAE,GAAG,MAAM,CA+C5E;AAED;;;;;;GAMG;AACH,wBAAgB,eAAe,CAAC,OAAO,EAAE,MAAM,GAAG,MAAM,GAAG,IAAI,CAK9D"}
@@ -0,0 +1,199 @@
1
+ /**
2
+ * What `edit_file` / `write_file` hand back so the model can see the result of
3
+ * a change without reading the file again.
4
+ *
5
+ * Measured on X0 benchmark runs: 7–31 `read_file` calls per build re-read a
6
+ * file the executor had just written or edited — to check the result, or to
7
+ * find line numbers for the next edit. Each is a full model round-trip. The
8
+ * edited region, numbered, answers both questions in the edit's own result.
9
+ *
10
+ * @module
11
+ */
12
+ /** Lines of unchanged context shown above and below each edited region. */
13
+ export const EDIT_EXCERPT_CONTEXT_LINES = 3;
14
+ /** Most lines an edit excerpt shows in total, across all regions. */
15
+ export const EDIT_EXCERPT_MAX_LINES = 60;
16
+ /** Longest single line an excerpt shows before cutting it (minified files). */
17
+ export const EDIT_EXCERPT_MAX_LINE_CHARS = 240;
18
+ /** A `write_file` result echoes the file back only when it has at most this many lines… */
19
+ export const WRITE_ECHO_MAX_LINES = 40;
20
+ /** …and at most this many characters. */
21
+ export const WRITE_ECHO_MAX_CHARS = 3000;
22
+ /**
23
+ * Number of lines in a file, counted the way `read_file` counts them
24
+ * (`split('\n').length`), so a line number from an excerpt is a valid
25
+ * `read_file` offset. An empty file has 0 lines.
26
+ *
27
+ * @param content - The file text.
28
+ * @returns The line count.
29
+ */
30
+ export function countLines(content) {
31
+ return content === '' ? 0 : content.split('\n').length;
32
+ }
33
+ /**
34
+ * The region of `after` that differs from `before`, found by trimming the
35
+ * common prefix and suffix. Exact for a single replacement, whatever matched it
36
+ * (verbatim or whitespace-tolerant), which is how `edit_file` uses it.
37
+ *
38
+ * @param before - Text before the change.
39
+ * @param after - Text after the change.
40
+ * @returns The changed span in `after`, or null when the texts are equal.
41
+ */
42
+ export function changedSpan(before, after) {
43
+ if (before === after)
44
+ return null;
45
+ const max = Math.min(before.length, after.length);
46
+ let prefix = 0;
47
+ while (prefix < max && before.charCodeAt(prefix) === after.charCodeAt(prefix))
48
+ prefix++;
49
+ let suffix = 0;
50
+ while (suffix < max - prefix &&
51
+ before.charCodeAt(before.length - 1 - suffix) === after.charCodeAt(after.length - 1 - suffix))
52
+ suffix++;
53
+ return { start: prefix, end: after.length - suffix };
54
+ }
55
+ /**
56
+ * Fold one more change into the spans already recorded, keeping every span
57
+ * expressed in the coordinates of the NEWEST text. Spans after the change shift
58
+ * by its length delta; spans it overlaps merge into it.
59
+ *
60
+ * @param spans - Spans recorded so far, in `before` coordinates.
61
+ * @param before - Text before this change.
62
+ * @param after - Text after this change.
63
+ * @returns The spans in `after` coordinates, sorted by start.
64
+ */
65
+ export function addChangeSpan(spans, before, after) {
66
+ const change = changedSpan(before, after);
67
+ if (!change)
68
+ return spans;
69
+ // The change's extent in BEFORE coordinates: same start, and the same suffix.
70
+ const beforeEnd = before.length - (after.length - change.end);
71
+ const delta = after.length - before.length;
72
+ let merged = { ...change };
73
+ const out = [];
74
+ for (const span of spans) {
75
+ if (span.end < change.start)
76
+ out.push(span);
77
+ else if (span.start > beforeEnd)
78
+ out.push({ start: span.start + delta, end: span.end + delta });
79
+ else
80
+ merged = {
81
+ start: Math.min(merged.start, span.start),
82
+ end: Math.max(merged.end, span.end > beforeEnd ? span.end + delta : change.end),
83
+ };
84
+ }
85
+ out.push(merged);
86
+ return out.sort((a, b) => a.start - b.start);
87
+ }
88
+ /**
89
+ * The 1-based line a character offset falls on.
90
+ *
91
+ * @param lineStarts - Offset of the first character of each line.
92
+ * @param offset - A character offset into the text.
93
+ * @returns The line number, 1-based.
94
+ */
95
+ function lineAt(lineStarts, offset) {
96
+ let lo = 0;
97
+ let hi = lineStarts.length - 1;
98
+ while (lo < hi) {
99
+ const mid = (lo + hi + 1) >> 1;
100
+ if (lineStarts[mid] <= offset)
101
+ lo = mid;
102
+ else
103
+ hi = mid - 1;
104
+ }
105
+ return lo + 1;
106
+ }
107
+ /**
108
+ * Render numbered lines `from..to` (1-based, inclusive), cutting very long lines.
109
+ *
110
+ * @param lines - The file's lines.
111
+ * @param from - First line to render.
112
+ * @param to - Last line to render.
113
+ * @returns One `N: text` row per line.
114
+ */
115
+ function numbered(lines, from, to) {
116
+ const rows = [];
117
+ for (let n = from; n <= to; n++) {
118
+ const text = lines[n - 1] ?? '';
119
+ rows.push(`${n}: ${text.length > EDIT_EXCERPT_MAX_LINE_CHARS
120
+ ? `${text.slice(0, EDIT_EXCERPT_MAX_LINE_CHARS)}… (+${text.length - EDIT_EXCERPT_MAX_LINE_CHARS} chars)`
121
+ : text}`);
122
+ }
123
+ return rows;
124
+ }
125
+ /**
126
+ * The edited regions of a file as they now read — numbered, with a few lines of
127
+ * context, capped at {@link EDIT_EXCERPT_MAX_LINES} in total. What does not fit
128
+ * is named by line range so the model can `read_file` exactly that window.
129
+ *
130
+ * @param content - The file's text after the edit.
131
+ * @param spans - Changed character spans in `content` (from {@link addChangeSpan}).
132
+ * @returns The excerpt text, or an empty string when there is nothing to show.
133
+ */
134
+ export function renderEditExcerpt(content, spans) {
135
+ if (spans.length === 0 || content === '')
136
+ return '';
137
+ const lines = content.split('\n');
138
+ const lineStarts = [0];
139
+ for (let i = 0; i < content.length; i++)
140
+ if (content[i] === '\n')
141
+ lineStarts.push(i + 1);
142
+ // Line ranges with context, merged where they touch.
143
+ const ranges = [];
144
+ for (const span of spans) {
145
+ const first = lineAt(lineStarts, span.start);
146
+ const last = lineAt(lineStarts, Math.max(span.start, span.end - 1));
147
+ const from = Math.max(1, first - EDIT_EXCERPT_CONTEXT_LINES);
148
+ const to = Math.min(lines.length, last + EDIT_EXCERPT_CONTEXT_LINES);
149
+ const prev = ranges[ranges.length - 1];
150
+ if (prev && from <= prev.to + 1)
151
+ prev.to = Math.max(prev.to, to);
152
+ else
153
+ ranges.push({ from, to });
154
+ }
155
+ let budget = EDIT_EXCERPT_MAX_LINES;
156
+ const rows = [];
157
+ const elided = [];
158
+ for (const { from, to } of ranges) {
159
+ if (budget <= 0) {
160
+ elided.push(from === to ? `${from}` : `${from}-${to}`);
161
+ continue;
162
+ }
163
+ if (rows.length > 0)
164
+ rows.push('…');
165
+ const size = to - from + 1;
166
+ if (size <= budget) {
167
+ rows.push(...numbered(lines, from, to));
168
+ budget -= size;
169
+ continue;
170
+ }
171
+ // Too big for what is left: show its head and tail, name the middle.
172
+ const head = Math.max(1, Math.ceil(budget / 2));
173
+ const tail = Math.max(0, budget - head);
174
+ rows.push(...numbered(lines, from, from + head - 1));
175
+ const gapFrom = from + head;
176
+ const gapTo = to - tail;
177
+ rows.push(`… (lines ${gapFrom}-${gapTo} not shown)`);
178
+ if (tail > 0)
179
+ rows.push(...numbered(lines, gapTo + 1, to));
180
+ budget = 0;
181
+ }
182
+ if (elided.length > 0) {
183
+ rows.push(`… (also edited, not shown: lines ${elided.join(', ')})`);
184
+ }
185
+ return rows.join('\n');
186
+ }
187
+ /**
188
+ * A small file, numbered, for a `write_file` result — or null when the file is
189
+ * too big to echo (the model has what it just wrote; only line numbers are new).
190
+ *
191
+ * @param content - The file's text as written.
192
+ * @returns The numbered text, or null when the file is over the echo limits.
193
+ */
194
+ export function renderSmallFile(content) {
195
+ const total = countLines(content);
196
+ if (total === 0 || total > WRITE_ECHO_MAX_LINES || content.length > WRITE_ECHO_MAX_CHARS)
197
+ return null;
198
+ return numbered(content.split('\n'), 1, total).join('\n');
199
+ }
package/dist/index.d.ts CHANGED
@@ -29,6 +29,15 @@
29
29
  * (`MAX_BATCH_READ_FILES` / `MAX_BATCH_READ_BYTES`, which report where they
30
30
  * stopped). Each entry carries its own content or its own error, so one bad
31
31
  * path never fails the batch.
32
+ * - **`edit_file` shows the result, so the model need not re-read.** A
33
+ * successful edit returns `excerpt`: each edited region as it now reads, with
34
+ * 1-based line numbers (`N: text`) and a few lines of context, capped at
35
+ * 60 lines in total (what did not fit is named by line range),
36
+ * plus `totalLines`. `write_file` returns `totalLines`, and `excerpt` only
37
+ * for a small file (up to 40 lines) — a big file is never echoed back.
38
+ * Line numbers count the way `read_file` does, so they are valid offsets. The
39
+ * excerpt is redacted like a read. The existing fields (`ok`,
40
+ * `replacementsApplied`, `alreadyApplied`, `syntaxError`) are unchanged.
32
41
  * - **A parameter under another name is not a broken tool.** Every tool's input
33
42
  * is normalized first (`cmd` → `command`, `file` → `path`, camelCase ⇄
34
43
  * snake_case), and a handler that throws returns an error naming the missing
@@ -1 +1 @@
1
- {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAgGG;AAEH,cAAc,qBAAqB,CAAA;AACnC,cAAc,uBAAuB,CAAA;AACrC,cAAc,oBAAoB,CAAA;AAClC,cAAc,uBAAuB,CAAA;AACrC,cAAc,cAAc,CAAA;AAC5B,cAAc,oBAAoB,CAAA;AAClC,cAAc,YAAY,CAAA;AAC1B,cAAc,YAAY,CAAA;AAC1B,cAAc,gBAAgB,CAAA"}
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAyGG;AAEH,cAAc,qBAAqB,CAAA;AACnC,cAAc,uBAAuB,CAAA;AACrC,cAAc,oBAAoB,CAAA;AAClC,cAAc,uBAAuB,CAAA;AACrC,cAAc,cAAc,CAAA;AAC5B,cAAc,oBAAoB,CAAA;AAClC,cAAc,YAAY,CAAA;AAC1B,cAAc,YAAY,CAAA;AAC1B,cAAc,gBAAgB,CAAA"}
package/dist/index.js CHANGED
@@ -29,6 +29,15 @@
29
29
  * (`MAX_BATCH_READ_FILES` / `MAX_BATCH_READ_BYTES`, which report where they
30
30
  * stopped). Each entry carries its own content or its own error, so one bad
31
31
  * path never fails the batch.
32
+ * - **`edit_file` shows the result, so the model need not re-read.** A
33
+ * successful edit returns `excerpt`: each edited region as it now reads, with
34
+ * 1-based line numbers (`N: text`) and a few lines of context, capped at
35
+ * 60 lines in total (what did not fit is named by line range),
36
+ * plus `totalLines`. `write_file` returns `totalLines`, and `excerpt` only
37
+ * for a small file (up to 40 lines) — a big file is never echoed back.
38
+ * Line numbers count the way `read_file` does, so they are valid offsets. The
39
+ * excerpt is redacted like a read. The existing fields (`ok`,
40
+ * `replacementsApplied`, `alreadyApplied`, `syntaxError`) are unchanged.
32
41
  * - **A parameter under another name is not a broken tool.** Every tool's input
33
42
  * is normalized first (`cmd` → `command`, `file` → `path`, camelCase ⇄
34
43
  * snake_case), and a handler that throws returns an error naming the missing
package/dist/schemas.js CHANGED
@@ -61,7 +61,7 @@ export const TOOL_SCHEMAS = {
61
61
  },
62
62
  write_file: {
63
63
  name: 'write_file',
64
- description: 'Create or overwrite a file with full content. Use edit_file for small targeted changes instead.',
64
+ description: "Create or overwrite a file with full content. Use edit_file for small targeted changes instead. The result gives the file's line count (and a small file's numbered lines).",
65
65
  parameters: {
66
66
  type: 'object',
67
67
  properties: {
@@ -79,7 +79,7 @@ export const TOOL_SCHEMAS = {
79
79
  },
80
80
  edit_file: {
81
81
  name: 'edit_file',
82
- description: 'Make targeted search-and-replace edits to a file. Preferred over write_file for small changes. Each old_string must match exactly once in the file.',
82
+ description: 'Make targeted search-and-replace edits to a file. Preferred over write_file for small changes. Each old_string must match exactly once in the file. The result shows each edited region as it now reads, with line numbers, so there is no need to re-read the file to check an edit.',
83
83
  parameters: {
84
84
  type: 'object',
85
85
  properties: {
@@ -1 +1 @@
1
- {"version":3,"file":"tools.d.ts","sourceRoot":"","sources":["../src/tools.ts"],"names":[],"mappings":"AAAA;;;;;;;GAOG;AAEH,OAAO,KAAK,EAAE,MAAM,EAAE,MAAM,kBAAkB,CAAA;AAY9C,OAAO,KAAK,EAAE,gBAAgB,EAAE,eAAe,EAAE,MAAM,YAAY,CAAA;AA+BnE;;;;;;GAMG;AACH,wBAAgB,UAAU,CAAC,OAAO,EAAE,gBAAgB,EAAE,MAAM,CAAC,EAAE,eAAe,GAAG,MAAM,EAAE,CAmvCxF"}
1
+ {"version":3,"file":"tools.d.ts","sourceRoot":"","sources":["../src/tools.ts"],"names":[],"mappings":"AAAA;;;;;;;GAOG;AAEH,OAAO,KAAK,EAAE,MAAM,EAAE,MAAM,kBAAkB,CAAA;AAoB9C,OAAO,KAAK,EAAE,gBAAgB,EAAE,eAAe,EAAE,MAAM,YAAY,CAAA;AA+BnE;;;;;;GAMG;AACH,wBAAgB,UAAU,CAAC,OAAO,EAAE,gBAAgB,EAAE,MAAM,CAAC,EAAE,eAAe,GAAG,MAAM,EAAE,CAqxCxF"}
package/dist/tools.js CHANGED
@@ -6,6 +6,7 @@
6
6
  *
7
7
  * @module
8
8
  */
9
+ import { addChangeSpan, changedSpan, countLines, renderEditExcerpt, renderSmallFile, } from './edit-excerpt.js';
9
10
  import { guardToolExecute, missingParamError } from './input-normalizer.js';
10
11
  import { TOOL_SCHEMAS } from './schemas.js';
11
12
  /**
@@ -487,7 +488,17 @@ export function buildTools(backend, config) {
487
488
  const diff = computeDiff(oldContent, finalContent);
488
489
  if (onFileChange)
489
490
  onFileChange({ type: oldContent === null ? 'created' : 'modified', path });
490
- return { path, ok: true, diff, ...(await syntaxErrorAfterWrite(path)) };
491
+ // The line count (and, for a small file, the numbered text) so the next
492
+ // edit or read does not need a read_file just to learn line numbers.
493
+ const echo = renderSmallFile(finalContent);
494
+ return {
495
+ path,
496
+ ok: true,
497
+ diff,
498
+ totalLines: countLines(finalContent),
499
+ ...(echo !== null ? { excerpt: sanitizeFileContent(echo, path) } : {}),
500
+ ...(await syntaxErrorAfterWrite(path)),
501
+ };
491
502
  }
492
503
  catch (e) {
493
504
  return { error: `Failed to write ${path}: ${e.message}` };
@@ -528,6 +539,9 @@ export function buildTools(backend, config) {
528
539
  }
529
540
  const oldContent = content;
530
541
  let alreadyApplied = 0;
542
+ // Where each applied replacement landed, in the coordinates of the
543
+ // latest content — rendered into the result so the model sees the edit.
544
+ let spans = [];
531
545
  for (const { old_string: oldString, new_string: newString } of replacements) {
532
546
  // Validate each replacement is well-formed BEFORE touching content. A
533
547
  // weak model sometimes emits a batch element missing a field or with a
@@ -570,6 +584,7 @@ export function buildTools(backend, config) {
570
584
  // churn source. Uniqueness keeps this safe; ambiguous matches error.
571
585
  const fuzzy = whitespaceTolerantReplace(content, oldString, newString);
572
586
  if (fuzzy !== null) {
587
+ spans = addChangeSpan(spans, content, fuzzy);
573
588
  content = fuzzy;
574
589
  continue;
575
590
  }
@@ -660,7 +675,9 @@ export function buildTools(backend, config) {
660
675
  // a self-inflicted syntax error written with ok:true that the executor
661
676
  // then can't locate. count === 1 here, so indexOf is the unique site.
662
677
  const at = content.indexOf(oldString);
663
- content = content.slice(0, at) + newString + content.slice(at + oldString.length);
678
+ const spliced = content.slice(0, at) + newString + content.slice(at + oldString.length);
679
+ spans = addChangeSpan(spans, content, spliced);
680
+ content = spliced;
664
681
  }
665
682
  const alreadyNote = alreadyApplied > 0
666
683
  ? `${alreadyApplied} of these edits ${alreadyApplied === 1 ? 'was' : 'were'} already in the file (its new_string is there and its old_string is not) — nothing to do for ${alreadyApplied === 1 ? 'it' : 'them'}.`
@@ -675,11 +692,28 @@ export function buildTools(backend, config) {
675
692
  await onAfterWrite(path).catch(() => { });
676
693
  if (onFileChange)
677
694
  onFileChange({ type: 'modified', path });
695
+ // Show the edited regions as they now read, numbered. Measured: most
696
+ // re-reads of a just-edited file were to see the result or to find line
697
+ // numbers for the next edit — a full round-trip each. If an after-write
698
+ // hook rewrote the file (a formatter), show what is really on disk.
699
+ let finalContent = content;
700
+ if (onAfterWrite) {
701
+ // A failed re-read just means the excerpt shows what was written.
702
+ const reread = await backend.readFile(path).catch(() => null);
703
+ if (typeof reread === 'string' && reread !== '' && reread !== content) {
704
+ finalContent = reread;
705
+ const whole = changedSpan(oldContent, reread);
706
+ spans = whole ? [whole] : [];
707
+ }
708
+ }
709
+ const excerpt = renderEditExcerpt(finalContent, spans);
678
710
  return {
679
711
  path,
680
712
  ok: true,
681
713
  replacementsApplied: replacements.length - alreadyApplied,
682
714
  ...(alreadyApplied > 0 ? { alreadyApplied, note: alreadyNote } : {}),
715
+ totalLines: countLines(finalContent),
716
+ ...(excerpt ? { excerpt: sanitizeFileContent(excerpt, path) } : {}),
683
717
  ...(await syntaxErrorAfterWrite(path)),
684
718
  };
685
719
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@molecule/api-ai-tools",
3
- "version": "1.2.1",
3
+ "version": "1.3.0",
4
4
  "description": "Shared AI agent tools with backend abstraction for sandbox and local execution",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",