ucode-agent 1.62.3 → 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
 
@@ -1486,6 +1518,7 @@ export class Agent {
1486
1518
  const problems = await this.autoCheck();
1487
1519
  if (problems) {
1488
1520
  fixRounds++;
1521
+ this.fixing = true;
1489
1522
  if (reply.text) this.push({ role: 'assistant', content: reply.text });
1490
1523
  this.push({
1491
1524
  role: 'user',
@@ -1571,6 +1604,7 @@ export class Agent {
1571
1604
  if (handed?.done) return;
1572
1605
  if (handed?.problems && fixRounds < MAX_FIX_ROUNDS) {
1573
1606
  fixRounds++;
1607
+ this.fixing = true;
1574
1608
  this.push({
1575
1609
  role: 'user',
1576
1610
  content:
@@ -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) {
@@ -1878,7 +1920,34 @@ export class Agent {
1878
1920
  const found = /^(\d+) problem/.exec(out.summary ?? '');
1879
1921
  this.ui.runStat?.(found ? `${found[1]} to fix` : 'clean');
1880
1922
  }
1881
- 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
+ }
1882
1951
  }
1883
1952
 
1884
1953
  /**
@@ -2034,6 +2103,27 @@ export class Agent {
2034
2103
  // refused as "unchanged", and a live build spent sixty steps being told so.
2035
2104
  if (call.name === 'read_file' && call.args?.path) {
2036
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
+ }
2037
2127
  const key = `${fileKey(file)}#${Number(call.args.offset) || 1}:${Number(call.args.limit) || 0}`;
2038
2128
  const seen = (this.reads ??= new Map()).get(key) ?? 0;
2039
2129
  if (seen >= 2) {
@@ -2809,6 +2899,8 @@ export class Agent {
2809
2899
  this.ui.stopSpinner();
2810
2900
  if (result.folded) {
2811
2901
  this.working = result.messages;
2902
+ // What was read is summarised away now, so it may be read again.
2903
+ this.reads = new Map();
2812
2904
  this.ui.note(
2813
2905
  `folded ${result.droppedCount} earlier messages into a summary ` +
2814
2906
  '(the full history is still saved in this session)'
@@ -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 {