ucode-agent 1.44.0 → 1.47.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.
@@ -1,743 +1,750 @@
1
- /**
2
- * files.js — reading and changing files.
3
- *
4
- * The rule that matters most in here: an edit never guesses. Zero matches or
5
- * two matches is an error with an explanation, never a silent partial change.
6
- * A wrong edit that reports success is the single most expensive thing a
7
- * coding agent can do, because everything after it is built on a lie.
8
- */
9
-
10
- import { promises as fs } from 'node:fs';
11
- import { remember } from '../core/undo.js';
12
- import path from 'node:path';
13
- import { ToolFailure } from '../core/failure.js';
14
- import {
15
- resolveIn, guard, result, fsFailure, looksBinary, toLines, bytes,
16
- changedRegion, renderDiff, renderNewFile, READ_LINES, MAX_FILE_OUTPUT,
17
- } from './shared.js';
18
- import { packageJsonWritten } from './shell.js';
19
- import { parse as parseSource } from '@babel/parser';
20
-
21
- export async function readFile({ path: p, offset = 1, limit = READ_LINES }) {
22
- const target = resolveIn(p, 'read_file');
23
- await guard(target, `read ${target.abs}`);
24
- const attempted = `reading ${target.show}`;
25
-
26
- let stat;
27
- try {
28
- stat = await fs.stat(target.abs);
29
- } catch (err) {
30
- throw fsFailure(err, attempted, target.show);
31
- }
32
-
33
- if (stat.isDirectory()) {
34
- throw new ToolFailure({
35
- kind: 'is_directory',
36
- attempted,
37
- failed: `${target.show} is a directory.`,
38
- fix: `Use list_dir with path "${target.show}" to see what is in it.`,
39
- });
40
- }
41
-
42
- let buf;
43
- try {
44
- buf = await fs.readFile(target.abs);
45
- } catch (err) {
46
- throw fsFailure(err, attempted, target.show);
47
- }
48
-
49
- if (looksBinary(buf.subarray(0, 8192))) {
50
- throw new ToolFailure({
51
- kind: 'binary',
52
- attempted,
53
- failed: `${target.show} is a binary file (${bytes(stat.size)}).`,
54
- fix: 'ucode reads text only. Inspect it with run_command and a tool built for the format.',
55
- });
56
- }
57
-
58
- const lines = toLines(buf.toString('utf8'));
59
- const from = Math.max(1, Math.floor(Number(offset) || 1));
60
- const count = Math.max(1, Math.floor(Number(limit) || READ_LINES));
61
- const slice = lines.slice(from - 1, from - 1 + count);
62
-
63
- if (slice.length === 0) {
64
- throw new ToolFailure({
65
- kind: 'bad_args',
66
- attempted,
67
- failed: `offset ${from} is past the end of the file, which has ${lines.length} lines.`,
68
- fix: `Read again with an offset between 1 and ${lines.length}.`,
69
- });
70
- }
71
-
72
- const last = from + slice.length - 1;
73
- const width = String(last).length;
74
- // The numbers are a gutter for the model to reason about, and they are
75
- // stated to be display-only in the tool description, because an edit whose
76
- // old_string still carries them will never match.
77
- const body = slice.map((line, i) => `${String(from + i).padStart(width)} | ${line}`).join('\n');
78
-
79
- const more = last < lines.length
80
- ? `\n\n[lines ${from}-${last} of ${lines.length}. Continue with offset=${last + 1}.]`
81
- : '';
82
-
83
- return result(
84
- body + more,
85
- more || from > 1 ? `lines ${from}-${last} of ${lines.length}` : `${lines.length} lines`,
86
- MAX_FILE_OUTPUT
87
- );
88
- }
89
-
90
- /**
91
- * Everything that writes a file ends here. A package.json with dependencies
92
- * starts its install in the background at once, while the rest of the app is
93
- * still being written.
94
- */
95
- function written(target, content) {
96
- if (path.basename(target.abs) === 'package.json') packageJsonWritten(target.abs, content);
97
- }
98
-
99
- const PARSEABLE = /\.(?:[cm]?[jt]sx?)$/i;
100
-
101
- /**
102
- * Does this source parse? Checked the moment a file is written.
103
- *
104
- * Measured on a real build: a JSX typo sat unnoticed until `npm run build`,
105
- * which takes up to a minute, failed on it — then the fix, then another full
106
- * build. Parsing the file takes milliseconds, so the error comes back in the
107
- * same step that wrote it, with the line, while the model still has the file
108
- * in front of it. Syntax only: types are checked at the end of the turn.
109
- */
110
- export function syntaxProblem(file, text) {
111
- if (!PARSEABLE.test(file)) return null;
112
- const ext = path.extname(file).toLowerCase();
113
- const plugins = ext === '.tsx'
114
- ? ['typescript', 'jsx']
115
- : /^\.[cm]?ts$/.test(ext) ? ['typescript'] : ['jsx'];
116
- const where = (loc) => (loc ? `line ${loc.line}, column ${loc.column + 1}` : 'somewhere in the file');
117
- const clean = (m) => String(m).replace(/\s*\(\d+:\d+\)\s*$/, '');
118
- try {
119
- const ast = parseSource(text, {
120
- sourceType: 'unambiguous',
121
- plugins: [...plugins, 'decorators-legacy'],
122
- errorRecovery: true,
123
- allowReturnOutsideFunction: true,
124
- allowAwaitOutsideFunction: true,
125
- });
126
- const first = ast.errors?.[0];
127
- return first ? `${where(first.loc)}: ${clean(first.message)}` : null;
128
- } catch (err) {
129
- return `${where(err.loc)}: ${clean(err.message)}`;
130
- }
131
- }
132
-
133
- /**
134
- * How many times in a row each file has come back unparseable.
135
- *
136
- * A model that has broken a file once usually fixes it. A model that has
137
- * broken it three times is guessing at a structure it has lost track of, and
138
- * will keep guessing: one run spent fifteen minutes on a single line before
139
- * giving up and rewriting the file, which is what it should have been told to
140
- * do after the second try.
141
- */
142
- const brokenRuns = new Map();
143
-
144
- export function forgetBrokenRuns() { brokenRuns.clear(); }
145
-
146
- /**
147
- * The note appended to a result: empty when the file parses, and the run of
148
- * failures forgotten, so three good writes later a single slip is a slip again.
149
- */
150
- const parseNote = (show, problem) => {
151
- if (!problem) { brokenRuns.delete(show); return ''; }
152
- return brokenNote(show, problem);
153
- };
154
-
155
- /** The warning appended to a result when a file does not parse. */
156
- const brokenNote = (show, problem) => {
157
- const runs = (brokenRuns.get(show) ?? 0) + 1;
158
- brokenRuns.set(show, runs);
159
- const base = `
160
-
161
- ⚠ ${show} does not parse — ${problem}.`;
162
- if (runs < 3) return `${base} Fix it now: the build will fail on it.`;
163
- return `${base} That is ${runs} attempts in a row on this file. Stop editing it: ` +
164
- `write the whole file again with write_file, in one piece, rather than patching ` +
165
- `a structure you have lost track of.`;
166
- };
167
-
168
- /** A file this short comes back whole after an edit; longer ones show the part around the change. */
169
- const SHOW_WHOLE = 250;
170
- const AROUND = 15;
171
-
172
- /**
173
- * The file as it stands after an edit, with the same line-number gutter as
174
- * read_file.
175
- *
176
- * Measured on a real build: the model read the same component thirteen times
177
- * in one turn, because an edit's result showed only the lines it replaced and
178
- * the next edit needed the file as it now was. Sending the current text back
179
- * with the edit costs the same tokens the re-read would have, and saves the
180
- * round trip every time.
181
- */
182
- function nowReads(show, text, at, span) {
183
- const lines = toLines(text);
184
- const whole = lines.length <= SHOW_WHOLE;
185
- const from = whole ? 1 : Math.max(1, at - AROUND);
186
- const to = whole ? lines.length : Math.min(lines.length, at + span + AROUND);
187
- const width = String(to).length;
188
- const body = lines.slice(from - 1, to).map((l, i) => `${String(from + i).padStart(width)} | ${l}`).join('\n');
189
- const heading = whole
190
- ? `${show} now reads (all ${lines.length} lines`
191
- : `${show} now reads, lines ${from}-${to} of ${lines.length}`;
192
- return `\n\n${heading} — this is the current text, so there is no need to read it again):\n${body}`;
193
- }
194
-
195
- /** At most this many files in one read_files call. */
196
- const MAX_BATCH = 20;
197
-
198
- /**
199
- * Several files in one call.
200
- *
201
- * Reading from disk takes a millisecond. What makes reading slow is the round
202
- * trip around it: every file read on its own is a whole request to the model,
203
- * and on a reasoning model that is several seconds of thinking before it even
204
- * asks for the next one. Reading the six files a change touches in one call
205
- * turns six of those into one.
206
- *
207
- * The files are read in parallel, and a missing one is reported in its place
208
- * rather than failing the rest — one wrong path should not cost the other five.
209
- */
210
- export async function readFiles({ paths, limit = READ_LINES }) {
211
- if (!Array.isArray(paths) || paths.length === 0) {
212
- throw new ToolFailure({
213
- kind: 'bad_args',
214
- attempted: 'reading several files',
215
- failed: 'The "paths" argument must be a non-empty array of file paths.',
216
- fix: 'Pass paths as ["src/app.js", "src/lib/api.js", ...].',
217
- });
218
- }
219
-
220
- const wanted = [...new Set(paths.map((p) => String(p ?? '').trim()).filter(Boolean))];
221
- const batch = wanted.slice(0, MAX_BATCH);
222
- const dropped = wanted.slice(MAX_BATCH);
223
-
224
- const readOne = (p) => readFile({ path: p, limit }).then(
225
- (out) => ({ p, out }),
226
- (err) => ({ p, err })
227
- );
228
-
229
- // Anything outside the project needs a yes, and two questions cannot be
230
- // asked at once — so those go one at a time. Everything else goes together.
231
- const outside = batch.some((p) => !resolveIn(p, 'read_files', 'paths').inside);
232
- const settled = [];
233
- if (outside) {
234
- for (const p of batch) settled.push(await readOne(p));
235
- } else {
236
- settled.push(...(await Promise.all(batch.map(readOne))));
237
- }
238
-
239
- // Two whole files' worth of output between them. Past that, the rest are
240
- // named rather than silently cut, so the model knows to ask again.
241
- const budget = MAX_FILE_OUTPUT * 2;
242
- let used = 0;
243
- let read = 0;
244
- let failed = 0;
245
- let lines = 0;
246
- const blocks = [];
247
- const deferred = [];
248
-
249
- for (const { p, out, err } of settled) {
250
- if (err) {
251
- failed++;
252
- blocks.push(`=== ${p} — could not be read ===\n${err.forModel ? err.forModel() : err.message}`);
253
- continue;
254
- }
255
- const block = `=== ${p} (${out.summary}) ===\n${out.content}`;
256
- if (read > 0 && used + block.length > budget) {
257
- deferred.push(p);
258
- continue;
259
- }
260
- blocks.push(block);
261
- used += block.length;
262
- read++;
263
- // "42 lines" for a whole file, "lines 1-600 of 900" for a page of one.
264
- const whole = /^(\d+) lines$/.exec(out.summary);
265
- const page = /^lines (\d+)-(\d+)/.exec(out.summary);
266
- lines += whole ? Number(whole[1]) : page ? Number(page[2]) - Number(page[1]) + 1 : 0;
267
- }
268
-
269
- const leftOver = [...deferred, ...dropped];
270
- if (leftOver.length) {
271
- blocks.push(
272
- `[not included, to stay inside one reply: ${leftOver.join(', ')}. ` +
273
- 'Read those with another read_files call.]'
274
- );
275
- }
276
-
277
- return result(
278
- blocks.join('\n\n'),
279
- `${read} file${read === 1 ? '' : 's'} · ${lines} lines` +
280
- (failed ? ` · ${failed} missing` : '') +
281
- (leftOver.length ? ` · ${leftOver.length} deferred` : ''),
282
- budget + 2_000
283
- );
284
- }
285
-
286
- /** Write one file, returning the rows that show what changed. */
287
- async function put(target, content, { diffMax = 16 } = {}) {
288
- const attempted = `writing ${target.show}`;
289
-
290
- // Read what is there before clobbering it, so an overwrite can be shown as
291
- // an actual diff rather than as a claim that something changed.
292
- let previous = null;
293
- try {
294
- previous = await fs.readFile(target.abs, 'utf8');
295
- } catch {
296
- previous = null; // missing, or binary — either way it is treated as new
297
- }
298
-
299
- try {
300
- await fs.mkdir(path.dirname(target.abs), { recursive: true });
301
- await remember(target.abs);
302
- await fs.writeFile(target.abs, content, 'utf8');
303
- } catch (err) {
304
- throw fsFailure(err, attempted, target.show);
305
- }
306
- written(target, content);
307
-
308
- const existed = previous !== null;
309
- const lineCount = content === '' ? 0 : toLines(content).length;
310
- const diff = existed
311
- ? renderDiff(changedRegion(previous, content), { max: diffMax })
312
- : (content === '' ? [] : renderNewFile(content, diffMax));
313
-
314
- return {
315
- existed,
316
- lineCount,
317
- diff,
318
- problem: syntaxProblem(target.abs, content),
319
- line: `${existed ? 'Overwrote' : 'Created'} ${target.show} ` +
320
- `(${lineCount} lines, ${bytes(Buffer.byteLength(content))})`,
321
- };
322
- }
323
-
324
- export async function writeFile({ path: p, content }) {
325
- const target = resolveIn(p, 'write_file');
326
- if (typeof content !== 'string') {
327
- throw new ToolFailure({
328
- kind: 'bad_args',
329
- attempted: `writing ${target.show}`,
330
- failed: 'The "content" argument was missing or was not a string.',
331
- fix: 'Call write_file again with content set to the whole text of the file.',
332
- });
333
- }
334
- await guard(target, `write ${target.abs}`);
335
-
336
- const written = await put(target, content);
337
- const out = result(
338
- `${written.line}.${parseNote(target.show, written.problem)}`,
339
- `${written.existed ? 'overwrote' : 'created'} · ${written.lineCount} lines${written.problem ? ' · does not parse' : ''}`
340
- );
341
- out.diff = written.diff;
342
- return out;
343
- }
344
-
345
- /**
346
- * Several files in one call.
347
- *
348
- * Scaffolding a project is twenty writes before anything can be run, and doing
349
- * that one round trip at a time is most of the wait.
350
- */
351
- export async function batchWrite({ files }) {
352
- if (!Array.isArray(files) || files.length === 0) {
353
- throw new ToolFailure({
354
- kind: 'bad_args',
355
- attempted: 'writing several files',
356
- failed: 'The "files" argument must be a non-empty array.',
357
- fix: 'Pass files as [{ path, content }, ...].',
358
- });
359
- }
360
-
361
- const lines = [];
362
- const diff = [];
363
- let created = 0;
364
- let broken = 0;
365
-
366
- for (const [index, file] of files.entries()) {
367
- const { path: p, content } = file ?? {};
368
- if (typeof p !== 'string' || typeof content !== 'string') {
369
- throw new ToolFailure({
370
- kind: 'bad_args',
371
- attempted: `writing file ${index + 1} of ${files.length}`,
372
- failed: 'Every entry needs "path" and "content", both strings.',
373
- fix: `Fix entry ${index + 1} and call batch_write again. ${index} file(s) were already written.`,
374
- });
375
- }
376
-
377
- const target = resolveIn(p, 'batch_write');
378
- await guard(target, `write ${target.abs}`);
379
-
380
- // Per-file diffs are kept short here; twenty files at sixteen rows each
381
- // would bury the reply under three hundred lines of gutter.
382
- const written = await put(target, content, { diffMax: 6 });
383
- if (!written.existed) created++;
384
- lines.push(written.line + parseNote(target.show, written.problem));
385
- if (written.problem) broken++;
386
- diff.push(`~${target.show}`, ...written.diff);
387
- }
388
-
389
- const out = result(
390
- lines.join('\n'),
391
- `${files.length} file${files.length === 1 ? '' : 's'} · ${created} new${broken ? ` · ${broken} do not parse` : ''}`
392
- );
393
- out.diff = diff;
394
- return out;
395
- }
396
-
397
- /**
398
- * Why an old_string missed, worked out rather than guessed at.
399
- *
400
- * "not found" tells the model nothing it did not already know. Whether the
401
- * text is present with different whitespace, or present but only its first
402
- * line, is the difference between a fix on the next step and three more
403
- * failed attempts.
404
- */
405
- function explainMiss(original, oldString, show) {
406
- const flatten = (s) => s.replace(/\s+/g, ' ').trim();
407
- const firstLine = oldString.split('\n')[0].trim();
408
-
409
- if (flatten(original).includes(flatten(oldString))) {
410
- return {
411
- failed: `old_string is not in ${show} as written — the text is there, but the whitespace differs.`,
412
- fix: "Match the file's own indentation exactly: tabs versus spaces, and the line breaks.",
413
- };
414
- }
415
-
416
- const nearby = firstLine.length > 3
417
- ? original.split(/\r?\n/)
418
- .map((line, i) => [i + 1, line])
419
- .filter(([, line]) => line.includes(firstLine))
420
- .slice(0, 3)
421
- : [];
422
-
423
- if (nearby.length) {
424
- return {
425
- failed:
426
- `old_string is not in ${show}. Its first line does appear at ` +
427
- `line${nearby.length > 1 ? 's' : ''} ${nearby.map(([n]) => n).join(', ')}, ` +
428
- 'so it is the lines after it that differ.',
429
- fix: `Read ${show} around line ${nearby[0][0]} and copy the block exactly as it is.`,
430
- };
431
- }
432
-
433
- return {
434
- failed: `old_string does not appear anywhere in ${show}.`,
435
- fix: `Read ${show} again and copy the text verbatim, without the line-number gutter.`,
436
- };
437
- }
438
-
439
- /** Apply one replacement to a string, or explain precisely why it cannot. */
440
- function replaceOnce(text, { old_string, new_string }, { show, attempted, label = '' }) {
441
- const prefix = label ? `${label}: ` : '';
442
-
443
- if (typeof old_string !== 'string' || typeof new_string !== 'string') {
444
- throw new ToolFailure({
445
- kind: 'bad_args', attempted,
446
- failed: `${prefix}old_string and new_string must both be strings.`,
447
- fix: 'Fix that entry and call again. Nothing was written.',
448
- });
449
- }
450
- if (old_string === '') {
451
- throw new ToolFailure({
452
- kind: 'bad_args', attempted,
453
- failed: `${prefix}old_string was empty.`,
454
- fix: 'edit_file replaces existing text. Use write_file to create a file.',
455
- });
456
- }
457
- // An edit whose two halves are the same asks for the file to stay as it is,
458
- // which it will. Refusing that was a hard failure, and the model answered it
459
- // by sending the same edit again — the stuck detector carries a special case
460
- // for exactly this loop. Saying "already done" ends it in one step.
461
- if (old_string === new_string) {
462
- const found = text.indexOf(old_string);
463
- return { text, at: found < 0 ? 1 : toLines(text.slice(0, found)).length, loose: false };
464
- }
465
-
466
- // Models write \n. A file checked out on Windows is often \r\n, and then an
467
- // otherwise perfect old_string can never match. Speak the file's dialect.
468
- let oldText = old_string;
469
- let newText = new_string;
470
- if (text.includes('\r\n') && !oldText.includes('\r')) {
471
- oldText = oldText.replace(/\r?\n/g, '\r\n');
472
- newText = newText.replace(/\r?\n/g, '\r\n');
473
- }
474
-
475
- const ambiguous = (hits, how = '') => new ToolFailure({
476
- kind: 'ambiguous', attempted,
477
- failed: `${prefix}old_string appears ${hits} times in ${show}${how}. Refusing to guess which one you meant.`,
478
- fix: 'Add surrounding lines to old_string until it matches exactly one place.',
479
- detail: { hits },
480
- });
481
-
482
- const hits = text.split(oldText).length - 1;
483
- if (hits > 1) throw ambiguous(hits);
484
- if (hits === 1) {
485
- const at = text.slice(0, text.indexOf(oldText)).split(/\r?\n/).length;
486
- return { text: text.replace(oldText, () => newText), at, loose: false };
487
- }
488
-
489
- // No exact match. The commonest reason by far is whitespace — tabs against
490
- // spaces, a different indent depth, trailing spaces — with every word right.
491
- // Match line by line ignoring that, and re-indent the replacement to fit.
492
- // Still unique or nothing: a loose match found twice is refused like any other.
493
- const loose = looseReplace(text, old_string, new_string);
494
- if (loose?.count === 1) return { text: loose.text, at: loose.at, loose: true };
495
- if (loose?.count > 1) throw ambiguous(loose.count, ' once whitespace is ignored');
496
-
497
- const { failed, fix } = explainMiss(text, old_string, show);
498
- throw new ToolFailure({ kind: 'no_match', attempted, failed: prefix + failed, fix });
499
- }
500
-
501
- /**
502
- * Find old_string by its lines' content alone and swap in new_string, indented
503
- * the way the file is indented at that spot. Returns { count } when it finds
504
- * none or several, and { count: 1, text, at } when it finds exactly one.
505
- */
506
- function looseReplace(text, oldString, newString) {
507
- const eol = text.includes('\r\n') ? '\r\n' : '\n';
508
- const lines = text.split(/\r?\n/);
509
-
510
- const want = oldString.replace(/\r/g, '').split('\n');
511
- while (want.length > 1 && !want[want.length - 1].trim()) want.pop();
512
- while (want.length > 1 && !want[0].trim()) want.shift();
513
- const target = want.map((l) => l.trim());
514
- if (target.every((t) => !t)) return null;
515
-
516
- const starts = [];
517
- for (let i = 0; i + want.length <= lines.length; i++) {
518
- let same = true;
519
- for (let j = 0; j < want.length; j++) {
520
- if (lines[i + j].trim() !== target[j]) { same = false; break; }
521
- }
522
- if (same) starts.push(i);
523
- }
524
- if (starts.length !== 1) return { count: starts.length };
525
-
526
- const start = starts[0];
527
- const indent = (l) => /^[ \t]*/.exec(l)[0];
528
- const first = want.findIndex((l) => l.trim());
529
- const fileIndent = indent(lines[start + first]);
530
- const wroteIndent = indent(want[first]);
531
-
532
- const replacement = newString.replace(/\r/g, '').split('\n');
533
- if (replacement.length > 1 && replacement[replacement.length - 1] === '') replacement.pop();
534
- const reindented = replacement.map((l) => {
535
- if (!l.trim()) return l.trim();
536
- const body = l.startsWith(wroteIndent) ? l.slice(wroteIndent.length) : l.replace(/^[ \t]*/, '');
537
- return fileIndent + body;
538
- });
539
-
540
- const out = [...lines.slice(0, start), ...reindented, ...lines.slice(start + want.length)];
541
- return { count: 1, text: out.join(eol), at: start + 1 };
542
- }
543
-
544
- export async function editFile({ path: p, old_string, new_string }) {
545
- const target = resolveIn(p, 'edit_file');
546
- const attempted = `editing ${target.show}`;
547
- await guard(target, `edit ${target.abs}`);
548
-
549
- let original;
550
- try {
551
- original = await fs.readFile(target.abs, 'utf8');
552
- } catch (err) {
553
- throw fsFailure(err, attempted, target.show);
554
- }
555
-
556
- const { text, at, loose } = replaceOnce(original, { old_string, new_string }, {
557
- show: target.show, attempted,
558
- });
559
-
560
- try {
561
- await remember(target.abs);
562
- await fs.writeFile(target.abs, text, 'utf8');
563
- } catch (err) {
564
- throw fsFailure(err, attempted, target.show);
565
- }
566
- written(target, text);
567
-
568
- const delta = toLines(text).length - toLines(original).length;
569
- const change = delta === 0 ? 'same line count' : `${delta > 0 ? '+' : ''}${delta} lines`;
570
- const how = loose ? ', matched ignoring whitespace and re-indented to fit' : '';
571
-
572
- const span = toLines(new_string).length;
573
- const out = result(
574
- `Replaced one occurrence in ${target.show} at line ${at} (${change}${how}).` +
575
- parseNote(target.show, syntaxProblem(target.abs, text)) +
576
- nowReads(target.show, text, at, span),
577
- `1 change at line ${at} · ${change}${loose ? ' · whitespace-tolerant' : ''}${syntaxProblem(target.abs, text) ? ' · does not parse' : ''}`,
578
- MAX_FILE_OUTPUT
579
- );
580
- // The replacement is diffed on its own and offset to where it landed, so
581
- // the gutter shows the file's line numbers rather than 1, 2, 3.
582
- out.diff = renderDiff(changedRegion(old_string, new_string), { offset: at - 1 });
583
- return out;
584
- }
585
-
586
- /**
587
- * Several replacements in one file, applied in order, each seeing the result
588
- * of the one before it.
589
- *
590
- * Everything is validated against a working copy first. If the third edit is
591
- * ambiguous, none of the three are written — a half-applied set of edits is a
592
- * file in a state nobody designed.
593
- */
594
- export async function multiEdit({ path: p, edits }) {
595
- const target = resolveIn(p, 'multi_edit');
596
- const attempted = `editing ${target.show}`;
597
-
598
- if (!Array.isArray(edits) || edits.length === 0) {
599
- throw new ToolFailure({
600
- kind: 'bad_args',
601
- attempted,
602
- failed: 'The "edits" argument must be a non-empty array.',
603
- fix: 'Pass edits as [{ old_string, new_string }, ...].',
604
- });
605
- }
606
-
607
- await guard(target, `edit ${target.abs}`);
608
-
609
- let original;
610
- try {
611
- original = await fs.readFile(target.abs, 'utf8');
612
- } catch (err) {
613
- throw fsFailure(err, attempted, target.show);
614
- }
615
-
616
- let text = original;
617
- const diff = [];
618
-
619
- for (const [index, edit] of edits.entries()) {
620
- const applied = replaceOnce(text, edit ?? {}, {
621
- show: target.show,
622
- attempted,
623
- label: `edit ${index + 1} of ${edits.length}`,
624
- });
625
- diff.push(
626
- ...renderDiff(changedRegion(edit.old_string, edit.new_string), {
627
- offset: applied.at - 1,
628
- max: 8,
629
- })
630
- );
631
- text = applied.text;
632
- }
633
-
634
- try {
635
- await remember(target.abs);
636
- await fs.writeFile(target.abs, text, 'utf8');
637
- } catch (err) {
638
- throw fsFailure(err, attempted, target.show);
639
- }
640
- written(target, text);
641
-
642
- const delta = toLines(text).length - toLines(original).length;
643
- const change = delta === 0 ? 'same line count' : `${delta > 0 ? '+' : ''}${delta} lines`;
644
-
645
- const out = result(
646
- `Applied ${edits.length} edits to ${target.show} (${change}).` +
647
- parseNote(target.show, syntaxProblem(target.abs, text)) +
648
- nowReads(target.show, text, 1, toLines(text).length),
649
- `${edits.length} edits · ${change}`,
650
- MAX_FILE_OUTPUT
651
- );
652
- out.diff = diff;
653
- return out;
654
- }
655
-
656
- /**
657
- * Exact replacements across several files in one call.
658
- *
659
- * A change that touches the route, the component and the type together is one
660
- * round trip instead of three. Every edit in every file is applied to a copy
661
- * in memory first; if any of them fails, nothing is written anywhere — a
662
- * cross-file change that half landed leaves the project in a state that
663
- * compiles nowhere.
664
- */
665
- export async function editFiles({ files }) {
666
- if (!Array.isArray(files) || files.length === 0) {
667
- throw new ToolFailure({
668
- kind: 'bad_args',
669
- attempted: 'editing several files',
670
- failed: 'The "files" argument must be a non-empty array.',
671
- fix: 'Pass files as [{ path, edits: [{ old_string, new_string }, ...] }, ...].',
672
- });
673
- }
674
-
675
- const planned = [];
676
- const seen = new Set();
677
-
678
- for (const [i, entry] of files.entries()) {
679
- const target = resolveIn(entry?.path, 'edit_files');
680
- const attempted = `editing ${target.show}`;
681
-
682
- if (seen.has(target.abs)) {
683
- throw new ToolFailure({
684
- kind: 'bad_args', attempted,
685
- failed: `${target.show} is listed twice.`,
686
- fix: 'List each file once, with all of its edits together. Nothing was written.',
687
- });
688
- }
689
- seen.add(target.abs);
690
-
691
- if (!Array.isArray(entry.edits) || entry.edits.length === 0) {
692
- throw new ToolFailure({
693
- kind: 'bad_args', attempted,
694
- failed: `File ${i + 1} (${target.show}) has no edits.`,
695
- fix: 'Give every file a non-empty edits array. Nothing was written.',
696
- });
697
- }
698
-
699
- await guard(target, `edit ${target.abs}`);
700
-
701
- let original;
702
- try {
703
- original = await fs.readFile(target.abs, 'utf8');
704
- } catch (err) {
705
- throw fsFailure(err, attempted, target.show);
706
- }
707
-
708
- let text = original;
709
- const diff = [`~${target.show}`];
710
- for (const [j, edit] of entry.edits.entries()) {
711
- const applied = replaceOnce(text, edit ?? {}, {
712
- show: target.show,
713
- attempted,
714
- label: `${target.show}, edit ${j + 1} of ${entry.edits.length} (nothing was written)`,
715
- });
716
- diff.push(...renderDiff(changedRegion(edit.old_string, edit.new_string), { offset: applied.at - 1, max: 6 }));
717
- text = applied.text;
718
- }
719
- planned.push({ target, text, diff, count: entry.edits.length });
720
- }
721
-
722
- for (const { target, text } of planned) {
723
- try {
724
- await remember(target.abs);
725
- await fs.writeFile(target.abs, text, 'utf8');
726
- } catch (err) {
727
- throw fsFailure(err, `editing ${target.show}`, target.show);
728
- }
729
- written(target, text);
730
- }
731
-
732
- const edits = planned.reduce((n, p) => n + p.count, 0);
733
- const out = result(
734
- planned.map((p) => {
735
- const problem = syntaxProblem(p.target.abs, p.text);
736
- return `Edited ${p.target.show} (${p.count} change${p.count === 1 ? '' : 's'})` +
737
- parseNote(p.target.show, problem);
738
- }).join('\n'),
739
- `${planned.length} files · ${edits} edits`
740
- );
741
- out.diff = planned.flatMap((p) => p.diff);
742
- return out;
743
- }
1
+ /**
2
+ * files.js — reading and changing files.
3
+ *
4
+ * The rule that matters most in here: an edit never guesses. Zero matches or
5
+ * two matches is an error with an explanation, never a silent partial change.
6
+ * A wrong edit that reports success is the single most expensive thing a
7
+ * coding agent can do, because everything after it is built on a lie.
8
+ */
9
+
10
+ import { promises as fs } from 'node:fs';
11
+ import { remember } from '../core/undo.js';
12
+ import path from 'node:path';
13
+ import { ToolFailure } from '../core/failure.js';
14
+ import {
15
+ resolveIn, guard, result, fsFailure, looksBinary, toLines, bytes,
16
+ changedRegion, renderDiff, renderNewFile, READ_LINES, MAX_FILE_OUTPUT,
17
+ noteFile, writeTracked, assertUnchanged,
18
+ } from './shared.js';
19
+ import { packageJsonWritten } from './shell.js';
20
+ import { parse as parseSource } from '@babel/parser';
21
+
22
+ export async function readFile({ path: p, offset = 1, limit = READ_LINES }) {
23
+ const target = resolveIn(p, 'read_file');
24
+ await guard(target, `read ${target.abs}`);
25
+ const attempted = `reading ${target.show}`;
26
+
27
+ let stat;
28
+ try {
29
+ stat = await fs.stat(target.abs);
30
+ } catch (err) {
31
+ throw fsFailure(err, attempted, target.show);
32
+ }
33
+
34
+ if (stat.isDirectory()) {
35
+ throw new ToolFailure({
36
+ kind: 'is_directory',
37
+ attempted,
38
+ failed: `${target.show} is a directory.`,
39
+ fix: `Use list_dir with path "${target.show}" to see what is in it.`,
40
+ });
41
+ }
42
+
43
+ let buf;
44
+ try {
45
+ buf = await fs.readFile(target.abs);
46
+ } catch (err) {
47
+ throw fsFailure(err, attempted, target.show);
48
+ }
49
+
50
+ if (looksBinary(buf.subarray(0, 8192))) {
51
+ throw new ToolFailure({
52
+ kind: 'binary',
53
+ attempted,
54
+ failed: `${target.show} is a binary file (${bytes(stat.size)}).`,
55
+ fix: 'ucode reads text only. Inspect it with run_command and a tool built for the format.',
56
+ });
57
+ }
58
+
59
+ // Read at this moment, so a later whole-file overwrite can tell its own
60
+ // change apart from somebody else's.
61
+ await noteFile(target.abs);
62
+
63
+ const lines = toLines(buf.toString('utf8'));
64
+ const from = Math.max(1, Math.floor(Number(offset) || 1));
65
+ const count = Math.max(1, Math.floor(Number(limit) || READ_LINES));
66
+ const slice = lines.slice(from - 1, from - 1 + count);
67
+
68
+ if (slice.length === 0) {
69
+ throw new ToolFailure({
70
+ kind: 'bad_args',
71
+ attempted,
72
+ failed: `offset ${from} is past the end of the file, which has ${lines.length} lines.`,
73
+ fix: `Read again with an offset between 1 and ${lines.length}.`,
74
+ });
75
+ }
76
+
77
+ const last = from + slice.length - 1;
78
+ const width = String(last).length;
79
+ // The numbers are a gutter for the model to reason about, and they are
80
+ // stated to be display-only in the tool description, because an edit whose
81
+ // old_string still carries them will never match.
82
+ const body = slice.map((line, i) => `${String(from + i).padStart(width)} | ${line}`).join('\n');
83
+
84
+ const more = last < lines.length
85
+ ? `\n\n[lines ${from}-${last} of ${lines.length}. Continue with offset=${last + 1}.]`
86
+ : '';
87
+
88
+ return result(
89
+ body + more,
90
+ more || from > 1 ? `lines ${from}-${last} of ${lines.length}` : `${lines.length} lines`,
91
+ MAX_FILE_OUTPUT
92
+ );
93
+ }
94
+
95
+ /**
96
+ * Everything that writes a file ends here. A package.json with dependencies
97
+ * starts its install in the background at once, while the rest of the app is
98
+ * still being written.
99
+ */
100
+ function written(target, content) {
101
+ if (path.basename(target.abs) === 'package.json') packageJsonWritten(target.abs, content);
102
+ }
103
+
104
+ const PARSEABLE = /\.(?:[cm]?[jt]sx?)$/i;
105
+
106
+ /**
107
+ * Does this source parse? Checked the moment a file is written.
108
+ *
109
+ * Measured on a real build: a JSX typo sat unnoticed until `npm run build`,
110
+ * which takes up to a minute, failed on it — then the fix, then another full
111
+ * build. Parsing the file takes milliseconds, so the error comes back in the
112
+ * same step that wrote it, with the line, while the model still has the file
113
+ * in front of it. Syntax only: types are checked at the end of the turn.
114
+ */
115
+ export function syntaxProblem(file, text) {
116
+ if (!PARSEABLE.test(file)) return null;
117
+ const ext = path.extname(file).toLowerCase();
118
+ const plugins = ext === '.tsx'
119
+ ? ['typescript', 'jsx']
120
+ : /^\.[cm]?ts$/.test(ext) ? ['typescript'] : ['jsx'];
121
+ const where = (loc) => (loc ? `line ${loc.line}, column ${loc.column + 1}` : 'somewhere in the file');
122
+ const clean = (m) => String(m).replace(/\s*\(\d+:\d+\)\s*$/, '');
123
+ try {
124
+ const ast = parseSource(text, {
125
+ sourceType: 'unambiguous',
126
+ plugins: [...plugins, 'decorators-legacy'],
127
+ errorRecovery: true,
128
+ allowReturnOutsideFunction: true,
129
+ allowAwaitOutsideFunction: true,
130
+ });
131
+ const first = ast.errors?.[0];
132
+ return first ? `${where(first.loc)}: ${clean(first.message)}` : null;
133
+ } catch (err) {
134
+ return `${where(err.loc)}: ${clean(err.message)}`;
135
+ }
136
+ }
137
+
138
+ /**
139
+ * How many times in a row each file has come back unparseable.
140
+ *
141
+ * A model that has broken a file once usually fixes it. A model that has
142
+ * broken it three times is guessing at a structure it has lost track of, and
143
+ * will keep guessing: one run spent fifteen minutes on a single line before
144
+ * giving up and rewriting the file, which is what it should have been told to
145
+ * do after the second try.
146
+ */
147
+ const brokenRuns = new Map();
148
+
149
+ export function forgetBrokenRuns() { brokenRuns.clear(); }
150
+
151
+ /**
152
+ * The note appended to a result: empty when the file parses, and the run of
153
+ * failures forgotten, so three good writes later a single slip is a slip again.
154
+ */
155
+ const parseNote = (show, problem) => {
156
+ if (!problem) { brokenRuns.delete(show); return ''; }
157
+ return brokenNote(show, problem);
158
+ };
159
+
160
+ /** The warning appended to a result when a file does not parse. */
161
+ const brokenNote = (show, problem) => {
162
+ const runs = (brokenRuns.get(show) ?? 0) + 1;
163
+ brokenRuns.set(show, runs);
164
+ const base = `
165
+
166
+ ⚠ ${show} does not parse — ${problem}.`;
167
+ if (runs < 3) return `${base} Fix it now: the build will fail on it.`;
168
+ return `${base} That is ${runs} attempts in a row on this file. Stop editing it: ` +
169
+ `write the whole file again with write_file, in one piece, rather than patching ` +
170
+ `a structure you have lost track of.`;
171
+ };
172
+
173
+ /** A file this short comes back whole after an edit; longer ones show the part around the change. */
174
+ const SHOW_WHOLE = 250;
175
+ const AROUND = 15;
176
+
177
+ /**
178
+ * The file as it stands after an edit, with the same line-number gutter as
179
+ * read_file.
180
+ *
181
+ * Measured on a real build: the model read the same component thirteen times
182
+ * in one turn, because an edit's result showed only the lines it replaced and
183
+ * the next edit needed the file as it now was. Sending the current text back
184
+ * with the edit costs the same tokens the re-read would have, and saves the
185
+ * round trip every time.
186
+ */
187
+ function nowReads(show, text, at, span) {
188
+ const lines = toLines(text);
189
+ const whole = lines.length <= SHOW_WHOLE;
190
+ const from = whole ? 1 : Math.max(1, at - AROUND);
191
+ const to = whole ? lines.length : Math.min(lines.length, at + span + AROUND);
192
+ const width = String(to).length;
193
+ const body = lines.slice(from - 1, to).map((l, i) => `${String(from + i).padStart(width)} | ${l}`).join('\n');
194
+ const heading = whole
195
+ ? `${show} now reads (all ${lines.length} lines`
196
+ : `${show} now reads, lines ${from}-${to} of ${lines.length}`;
197
+ return `\n\n${heading} — this is the current text, so there is no need to read it again):\n${body}`;
198
+ }
199
+
200
+ /** At most this many files in one read_files call. */
201
+ const MAX_BATCH = 20;
202
+
203
+ /**
204
+ * Several files in one call.
205
+ *
206
+ * Reading from disk takes a millisecond. What makes reading slow is the round
207
+ * trip around it: every file read on its own is a whole request to the model,
208
+ * and on a reasoning model that is several seconds of thinking before it even
209
+ * asks for the next one. Reading the six files a change touches in one call
210
+ * turns six of those into one.
211
+ *
212
+ * The files are read in parallel, and a missing one is reported in its place
213
+ * rather than failing the rest — one wrong path should not cost the other five.
214
+ */
215
+ export async function readFiles({ paths, limit = READ_LINES }) {
216
+ if (!Array.isArray(paths) || paths.length === 0) {
217
+ throw new ToolFailure({
218
+ kind: 'bad_args',
219
+ attempted: 'reading several files',
220
+ failed: 'The "paths" argument must be a non-empty array of file paths.',
221
+ fix: 'Pass paths as ["src/app.js", "src/lib/api.js", ...].',
222
+ });
223
+ }
224
+
225
+ const wanted = [...new Set(paths.map((p) => String(p ?? '').trim()).filter(Boolean))];
226
+ const batch = wanted.slice(0, MAX_BATCH);
227
+ const dropped = wanted.slice(MAX_BATCH);
228
+
229
+ const readOne = (p) => readFile({ path: p, limit }).then(
230
+ (out) => ({ p, out }),
231
+ (err) => ({ p, err })
232
+ );
233
+
234
+ // Anything outside the project needs a yes, and two questions cannot be
235
+ // asked at once — so those go one at a time. Everything else goes together.
236
+ const outside = batch.some((p) => !resolveIn(p, 'read_files', 'paths').inside);
237
+ const settled = [];
238
+ if (outside) {
239
+ for (const p of batch) settled.push(await readOne(p));
240
+ } else {
241
+ settled.push(...(await Promise.all(batch.map(readOne))));
242
+ }
243
+
244
+ // Two whole files' worth of output between them. Past that, the rest are
245
+ // named rather than silently cut, so the model knows to ask again.
246
+ const budget = MAX_FILE_OUTPUT * 2;
247
+ let used = 0;
248
+ let read = 0;
249
+ let failed = 0;
250
+ let lines = 0;
251
+ const blocks = [];
252
+ const deferred = [];
253
+
254
+ for (const { p, out, err } of settled) {
255
+ if (err) {
256
+ failed++;
257
+ blocks.push(`=== ${p} — could not be read ===\n${err.forModel ? err.forModel() : err.message}`);
258
+ continue;
259
+ }
260
+ const block = `=== ${p} (${out.summary}) ===\n${out.content}`;
261
+ if (read > 0 && used + block.length > budget) {
262
+ deferred.push(p);
263
+ continue;
264
+ }
265
+ blocks.push(block);
266
+ used += block.length;
267
+ read++;
268
+ // "42 lines" for a whole file, "lines 1-600 of 900" for a page of one.
269
+ const whole = /^(\d+) lines$/.exec(out.summary);
270
+ const page = /^lines (\d+)-(\d+)/.exec(out.summary);
271
+ lines += whole ? Number(whole[1]) : page ? Number(page[2]) - Number(page[1]) + 1 : 0;
272
+ }
273
+
274
+ const leftOver = [...deferred, ...dropped];
275
+ if (leftOver.length) {
276
+ blocks.push(
277
+ `[not included, to stay inside one reply: ${leftOver.join(', ')}. ` +
278
+ 'Read those with another read_files call.]'
279
+ );
280
+ }
281
+
282
+ return result(
283
+ blocks.join('\n\n'),
284
+ `${read} file${read === 1 ? '' : 's'} · ${lines} lines` +
285
+ (failed ? ` · ${failed} missing` : '') +
286
+ (leftOver.length ? ` · ${leftOver.length} deferred` : ''),
287
+ budget + 2_000
288
+ );
289
+ }
290
+
291
+ /** Write one file, returning the rows that show what changed. */
292
+ async function put(target, content, { diffMax = 16 } = {}) {
293
+ const attempted = `writing ${target.show}`;
294
+
295
+ // Read what is there before clobbering it, so an overwrite can be shown as
296
+ // an actual diff rather than as a claim that something changed.
297
+ let previous = null;
298
+ try {
299
+ previous = await fs.readFile(target.abs, 'utf8');
300
+ } catch {
301
+ previous = null; // missing, or binary — either way it is treated as new
302
+ }
303
+
304
+ if (previous !== null) await assertUnchanged(target.abs, target.show);
305
+
306
+ try {
307
+ await fs.mkdir(path.dirname(target.abs), { recursive: true });
308
+ await remember(target.abs);
309
+ await writeTracked(target.abs, content);
310
+ } catch (err) {
311
+ throw fsFailure(err, attempted, target.show);
312
+ }
313
+ written(target, content);
314
+
315
+ const existed = previous !== null;
316
+ const lineCount = content === '' ? 0 : toLines(content).length;
317
+ const diff = existed
318
+ ? renderDiff(changedRegion(previous, content), { max: diffMax })
319
+ : (content === '' ? [] : renderNewFile(content, diffMax));
320
+
321
+ return {
322
+ existed,
323
+ lineCount,
324
+ diff,
325
+ problem: syntaxProblem(target.abs, content),
326
+ line: `${existed ? 'Overwrote' : 'Created'} ${target.show} ` +
327
+ `(${lineCount} lines, ${bytes(Buffer.byteLength(content))})`,
328
+ };
329
+ }
330
+
331
+ export async function writeFile({ path: p, content }) {
332
+ const target = resolveIn(p, 'write_file');
333
+ if (typeof content !== 'string') {
334
+ throw new ToolFailure({
335
+ kind: 'bad_args',
336
+ attempted: `writing ${target.show}`,
337
+ failed: 'The "content" argument was missing or was not a string.',
338
+ fix: 'Call write_file again with content set to the whole text of the file.',
339
+ });
340
+ }
341
+ await guard(target, `write ${target.abs}`);
342
+
343
+ const written = await put(target, content);
344
+ const out = result(
345
+ `${written.line}.${parseNote(target.show, written.problem)}`,
346
+ `${written.existed ? 'overwrote' : 'created'} · ${written.lineCount} lines${written.problem ? ' · does not parse' : ''}`
347
+ );
348
+ out.diff = written.diff;
349
+ return out;
350
+ }
351
+
352
+ /**
353
+ * Several files in one call.
354
+ *
355
+ * Scaffolding a project is twenty writes before anything can be run, and doing
356
+ * that one round trip at a time is most of the wait.
357
+ */
358
+ export async function batchWrite({ files }) {
359
+ if (!Array.isArray(files) || files.length === 0) {
360
+ throw new ToolFailure({
361
+ kind: 'bad_args',
362
+ attempted: 'writing several files',
363
+ failed: 'The "files" argument must be a non-empty array.',
364
+ fix: 'Pass files as [{ path, content }, ...].',
365
+ });
366
+ }
367
+
368
+ const lines = [];
369
+ const diff = [];
370
+ let created = 0;
371
+ let broken = 0;
372
+
373
+ for (const [index, file] of files.entries()) {
374
+ const { path: p, content } = file ?? {};
375
+ if (typeof p !== 'string' || typeof content !== 'string') {
376
+ throw new ToolFailure({
377
+ kind: 'bad_args',
378
+ attempted: `writing file ${index + 1} of ${files.length}`,
379
+ failed: 'Every entry needs "path" and "content", both strings.',
380
+ fix: `Fix entry ${index + 1} and call batch_write again. ${index} file(s) were already written.`,
381
+ });
382
+ }
383
+
384
+ const target = resolveIn(p, 'batch_write');
385
+ await guard(target, `write ${target.abs}`);
386
+
387
+ // Per-file diffs are kept short here; twenty files at sixteen rows each
388
+ // would bury the reply under three hundred lines of gutter.
389
+ const written = await put(target, content, { diffMax: 6 });
390
+ if (!written.existed) created++;
391
+ lines.push(written.line + parseNote(target.show, written.problem));
392
+ if (written.problem) broken++;
393
+ diff.push(`~${target.show}`, ...written.diff);
394
+ }
395
+
396
+ const out = result(
397
+ lines.join('\n'),
398
+ `${files.length} file${files.length === 1 ? '' : 's'} · ${created} new${broken ? ` · ${broken} do not parse` : ''}`
399
+ );
400
+ out.diff = diff;
401
+ return out;
402
+ }
403
+
404
+ /**
405
+ * Why an old_string missed, worked out rather than guessed at.
406
+ *
407
+ * "not found" tells the model nothing it did not already know. Whether the
408
+ * text is present with different whitespace, or present but only its first
409
+ * line, is the difference between a fix on the next step and three more
410
+ * failed attempts.
411
+ */
412
+ function explainMiss(original, oldString, show) {
413
+ const flatten = (s) => s.replace(/\s+/g, ' ').trim();
414
+ const firstLine = oldString.split('\n')[0].trim();
415
+
416
+ if (flatten(original).includes(flatten(oldString))) {
417
+ return {
418
+ failed: `old_string is not in ${show} as written — the text is there, but the whitespace differs.`,
419
+ fix: "Match the file's own indentation exactly: tabs versus spaces, and the line breaks.",
420
+ };
421
+ }
422
+
423
+ const nearby = firstLine.length > 3
424
+ ? original.split(/\r?\n/)
425
+ .map((line, i) => [i + 1, line])
426
+ .filter(([, line]) => line.includes(firstLine))
427
+ .slice(0, 3)
428
+ : [];
429
+
430
+ if (nearby.length) {
431
+ return {
432
+ failed:
433
+ `old_string is not in ${show}. Its first line does appear at ` +
434
+ `line${nearby.length > 1 ? 's' : ''} ${nearby.map(([n]) => n).join(', ')}, ` +
435
+ 'so it is the lines after it that differ.',
436
+ fix: `Read ${show} around line ${nearby[0][0]} and copy the block exactly as it is.`,
437
+ };
438
+ }
439
+
440
+ return {
441
+ failed: `old_string does not appear anywhere in ${show}.`,
442
+ fix: `Read ${show} again and copy the text verbatim, without the line-number gutter.`,
443
+ };
444
+ }
445
+
446
+ /** Apply one replacement to a string, or explain precisely why it cannot. */
447
+ function replaceOnce(text, { old_string, new_string }, { show, attempted, label = '' }) {
448
+ const prefix = label ? `${label}: ` : '';
449
+
450
+ if (typeof old_string !== 'string' || typeof new_string !== 'string') {
451
+ throw new ToolFailure({
452
+ kind: 'bad_args', attempted,
453
+ failed: `${prefix}old_string and new_string must both be strings.`,
454
+ fix: 'Fix that entry and call again. Nothing was written.',
455
+ });
456
+ }
457
+ if (old_string === '') {
458
+ throw new ToolFailure({
459
+ kind: 'bad_args', attempted,
460
+ failed: `${prefix}old_string was empty.`,
461
+ fix: 'edit_file replaces existing text. Use write_file to create a file.',
462
+ });
463
+ }
464
+ // An edit whose two halves are the same asks for the file to stay as it is,
465
+ // which it will. Refusing that was a hard failure, and the model answered it
466
+ // by sending the same edit again — the stuck detector carries a special case
467
+ // for exactly this loop. Saying "already done" ends it in one step.
468
+ if (old_string === new_string) {
469
+ const found = text.indexOf(old_string);
470
+ return { text, at: found < 0 ? 1 : toLines(text.slice(0, found)).length, loose: false };
471
+ }
472
+
473
+ // Models write \n. A file checked out on Windows is often \r\n, and then an
474
+ // otherwise perfect old_string can never match. Speak the file's dialect.
475
+ let oldText = old_string;
476
+ let newText = new_string;
477
+ if (text.includes('\r\n') && !oldText.includes('\r')) {
478
+ oldText = oldText.replace(/\r?\n/g, '\r\n');
479
+ newText = newText.replace(/\r?\n/g, '\r\n');
480
+ }
481
+
482
+ const ambiguous = (hits, how = '') => new ToolFailure({
483
+ kind: 'ambiguous', attempted,
484
+ failed: `${prefix}old_string appears ${hits} times in ${show}${how}. Refusing to guess which one you meant.`,
485
+ fix: 'Add surrounding lines to old_string until it matches exactly one place.',
486
+ detail: { hits },
487
+ });
488
+
489
+ const hits = text.split(oldText).length - 1;
490
+ if (hits > 1) throw ambiguous(hits);
491
+ if (hits === 1) {
492
+ const at = text.slice(0, text.indexOf(oldText)).split(/\r?\n/).length;
493
+ return { text: text.replace(oldText, () => newText), at, loose: false };
494
+ }
495
+
496
+ // No exact match. The commonest reason by far is whitespace — tabs against
497
+ // spaces, a different indent depth, trailing spaces — with every word right.
498
+ // Match line by line ignoring that, and re-indent the replacement to fit.
499
+ // Still unique or nothing: a loose match found twice is refused like any other.
500
+ const loose = looseReplace(text, old_string, new_string);
501
+ if (loose?.count === 1) return { text: loose.text, at: loose.at, loose: true };
502
+ if (loose?.count > 1) throw ambiguous(loose.count, ' once whitespace is ignored');
503
+
504
+ const { failed, fix } = explainMiss(text, old_string, show);
505
+ throw new ToolFailure({ kind: 'no_match', attempted, failed: prefix + failed, fix });
506
+ }
507
+
508
+ /**
509
+ * Find old_string by its lines' content alone and swap in new_string, indented
510
+ * the way the file is indented at that spot. Returns { count } when it finds
511
+ * none or several, and { count: 1, text, at } when it finds exactly one.
512
+ */
513
+ function looseReplace(text, oldString, newString) {
514
+ const eol = text.includes('\r\n') ? '\r\n' : '\n';
515
+ const lines = text.split(/\r?\n/);
516
+
517
+ const want = oldString.replace(/\r/g, '').split('\n');
518
+ while (want.length > 1 && !want[want.length - 1].trim()) want.pop();
519
+ while (want.length > 1 && !want[0].trim()) want.shift();
520
+ const target = want.map((l) => l.trim());
521
+ if (target.every((t) => !t)) return null;
522
+
523
+ const starts = [];
524
+ for (let i = 0; i + want.length <= lines.length; i++) {
525
+ let same = true;
526
+ for (let j = 0; j < want.length; j++) {
527
+ if (lines[i + j].trim() !== target[j]) { same = false; break; }
528
+ }
529
+ if (same) starts.push(i);
530
+ }
531
+ if (starts.length !== 1) return { count: starts.length };
532
+
533
+ const start = starts[0];
534
+ const indent = (l) => /^[ \t]*/.exec(l)[0];
535
+ const first = want.findIndex((l) => l.trim());
536
+ const fileIndent = indent(lines[start + first]);
537
+ const wroteIndent = indent(want[first]);
538
+
539
+ const replacement = newString.replace(/\r/g, '').split('\n');
540
+ if (replacement.length > 1 && replacement[replacement.length - 1] === '') replacement.pop();
541
+ const reindented = replacement.map((l) => {
542
+ if (!l.trim()) return l.trim();
543
+ const body = l.startsWith(wroteIndent) ? l.slice(wroteIndent.length) : l.replace(/^[ \t]*/, '');
544
+ return fileIndent + body;
545
+ });
546
+
547
+ const out = [...lines.slice(0, start), ...reindented, ...lines.slice(start + want.length)];
548
+ return { count: 1, text: out.join(eol), at: start + 1 };
549
+ }
550
+
551
+ export async function editFile({ path: p, old_string, new_string }) {
552
+ const target = resolveIn(p, 'edit_file');
553
+ const attempted = `editing ${target.show}`;
554
+ await guard(target, `edit ${target.abs}`);
555
+
556
+ let original;
557
+ try {
558
+ original = await fs.readFile(target.abs, 'utf8');
559
+ } catch (err) {
560
+ throw fsFailure(err, attempted, target.show);
561
+ }
562
+
563
+ const { text, at, loose } = replaceOnce(original, { old_string, new_string }, {
564
+ show: target.show, attempted,
565
+ });
566
+
567
+ try {
568
+ await remember(target.abs);
569
+ await writeTracked(target.abs, text);
570
+ } catch (err) {
571
+ throw fsFailure(err, attempted, target.show);
572
+ }
573
+ written(target, text);
574
+
575
+ const delta = toLines(text).length - toLines(original).length;
576
+ const change = delta === 0 ? 'same line count' : `${delta > 0 ? '+' : ''}${delta} lines`;
577
+ const how = loose ? ', matched ignoring whitespace and re-indented to fit' : '';
578
+
579
+ const span = toLines(new_string).length;
580
+ const out = result(
581
+ `Replaced one occurrence in ${target.show} at line ${at} (${change}${how}).` +
582
+ parseNote(target.show, syntaxProblem(target.abs, text)) +
583
+ nowReads(target.show, text, at, span),
584
+ `1 change at line ${at} · ${change}${loose ? ' · whitespace-tolerant' : ''}${syntaxProblem(target.abs, text) ? ' · does not parse' : ''}`,
585
+ MAX_FILE_OUTPUT
586
+ );
587
+ // The replacement is diffed on its own and offset to where it landed, so
588
+ // the gutter shows the file's line numbers rather than 1, 2, 3.
589
+ out.diff = renderDiff(changedRegion(old_string, new_string), { offset: at - 1 });
590
+ return out;
591
+ }
592
+
593
+ /**
594
+ * Several replacements in one file, applied in order, each seeing the result
595
+ * of the one before it.
596
+ *
597
+ * Everything is validated against a working copy first. If the third edit is
598
+ * ambiguous, none of the three are written — a half-applied set of edits is a
599
+ * file in a state nobody designed.
600
+ */
601
+ export async function multiEdit({ path: p, edits }) {
602
+ const target = resolveIn(p, 'multi_edit');
603
+ const attempted = `editing ${target.show}`;
604
+
605
+ if (!Array.isArray(edits) || edits.length === 0) {
606
+ throw new ToolFailure({
607
+ kind: 'bad_args',
608
+ attempted,
609
+ failed: 'The "edits" argument must be a non-empty array.',
610
+ fix: 'Pass edits as [{ old_string, new_string }, ...].',
611
+ });
612
+ }
613
+
614
+ await guard(target, `edit ${target.abs}`);
615
+
616
+ let original;
617
+ try {
618
+ original = await fs.readFile(target.abs, 'utf8');
619
+ } catch (err) {
620
+ throw fsFailure(err, attempted, target.show);
621
+ }
622
+
623
+ let text = original;
624
+ const diff = [];
625
+
626
+ for (const [index, edit] of edits.entries()) {
627
+ const applied = replaceOnce(text, edit ?? {}, {
628
+ show: target.show,
629
+ attempted,
630
+ label: `edit ${index + 1} of ${edits.length}`,
631
+ });
632
+ diff.push(
633
+ ...renderDiff(changedRegion(edit.old_string, edit.new_string), {
634
+ offset: applied.at - 1,
635
+ max: 8,
636
+ })
637
+ );
638
+ text = applied.text;
639
+ }
640
+
641
+ try {
642
+ await remember(target.abs);
643
+ await writeTracked(target.abs, text);
644
+ } catch (err) {
645
+ throw fsFailure(err, attempted, target.show);
646
+ }
647
+ written(target, text);
648
+
649
+ const delta = toLines(text).length - toLines(original).length;
650
+ const change = delta === 0 ? 'same line count' : `${delta > 0 ? '+' : ''}${delta} lines`;
651
+
652
+ const out = result(
653
+ `Applied ${edits.length} edits to ${target.show} (${change}).` +
654
+ parseNote(target.show, syntaxProblem(target.abs, text)) +
655
+ nowReads(target.show, text, 1, toLines(text).length),
656
+ `${edits.length} edits · ${change}`,
657
+ MAX_FILE_OUTPUT
658
+ );
659
+ out.diff = diff;
660
+ return out;
661
+ }
662
+
663
+ /**
664
+ * Exact replacements across several files in one call.
665
+ *
666
+ * A change that touches the route, the component and the type together is one
667
+ * round trip instead of three. Every edit in every file is applied to a copy
668
+ * in memory first; if any of them fails, nothing is written anywhere — a
669
+ * cross-file change that half landed leaves the project in a state that
670
+ * compiles nowhere.
671
+ */
672
+ export async function editFiles({ files }) {
673
+ if (!Array.isArray(files) || files.length === 0) {
674
+ throw new ToolFailure({
675
+ kind: 'bad_args',
676
+ attempted: 'editing several files',
677
+ failed: 'The "files" argument must be a non-empty array.',
678
+ fix: 'Pass files as [{ path, edits: [{ old_string, new_string }, ...] }, ...].',
679
+ });
680
+ }
681
+
682
+ const planned = [];
683
+ const seen = new Set();
684
+
685
+ for (const [i, entry] of files.entries()) {
686
+ const target = resolveIn(entry?.path, 'edit_files');
687
+ const attempted = `editing ${target.show}`;
688
+
689
+ if (seen.has(target.abs)) {
690
+ throw new ToolFailure({
691
+ kind: 'bad_args', attempted,
692
+ failed: `${target.show} is listed twice.`,
693
+ fix: 'List each file once, with all of its edits together. Nothing was written.',
694
+ });
695
+ }
696
+ seen.add(target.abs);
697
+
698
+ if (!Array.isArray(entry.edits) || entry.edits.length === 0) {
699
+ throw new ToolFailure({
700
+ kind: 'bad_args', attempted,
701
+ failed: `File ${i + 1} (${target.show}) has no edits.`,
702
+ fix: 'Give every file a non-empty edits array. Nothing was written.',
703
+ });
704
+ }
705
+
706
+ await guard(target, `edit ${target.abs}`);
707
+
708
+ let original;
709
+ try {
710
+ original = await fs.readFile(target.abs, 'utf8');
711
+ } catch (err) {
712
+ throw fsFailure(err, attempted, target.show);
713
+ }
714
+
715
+ let text = original;
716
+ const diff = [`~${target.show}`];
717
+ for (const [j, edit] of entry.edits.entries()) {
718
+ const applied = replaceOnce(text, edit ?? {}, {
719
+ show: target.show,
720
+ attempted,
721
+ label: `${target.show}, edit ${j + 1} of ${entry.edits.length} (nothing was written)`,
722
+ });
723
+ diff.push(...renderDiff(changedRegion(edit.old_string, edit.new_string), { offset: applied.at - 1, max: 6 }));
724
+ text = applied.text;
725
+ }
726
+ planned.push({ target, text, diff, count: entry.edits.length });
727
+ }
728
+
729
+ for (const { target, text } of planned) {
730
+ try {
731
+ await remember(target.abs);
732
+ await writeTracked(target.abs, text);
733
+ } catch (err) {
734
+ throw fsFailure(err, `editing ${target.show}`, target.show);
735
+ }
736
+ written(target, text);
737
+ }
738
+
739
+ const edits = planned.reduce((n, p) => n + p.count, 0);
740
+ const out = result(
741
+ planned.map((p) => {
742
+ const problem = syntaxProblem(p.target.abs, p.text);
743
+ return `Edited ${p.target.show} (${p.count} change${p.count === 1 ? '' : 's'})` +
744
+ parseNote(p.target.show, problem);
745
+ }).join('\n'),
746
+ `${planned.length} files · ${edits} edits`
747
+ );
748
+ out.diff = planned.flatMap((p) => p.diff);
749
+ return out;
750
+ }