@molecule/api-ai-tools 1.2.0 → 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
@@ -1 +1 @@
1
- {"version":3,"file":"sandbox.d.ts","sourceRoot":"","sources":["../../src/backends/sandbox.ts"],"names":[],"mappings":"AAAA;;;;;GAKG;AAEH,OAAO,KAAK,EAAE,gBAAgB,EAAE,MAAM,aAAa,CAAA;AAGnD,sFAAsF;AACtF,UAAU,WAAW;IACnB,QAAQ,CAAC,IAAI,EAAE,MAAM,GAAG,OAAO,CAAC,MAAM,CAAC,CAAA;IACvC,SAAS,CAAC,IAAI,EAAE,MAAM,EAAE,OAAO,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC,CAAA;IACvD,UAAU,CAAC,IAAI,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC,CAAA;IACvC,OAAO,CAAC,IAAI,EAAE,MAAM,GAAG,OAAO,CAAC,KAAK,CAAC;QAAE,IAAI,EAAE,MAAM,CAAC;QAAC,IAAI,EAAE,MAAM,GAAG,WAAW,CAAA;KAAE,CAAC,CAAC,CAAA;IACnF,mEAAmE;IAEnE,CAAC,GAAG,EAAE,MAAM,GAAG,GAAG,CAAA;CACnB;AAED;;;;;;;GAOG;AACH,wBAAgB,oBAAoB,CAClC,OAAO,EAAE,WAAW,EACpB,WAAW,SAAe,GACzB,gBAAgB,CA2ClB"}
1
+ {"version":3,"file":"sandbox.d.ts","sourceRoot":"","sources":["../../src/backends/sandbox.ts"],"names":[],"mappings":"AAAA;;;;;GAKG;AAEH,OAAO,KAAK,EAAE,gBAAgB,EAAE,MAAM,aAAa,CAAA;AAGnD,sFAAsF;AACtF,UAAU,WAAW;IACnB,QAAQ,CAAC,IAAI,EAAE,MAAM,GAAG,OAAO,CAAC,MAAM,CAAC,CAAA;IACvC,SAAS,CAAC,IAAI,EAAE,MAAM,EAAE,OAAO,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC,CAAA;IACvD,UAAU,CAAC,IAAI,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC,CAAA;IACvC,OAAO,CAAC,IAAI,EAAE,MAAM,GAAG,OAAO,CAAC,KAAK,CAAC;QAAE,IAAI,EAAE,MAAM,CAAC;QAAC,IAAI,EAAE,MAAM,GAAG,WAAW,CAAA;KAAE,CAAC,CAAC,CAAA;IACnF,mEAAmE;IAEnE,CAAC,GAAG,EAAE,MAAM,GAAG,GAAG,CAAA;CACnB;AAED;;;;;;;GAOG;AACH,wBAAgB,oBAAoB,CAClC,OAAO,EAAE,WAAW,EACpB,WAAW,SAAe,GACzB,gBAAgB,CAmDlB"}
@@ -42,9 +42,17 @@ export function createSandboxBackend(sandbox, projectRoot = '/workspace') {
42
42
  // printed still comes back, with exit code 124. Wrapping only the inner
43
43
  // command would run it in a child shell that never sees unexported variables
44
44
  // the consumer's sourcing set.
45
+ // Its output goes to files that are printed once the shell exits, never to
46
+ // the exec's own pipe: `cd app && server > log &` backgrounds a SUBSHELL whose
47
+ // stdout is still that pipe (the redirect covers the server only), and the
48
+ // exec waits for every holder of the pipe — past the budget, which kills the
49
+ // shell but not the detached subshell, until the caller's outer timeout
50
+ // throws the output away. Measured: three 300 s calls in two runs (X0 x31,
51
+ // x33). A background child now holds a file, and the call returns when the
52
+ // command does.
45
53
  const budgetSeconds = opts?.budgetMs ? Math.max(1, Math.round(opts.budgetMs / 1000)) : 0;
46
54
  const fullCommand = budgetSeconds
47
- ? `timeout -k 5 ${budgetSeconds} bash -c ${shellQuote(anchored)}`
55
+ ? `o=$(mktemp); e=$(mktemp); timeout -k 5 ${budgetSeconds} bash -c ${shellQuote(anchored)} > "$o" 2> "$e" < /dev/null; c=$?; cat "$o"; cat "$e" >&2; rm -f "$o" "$e"; exit $c`
48
56
  : anchored;
49
57
  // sandbox.exec is the Sandbox interface method — runs inside Docker, inherently sandboxed
50
58
  const result = await sandbox.exec(fullCommand, { timeout: opts?.timeout });
@@ -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,CA0tCxF"}
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}` };
@@ -527,6 +538,10 @@ export function buildTools(backend, config) {
527
538
  content = verdict.content;
528
539
  }
529
540
  const oldContent = content;
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 = [];
530
545
  for (const { old_string: oldString, new_string: newString } of replacements) {
531
546
  // Validate each replacement is well-formed BEFORE touching content. A
532
547
  // weak model sometimes emits a batch element missing a field or with a
@@ -539,6 +554,19 @@ export function buildTools(backend, config) {
539
554
  };
540
555
  }
541
556
  const count = content.split(oldString).length - 1;
557
+ // The edit is ALREADY in the file: old_string is gone and a distinctive
558
+ // new_string is there exactly once. The executor re-sends an edit it
559
+ // made a few calls ago (it works from its memory of the file), and the
560
+ // "not found" error cost it a read_file plus a retry each time — 6 to 11
561
+ // times a build in X0 x30/x34. Report it as applied instead. Short or
562
+ // repeated new_strings still error: they could be there by coincidence.
563
+ if (count === 0 &&
564
+ newString !== oldString &&
565
+ newString.trim().length >= 20 &&
566
+ content.split(newString).length - 1 === 1) {
567
+ alreadyApplied++;
568
+ continue;
569
+ }
542
570
  if (count === 0) {
543
571
  // A bare "old_string not found" gives the model nothing to correct, so
544
572
  // it retries blindly (the #1 edit_file failure mode). Diagnose WHY the
@@ -556,6 +584,7 @@ export function buildTools(backend, config) {
556
584
  // churn source. Uniqueness keeps this safe; ambiguous matches error.
557
585
  const fuzzy = whitespaceTolerantReplace(content, oldString, newString);
558
586
  if (fuzzy !== null) {
587
+ spans = addChangeSpan(spans, content, fuzzy);
559
588
  content = fuzzy;
560
589
  continue;
561
590
  }
@@ -646,7 +675,15 @@ export function buildTools(backend, config) {
646
675
  // a self-inflicted syntax error written with ok:true that the executor
647
676
  // then can't locate. count === 1 here, so indexOf is the unique site.
648
677
  const at = content.indexOf(oldString);
649
- 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;
681
+ }
682
+ const alreadyNote = alreadyApplied > 0
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'}.`
684
+ : null;
685
+ if (content === oldContent) {
686
+ return { path, ok: true, replacementsApplied: 0, alreadyApplied, note: alreadyNote };
650
687
  }
651
688
  if (onFileDiff)
652
689
  onFileDiff({ path, oldContent, newContent: content });
@@ -655,10 +692,28 @@ export function buildTools(backend, config) {
655
692
  await onAfterWrite(path).catch(() => { });
656
693
  if (onFileChange)
657
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);
658
710
  return {
659
711
  path,
660
712
  ok: true,
661
- replacementsApplied: replacements.length,
713
+ replacementsApplied: replacements.length - alreadyApplied,
714
+ ...(alreadyApplied > 0 ? { alreadyApplied, note: alreadyNote } : {}),
715
+ totalLines: countLines(finalContent),
716
+ ...(excerpt ? { excerpt: sanitizeFileContent(excerpt, path) } : {}),
662
717
  ...(await syntaxErrorAfterWrite(path)),
663
718
  };
664
719
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@molecule/api-ai-tools",
3
- "version": "1.2.0",
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",