ucode-agent 1.1.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.
@@ -55,28 +55,28 @@ dotenv.config({ path: join(PACKAGE_ROOT, '.env'), quiet: true });
55
55
  */
56
56
  export const MODELS = {
57
57
  'nvidia/nemotron-3-ultra-550b-a55b:free': {
58
- name: 'Nemotron 3 Ultra (free)',
58
+ name: 'Nemotron 3 Ultra',
59
59
  context: 1_000_000,
60
60
  star: true,
61
61
  note: 'deepest reasoning, 1M context — the default',
62
62
  },
63
63
  'nvidia/nemotron-3.5-lightning:free': {
64
- name: 'Nemotron 3.5 Lightning (free)',
64
+ name: 'Nemotron 3.5 Lightning',
65
65
  context: 1_000_000,
66
66
  note: 'same huge window, answers much sooner',
67
67
  },
68
68
  'nvidia/nemotron-3-super-120b-a12b:free': {
69
- name: 'Nemotron 3 Super (free)',
69
+ name: 'Nemotron 3 Super',
70
70
  context: 262_144,
71
71
  note: 'strong all-rounder, quick to first token',
72
72
  },
73
73
  'nvidia/nemotron-3-nano-omni-30b-a3b-reasoning:free': {
74
- name: 'Nemotron 3 Nano Omni (free)',
74
+ name: 'Nemotron 3 Nano Omni',
75
75
  context: 256_000,
76
76
  note: 'small and fast, reasoning tuned',
77
77
  },
78
78
  'cohere/north-mini-code:free': {
79
- name: 'North Mini Code (free)',
79
+ name: 'North Mini Code',
80
80
  context: 256_000,
81
81
  star: true,
82
82
  note: 'code and UI specialist — reach for it on frontend work',
@@ -121,7 +121,7 @@ export function setModel(id) {
121
121
  return current;
122
122
  }
123
123
 
124
- /** The short name for a model id: "Nemotron 3 Ultra (free)". */
124
+ /** The short name for a model id: "Nemotron 3 Ultra". */
125
125
  export function modelName(id = current) {
126
126
  return MODELS[id]?.name ?? id;
127
127
  }
@@ -179,16 +179,18 @@ export function estimateConversation(messages) {
179
179
  * of first-run convenience is worth handing out a live credential.
180
180
  */
181
181
  function apiKey() {
182
- const key = (process.env.OPENROUTER_API_KEY ?? '').trim();
182
+ // UCODE_API_KEY is the documented name. The provider's own variable name is
183
+ // still read, so a key set up for another tool keeps working here.
184
+ const key = (process.env.UCODE_API_KEY || process.env.OPENROUTER_API_KEY || '').trim();
183
185
  if (!key) {
184
186
  throw new Failure({
185
187
  kind: 'no_api_key',
186
- attempted: 'connecting to OpenRouter',
187
- failed: 'OPENROUTER_API_KEY is not set in the environment or in any .env file.',
188
+ attempted: 'connecting to the model',
189
+ failed: 'No API key is set - UCODE_API_KEY is missing from the environment and from every .env file.',
188
190
  fix:
189
- `Put OPENROUTER_API_KEY=your-key in ${ENV_FILE} — that applies to every ` +
191
+ `Put UCODE_API_KEY=your-key in ${ENV_FILE} — that applies to every ` +
190
192
  'project on this machine — or in a .env file beside your code. ' +
191
- 'Keys are free at https://openrouter.ai/keys',
193
+ 'Free keys: https://openrouter.ai/keys',
192
194
  });
193
195
  }
194
196
  return key;
@@ -349,10 +351,10 @@ export function explain(err, id) {
349
351
  return new Failure({
350
352
  kind: 'invalid_api_key',
351
353
  attempted,
352
- failed: `OpenRouter rejected the API key (HTTP ${status ?? 401}).`,
354
+ failed: `The API key was rejected (HTTP ${status ?? 401}).`,
353
355
  fix:
354
- 'Check OPENROUTER_API_KEY for a typo or trailing space, and confirm the key ' +
355
- 'is still active at https://openrouter.ai/keys',
356
+ 'Check UCODE_API_KEY in ~/.ucode/.env for a typo or trailing space, and ' +
357
+ 'confirm the key is still active in your account.',
356
358
  cause: err,
357
359
  });
358
360
  }
@@ -380,8 +382,8 @@ export function explain(err, id) {
380
382
  ? `The free daily request cap for ${modelName(id)} is used up.`
381
383
  : `Too many requests for ${modelName(id)} just now${wait ? ` — clear in ${wait}` : ''}.`,
382
384
  fix: daily
383
- ? 'Free caps reset each day. /model switches to another one, or add credit at ' +
384
- 'https://openrouter.ai/credits to lift the ceiling.'
385
+ ? 'Free caps reset each day. /model switches to another one, or add credit to ' +
386
+ 'your account to lift the ceiling.'
385
387
  : 'ucode waits these out on its own. Free endpoints are shared, so it usually ' +
386
388
  'clears in seconds; /model moves to a quieter one.',
387
389
  detail: { retryAfter, daily },
@@ -406,7 +408,7 @@ export function explain(err, id) {
406
408
  return new Failure({
407
409
  kind: 'bad_model',
408
410
  attempted,
409
- failed: `OpenRouter has no model "${id}" available to this key.`,
411
+ failed: `No model "${id}" is available to this key.`,
410
412
  fix: `Run /model. ucode ships with: ${Object.keys(MODELS).join(', ')}`,
411
413
  cause: err,
412
414
  });
@@ -435,7 +437,7 @@ export function explain(err, id) {
435
437
  attempted,
436
438
  failed: noTools
437
439
  ? `${modelName(id)} cannot call tools, which ucode needs for every task.`
438
- : `OpenRouter rejected the request as malformed (HTTP 400): ${detail}`,
440
+ : `The request was rejected as malformed (HTTP 400): ${detail}`,
439
441
  fix: noTools
440
442
  ? 'Run /model and pick another one.'
441
443
  : 'Usually an oversized conversation. /new starts a fresh one.',
@@ -497,7 +499,7 @@ export function explain(err, id) {
497
499
  return new Failure({
498
500
  kind: 'network',
499
501
  attempted,
500
- failed: `The connection to OpenRouter dropped: ${raw}`,
502
+ failed: `The connection to the model dropped: ${raw}`,
501
503
  fix:
502
504
  'ucode retries this by itself. If it keeps happening, check your connection, ' +
503
505
  'VPN and any corporate proxy (HTTPS_PROXY) — or /model to a lighter one, since ' +
@@ -628,6 +630,8 @@ async function streamed(request, opts, id) {
628
630
  let finishReason = 'stop';
629
631
  let usage = null;
630
632
  const partial = new Map();
633
+ const handed = new Set();
634
+ let highest = -1;
631
635
 
632
636
  for await (const chunk of stream) {
633
637
  if (opts.signal?.aborted) break;
@@ -654,6 +658,20 @@ async function streamed(request, opts, id) {
654
658
  // A tool call's name and arguments arrive across several chunks, keyed by
655
659
  // index, so they are stitched back together here.
656
660
  for (const call of delta.tool_calls ?? []) {
661
+ // Calls arrive one after another, so the first chunk of call N means
662
+ // every call before it is complete. Those are handed over at once, and
663
+ // the caller can start running them while the rest are still being
664
+ // written — the reply streaming and the tools working overlap.
665
+ if (opts.onToolCall && call.index > highest) {
666
+ for (const [index, slot] of partial) {
667
+ if (index < call.index && !handed.has(index)) {
668
+ handed.add(index);
669
+ opts.onToolCall(readCall({ id: slot.id || `call_${index}`, name: slot.name, raw: slot.args }));
670
+ }
671
+ }
672
+ highest = call.index;
673
+ }
674
+
657
675
  const slot = partial.get(call.index) ?? { id: '', name: '', args: '' };
658
676
  if (call.id) slot.id = call.id;
659
677
  if (call.function?.name) slot.name += call.function.name;
@@ -663,9 +681,8 @@ async function streamed(request, opts, id) {
663
681
  }
664
682
 
665
683
  const toolCalls = [];
666
- let n = 0;
667
- for (const slot of partial.values()) {
668
- toolCalls.push(readCall({ id: slot.id || `call_${n++}`, name: slot.name, raw: slot.args }));
684
+ for (const [index, slot] of partial) {
685
+ toolCalls.push(readCall({ id: slot.id || `call_${index}`, name: slot.name, raw: slot.args }));
669
686
  }
670
687
 
671
688
  return {
@@ -0,0 +1,16 @@
1
+ /**
2
+ * version.js — the version, read from package.json rather than typed twice.
3
+ *
4
+ * package.json always ships in an npm package, so this is right wherever ucode
5
+ * is installed, and it can never drift from what `npm publish` released.
6
+ */
7
+
8
+ import { readFileSync } from 'node:fs';
9
+
10
+ export const VERSION = (() => {
11
+ try {
12
+ return JSON.parse(readFileSync(new URL('../../package.json', import.meta.url), 'utf8')).version;
13
+ } catch {
14
+ return '';
15
+ }
16
+ })();
@@ -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
+ }