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.
@@ -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
- const hits = text.split(old_string).length - 1;
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
- if (hits === 0) {
352
- const { failed, fix } = explainMiss(text, old_string, show);
353
- throw new ToolFailure({ kind: 'no_match', attempted, failed: prefix + failed, fix });
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
- 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
- });
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 at = text.slice(0, text.indexOf(old_string)).split(/\r?\n/).length;
365
- return { text: text.replace(old_string, () => new_string), at };
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
+ }