ucode-agent 1.2.0 → 1.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +42 -8
- package/package.json +1 -1
- package/src/core/context.js +151 -0
- package/src/core/loop.js +376 -25
- package/src/core/provider.js +18 -3
- package/src/tools/files.js +173 -16
- package/src/tools/index.js +445 -394
- package/src/tools/shell.js +141 -0
- package/src/ui/plain.js +330 -325
- package/src/ui/screen.js +8 -2
- package/src/ui/theme.js +18 -0
package/src/tools/files.js
CHANGED
|
@@ -14,6 +14,7 @@ import {
|
|
|
14
14
|
resolveIn, guard, result, fsFailure, looksBinary, toLines, bytes,
|
|
15
15
|
changedRegion, renderDiff, renderNewFile, READ_LINES, MAX_FILE_OUTPUT,
|
|
16
16
|
} from './shared.js';
|
|
17
|
+
import { packageJsonWritten } from './shell.js';
|
|
17
18
|
|
|
18
19
|
export async function readFile({ path: p, offset = 1, limit = READ_LINES }) {
|
|
19
20
|
const target = resolveIn(p, 'read_file');
|
|
@@ -84,6 +85,15 @@ export async function readFile({ path: p, offset = 1, limit = READ_LINES }) {
|
|
|
84
85
|
);
|
|
85
86
|
}
|
|
86
87
|
|
|
88
|
+
/**
|
|
89
|
+
* Everything that writes a file ends here. A package.json with dependencies
|
|
90
|
+
* starts its install in the background at once, while the rest of the app is
|
|
91
|
+
* still being written.
|
|
92
|
+
*/
|
|
93
|
+
function written(target, content) {
|
|
94
|
+
if (path.basename(target.abs) === 'package.json') packageJsonWritten(target.abs, content);
|
|
95
|
+
}
|
|
96
|
+
|
|
87
97
|
/** At most this many files in one read_files call. */
|
|
88
98
|
const MAX_BATCH = 20;
|
|
89
99
|
|
|
@@ -194,6 +204,7 @@ async function put(target, content, { diffMax = 16 } = {}) {
|
|
|
194
204
|
} catch (err) {
|
|
195
205
|
throw fsFailure(err, attempted, target.show);
|
|
196
206
|
}
|
|
207
|
+
written(target, content);
|
|
197
208
|
|
|
198
209
|
const existed = previous !== null;
|
|
199
210
|
const lineCount = content === '' ? 0 : toLines(content).length;
|
|
@@ -346,23 +357,82 @@ function replaceOnce(text, { old_string, new_string }, { show, attempted, label
|
|
|
346
357
|
});
|
|
347
358
|
}
|
|
348
359
|
|
|
349
|
-
|
|
360
|
+
// Models write \n. A file checked out on Windows is often \r\n, and then an
|
|
361
|
+
// otherwise perfect old_string can never match. Speak the file's dialect.
|
|
362
|
+
let oldText = old_string;
|
|
363
|
+
let newText = new_string;
|
|
364
|
+
if (text.includes('\r\n') && !oldText.includes('\r')) {
|
|
365
|
+
oldText = oldText.replace(/\r?\n/g, '\r\n');
|
|
366
|
+
newText = newText.replace(/\r?\n/g, '\r\n');
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
const ambiguous = (hits, how = '') => new ToolFailure({
|
|
370
|
+
kind: 'ambiguous', attempted,
|
|
371
|
+
failed: `${prefix}old_string appears ${hits} times in ${show}${how}. Refusing to guess which one you meant.`,
|
|
372
|
+
fix: 'Add surrounding lines to old_string until it matches exactly one place.',
|
|
373
|
+
detail: { hits },
|
|
374
|
+
});
|
|
350
375
|
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
376
|
+
const hits = text.split(oldText).length - 1;
|
|
377
|
+
if (hits > 1) throw ambiguous(hits);
|
|
378
|
+
if (hits === 1) {
|
|
379
|
+
const at = text.slice(0, text.indexOf(oldText)).split(/\r?\n/).length;
|
|
380
|
+
return { text: text.replace(oldText, () => newText), at, loose: false };
|
|
354
381
|
}
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
382
|
+
|
|
383
|
+
// No exact match. The commonest reason by far is whitespace — tabs against
|
|
384
|
+
// spaces, a different indent depth, trailing spaces — with every word right.
|
|
385
|
+
// Match line by line ignoring that, and re-indent the replacement to fit.
|
|
386
|
+
// Still unique or nothing: a loose match found twice is refused like any other.
|
|
387
|
+
const loose = looseReplace(text, old_string, new_string);
|
|
388
|
+
if (loose?.count === 1) return { text: loose.text, at: loose.at, loose: true };
|
|
389
|
+
if (loose?.count > 1) throw ambiguous(loose.count, ' once whitespace is ignored');
|
|
390
|
+
|
|
391
|
+
const { failed, fix } = explainMiss(text, old_string, show);
|
|
392
|
+
throw new ToolFailure({ kind: 'no_match', attempted, failed: prefix + failed, fix });
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
/**
|
|
396
|
+
* Find old_string by its lines' content alone and swap in new_string, indented
|
|
397
|
+
* the way the file is indented at that spot. Returns { count } when it finds
|
|
398
|
+
* none or several, and { count: 1, text, at } when it finds exactly one.
|
|
399
|
+
*/
|
|
400
|
+
function looseReplace(text, oldString, newString) {
|
|
401
|
+
const eol = text.includes('\r\n') ? '\r\n' : '\n';
|
|
402
|
+
const lines = text.split(/\r?\n/);
|
|
403
|
+
|
|
404
|
+
const want = oldString.replace(/\r/g, '').split('\n');
|
|
405
|
+
while (want.length > 1 && !want[want.length - 1].trim()) want.pop();
|
|
406
|
+
while (want.length > 1 && !want[0].trim()) want.shift();
|
|
407
|
+
const target = want.map((l) => l.trim());
|
|
408
|
+
if (target.every((t) => !t)) return null;
|
|
409
|
+
|
|
410
|
+
const starts = [];
|
|
411
|
+
for (let i = 0; i + want.length <= lines.length; i++) {
|
|
412
|
+
let same = true;
|
|
413
|
+
for (let j = 0; j < want.length; j++) {
|
|
414
|
+
if (lines[i + j].trim() !== target[j]) { same = false; break; }
|
|
415
|
+
}
|
|
416
|
+
if (same) starts.push(i);
|
|
362
417
|
}
|
|
418
|
+
if (starts.length !== 1) return { count: starts.length };
|
|
419
|
+
|
|
420
|
+
const start = starts[0];
|
|
421
|
+
const indent = (l) => /^[ \t]*/.exec(l)[0];
|
|
422
|
+
const first = want.findIndex((l) => l.trim());
|
|
423
|
+
const fileIndent = indent(lines[start + first]);
|
|
424
|
+
const wroteIndent = indent(want[first]);
|
|
425
|
+
|
|
426
|
+
const replacement = newString.replace(/\r/g, '').split('\n');
|
|
427
|
+
if (replacement.length > 1 && replacement[replacement.length - 1] === '') replacement.pop();
|
|
428
|
+
const reindented = replacement.map((l) => {
|
|
429
|
+
if (!l.trim()) return l.trim();
|
|
430
|
+
const body = l.startsWith(wroteIndent) ? l.slice(wroteIndent.length) : l.replace(/^[ \t]*/, '');
|
|
431
|
+
return fileIndent + body;
|
|
432
|
+
});
|
|
363
433
|
|
|
364
|
-
const
|
|
365
|
-
return { text:
|
|
434
|
+
const out = [...lines.slice(0, start), ...reindented, ...lines.slice(start + want.length)];
|
|
435
|
+
return { count: 1, text: out.join(eol), at: start + 1 };
|
|
366
436
|
}
|
|
367
437
|
|
|
368
438
|
export async function editFile({ path: p, old_string, new_string }) {
|
|
@@ -377,7 +447,7 @@ export async function editFile({ path: p, old_string, new_string }) {
|
|
|
377
447
|
throw fsFailure(err, attempted, target.show);
|
|
378
448
|
}
|
|
379
449
|
|
|
380
|
-
const { text, at } = replaceOnce(original, { old_string, new_string }, {
|
|
450
|
+
const { text, at, loose } = replaceOnce(original, { old_string, new_string }, {
|
|
381
451
|
show: target.show, attempted,
|
|
382
452
|
});
|
|
383
453
|
|
|
@@ -386,13 +456,15 @@ export async function editFile({ path: p, old_string, new_string }) {
|
|
|
386
456
|
} catch (err) {
|
|
387
457
|
throw fsFailure(err, attempted, target.show);
|
|
388
458
|
}
|
|
459
|
+
written(target, text);
|
|
389
460
|
|
|
390
461
|
const delta = toLines(text).length - toLines(original).length;
|
|
391
462
|
const change = delta === 0 ? 'same line count' : `${delta > 0 ? '+' : ''}${delta} lines`;
|
|
463
|
+
const how = loose ? ', matched ignoring whitespace and re-indented to fit' : '';
|
|
392
464
|
|
|
393
465
|
const out = result(
|
|
394
|
-
`Replaced one occurrence in ${target.show} at line ${at} (${change}).`,
|
|
395
|
-
`1 change at line ${at} · ${change}`
|
|
466
|
+
`Replaced one occurrence in ${target.show} at line ${at} (${change}${how}).`,
|
|
467
|
+
`1 change at line ${at} · ${change}${loose ? ' · whitespace-tolerant' : ''}`
|
|
396
468
|
);
|
|
397
469
|
// The replacement is diffed on its own and offset to where it landed, so
|
|
398
470
|
// the gutter shows the file's line numbers rather than 1, 2, 3.
|
|
@@ -453,6 +525,7 @@ export async function multiEdit({ path: p, edits }) {
|
|
|
453
525
|
} catch (err) {
|
|
454
526
|
throw fsFailure(err, attempted, target.show);
|
|
455
527
|
}
|
|
528
|
+
written(target, text);
|
|
456
529
|
|
|
457
530
|
const delta = toLines(text).length - toLines(original).length;
|
|
458
531
|
const change = delta === 0 ? 'same line count' : `${delta > 0 ? '+' : ''}${delta} lines`;
|
|
@@ -464,3 +537,87 @@ export async function multiEdit({ path: p, edits }) {
|
|
|
464
537
|
out.diff = diff;
|
|
465
538
|
return out;
|
|
466
539
|
}
|
|
540
|
+
|
|
541
|
+
/**
|
|
542
|
+
* Exact replacements across several files in one call.
|
|
543
|
+
*
|
|
544
|
+
* A change that touches the route, the component and the type together is one
|
|
545
|
+
* round trip instead of three. Every edit in every file is applied to a copy
|
|
546
|
+
* in memory first; if any of them fails, nothing is written anywhere — a
|
|
547
|
+
* cross-file change that half landed leaves the project in a state that
|
|
548
|
+
* compiles nowhere.
|
|
549
|
+
*/
|
|
550
|
+
export async function editFiles({ files }) {
|
|
551
|
+
if (!Array.isArray(files) || files.length === 0) {
|
|
552
|
+
throw new ToolFailure({
|
|
553
|
+
kind: 'bad_args',
|
|
554
|
+
attempted: 'editing several files',
|
|
555
|
+
failed: 'The "files" argument must be a non-empty array.',
|
|
556
|
+
fix: 'Pass files as [{ path, edits: [{ old_string, new_string }, ...] }, ...].',
|
|
557
|
+
});
|
|
558
|
+
}
|
|
559
|
+
|
|
560
|
+
const planned = [];
|
|
561
|
+
const seen = new Set();
|
|
562
|
+
|
|
563
|
+
for (const [i, entry] of files.entries()) {
|
|
564
|
+
const target = resolveIn(entry?.path, 'edit_files');
|
|
565
|
+
const attempted = `editing ${target.show}`;
|
|
566
|
+
|
|
567
|
+
if (seen.has(target.abs)) {
|
|
568
|
+
throw new ToolFailure({
|
|
569
|
+
kind: 'bad_args', attempted,
|
|
570
|
+
failed: `${target.show} is listed twice.`,
|
|
571
|
+
fix: 'List each file once, with all of its edits together. Nothing was written.',
|
|
572
|
+
});
|
|
573
|
+
}
|
|
574
|
+
seen.add(target.abs);
|
|
575
|
+
|
|
576
|
+
if (!Array.isArray(entry.edits) || entry.edits.length === 0) {
|
|
577
|
+
throw new ToolFailure({
|
|
578
|
+
kind: 'bad_args', attempted,
|
|
579
|
+
failed: `File ${i + 1} (${target.show}) has no edits.`,
|
|
580
|
+
fix: 'Give every file a non-empty edits array. Nothing was written.',
|
|
581
|
+
});
|
|
582
|
+
}
|
|
583
|
+
|
|
584
|
+
await guard(target, `edit ${target.abs}`);
|
|
585
|
+
|
|
586
|
+
let original;
|
|
587
|
+
try {
|
|
588
|
+
original = await fs.readFile(target.abs, 'utf8');
|
|
589
|
+
} catch (err) {
|
|
590
|
+
throw fsFailure(err, attempted, target.show);
|
|
591
|
+
}
|
|
592
|
+
|
|
593
|
+
let text = original;
|
|
594
|
+
const diff = [`~${target.show}`];
|
|
595
|
+
for (const [j, edit] of entry.edits.entries()) {
|
|
596
|
+
const applied = replaceOnce(text, edit ?? {}, {
|
|
597
|
+
show: target.show,
|
|
598
|
+
attempted,
|
|
599
|
+
label: `${target.show}, edit ${j + 1} of ${entry.edits.length} (nothing was written)`,
|
|
600
|
+
});
|
|
601
|
+
diff.push(...renderDiff(changedRegion(edit.old_string, edit.new_string), { offset: applied.at - 1, max: 6 }));
|
|
602
|
+
text = applied.text;
|
|
603
|
+
}
|
|
604
|
+
planned.push({ target, text, diff, count: entry.edits.length });
|
|
605
|
+
}
|
|
606
|
+
|
|
607
|
+
for (const { target, text } of planned) {
|
|
608
|
+
try {
|
|
609
|
+
await fs.writeFile(target.abs, text, 'utf8');
|
|
610
|
+
} catch (err) {
|
|
611
|
+
throw fsFailure(err, `editing ${target.show}`, target.show);
|
|
612
|
+
}
|
|
613
|
+
written(target, text);
|
|
614
|
+
}
|
|
615
|
+
|
|
616
|
+
const edits = planned.reduce((n, p) => n + p.count, 0);
|
|
617
|
+
const out = result(
|
|
618
|
+
planned.map((p) => `Edited ${p.target.show} (${p.count} change${p.count === 1 ? '' : 's'})`).join('\n'),
|
|
619
|
+
`${planned.length} files · ${edits} edits`
|
|
620
|
+
);
|
|
621
|
+
out.diff = planned.flatMap((p) => p.diff);
|
|
622
|
+
return out;
|
|
623
|
+
}
|