ucode-agent 1.57.0 → 1.58.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.
- package/package.json +1 -1
- package/src/core/loop.js +95 -8
- package/src/tools/shell.js +13 -0
- package/src/ui/plain.js +2 -3
- package/src/ui/screen.js +1735 -1749
package/package.json
CHANGED
package/src/core/loop.js
CHANGED
|
@@ -13,6 +13,7 @@
|
|
|
13
13
|
|
|
14
14
|
import path from 'node:path';
|
|
15
15
|
import os from 'node:os';
|
|
16
|
+
import { pathToFileURL } from 'node:url';
|
|
16
17
|
import { appendFileSync, existsSync } from 'node:fs';
|
|
17
18
|
import { readFile, readdir, access, mkdir } from 'node:fs/promises';
|
|
18
19
|
import { testRunnerFor, relatedCommand, summariseFailures } from './tests.js';
|
|
@@ -784,8 +785,9 @@ function systemPrompt({ cwd, skills, mode, check, map, memory }) {
|
|
|
784
785
|
' Next.js, Vite, anything with an npm run dev - start it and leave it up when you',
|
|
785
786
|
' finish. ucode opens it in the browser for the user as soon as it is ready, so a',
|
|
786
787
|
' build that ends with the server stopped ends with nothing to look at. A one-page',
|
|
787
|
-
' app with no server needs none of this: the file is the app.',
|
|
788
|
-
'
|
|
788
|
+
' app with no server needs none of this: the file is the app. Write all of it in',
|
|
789
|
+
' create_app\'s files, then stop: ucode opens the page itself and hands it to the',
|
|
790
|
+
' user the moment it works. Do not start a server, curl it or re-read it to check.',
|
|
789
791
|
'- Nothing you run has a keyboard. Pass the non-interactive flag to anything that',
|
|
790
792
|
' would ask a question, or it fails instead of waiting: create-next-app --yes,',
|
|
791
793
|
' npx shadcn@latest init -d -y, npx shadcn@latest add <names> -y, npm init -y.',
|
|
@@ -1184,13 +1186,9 @@ export class Agent {
|
|
|
1184
1186
|
const message = skillMessage(skill, { automatic: true, short: true });
|
|
1185
1187
|
this.loaded.add(skill.name);
|
|
1186
1188
|
if (message.short) this.short.add(skill.name);
|
|
1189
|
+
// Nothing on screen: which rules the model was handed is bookkeeping,
|
|
1190
|
+
// and the only text a build should leave is the answer at the end.
|
|
1187
1191
|
this.push(message);
|
|
1188
|
-
this.ui.note(`${skill.name} skill loaded for this${message.short ? ' (short form)' : ''}`);
|
|
1189
|
-
// A clear row under it. This is bookkeeping about the turn, not part of
|
|
1190
|
-
// it, and against the first line of thinking the two read as one block —
|
|
1191
|
-
// the notice looks like the opening of the answer rather than a note
|
|
1192
|
-
// about how the answer is being reached.
|
|
1193
|
-
this.ui.blank();
|
|
1194
1192
|
}
|
|
1195
1193
|
}
|
|
1196
1194
|
|
|
@@ -1200,6 +1198,9 @@ export class Agent {
|
|
|
1200
1198
|
beginTurn();
|
|
1201
1199
|
this.lookedThisTurn = false;
|
|
1202
1200
|
this.lookAgain = null;
|
|
1201
|
+
this.handOverPending = false;
|
|
1202
|
+
this.appName = null;
|
|
1203
|
+
this.appTemplate = null;
|
|
1203
1204
|
this.reads = new Map();
|
|
1204
1205
|
this.declines = 0;
|
|
1205
1206
|
this.apps = [];
|
|
@@ -1559,6 +1560,24 @@ export class Agent {
|
|
|
1559
1560
|
const badArgs = await this.runCalls(reply.toolCalls);
|
|
1560
1561
|
if (this.abort.signal.aborted) return;
|
|
1561
1562
|
|
|
1563
|
+
// The app is written: see whether it works, and if it does, that is the
|
|
1564
|
+
// answer. Waiting for the model to decide it is finished was most of the
|
|
1565
|
+
// time a build took (see handOver).
|
|
1566
|
+
const handed = badArgs ? null : await this.handOver(reply.toolCalls);
|
|
1567
|
+
if (handed?.done) return;
|
|
1568
|
+
if (handed?.problems && fixRounds < MAX_FIX_ROUNDS) {
|
|
1569
|
+
fixRounds++;
|
|
1570
|
+
this.push({
|
|
1571
|
+
role: 'user',
|
|
1572
|
+
content:
|
|
1573
|
+
`ucode opened the app and found errors (round ${fixRounds} of ${MAX_FIX_ROUNDS}). Fix all ` +
|
|
1574
|
+
'of them with the smallest edits that do it. Change nothing else. As soon as it works, ' +
|
|
1575
|
+
`ucode hands it to the user.\n\n${handed.problems}`,
|
|
1576
|
+
});
|
|
1577
|
+
await this.persist();
|
|
1578
|
+
continue;
|
|
1579
|
+
}
|
|
1580
|
+
|
|
1562
1581
|
// Malformed arguments go back to the model, but only so many times.
|
|
1563
1582
|
if (badArgs) {
|
|
1564
1583
|
argRetries++;
|
|
@@ -1798,6 +1817,8 @@ export class Agent {
|
|
|
1798
1817
|
if (call.name === 'create_app' && call.args?.folder) {
|
|
1799
1818
|
const made = path.resolve(this.cwd, String(call.args.folder));
|
|
1800
1819
|
if (!(this.apps ??= []).includes(made)) this.apps.push(made);
|
|
1820
|
+
if (call.args.name) this.appName = String(call.args.name);
|
|
1821
|
+
this.appTemplate = String(call.args.template || 'plain-html');
|
|
1801
1822
|
}
|
|
1802
1823
|
|
|
1803
1824
|
if (call.name === 'look_at_app') {
|
|
@@ -2455,6 +2476,72 @@ export class Agent {
|
|
|
2455
2476
|
* Never throws. A browser that will not start is a reason to skip the look,
|
|
2456
2477
|
* never a reason to fail the turn that built the app.
|
|
2457
2478
|
*/
|
|
2479
|
+
/**
|
|
2480
|
+
* Hand a plain-page app over the moment it works.
|
|
2481
|
+
*
|
|
2482
|
+
* Traced: a tip calculator was on disk and complete 60 seconds in, and the
|
|
2483
|
+
* turn ran to 270 — re-reading the files it had just written, starting
|
|
2484
|
+
* servers to look at a page that needs none, rewriting it to "verify". The
|
|
2485
|
+
* user waited three and a half minutes for an app that was already there.
|
|
2486
|
+
*
|
|
2487
|
+
* So right after a step that writes the app — create_app with its files, a
|
|
2488
|
+
* batch of files, or any write while earlier problems are being fixed —
|
|
2489
|
+
* ucode opens the page itself. No console errors, no failed requests, and
|
|
2490
|
+
* it responds when used: the turn ends there with where to find it. If it
|
|
2491
|
+
* is broken, the problems go straight back, and the next write checks again.
|
|
2492
|
+
* Plain pages only: a framework app is checked when the model says it is
|
|
2493
|
+
* done, since its build and server take their own time.
|
|
2494
|
+
*
|
|
2495
|
+
* Returns { done: true }, { problems }, or null when there was nothing to check.
|
|
2496
|
+
*/
|
|
2497
|
+
async handOver(calls) {
|
|
2498
|
+
if (this.ui.mode === 'plan' || !this.apps?.length) return null;
|
|
2499
|
+
const app = this.apps.at(-1);
|
|
2500
|
+
// Plain by the starter it came from, not by what is in the folder: a model
|
|
2501
|
+
// that adds a package.json to a one-page app still made a one-page app, and
|
|
2502
|
+
// judging by the file sent a stopwatch on a 22-step, six-minute detour.
|
|
2503
|
+
if (this.appTemplate !== 'plain-html' || !(await exists(path.join(app, 'index.html')))) return null;
|
|
2504
|
+
|
|
2505
|
+
const inApp = (p) => {
|
|
2506
|
+
const rel = path.relative(app, path.resolve(this.cwd, String(p)));
|
|
2507
|
+
return !rel.startsWith('..') && !path.isAbsolute(rel);
|
|
2508
|
+
};
|
|
2509
|
+
const writes = calls.filter((c) => FILE_WRITES.has(c.name) && pathsOf(c).some(inApp));
|
|
2510
|
+
const whole = writes.some((c) => (c.name === 'create_app' || c.name === 'batch_write') && pathsOf(c).length > 0);
|
|
2511
|
+
if (!writes.length || (!whole && !this.handOverPending)) return null;
|
|
2512
|
+
|
|
2513
|
+
const show = path.relative(this.cwd, app).split(path.sep).join('/') || '.';
|
|
2514
|
+
let out;
|
|
2515
|
+
try {
|
|
2516
|
+
const { lookAtApp, withStaticServer } = await import('../tools/browser.js');
|
|
2517
|
+
this.ui.startSpinner(`opening ${show} to check it works`);
|
|
2518
|
+
out = await withStaticServer(app, (url) => lookAtApp({ url, review: false }));
|
|
2519
|
+
} catch {
|
|
2520
|
+
return null; // no browser: the check at the end of the turn still runs
|
|
2521
|
+
} finally {
|
|
2522
|
+
this.ui.stopSpinner();
|
|
2523
|
+
}
|
|
2524
|
+
|
|
2525
|
+
const content = String(out?.content ?? '');
|
|
2526
|
+
if (/Console errors|Failed requests|NOTHING HAPPENS|does not parse/i.test(content)) {
|
|
2527
|
+
this.handOverPending = true;
|
|
2528
|
+
this.ui.runStat?.('needs a fix');
|
|
2529
|
+
return { problems: `I opened the app and looked at it:\n\n${content}` };
|
|
2530
|
+
}
|
|
2531
|
+
|
|
2532
|
+
this.handOverPending = false;
|
|
2533
|
+
// "Tip Calculator is done", with the page as a link the terminal can open.
|
|
2534
|
+
const made = calls.find((c) => c.name === 'create_app' && c.args?.name)?.args.name ?? this.appName;
|
|
2535
|
+
const name = String(made ?? path.basename(app)).trim();
|
|
2536
|
+
this.appName = name;
|
|
2537
|
+
const link = pathToFileURL(path.join(app, 'index.html')).href;
|
|
2538
|
+
const text = `${name} is done. Open it here: ${link}\n\n(or open \`${show}/index.html\` in your browser)`;
|
|
2539
|
+
this.push({ role: 'assistant', content: text });
|
|
2540
|
+
this.ui.assistant(text, { closing: true });
|
|
2541
|
+
await this.persist();
|
|
2542
|
+
return { done: true };
|
|
2543
|
+
}
|
|
2544
|
+
|
|
2458
2545
|
async lookOnceThisTurn(root, changed) {
|
|
2459
2546
|
// A look that found problems is taken again after the fix round, even if
|
|
2460
2547
|
// the fix only touched the page's script. Looking once a turn let a live
|
package/src/tools/shell.js
CHANGED
|
@@ -54,6 +54,9 @@ const INSTALL_TIMEOUT = 600_000;
|
|
|
54
54
|
const LOOKS_LIKE_INSTALL =
|
|
55
55
|
/(\b(?:npm|pnpm|yarn|bun)\s+(?:i|install|add|ci|create)\b|\bnpx\s+(?:create-|degit\b|shadcn)|\bpip3?\s+install\b|\bpoetry\s+(?:install|add)\b|\bcargo\s+(?:build|install|fetch)\b|\bgo\s+(?:mod\s+download|get)\b|\bbundle\s+install\b|\bcomposer\s+(?:install|require)\b|\bgit\s+clone\b)/i;
|
|
56
56
|
|
|
57
|
+
/** Opening a file or URL in the desktop browser: start, open, xdg-open, explorer. */
|
|
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
|
+
|
|
57
60
|
/**
|
|
58
61
|
* Any kill by program name rather than by process number.
|
|
59
62
|
*
|
|
@@ -659,6 +662,16 @@ export async function runCommand({ command, cwd, timeout_ms, background }, { onO
|
|
|
659
662
|
});
|
|
660
663
|
}
|
|
661
664
|
|
|
665
|
+
// Opening a page in the user's browser is ucode's job, once the app works.
|
|
666
|
+
// Run by the model, `start index.html` hands the pipe to the browser, which
|
|
667
|
+
// never closes it: a live build sat on it for the full two-minute timeout.
|
|
668
|
+
if (OPENS_BROWSER.test(command)) {
|
|
669
|
+
return result(
|
|
670
|
+
'Not run: ucode opens the app for the user itself once it works. There is no need to open it.',
|
|
671
|
+
'not needed — ucode shows the app',
|
|
672
|
+
);
|
|
673
|
+
}
|
|
674
|
+
|
|
662
675
|
if (KILLS_BY_NAME.test(command)) {
|
|
663
676
|
const attempted = `running \`${command.trim().slice(0, 80)}\``;
|
|
664
677
|
try {
|
package/src/ui/plain.js
CHANGED
|
@@ -200,11 +200,10 @@ export class Plain {
|
|
|
200
200
|
if (first.trim()) this.output.write(`\n${blue('›')} ${first}\n`);
|
|
201
201
|
}
|
|
202
202
|
|
|
203
|
+
/** Said beside a tool call, so not the answer: it moves the spinner and is not printed. */
|
|
203
204
|
narrate(text) {
|
|
204
205
|
const line = asLabel(text);
|
|
205
|
-
if (
|
|
206
|
-
this.stopSpinner();
|
|
207
|
-
this.write(dim(` ⋮ ${line}`));
|
|
206
|
+
if (line) this.updateSpinner(line);
|
|
208
207
|
}
|
|
209
208
|
|
|
210
209
|
progress(lines) {
|