ucode-agent 1.0.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.
@@ -0,0 +1,466 @@
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 path from 'node:path';
12
+ import { ToolFailure } from '../core/failure.js';
13
+ import {
14
+ resolveIn, guard, result, fsFailure, looksBinary, toLines, bytes,
15
+ changedRegion, renderDiff, renderNewFile, READ_LINES, MAX_FILE_OUTPUT,
16
+ } from './shared.js';
17
+
18
+ export async function readFile({ path: p, offset = 1, limit = READ_LINES }) {
19
+ const target = resolveIn(p, 'read_file');
20
+ await guard(target, `read ${target.abs}`);
21
+ const attempted = `reading ${target.show}`;
22
+
23
+ let stat;
24
+ try {
25
+ stat = await fs.stat(target.abs);
26
+ } catch (err) {
27
+ throw fsFailure(err, attempted, target.show);
28
+ }
29
+
30
+ if (stat.isDirectory()) {
31
+ throw new ToolFailure({
32
+ kind: 'is_directory',
33
+ attempted,
34
+ failed: `${target.show} is a directory.`,
35
+ fix: `Use list_dir with path "${target.show}" to see what is in it.`,
36
+ });
37
+ }
38
+
39
+ let buf;
40
+ try {
41
+ buf = await fs.readFile(target.abs);
42
+ } catch (err) {
43
+ throw fsFailure(err, attempted, target.show);
44
+ }
45
+
46
+ if (looksBinary(buf.subarray(0, 8192))) {
47
+ throw new ToolFailure({
48
+ kind: 'binary',
49
+ attempted,
50
+ failed: `${target.show} is a binary file (${bytes(stat.size)}).`,
51
+ fix: 'ucode reads text only. Inspect it with run_command and a tool built for the format.',
52
+ });
53
+ }
54
+
55
+ const lines = toLines(buf.toString('utf8'));
56
+ const from = Math.max(1, Math.floor(Number(offset) || 1));
57
+ const count = Math.max(1, Math.floor(Number(limit) || READ_LINES));
58
+ const slice = lines.slice(from - 1, from - 1 + count);
59
+
60
+ if (slice.length === 0) {
61
+ throw new ToolFailure({
62
+ kind: 'bad_args',
63
+ attempted,
64
+ failed: `offset ${from} is past the end of the file, which has ${lines.length} lines.`,
65
+ fix: `Read again with an offset between 1 and ${lines.length}.`,
66
+ });
67
+ }
68
+
69
+ const last = from + slice.length - 1;
70
+ const width = String(last).length;
71
+ // The numbers are a gutter for the model to reason about, and they are
72
+ // stated to be display-only in the tool description, because an edit whose
73
+ // old_string still carries them will never match.
74
+ const body = slice.map((line, i) => `${String(from + i).padStart(width)} | ${line}`).join('\n');
75
+
76
+ const more = last < lines.length
77
+ ? `\n\n[lines ${from}-${last} of ${lines.length}. Continue with offset=${last + 1}.]`
78
+ : '';
79
+
80
+ return result(
81
+ body + more,
82
+ more || from > 1 ? `lines ${from}-${last} of ${lines.length}` : `${lines.length} lines`,
83
+ MAX_FILE_OUTPUT
84
+ );
85
+ }
86
+
87
+ /** At most this many files in one read_files call. */
88
+ const MAX_BATCH = 20;
89
+
90
+ /**
91
+ * Several files in one call.
92
+ *
93
+ * Reading from disk takes a millisecond. What makes reading slow is the round
94
+ * trip around it: every file read on its own is a whole request to the model,
95
+ * and on a reasoning model that is several seconds of thinking before it even
96
+ * asks for the next one. Reading the six files a change touches in one call
97
+ * turns six of those into one.
98
+ *
99
+ * The files are read in parallel, and a missing one is reported in its place
100
+ * rather than failing the rest — one wrong path should not cost the other five.
101
+ */
102
+ export async function readFiles({ paths, limit = READ_LINES }) {
103
+ if (!Array.isArray(paths) || paths.length === 0) {
104
+ throw new ToolFailure({
105
+ kind: 'bad_args',
106
+ attempted: 'reading several files',
107
+ failed: 'The "paths" argument must be a non-empty array of file paths.',
108
+ fix: 'Pass paths as ["src/app.js", "src/lib/api.js", ...].',
109
+ });
110
+ }
111
+
112
+ const wanted = [...new Set(paths.map((p) => String(p ?? '').trim()).filter(Boolean))];
113
+ const batch = wanted.slice(0, MAX_BATCH);
114
+ const dropped = wanted.slice(MAX_BATCH);
115
+
116
+ const readOne = (p) => readFile({ path: p, limit }).then(
117
+ (out) => ({ p, out }),
118
+ (err) => ({ p, err })
119
+ );
120
+
121
+ // Anything outside the project needs a yes, and two questions cannot be
122
+ // asked at once — so those go one at a time. Everything else goes together.
123
+ const outside = batch.some((p) => !resolveIn(p, 'read_files', 'paths').inside);
124
+ const settled = [];
125
+ if (outside) {
126
+ for (const p of batch) settled.push(await readOne(p));
127
+ } else {
128
+ settled.push(...(await Promise.all(batch.map(readOne))));
129
+ }
130
+
131
+ // Two whole files' worth of output between them. Past that, the rest are
132
+ // named rather than silently cut, so the model knows to ask again.
133
+ const budget = MAX_FILE_OUTPUT * 2;
134
+ let used = 0;
135
+ let read = 0;
136
+ let failed = 0;
137
+ let lines = 0;
138
+ const blocks = [];
139
+ const deferred = [];
140
+
141
+ for (const { p, out, err } of settled) {
142
+ if (err) {
143
+ failed++;
144
+ blocks.push(`=== ${p} — could not be read ===\n${err.forModel ? err.forModel() : err.message}`);
145
+ continue;
146
+ }
147
+ const block = `=== ${p} (${out.summary}) ===\n${out.content}`;
148
+ if (read > 0 && used + block.length > budget) {
149
+ deferred.push(p);
150
+ continue;
151
+ }
152
+ blocks.push(block);
153
+ used += block.length;
154
+ read++;
155
+ // "42 lines" for a whole file, "lines 1-600 of 900" for a page of one.
156
+ const whole = /^(\d+) lines$/.exec(out.summary);
157
+ const page = /^lines (\d+)-(\d+)/.exec(out.summary);
158
+ lines += whole ? Number(whole[1]) : page ? Number(page[2]) - Number(page[1]) + 1 : 0;
159
+ }
160
+
161
+ const leftOver = [...deferred, ...dropped];
162
+ if (leftOver.length) {
163
+ blocks.push(
164
+ `[not included, to stay inside one reply: ${leftOver.join(', ')}. ` +
165
+ 'Read those with another read_files call.]'
166
+ );
167
+ }
168
+
169
+ return result(
170
+ blocks.join('\n\n'),
171
+ `${read} file${read === 1 ? '' : 's'} · ${lines} lines` +
172
+ (failed ? ` · ${failed} missing` : '') +
173
+ (leftOver.length ? ` · ${leftOver.length} deferred` : ''),
174
+ budget + 2_000
175
+ );
176
+ }
177
+
178
+ /** Write one file, returning the rows that show what changed. */
179
+ async function put(target, content, { diffMax = 16 } = {}) {
180
+ const attempted = `writing ${target.show}`;
181
+
182
+ // Read what is there before clobbering it, so an overwrite can be shown as
183
+ // an actual diff rather than as a claim that something changed.
184
+ let previous = null;
185
+ try {
186
+ previous = await fs.readFile(target.abs, 'utf8');
187
+ } catch {
188
+ previous = null; // missing, or binary — either way it is treated as new
189
+ }
190
+
191
+ try {
192
+ await fs.mkdir(path.dirname(target.abs), { recursive: true });
193
+ await fs.writeFile(target.abs, content, 'utf8');
194
+ } catch (err) {
195
+ throw fsFailure(err, attempted, target.show);
196
+ }
197
+
198
+ const existed = previous !== null;
199
+ const lineCount = content === '' ? 0 : toLines(content).length;
200
+ const diff = existed
201
+ ? renderDiff(changedRegion(previous, content), { max: diffMax })
202
+ : (content === '' ? [] : renderNewFile(content, diffMax));
203
+
204
+ return {
205
+ existed,
206
+ lineCount,
207
+ diff,
208
+ line: `${existed ? 'Overwrote' : 'Created'} ${target.show} ` +
209
+ `(${lineCount} lines, ${bytes(Buffer.byteLength(content))})`,
210
+ };
211
+ }
212
+
213
+ export async function writeFile({ path: p, content }) {
214
+ const target = resolveIn(p, 'write_file');
215
+ if (typeof content !== 'string') {
216
+ throw new ToolFailure({
217
+ kind: 'bad_args',
218
+ attempted: `writing ${target.show}`,
219
+ failed: 'The "content" argument was missing or was not a string.',
220
+ fix: 'Call write_file again with content set to the whole text of the file.',
221
+ });
222
+ }
223
+ await guard(target, `write ${target.abs}`);
224
+
225
+ const written = await put(target, content);
226
+ const out = result(`${written.line}.`, `${written.existed ? 'overwrote' : 'created'} · ${written.lineCount} lines`);
227
+ out.diff = written.diff;
228
+ return out;
229
+ }
230
+
231
+ /**
232
+ * Several files in one call.
233
+ *
234
+ * Scaffolding a project is twenty writes before anything can be run, and doing
235
+ * that one round trip at a time is most of the wait.
236
+ */
237
+ export async function batchWrite({ files }) {
238
+ if (!Array.isArray(files) || files.length === 0) {
239
+ throw new ToolFailure({
240
+ kind: 'bad_args',
241
+ attempted: 'writing several files',
242
+ failed: 'The "files" argument must be a non-empty array.',
243
+ fix: 'Pass files as [{ path, content }, ...].',
244
+ });
245
+ }
246
+
247
+ const lines = [];
248
+ const diff = [];
249
+ let created = 0;
250
+
251
+ for (const [index, file] of files.entries()) {
252
+ const { path: p, content } = file ?? {};
253
+ if (typeof p !== 'string' || typeof content !== 'string') {
254
+ throw new ToolFailure({
255
+ kind: 'bad_args',
256
+ attempted: `writing file ${index + 1} of ${files.length}`,
257
+ failed: 'Every entry needs "path" and "content", both strings.',
258
+ fix: `Fix entry ${index + 1} and call batch_write again. ${index} file(s) were already written.`,
259
+ });
260
+ }
261
+
262
+ const target = resolveIn(p, 'batch_write');
263
+ await guard(target, `write ${target.abs}`);
264
+
265
+ // Per-file diffs are kept short here; twenty files at sixteen rows each
266
+ // would bury the reply under three hundred lines of gutter.
267
+ const written = await put(target, content, { diffMax: 6 });
268
+ if (!written.existed) created++;
269
+ lines.push(written.line);
270
+ diff.push(`~${target.show}`, ...written.diff);
271
+ }
272
+
273
+ const out = result(
274
+ lines.join('\n'),
275
+ `${files.length} file${files.length === 1 ? '' : 's'} · ${created} new`
276
+ );
277
+ out.diff = diff;
278
+ return out;
279
+ }
280
+
281
+ /**
282
+ * Why an old_string missed, worked out rather than guessed at.
283
+ *
284
+ * "not found" tells the model nothing it did not already know. Whether the
285
+ * text is present with different whitespace, or present but only its first
286
+ * line, is the difference between a fix on the next step and three more
287
+ * failed attempts.
288
+ */
289
+ function explainMiss(original, oldString, show) {
290
+ const flatten = (s) => s.replace(/\s+/g, ' ').trim();
291
+ const firstLine = oldString.split('\n')[0].trim();
292
+
293
+ if (flatten(original).includes(flatten(oldString))) {
294
+ return {
295
+ failed: `old_string is not in ${show} as written — the text is there, but the whitespace differs.`,
296
+ fix: "Match the file's own indentation exactly: tabs versus spaces, and the line breaks.",
297
+ };
298
+ }
299
+
300
+ const nearby = firstLine.length > 3
301
+ ? original.split(/\r?\n/)
302
+ .map((line, i) => [i + 1, line])
303
+ .filter(([, line]) => line.includes(firstLine))
304
+ .slice(0, 3)
305
+ : [];
306
+
307
+ if (nearby.length) {
308
+ return {
309
+ failed:
310
+ `old_string is not in ${show}. Its first line does appear at ` +
311
+ `line${nearby.length > 1 ? 's' : ''} ${nearby.map(([n]) => n).join(', ')}, ` +
312
+ 'so it is the lines after it that differ.',
313
+ fix: `Read ${show} around line ${nearby[0][0]} and copy the block exactly as it is.`,
314
+ };
315
+ }
316
+
317
+ return {
318
+ failed: `old_string does not appear anywhere in ${show}.`,
319
+ fix: `Read ${show} again and copy the text verbatim, without the line-number gutter.`,
320
+ };
321
+ }
322
+
323
+ /** Apply one replacement to a string, or explain precisely why it cannot. */
324
+ function replaceOnce(text, { old_string, new_string }, { show, attempted, label = '' }) {
325
+ const prefix = label ? `${label}: ` : '';
326
+
327
+ if (typeof old_string !== 'string' || typeof new_string !== 'string') {
328
+ throw new ToolFailure({
329
+ kind: 'bad_args', attempted,
330
+ failed: `${prefix}old_string and new_string must both be strings.`,
331
+ fix: 'Fix that entry and call again. Nothing was written.',
332
+ });
333
+ }
334
+ if (old_string === '') {
335
+ throw new ToolFailure({
336
+ kind: 'bad_args', attempted,
337
+ failed: `${prefix}old_string was empty.`,
338
+ fix: 'edit_file replaces existing text. Use write_file to create a file.',
339
+ });
340
+ }
341
+ if (old_string === new_string) {
342
+ throw new ToolFailure({
343
+ kind: 'bad_args', attempted,
344
+ failed: `${prefix}old_string and new_string are identical, so the edit would change nothing.`,
345
+ fix: 'Set new_string to the text you actually want there.',
346
+ });
347
+ }
348
+
349
+ const hits = text.split(old_string).length - 1;
350
+
351
+ if (hits === 0) {
352
+ const { failed, fix } = explainMiss(text, old_string, show);
353
+ throw new ToolFailure({ kind: 'no_match', attempted, failed: prefix + failed, fix });
354
+ }
355
+ if (hits > 1) {
356
+ throw new ToolFailure({
357
+ kind: 'ambiguous', attempted,
358
+ failed: `${prefix}old_string appears ${hits} times in ${show}. Refusing to guess which one you meant.`,
359
+ fix: 'Add surrounding lines to old_string until it matches exactly one place.',
360
+ detail: { hits },
361
+ });
362
+ }
363
+
364
+ const at = text.slice(0, text.indexOf(old_string)).split(/\r?\n/).length;
365
+ return { text: text.replace(old_string, () => new_string), at };
366
+ }
367
+
368
+ export async function editFile({ path: p, old_string, new_string }) {
369
+ const target = resolveIn(p, 'edit_file');
370
+ const attempted = `editing ${target.show}`;
371
+ await guard(target, `edit ${target.abs}`);
372
+
373
+ let original;
374
+ try {
375
+ original = await fs.readFile(target.abs, 'utf8');
376
+ } catch (err) {
377
+ throw fsFailure(err, attempted, target.show);
378
+ }
379
+
380
+ const { text, at } = replaceOnce(original, { old_string, new_string }, {
381
+ show: target.show, attempted,
382
+ });
383
+
384
+ try {
385
+ await fs.writeFile(target.abs, text, 'utf8');
386
+ } catch (err) {
387
+ throw fsFailure(err, attempted, target.show);
388
+ }
389
+
390
+ const delta = toLines(text).length - toLines(original).length;
391
+ const change = delta === 0 ? 'same line count' : `${delta > 0 ? '+' : ''}${delta} lines`;
392
+
393
+ const out = result(
394
+ `Replaced one occurrence in ${target.show} at line ${at} (${change}).`,
395
+ `1 change at line ${at} · ${change}`
396
+ );
397
+ // The replacement is diffed on its own and offset to where it landed, so
398
+ // the gutter shows the file's line numbers rather than 1, 2, 3.
399
+ out.diff = renderDiff(changedRegion(old_string, new_string), { offset: at - 1 });
400
+ return out;
401
+ }
402
+
403
+ /**
404
+ * Several replacements in one file, applied in order, each seeing the result
405
+ * of the one before it.
406
+ *
407
+ * Everything is validated against a working copy first. If the third edit is
408
+ * ambiguous, none of the three are written — a half-applied set of edits is a
409
+ * file in a state nobody designed.
410
+ */
411
+ export async function multiEdit({ path: p, edits }) {
412
+ const target = resolveIn(p, 'multi_edit');
413
+ const attempted = `editing ${target.show}`;
414
+
415
+ if (!Array.isArray(edits) || edits.length === 0) {
416
+ throw new ToolFailure({
417
+ kind: 'bad_args',
418
+ attempted,
419
+ failed: 'The "edits" argument must be a non-empty array.',
420
+ fix: 'Pass edits as [{ old_string, new_string }, ...].',
421
+ });
422
+ }
423
+
424
+ await guard(target, `edit ${target.abs}`);
425
+
426
+ let original;
427
+ try {
428
+ original = await fs.readFile(target.abs, 'utf8');
429
+ } catch (err) {
430
+ throw fsFailure(err, attempted, target.show);
431
+ }
432
+
433
+ let text = original;
434
+ const diff = [];
435
+
436
+ for (const [index, edit] of edits.entries()) {
437
+ const applied = replaceOnce(text, edit ?? {}, {
438
+ show: target.show,
439
+ attempted,
440
+ label: `edit ${index + 1} of ${edits.length}`,
441
+ });
442
+ diff.push(
443
+ ...renderDiff(changedRegion(edit.old_string, edit.new_string), {
444
+ offset: applied.at - 1,
445
+ max: 8,
446
+ })
447
+ );
448
+ text = applied.text;
449
+ }
450
+
451
+ try {
452
+ await fs.writeFile(target.abs, text, 'utf8');
453
+ } catch (err) {
454
+ throw fsFailure(err, attempted, target.show);
455
+ }
456
+
457
+ const delta = toLines(text).length - toLines(original).length;
458
+ const change = delta === 0 ? 'same line count' : `${delta > 0 ? '+' : ''}${delta} lines`;
459
+
460
+ const out = result(
461
+ `Applied ${edits.length} edits to ${target.show} (${change}).`,
462
+ `${edits.length} edits · ${change}`
463
+ );
464
+ out.diff = diff;
465
+ return out;
466
+ }