ucode-agent 1.62.2 → 1.62.4

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/src/core/loop.js CHANGED
@@ -50,6 +50,7 @@ import { Failure, ToolFailure, Declined } from './failure.js';
50
50
  import { StuckWatch, eventFor, describeHit } from './stuck.js';
51
51
  import { serversReadySince } from '../tools/shell.js';
52
52
  import { formatDuration } from '../ui/activity.js';
53
+ import { MAX_FILE_OUTPUT } from '../tools/shared.js';
53
54
  import { runDoctor } from './doctor.js';
54
55
  import { JS_LOGIC } from './jslogic.js';
55
56
  import { deploy } from '../tools/deploy.js';
@@ -109,6 +110,25 @@ const SILENT = new Set(['update_plan']);
109
110
  /** How many rounds of "the type check found errors, fix them" one turn may take. */
110
111
  const MAX_FIX_ROUNDS = 3;
111
112
 
113
+ /**
114
+ * Rides on a new app's request, the last thing the model reads. In the system
115
+ * prompt and the skill alone, Flash-Lite still built "make me a tasks app" as a
116
+ * tasks app plus pomodoro timer, kanban board and analytics — twice running.
117
+ */
118
+ const SCOPE_NOTE =
119
+ '(From ucode: if this asks for a new app, build it complete and well made, but as the one app asked ' +
120
+ 'for - no extra views or tools such as timers, calendars, kanban boards, analytics, stats or an ' +
121
+ 'editor for making your own, unless the request names them.)';
122
+
123
+ /** A request that may be for a new app. Loose on purpose: the note it adds says "if". */
124
+ const BUILD_ASK = /\b(?:make|build|create|design|code|write|generate|develop)\b|\bi\s+(?:want|need)\b|\b(?:app|game|website|site|page|tracker|calculator|quiz)\b/i;
125
+
126
+ /** A file up to this many lines is sent whole on its first read, whatever slice was asked for. */
127
+ const WHOLE_READ_LINES = 1500;
128
+
129
+ /** Lookups in a row, during a fix round with nothing changed, before ucode says to stop reading. */
130
+ const LOOKUP_NUDGE = 5;
131
+
112
132
  /** Files worth checking after they change. */
113
133
  /** A file with a page in it — something a browser can be pointed at. */
114
134
  const PAGE = /\.(?:html?|tsx|jsx)$/i;
