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