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/README.md +464 -464
- package/package.json +63 -63
- package/skills/build-app/DIGEST.md +154 -145
- package/skills/build-app/SKILL.md +10 -1
- package/src/core/loop.js +98 -6
- package/src/tools/index.js +5 -1
- package/src/tools/shell.js +15 -0
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
|
-
|
|
1220
|
-
|
|
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)'
|
package/src/tools/index.js
CHANGED
|
@@ -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: {
|
package/src/tools/shell.js
CHANGED
|
@@ -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 {
|