@@ -621,6 +641,15 @@ function systemPrompt({ cwd, skills, mode, check, map, memory }) {
621
641
  'already contains it. Do not re-check work the checks have already reported on.',
622
642
  'Fast is not sloppy: it is the same work with the waiting taken out.',
623
643
  '',
644
+ 'BUILD WHAT WAS ASKED - ALL OF IT, AND NOTHING ELSE. A short request ("a tasks',
645
+ 'app", "a quiz", "a snake game") still gets a complete, well-made app: every',
646
+ 'feature someone using that kind of app expects on first use. A tasks app adds,',
647
+ 'edits, ticks off, deletes, filters and remembers across a reload. It does NOT',
648
+ 'also get a pomodoro timer, a calendar, an analytics dashboard, stats charts or a',
649
+ 'shortcuts panel - those are other apps. Extra views are where builds break, and',
650
+ 'each one costs the user minutes. One screen that does its job perfectly beats',
651
+ 'four that half work. An extra worth having: offer it in one line at the end.',
652
+ '',
624
653
  'DESIGN IT BEFORE YOU TYPE IT. Fast means fewer round trips. It does not mean a',
625
654
  'default theme, and an app that goes out in the palette its starter came with is',
626
655
  'not a fast build, it is an undesigned one. There is no design pass after the',
@@ -632,7 +661,7 @@ function systemPrompt({ cwd, skills, mode, check, map, memory }) {
632
661
  ' - An ACCENT that is not the one the starter shipped with.',
633
662
  ' - ONE memorable thing this app has that other apps do not: a colour, a type',
634
663
  ' move, a texture, one interaction. Exactly one. It is the difference between',
635
- ' a design and a theme.',
664
+ ' a design and a theme - a design choice, never an extra feature or view.',
636
665
  'Name the tone and the accent in your opening line, so they are settled before any',
637
666
  'file exists: "I will build Tide - a tasks app in one HTML file, calm, warm grey',
638
667
  'with a single amber accent." That is not narrating a plan, that is the decision.',
@@ -1205,6 +1234,9 @@ export class Agent {
1205
1234
  this.appName = null;
1206
1235
  this.appTemplate = null;
1207
1236
  this.reads = new Map();
1237
+ this.fixing = false;
1238
+ this.lookups = 0;
1239
+ this.nudgedAt = 0;
1208
1240
  this.declines = 0;
1209
1241
  this.apps = [];
1210
1242
  this.wrote = new Map();
@@ -1216,9 +1248,8 @@ export class Agent {
1216
1248
  // model answered: asked for a dark mode toggle on its second turn, a live
1217
1249
  // run re-read two files and repeated its first turn's summary instead.
1218
1250
  this.autoLoad(input);
1219
- this.push(images.length
1220
- ? { role: 'user', content: input, images }
1221
- : { role: 'user', content: input });
1251
+ const request = images.length ? { role: 'user', content: input, images } : { role: 'user', content: input };
1252
+ this.push(request);
1222
1253
 
1223
1254
  if (!this.session.title || this.session.title === 'Untitled') {
1224
1255
  this.session.title = titleFrom(input);
@@ -1231,6 +1262,7 @@ export class Agent {
1231
1262
  // Nothing to look up in an empty folder, so those tools do not go out with
1232
1263
  // the request. Decided per turn: the moment there is code, they are back.
1233
1264
  this.fresh = !hasCode(this.map);
1265
+ if (this.fresh || BUILD_ASK.test(input)) request.content = `${input}\n\n${SCOPE_NOTE}`;
1234
1266
  this.wantsWeb = WANTS_WEB.test(input);
1235
1267
  await this.persist();
1236
1268
 
@@ -1246,7 +1278,8 @@ export class Agent {
1246
1278
  this.abort = new AbortController();
1247
1279
  this.endedSilently = false;
1248
1280
 
1249
- const turnStarted = Date.now();
1281
+ // Kept on the agent too: the hand-over says how long the build took.
1282
+ const turnStarted = (this.turnStarted = Date.now());
1250
1283
  let finished = false;
1251
1284
  this.ui.turnStart?.();
1252
1285
  try {
@@ -1485,6 +1518,7 @@ export class Agent {
1485
1518
  const problems = await this.autoCheck();
1486
1519
  if (problems) {
1487
1520
  fixRounds++;
1521
+ this.fixing = true;
1488
1522
  if (reply.text) this.push({ role: 'assistant', content: reply.text });
1489
1523
  this.push({
1490
1524
  role: 'user',
@@ -1570,6 +1604,7 @@ export class Agent {
1570
1604
  if (handed?.done) return;
1571
1605
  if (handed?.problems && fixRounds < MAX_FIX_ROUNDS) {
1572
1606
  fixRounds++;
1607
+ this.fixing = true;
1573
1608
  this.push({
1574
1609
  role: 'user',
1575
1610
  content:
@@ -1592,8 +1627,7 @@ export class Agent {
1592
1627
  `${modelName()} produced invalid tool arguments ${argRetries} times running ` +
1593
1628
  'and could not correct itself.',
1594
1629
  fix:
1595
- 'Say what you want more concretely, or /model to another one — North Mini ' +
1596
- 'Code and Nemotron 3.5 Lightning are both steadier with tool arguments.',
1630
+ 'Say what you want more concretely, or /model to another one.',
1597
1631
  });
1598
1632
  }
1599
1633
  } else {
@@ -1643,8 +1677,16 @@ export class Agent {
1643
1677
  const noted = (call) => {
1644
1678
  if (FILE_WRITES.has(call.name)) {
1645
1679
  for (const p of pathsOf(call)) { this.touched.add(p); this.sinceCheck.add(p); this.forgetReads(p); }
1680
+ this.lookups = 0;
1681
+ this.nudgedAt = 0;
1682
+ } else if (PARALLEL_SAFE.has(call.name)) {
1683
+ this.lookups = (this.lookups ?? 0) + 1;
1684
+ }
1685
+ if (call.name === 'run_command' || call.name === 'run_commands') {
1686
+ this.ranSomething = true;
1687
+ // A command can rewrite any file, so no whole read is still known to be current.
1688
+ for (const key of this.reads?.keys() ?? []) if (key.endsWith('#whole')) this.reads.delete(key);
1646
1689
  }
1647
- if (call.name === 'run_command' || call.name === 'run_commands') this.ranSomething = true;
1648
1690
  };
1649
1691
 
1650
1692
  if (group.length > 1) {
@@ -1750,13 +1792,29 @@ export class Agent {
1750
1792
  const server = serversReadySince(since).at(-1);
1751
1793
  if (!server || (this.opened ??= new Set()).has(server.url)) return;
1752
1794
  this.opened.add(server.url);
1795
+ if (this.openInBrowser(server.url)) this.ui.note(`Opened ${server.url} in your browser`);
1796
+ }
1797
+
1798
+ /**
1799
+ * Show a URL or a local page in the desktop browser. False when it did not.
1800
+ *
1801
+ * A file goes to explorer on Windows, not `cmd /c start`: the folder name
1802
+ * comes from the model, and an & in it would be a second command to cmd.
1803
+ */
1804
+ openInBrowser(target) {
1805
+ if (!this.full || process.env.UCODE_OPEN === '0') return false;
1806
+ const web = /^https?:\/\//.test(target);
1807
+ // Only pages inside the project: a UNC path would have explorer reach out to another machine.
1808
+ const rel = web ? '' : path.relative(this.cwd, target);
1809
+ if (!web && (!rel || rel.startsWith('..') || path.isAbsolute(rel))) return false;
1753
1810
  const [cmd, args] = process.platform === 'win32'
1754
- ? ['cmd', ['/c', 'start', '', server.url]]
1755
- : [process.platform === 'darwin' ? 'open' : 'xdg-open', [server.url]];
1811
+ ? (web ? ['cmd', ['/c', 'start', '', target]] : ['explorer.exe', [target]])
1812
+ : [process.platform === 'darwin' ? 'open' : 'xdg-open', [target]];
1756
1813
  try {
1757
- spawn(cmd, args, { stdio: 'ignore', detached: true, windowsHide: true }).unref();
1758
- this.ui.note(`Opened ${server.url} in your browser`);
1759
- } catch { /* no browser to open — the link is in the answer */ }
1814
+ // A missing opener (no xdg-open) fails later, as an event; unheard, it would crash ucode.
1815
+ spawn(cmd, args, { stdio: 'ignore', detached: true, windowsHide: true }).on('error', () => {}).unref();
1816
+ return true;
1817
+ } catch { return false; /* no browser to open — the link is in the answer */ }
1760
1818
  }
1761
1819
 
1762
1820
  cmdStats() {
@@ -1862,7 +1920,34 @@ export class Agent {
1862
1920
  const found = /^(\d+) problem/.exec(out.summary ?? '');
1863
1921
  this.ui.runStat?.(found ? `${found[1]} to fix` : 'clean');
1864
1922
  }
1865
- this.push({ role: 'tool', toolCallId: call.id, name: call.name, content: out.content + this.stuckNote(call, { out }) });
1923
+ this.push({ role: 'tool', toolCallId: call.id, name: call.name, content: out.content + this.stuckNote(call, { out }) + this.lookupNote(call) });
1924
+ }
1925
+
1926
+ /**
1927
+ * Reading on and on while ucode's list of errors waits. A live fix round
1928
+ * read a 1000-line app.js in seventeen slices, changed nothing, and ran the
1929
+ * free key into its per-minute limit: every read resends the conversation.
1930
+ */
1931
+ lookupNote(call) {
1932
+ if (!this.fixing || !PARALLEL_SAFE.has(call.name)) return '';
1933
+ if (this.lookups < LOOKUP_NUDGE || this.lookups - this.nudgedAt < LOOKUP_NUDGE) return '';
1934
+ this.nudgedAt = this.lookups;
1935
+ return `\n\nSTOP reading - that is ${this.lookups} lookups since ucode listed the errors, and nothing has been ` +
1936
+ 'changed yet. What you need is already above. Make the fix now with edit_file or multi_edit; ucode ' +
1937
+ 'checks the app again straight after.';
1938
+ }
1939
+
1940
+ /** Lines in a file small enough to send whole in one read, or null. */
1941
+ async wholeSize(file) {
1942
+ try {
1943
+ const text = await readFile(path.resolve(this.cwd, file), 'utf8');
1944
+ if (text.includes('\0')) return null;
1945
+ const lines = text.split('\n').length;
1946
+ // The line-number gutter adds about eight characters a line to what read_file sends.
1947
+ return lines <= WHOLE_READ_LINES && text.length + lines * 8 <= MAX_FILE_OUTPUT ? { lines } : null;
1948
+ } catch {
1949
+ return null;
1950
+ }
1866
1951
  }
1867
1952
 
1868
1953
  /**
@@ -2018,6 +2103,27 @@ export class Agent {
2018
2103
  // refused as "unchanged", and a live build spent sixty steps being told so.
2019
2104
  if (call.name === 'read_file' && call.args?.path) {
2020
2105
  const file = String(call.args.path);
2106
+
2107
+ // A file that fits is sent whole the first time, and not again until
2108
+ // something writes to it. Slices were the loop: a live fix round read one
2109
+ // app.js in seventeen of them, never edited, and hit the per-minute limit.
2110
+ // The lead only — a worker has its own conversation, without the lead's reads.
2111
+ if (offered === this.offering) {
2112
+ const whole = `${fileKey(file)}#whole`;
2113
+ if (this.reads.has(whole)) {
2114
+ return {
2115
+ content: `All of ${file} is already above: you read the whole file and nothing has written to it ` +
2116
+ 'since. Use that text instead of reading it again. If something in it needs changing, change it ' +
2117
+ 'now with edit_file or multi_edit.',
2118
+ summary: 'unchanged, already read in full',
2119
+ };
2120
+ }
2121
+ // Marked before the await, so two reads of one file in a parallel batch send it once.
2122
+ this.reads.set(whole, 1);
2123
+ const size = await this.wholeSize(file);
2124
+ if (size) call.args = { ...call.args, offset: 1, limit: size.lines };
2125
+ else this.reads.delete(whole);
2126
+ }
2021
2127
  const key = `${fileKey(file)}#${Number(call.args.offset) || 1}:${Number(call.args.limit) || 0}`;
2022
2128
  const seen = (this.reads ??= new Map()).get(key) ?? 0;
2023
2129
  if (seen >= 2) {
@@ -2597,11 +2703,16 @@ export class Agent {
2597
2703
  const made = calls.find((c) => c.name === 'create_app' && c.args?.name)?.args.name ?? this.appName;
2598
2704
  const name = String(made ?? path.basename(app)).trim();
2599
2705
  this.appName = name;
2600
- const link = pathToFileURL(path.join(app, 'index.html')).href;
2601
- const text = `${name} is done. Open it here: ${link}\n\n(or open \`${show}/index.html\` in your browser)`;
2706
+ const page = path.join(app, 'index.html');
2707
+ const link = pathToFileURL(page).href;
2708
+ // "Quiz is done in 42s": how long the whole build took, from the request.
2709
+ const took = this.turnStarted ? ` in ${formatDuration(Date.now() - this.turnStarted)}` : '';
2710
+ const text = `${name} is done${took}. Open it here: ${link}\n\n(or open \`${show}/index.html\` in your browser)`;
2602
2711
  this.push({ role: 'assistant', content: text });
2603
2712
  this.ui.assistant(text, { closing: true });
2604
2713
  await this.persist();
2714
+ // The app pops up on its own; UCODE_OPEN=0 keeps it to the link.
2715
+ this.openInBrowser(page);
2605
2716
  return { done: true };
2606
2717
  }
2607
2718
 
@@ -2788,6 +2899,8 @@ export class Agent {
2788
2899
  this.ui.stopSpinner();
2789
2900
  if (result.folded) {
2790
2901
  this.working = result.messages;
2902
+ // What was read is summarised away now, so it may be read again.
2903
+ this.reads = new Map();
2791
2904
  this.ui.note(
2792
2905
  `folded ${result.droppedCount} earlier messages into a summary ` +
2793
2906
  '(the full history is still saved in this session)'
@@ -500,9 +500,8 @@ export function explain(err, id) {
500
500
  : `Too many requests for ${modelName(id)} just now${wait ? ` — clear in ${wait}` : ''}.`,
501
501
  fix: daily
502
502
  ? `It resets ${resetAt ? `at ${resetAt.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })}` : 'once a day'}. ` +
503
- 'Adding credit to your account raises the limit.'
504
- : 'ucode waits these out on its own. Free endpoints are shared, so it usually ' +
505
- 'clears in seconds; /model moves to a quieter one.',
503
+ 'Nothing to do until then — the key is fine.'
504
+ : 'ucode waits these out on its own, usually for a few seconds. /model moves to another one.',
506
505
  detail: { retryAfter, daily, resetAt: resetAt?.getTime() ?? null },
507
506
  cause: err,
508
507
  });
@@ -596,10 +595,7 @@ export function explain(err, id) {
596
595
  kind: 'timeout',
597
596
  attempted,
598
597
  failed: `${modelName(id)} sent nothing back in time — the provider dropped the request.`,
599
- fix:
600
- 'Free endpoints stall under load, and the largest reasoning models are the ' +
601
- 'first to. ucode already retried. If it keeps happening, /model to Nemotron ' +
602
- '3.5 Lightning or North Mini Code, which answer sooner.',
598
+ fix: 'ucode already retried. Google is busy right now — send again, or /model to another one.',
603
599
  detail: { status },
604
600
  cause: err,
605
601
  });
@@ -609,7 +605,7 @@ export function explain(err, id) {
609
605
  return new Failure({
610
606
  kind: 'server',
611
607
  attempted,
612
- failed: `The provider returned HTTP ${status}. That is their side, not yours.`,
608
+ failed: `Google's servers are busy right now (HTTP ${status}). That is their side, not yours.`,
613
609
  fix: 'Wait a few seconds and send again. If it persists, /model to another one.',
614
610
  cause: err,
615
611
  });
@@ -627,11 +623,14 @@ export function explain(err, id) {
627
623
  return new Failure({
628
624
  kind: 'network',
629
625
  attempted,
630
- failed: `The connection to the model dropped: ${raw}`,
626
+ // No DNS answer: the machine is offline, and saying so
627
+ // beats a raw "getaddrinfo ENOTFOUND" on a demo screen.
628
+ failed: /ENOTFOUND|EAI_AGAIN|getaddrinfo/i.test(raw)
629
+ ? 'ucode could not reach Google — this computer looks offline.'
630
+ : `The connection to Google dropped: ${raw}`,
631
631
  fix:
632
- 'ucode retries this by itself. If it keeps happening, check your connection, ' +
633
- 'VPN and any corporate proxy (HTTPS_PROXY) — or /model to a lighter one, since ' +
634
- 'a long think on a busy free endpoint is the usual cause.',
632
+ 'ucode retries this by itself. If it keeps happening, check the Wi-Fi, ' +
633
+ 'VPN and any proxy (HTTPS_PROXY), then send again.',
635
634
  cause: err,
636
635
  });
637
636
  }
@@ -785,7 +784,7 @@ async function streamed(request, opts, id) {
785
784
  kind: 'timeout',
786
785
  attempted: `asking ${modelName(id)} for a reply`,
787
786
  failed: `${modelName(id)} went silent for ${Math.round(limit() / 1000)}s, so ucode stopped waiting.`,
788
- fix: 'ucode asks again by itself. If it keeps freezing, /model to North Mini Code.',
787
+ fix: 'ucode asks again by itself. If it keeps freezing, /model to another one.',
789
788
  detail: { stalled: true, handed: handed.size },
790
789
  cause,
791
790
  });
@@ -99,7 +99,11 @@ export const tools = [
99
99
  'In "next-shadcn" every path in "files" goes under src/: a page is a route only at ' +
100
100
  '<app>/src/app/page.tsx or <app>/src/app/<segment>/page.tsx, an API handler only at ' +
101
101
  '<app>/src/app/api/<name>/route.ts, and components at <app>/src/components/<feature>/. ' +
102
- 'A page.tsx written anywhere else is an ordinary file the router never serves.',
102
+ 'A page.tsx written anywhere else is an ordinary file the router never serves. ' +
103
+ 'Scope: build what was asked, complete and well made - every feature someone using that kind of ' +
104
+ 'app expects on first use (a tasks app adds, edits, ticks off, deletes, filters and remembers across ' +
105
+ 'a reload) - and nothing from a different app (no timer, calendar or analytics bolted onto a tasks ' +
106
+ 'app). Features nobody named cost minutes and are where builds break.',
103
107
  parameters: {
104
108
  type: 'object',
105
109
  properties: {
@@ -57,6 +57,13 @@ const LOOKS_LIKE_INSTALL =
57
57
  /** Opening a file or URL in the desktop browser: start, open, xdg-open, explorer. */
58
58
  const OPENS_BROWSER = /^\s*(?:cd\s+[^&;|]+&&\s*)?(?:start(?:\s+"")?|open|xdg-open|explorer(?:\.exe)?|cmd(?:\.exe)?\s+\/c\s+start)\s+\S+\.html?\b|^\s*(?:start|open|xdg-open)\s+https?:\/\//i;
59
59
 
60
+ /**
61
+ * Installing a browser to test the app. ucode already drives a real one after
62
+ * every change; a live quiz build spent two minutes on `npx @puppeteer/browsers
63
+ * install chrome` before it timed out.
64
+ */
65
+ const INSTALLS_BROWSER = /@puppeteer\/browsers\s+install|\bplaywright(?:@\S+)?\s+install\b/i;
66
+
60
67
  /**
61
68
  * Any kill by program name rather than by process number.
62
69
  *
@@ -672,6 +679,14 @@ export async function runCommand({ command, cwd, timeout_ms, background }, { onO
672
679
  );
673
680
  }
674
681
 
682
+ if (INSTALLS_BROWSER.test(command)) {
683
+ return result(
684
+ 'Not run: ucode already opens the app in a real browser after every change and reports what it finds. ' +
685
+ 'Fix what that report names; there is no browser to install.',
686
+ 'not needed — ucode has a browser',
687
+ );
688
+ }
689
+
675
690
  if (KILLS_BY_NAME.test(command)) {
676
691
  const attempted = `running \`${command.trim().slice(0, 80)}\``;
677
692
  try {