ucode-agent 1.56.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 +158 -26
- package/src/core/skills.js +1 -1
- package/src/tools/files.js +46 -7
- package/src/tools/shared.js +12 -5
- package/src/tools/shell.js +65 -2
- 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';
|
|
@@ -306,6 +307,9 @@ export function lean(messages, keep = 3) {
|
|
|
306
307
|
});
|
|
307
308
|
}
|
|
308
309
|
|
|
310
|
+
/** One spelling per file for the read counts: "./a/b.js", "a\b.js" and "a/b.js" are one file. */
|
|
311
|
+
const fileKey = (p) => { const k = path.normalize(String(p)).split(path.sep).join('/'); return process.platform === 'win32' || process.platform === 'darwin' ? k.toLowerCase() : k; };
|
|
312
|
+
|
|
309
313
|
/** Tools that write whole files, whose sizes are remembered for the turn. */
|
|
310
314
|
const WHOLE_WRITES = new Set(['write_file', 'batch_write', 'create_app']);
|
|
311
315
|
|
|
@@ -781,8 +785,9 @@ function systemPrompt({ cwd, skills, mode, check, map, memory }) {
|
|
|
781
785
|
' Next.js, Vite, anything with an npm run dev - start it and leave it up when you',
|
|
782
786
|
' finish. ucode opens it in the browser for the user as soon as it is ready, so a',
|
|
783
787
|
' build that ends with the server stopped ends with nothing to look at. A one-page',
|
|
784
|
-
' app with no server needs none of this: the file is the app.',
|
|
785
|
-
'
|
|
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.',
|
|
786
791
|
'- Nothing you run has a keyboard. Pass the non-interactive flag to anything that',
|
|
787
792
|
' would ask a question, or it fails instead of waiting: create-next-app --yes,',
|
|
788
793
|
' npx shadcn@latest init -d -y, npx shadcn@latest add <names> -y, npm init -y.',
|
|
@@ -1181,13 +1186,9 @@ export class Agent {
|
|
|
1181
1186
|
const message = skillMessage(skill, { automatic: true, short: true });
|
|
1182
1187
|
this.loaded.add(skill.name);
|
|
1183
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.
|
|
1184
1191
|
this.push(message);
|
|
1185
|
-
this.ui.note(`${skill.name} skill loaded for this${message.short ? ' (short form)' : ''}`);
|
|
1186
|
-
// A clear row under it. This is bookkeeping about the turn, not part of
|
|
1187
|
-
// it, and against the first line of thinking the two read as one block —
|
|
1188
|
-
// the notice looks like the opening of the answer rather than a note
|
|
1189
|
-
// about how the answer is being reached.
|
|
1190
|
-
this.ui.blank();
|
|
1191
1192
|
}
|
|
1192
1193
|
}
|
|
1193
1194
|
|
|
@@ -1196,6 +1197,10 @@ export class Agent {
|
|
|
1196
1197
|
// /undo can put the whole turn back.
|
|
1197
1198
|
beginTurn();
|
|
1198
1199
|
this.lookedThisTurn = false;
|
|
1200
|
+
this.lookAgain = null;
|
|
1201
|
+
this.handOverPending = false;
|
|
1202
|
+
this.appName = null;
|
|
1203
|
+
this.appTemplate = null;
|
|
1199
1204
|
this.reads = new Map();
|
|
1200
1205
|
this.declines = 0;
|
|
1201
1206
|
this.apps = [];
|
|
@@ -1203,6 +1208,11 @@ export class Agent {
|
|
|
1203
1208
|
forgetReviews(); // a new request: its apps get a fresh design review
|
|
1204
1209
|
setRequest(input); // create_app checks this before choosing a starter
|
|
1205
1210
|
const images = await this.attachImages(input);
|
|
1211
|
+
// The skill goes in ahead of the request, so the request is the last
|
|
1212
|
+
// thing the model reads. After it, a page of house rules was what the
|
|
1213
|
+
// model answered: asked for a dark mode toggle on its second turn, a live
|
|
1214
|
+
// run re-read two files and repeated its first turn's summary instead.
|
|
1215
|
+
this.autoLoad(input);
|
|
1206
1216
|
this.push(images.length
|
|
1207
1217
|
? { role: 'user', content: input, images }
|
|
1208
1218
|
: { role: 'user', content: input });
|
|
@@ -1210,8 +1220,6 @@ export class Agent {
|
|
|
1210
1220
|
if (!this.session.title || this.session.title === 'Untitled') {
|
|
1211
1221
|
this.session.title = titleFrom(input);
|
|
1212
1222
|
}
|
|
1213
|
-
|
|
1214
|
-
this.autoLoad(input);
|
|
1215
1223
|
// What the model is told about the project, fresh for this turn.
|
|
1216
1224
|
[this.map, this.memory] = await Promise.all([
|
|
1217
1225
|
projectMap(this.cwd).catch(() => ''),
|
|
@@ -1552,6 +1560,24 @@ export class Agent {
|
|
|
1552
1560
|
const badArgs = await this.runCalls(reply.toolCalls);
|
|
1553
1561
|
if (this.abort.signal.aborted) return;
|
|
1554
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
|
+
|
|
1555
1581
|
// Malformed arguments go back to the model, but only so many times.
|
|
1556
1582
|
if (badArgs) {
|
|
1557
1583
|
argRetries++;
|
|
@@ -1613,7 +1639,7 @@ export class Agent {
|
|
|
1613
1639
|
|
|
1614
1640
|
const noted = (call) => {
|
|
1615
1641
|
if (FILE_WRITES.has(call.name)) {
|
|
1616
|
-
for (const p of pathsOf(call)) { this.touched.add(p); this.sinceCheck.add(p); }
|
|
1642
|
+
for (const p of pathsOf(call)) { this.touched.add(p); this.sinceCheck.add(p); this.forgetReads(p); }
|
|
1617
1643
|
}
|
|
1618
1644
|
if (call.name === 'run_command' || call.name === 'run_commands') this.ranSomething = true;
|
|
1619
1645
|
};
|
|
@@ -1791,6 +1817,8 @@ export class Agent {
|
|
|
1791
1817
|
if (call.name === 'create_app' && call.args?.folder) {
|
|
1792
1818
|
const made = path.resolve(this.cwd, String(call.args.folder));
|
|
1793
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');
|
|
1794
1822
|
}
|
|
1795
1823
|
|
|
1796
1824
|
if (call.name === 'look_at_app') {
|
|
@@ -1882,7 +1910,7 @@ export class Agent {
|
|
|
1882
1910
|
.map((f) => ({ abs: path.resolve(this.cwd, f.path), size: f.content.length, path: f.path }));
|
|
1883
1911
|
}
|
|
1884
1912
|
|
|
1885
|
-
async dispatch(call) {
|
|
1913
|
+
async dispatch(call, offered = this.offering) {
|
|
1886
1914
|
// The model emitted arguments that were not valid JSON. Hand the parser's
|
|
1887
1915
|
// own complaint straight back so it can correct itself next step.
|
|
1888
1916
|
if (call.parseError) {
|
|
@@ -1899,12 +1927,13 @@ export class Agent {
|
|
|
1899
1927
|
// tool that writes, and a new project is not sent the ones that look code
|
|
1900
1928
|
// up — and a model naming one from memory was, until this check, executing
|
|
1901
1929
|
// it. A withheld tool has to be refused, not just left out of the menu.
|
|
1902
|
-
|
|
1930
|
+
// A worker is checked against its own, narrower list, not the lead's.
|
|
1931
|
+
if (!offered?.has(call.name)) {
|
|
1903
1932
|
throw new ToolFailure({
|
|
1904
1933
|
kind: 'no_such_tool',
|
|
1905
1934
|
attempted: `calling ${call.name}`,
|
|
1906
1935
|
failed: `${call.name} is not available${this.ui.mode === 'plan' ? ' in plan mode' : ' in this project'}.`,
|
|
1907
|
-
fix: `Use one of the tools you were given: ${[...
|
|
1936
|
+
fix: `Use one of the tools you were given: ${[...offered ?? []].join(', ')}.`,
|
|
1908
1937
|
});
|
|
1909
1938
|
}
|
|
1910
1939
|
|
|
@@ -1921,14 +1950,24 @@ export class Agent {
|
|
|
1921
1950
|
// usually a fair re-check after an edit. Only the third is a loop. And it
|
|
1922
1951
|
// is keyed on the file being untouched since — the moment anything writes
|
|
1923
1952
|
// to it, the count starts again and a real re-read goes through.
|
|
1953
|
+
//
|
|
1954
|
+
// Counted per page, not per file: paging through a long file with offset
|
|
1955
|
+
// is new text every time, and a live run was refused four pages in a row
|
|
1956
|
+
// of a 900-line file it had only ever seen the start of.
|
|
1957
|
+
//
|
|
1958
|
+
// A write starts the count again (forgetReads). It used to be skipped only
|
|
1959
|
+
// while the file sat in sinceCheck, which the checks empty every fix round:
|
|
1960
|
+
// after round one, every read of a file the model had just edited was
|
|
1961
|
+
// refused as "unchanged", and a live build spent sixty steps being told so.
|
|
1924
1962
|
if (call.name === 'read_file' && call.args?.path) {
|
|
1925
|
-
const
|
|
1963
|
+
const file = String(call.args.path);
|
|
1964
|
+
const key = `${fileKey(file)}#${Number(call.args.offset) || 1}:${Number(call.args.limit) || 0}`;
|
|
1926
1965
|
const seen = (this.reads ??= new Map()).get(key) ?? 0;
|
|
1927
|
-
if (seen >= 2
|
|
1966
|
+
if (seen >= 2) {
|
|
1928
1967
|
this.reads.set(key, seen + 1);
|
|
1929
1968
|
return {
|
|
1930
|
-
content: `You have already read ${
|
|
1931
|
-
'so
|
|
1969
|
+
content: `You have already read that part of ${file} ${seen} times this turn and nothing has written ` +
|
|
1970
|
+
'to it since, so it is unchanged and already above. Use what is there. If you need a part ' +
|
|
1932
1971
|
'you have lost, say which and read the files you still need together in one read_files.',
|
|
1933
1972
|
summary: 'unchanged since you last read it',
|
|
1934
1973
|
};
|
|
@@ -2031,9 +2070,9 @@ export class Agent {
|
|
|
2031
2070
|
}
|
|
2032
2071
|
|
|
2033
2072
|
/** Run a call and settle to { out } or { err } — never throws. */
|
|
2034
|
-
execute(call) {
|
|
2073
|
+
execute(call, offered = this.offering) {
|
|
2035
2074
|
const started = Date.now();
|
|
2036
|
-
return this.dispatch(call).then(
|
|
2075
|
+
return this.dispatch(call, offered).then(
|
|
2037
2076
|
(out) => { trace({ kind: 'tool', name: call.name, ms: Date.now() - started }); countTool(this.stats, call, out); return { out }; },
|
|
2038
2077
|
(err) => { trace({ kind: 'tool', name: call.name, ms: Date.now() - started, err: err?.kind }); countTool(this.stats, call, null, err); return { err }; }
|
|
2039
2078
|
);
|
|
@@ -2048,6 +2087,12 @@ export class Agent {
|
|
|
2048
2087
|
return { content: `Plan updated: ${done} of ${list.length} done.`, summary: `${done}/${list.length}` };
|
|
2049
2088
|
}
|
|
2050
2089
|
|
|
2090
|
+
/** A file was written: every page of it read so far is news again. */
|
|
2091
|
+
forgetReads(file) {
|
|
2092
|
+
const prefix = `${fileKey(file)}#`;
|
|
2093
|
+
for (const key of this.reads?.keys() ?? []) if (key.startsWith(prefix)) this.reads.delete(key);
|
|
2094
|
+
}
|
|
2095
|
+
|
|
2051
2096
|
/** File writes from parallel workers take turns, so two never interleave. */
|
|
2052
2097
|
fileLock(fn) {
|
|
2053
2098
|
const run = (this.lockChain ?? Promise.resolve()).then(fn, fn);
|
|
@@ -2079,7 +2124,7 @@ export class Agent {
|
|
|
2079
2124
|
touched: [],
|
|
2080
2125
|
}))));
|
|
2081
2126
|
|
|
2082
|
-
for (const r of results) for (const f of r.touched) { this.touched.add(f); this.sinceCheck.add(f); }
|
|
2127
|
+
for (const r of results) for (const f of r.touched) { this.touched.add(f); this.sinceCheck.add(f); this.forgetReads(f); }
|
|
2083
2128
|
|
|
2084
2129
|
// A worker that wrote nothing has not done its part, whatever it said.
|
|
2085
2130
|
// The lead builds those itself rather than leaving holes in the app.
|
|
@@ -2111,6 +2156,7 @@ export class Agent {
|
|
|
2111
2156
|
{ role: 'user', content: String(task.instructions) },
|
|
2112
2157
|
];
|
|
2113
2158
|
const available = this.toolsNow().filter((t) => !WORKER_EXCLUDED.has(t.name));
|
|
2159
|
+
const offered = new Set(available.map((t) => t.name));
|
|
2114
2160
|
const wanted = process.env.UCODE_WORKER_MODEL;
|
|
2115
2161
|
let workerModel = wanted && MODELS[wanted] ? wanted : model();
|
|
2116
2162
|
const tried = new Set([workerModel]);
|
|
@@ -2172,8 +2218,8 @@ export class Agent {
|
|
|
2172
2218
|
const exclusive =
|
|
2173
2219
|
FILE_WRITES.has(call.name) || call.name === 'run_command' || call.name === 'run_commands';
|
|
2174
2220
|
const { out, err } = exclusive
|
|
2175
|
-
? await this.fileLock(() => this.execute(call))
|
|
2176
|
-
: await this.execute(call);
|
|
2221
|
+
? await this.fileLock(() => this.execute(call, offered))
|
|
2222
|
+
: await this.execute(call, offered);
|
|
2177
2223
|
if (err) {
|
|
2178
2224
|
if (!(err instanceof ToolFailure)) throw err;
|
|
2179
2225
|
this.ui.toolFailed(`${name}: ${err.kind}: ${err.failed}`);
|
|
@@ -2430,12 +2476,97 @@ export class Agent {
|
|
|
2430
2476
|
* Never throws. A browser that will not start is a reason to skip the look,
|
|
2431
2477
|
* never a reason to fail the turn that built the app.
|
|
2432
2478
|
*/
|
|
2433
|
-
|
|
2434
|
-
|
|
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
|
+
}
|
|
2435
2524
|
|
|
2436
|
-
const
|
|
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
|
+
|
|
2545
|
+
async lookOnceThisTurn(root, changed) {
|
|
2546
|
+
// A look that found problems is taken again after the fix round, even if
|
|
2547
|
+
// the fix only touched the page's script. Looking once a turn let a live
|
|
2548
|
+
// build report "Failed to load module script" on its first look, fail to
|
|
2549
|
+
// fix it, and finish as done with a page whose script never ran — nothing
|
|
2550
|
+
// opened the page again. The fix rounds bound how often this repeats.
|
|
2551
|
+
const again = this.lookAgain;
|
|
2552
|
+
if (this.lookedThisTurn && !again) return null;
|
|
2553
|
+
|
|
2554
|
+
let pages = [...changed].filter((f) => PAGE.test(f));
|
|
2555
|
+
// A plain page's script changed and its index.html did not — the usual
|
|
2556
|
+
// shape of a build on the HTML starter, whose page comes from the starter
|
|
2557
|
+
// while the model writes app.js. The page is still the thing to open: a
|
|
2558
|
+
// live build whose "Add" button threw on every click finished as done
|
|
2559
|
+
// because nothing with .html in its name had changed, so nobody looked.
|
|
2560
|
+
if (!pages.length) {
|
|
2561
|
+
for (const f of changed) {
|
|
2562
|
+
const page = path.join(path.dirname(f), 'index.html');
|
|
2563
|
+
if (/\.m?js$/i.test(f) && (await exists(path.resolve(root, page)))) { pages = [page]; break; }
|
|
2564
|
+
}
|
|
2565
|
+
}
|
|
2566
|
+
if (!pages.length && again) pages = [again];
|
|
2437
2567
|
if (!pages.length) return null;
|
|
2438
2568
|
this.lookedThisTurn = true;
|
|
2569
|
+
this.lookAgain = null;
|
|
2439
2570
|
|
|
2440
2571
|
try {
|
|
2441
2572
|
const { lookAtApp, withStaticServer } = await import('../tools/browser.js');
|
|
@@ -2448,6 +2579,7 @@ export class Agent {
|
|
|
2448
2579
|
const out = await lookAtApp({ url, review: false });
|
|
2449
2580
|
const found = /^(\d+) problem/.exec(out.summary ?? '');
|
|
2450
2581
|
this.ui.runStat?.(found ? `${found[1]} to fix` : 'clean');
|
|
2582
|
+
if (found) this.lookAgain = pages.find((f) => /\.html?$/i.test(f)) ?? pages[0];
|
|
2451
2583
|
return found ? `I opened the app and looked at it:\n\n${out.content}` : null;
|
|
2452
2584
|
};
|
|
2453
2585
|
|
package/src/core/skills.js
CHANGED
|
@@ -172,7 +172,7 @@ export function autoLoadFor(skills, text) {
|
|
|
172
172
|
export function skillMessage(skill, { automatic = false, short = false } = {}) {
|
|
173
173
|
const digest = short && skill.digest ? skill.digest : null;
|
|
174
174
|
const why = automatic
|
|
175
|
-
? `The "${skill.name}" skill was loaded automatically because
|
|
175
|
+
? `The "${skill.name}" skill was loaded automatically because the request that follows is the kind it covers.`
|
|
176
176
|
: `The "${skill.name}" skill was loaded for this task.`;
|
|
177
177
|
return {
|
|
178
178
|
role: 'system',
|
package/src/tools/files.js
CHANGED
|
@@ -7,14 +7,14 @@
|
|
|
7
7
|
* coding agent can do, because everything after it is built on a lie.
|
|
8
8
|
*/
|
|
9
9
|
|
|
10
|
-
import { promises as fs } from 'node:fs';
|
|
10
|
+
import { promises as fs, existsSync } from 'node:fs';
|
|
11
11
|
import { remember } from '../core/undo.js';
|
|
12
12
|
import path from 'node:path';
|
|
13
13
|
import { ToolFailure } from '../core/failure.js';
|
|
14
14
|
import {
|
|
15
15
|
resolveIn, guard, result, fsFailure, looksBinary, toLines, bytes,
|
|
16
16
|
changedRegion, renderDiff, renderNewFile, READ_LINES, MAX_FILE_OUTPUT,
|
|
17
|
-
noteFile, writeTracked, assertUnchanged,
|
|
17
|
+
noteFile, writeTracked, assertUnchanged, getRoot,
|
|
18
18
|
} from './shared.js';
|
|
19
19
|
import { packageJsonWritten } from './shell.js';
|
|
20
20
|
import { fuzzyReplace } from './fuzzy.js';
|
|
@@ -196,6 +196,44 @@ const brokenNote = (show, problem) => {
|
|
|
196
196
|
`a structure you have lost track of.`;
|
|
197
197
|
};
|
|
198
198
|
|
|
199
|
+
/**
|
|
200
|
+
* A module in a page with no build step that imports a stylesheet or an image.
|
|
201
|
+
*
|
|
202
|
+
* A bundler would take it; a browser refuses the whole module ("Failed to
|
|
203
|
+
* load module script ... MIME type text/css"), so none of the app's script
|
|
204
|
+
* runs and every button is dead. The syntax is fine, so nothing else notices
|
|
205
|
+
* until the page is opened — a live build shipped exactly this as done.
|
|
206
|
+
*/
|
|
207
|
+
const ASSET_IMPORT = /^[ \t]*import\s+(?:[\w$*{}\s,]+?\s+from\s+)?['"]([^'"]+\.(?:css|scss|sass|less|svg|png|jpe?g|gif|webp))['"](?!\s*(?:with|assert)\s*\{)/m;
|
|
208
|
+
|
|
209
|
+
export function assetImport(abs, text) {
|
|
210
|
+
if (!/\.m?js$/i.test(abs)) return null;
|
|
211
|
+
// An import quoted inside a block comment is not an import.
|
|
212
|
+
const hit = ASSET_IMPORT.exec(String(text).replace(/\/\*[\s\S]*?\*\//g, ''));
|
|
213
|
+
if (!hit) return null;
|
|
214
|
+
// Anything with a package.json above it, up to the project root, may well be
|
|
215
|
+
// bundled. path.relative, not a string prefix: "ucode2" starts with "ucode",
|
|
216
|
+
// and Windows paths differ in case from one shell to the next.
|
|
217
|
+
const top = getRoot();
|
|
218
|
+
const within = (dir) => {
|
|
219
|
+
const rel = path.relative(top, dir);
|
|
220
|
+
return rel === '' || (!rel.startsWith('..') && !path.isAbsolute(rel));
|
|
221
|
+
};
|
|
222
|
+
for (let dir = path.dirname(abs); ; dir = path.dirname(dir)) {
|
|
223
|
+
if (existsSync(path.join(dir, 'package.json'))) return null;
|
|
224
|
+
if (!within(dir) || path.relative(top, dir) === '' || path.dirname(dir) === dir) break;
|
|
225
|
+
}
|
|
226
|
+
return `imports ${hit[1]}, which a page with no build step cannot do: the browser refuses the ` +
|
|
227
|
+
'whole module, so none of its script runs. Remove that import and load it from the page ' +
|
|
228
|
+
'instead — <link rel="stylesheet" href="…"> for a stylesheet, an <img> or a plain URL for an image.';
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
/** The note for that, appended to a write's result like a parse problem is. */
|
|
232
|
+
const pageNote = (target, text) => {
|
|
233
|
+
const problem = assetImport(target.abs, text);
|
|
234
|
+
return problem ? `\n\n⚠ ${target.show} ${problem}` : '';
|
|
235
|
+
};
|
|
236
|
+
|
|
199
237
|
/** A file this short comes back whole after an edit; longer ones show the part around the change. */
|
|
200
238
|
const SHOW_WHOLE = 250;
|
|
201
239
|
const AROUND = 15;
|
|
@@ -349,6 +387,7 @@ async function put(target, content, { diffMax = 16 } = {}) {
|
|
|
349
387
|
lineCount,
|
|
350
388
|
diff,
|
|
351
389
|
problem: syntaxProblem(target.abs, content),
|
|
390
|
+
note: pageNote(target, content),
|
|
352
391
|
line: `${existed ? 'Overwrote' : 'Created'} ${target.show} ` +
|
|
353
392
|
`(${lineCount} lines, ${bytes(Buffer.byteLength(content))})`,
|
|
354
393
|
};
|
|
@@ -368,7 +407,7 @@ export async function writeFile({ path: p, content }) {
|
|
|
368
407
|
|
|
369
408
|
const written = await put(target, content);
|
|
370
409
|
const out = result(
|
|
371
|
-
`${written.line}.${parseNote(target.show, written.problem)}`,
|
|
410
|
+
`${written.line}.${parseNote(target.show, written.problem)}${written.note}`,
|
|
372
411
|
`${written.existed ? 'overwrote' : 'created'} · ${written.lineCount} lines${written.problem ? ' · does not parse' : ''}`
|
|
373
412
|
);
|
|
374
413
|
out.diff = written.diff;
|
|
@@ -414,7 +453,7 @@ export async function batchWrite({ files }) {
|
|
|
414
453
|
// would bury the reply under three hundred lines of gutter.
|
|
415
454
|
const written = await put(target, content, { diffMax: 6 });
|
|
416
455
|
if (!written.existed) created++;
|
|
417
|
-
lines.push(written.line + parseNote(target.show, written.problem));
|
|
456
|
+
lines.push(written.line + parseNote(target.show, written.problem) + written.note);
|
|
418
457
|
if (written.problem) broken++;
|
|
419
458
|
diff.push(`~${target.show}`, ...written.diff);
|
|
420
459
|
}
|
|
@@ -632,7 +671,7 @@ export async function editFile({ path: p, old_string, new_string, replace_all })
|
|
|
632
671
|
const span = toLines(new_string).length;
|
|
633
672
|
const out = result(
|
|
634
673
|
`Replaced ${where} (${change}${matched}).` +
|
|
635
|
-
parseNote(target.show, syntaxProblem(target.abs, text)) +
|
|
674
|
+
parseNote(target.show, syntaxProblem(target.abs, text)) + pageNote(target, text) +
|
|
636
675
|
nowReads(target.show, text, at, span),
|
|
637
676
|
`${count > 1 ? `${count} changes from line` : '1 change at line'} ${at} · ${change}` +
|
|
638
677
|
`${how ? ' · whitespace-tolerant' : ''}${syntaxProblem(target.abs, text) ? ' · does not parse' : ''}`,
|
|
@@ -705,7 +744,7 @@ export async function multiEdit({ path: p, edits }) {
|
|
|
705
744
|
|
|
706
745
|
const out = result(
|
|
707
746
|
`Applied ${edits.length} edits to ${target.show} (${change}).` +
|
|
708
|
-
parseNote(target.show, syntaxProblem(target.abs, text)) +
|
|
747
|
+
parseNote(target.show, syntaxProblem(target.abs, text)) + pageNote(target, text) +
|
|
709
748
|
nowReads(target.show, text, 1, toLines(text).length),
|
|
710
749
|
`${edits.length} edits · ${change}`,
|
|
711
750
|
MAX_FILE_OUTPUT
|
|
@@ -795,7 +834,7 @@ export async function editFiles({ files }) {
|
|
|
795
834
|
planned.map((p) => {
|
|
796
835
|
const problem = syntaxProblem(p.target.abs, p.text);
|
|
797
836
|
return `Edited ${p.target.show} (${p.count} change${p.count === 1 ? '' : 's'})` +
|
|
798
|
-
parseNote(p.target.show, problem);
|
|
837
|
+
parseNote(p.target.show, problem) + pageNote(p.target, p.text);
|
|
799
838
|
}).join('\n'),
|
|
800
839
|
`${planned.length} files · ${edits} edits`
|
|
801
840
|
);
|
package/src/tools/shared.js
CHANGED
|
@@ -408,12 +408,19 @@ export const WALK_WIDTH = 32;
|
|
|
408
408
|
*/
|
|
409
409
|
const known = new Map();
|
|
410
410
|
|
|
411
|
+
/**
|
|
412
|
+
* One key per file. Windows and macOS do not care about case, so "App.js" and
|
|
413
|
+
* "app.js" are the same file there — and keyed apart, ucode's own write under
|
|
414
|
+
* one spelling read as somebody else's edit under the other.
|
|
415
|
+
*/
|
|
416
|
+
const stampKey = (abs) => (process.platform === 'win32' || process.platform === 'darwin' ? abs.toLowerCase() : abs);
|
|
417
|
+
|
|
411
418
|
/** Record a file as ucode now knows it. Never throws: a missing stamp only costs the check. */
|
|
412
419
|
export async function noteFile(abs) {
|
|
413
420
|
try {
|
|
414
|
-
known.set(abs, (await fs.stat(abs)).mtimeMs);
|
|
421
|
+
known.set(stampKey(abs), (await fs.stat(abs)).mtimeMs);
|
|
415
422
|
} catch {
|
|
416
|
-
known.delete(abs);
|
|
423
|
+
known.delete(stampKey(abs));
|
|
417
424
|
}
|
|
418
425
|
}
|
|
419
426
|
|
|
@@ -431,7 +438,7 @@ export async function writeTracked(abs, data, encoding = 'utf8') {
|
|
|
431
438
|
|
|
432
439
|
/** Forget a file, so the next write to it goes through unchallenged. */
|
|
433
440
|
export function forgetFile(abs) {
|
|
434
|
-
known.delete(abs);
|
|
441
|
+
known.delete(stampKey(abs));
|
|
435
442
|
}
|
|
436
443
|
|
|
437
444
|
/**
|
|
@@ -444,7 +451,7 @@ export function forgetFile(abs) {
|
|
|
444
451
|
* clobber.
|
|
445
452
|
*/
|
|
446
453
|
export async function assertUnchanged(abs, show) {
|
|
447
|
-
const seen = known.get(abs);
|
|
454
|
+
const seen = known.get(stampKey(abs));
|
|
448
455
|
if (seen === undefined) return;
|
|
449
456
|
|
|
450
457
|
let now;
|
|
@@ -457,7 +464,7 @@ export async function assertUnchanged(abs, show) {
|
|
|
457
464
|
// costs nothing and stops a same-second write reading as someone else's.
|
|
458
465
|
if (Math.abs(now - seen) < 1) return;
|
|
459
466
|
|
|
460
|
-
known.delete(abs);
|
|
467
|
+
known.delete(stampKey(abs));
|
|
461
468
|
throw new ToolFailure({
|
|
462
469
|
kind: 'changed_on_disk',
|
|
463
470
|
attempted: `overwriting ${show}`,
|
package/src/tools/shell.js
CHANGED
|
@@ -14,7 +14,7 @@ import { openSync, closeSync, readFileSync, mkdirSync, statSync } from 'node:fs'
|
|
|
14
14
|
import os from 'node:os';
|
|
15
15
|
import path from 'node:path';
|
|
16
16
|
import { ToolFailure } from '../core/failure.js';
|
|
17
|
-
import { resolveIn, guard, result, getRoot, MAX_OUTPUT } from './shared.js';
|
|
17
|
+
import { resolveIn, guard, confirm, result, getRoot, MAX_OUTPUT } from './shared.js';
|
|
18
18
|
|
|
19
19
|
const DEFAULT_TIMEOUT = 120_000;
|
|
20
20
|
const MAX_TIMEOUT = 600_000;
|
|
@@ -28,6 +28,19 @@ const LIVE_LINES = 200;
|
|
|
28
28
|
const LOOKS_LIKE_SERVER =
|
|
29
29
|
/(\b(?:npm|pnpm|yarn|bun)\s+(?:run\s+)?(?:dev|start|serve|preview|watch)\b|\b(?:vite|next dev|next start|nuxt dev|astro dev|webpack serve|svelte-kit dev|serve)\b|\buvicorn\b|\bgunicorn\b|\bflask run\b|\bdjango[\w-]* runserver\b|\bmanage\.py runserver\b|\brails server\b|\bhttp\.server\b|\bhttp-server\b|\blive-server\b)/i;
|
|
30
30
|
|
|
31
|
+
/**
|
|
32
|
+
* Does some part of this command actually run a server? A segment that only
|
|
33
|
+
* searches for or lists one does not: `ps aux | grep http-server` was
|
|
34
|
+
* backgrounded as a server and reported as having died before it was ready.
|
|
35
|
+
*/
|
|
36
|
+
const INSPECTS = /^(?:grep|egrep|findstr|find|ps|echo|cat|type|tasklist|netstat|curl|wget|ls|dir|which|where|head|tail|less|more)\b/i;
|
|
37
|
+
export function startsServer(command) {
|
|
38
|
+
return String(command).replace(/"[^"]*"|'[^']*'/g, '""').split(/&&|\|\||[;|&]/).some((part) => {
|
|
39
|
+
const segment = part.trim();
|
|
40
|
+
return !INSPECTS.test(segment) && LOOKS_LIKE_SERVER.test(segment);
|
|
41
|
+
});
|
|
42
|
+
}
|
|
43
|
+
|
|
31
44
|
/** A foreground server that was explicitly asked for still gets a short leash. */
|
|
32
45
|
const SERVER_TIMEOUT = 30_000;
|
|
33
46
|
|
|
@@ -41,6 +54,20 @@ const INSTALL_TIMEOUT = 600_000;
|
|
|
41
54
|
const LOOKS_LIKE_INSTALL =
|
|
42
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;
|
|
43
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
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Any kill by program name rather than by process number.
|
|
62
|
+
*
|
|
63
|
+
* `taskkill /F /IM python.exe` in a live build ended six Python processes on
|
|
64
|
+
* the machine, most of them nothing to do with the app. Every process ucode
|
|
65
|
+
* starts comes back with its PID, so the narrow kill is always available;
|
|
66
|
+
* the broad one is the user's call, and is asked about.
|
|
67
|
+
*/
|
|
68
|
+
const KILLS_BY_NAME =
|
|
69
|
+
/(\btaskkill\b[^|;&]*\/IM\b|\b(?:killall|pkill)\b|\b(?:Stop-Process|spps)\b[^|;&]*-(?:N(?:a(?:me?)?)?|ProcessName)\b|\bGet-Process\b[^;&]*\|\s*(?:Stop-Process|spps|kill)\b|\bwmic\b[^|;&]*\bprocess\b[^|;&]*\b(?:delete|call\s+terminate)\b|\bkill\b[^;&]*\$\(\s*pgrep\b|\bpgrep\b[^;&]*\|\s*xargs\s+(?:-\S+\s+)*kill\b)/i;
|
|
70
|
+
|
|
44
71
|
/**
|
|
45
72
|
* Commands that kill a whole class of process rather than one process.
|
|
46
73
|
*
|
|
@@ -73,6 +100,10 @@ const KILLS_EVERYTHING =
|
|
|
73
100
|
* NEXT_TELEMETRY Next skips its telemetry notice and ping
|
|
74
101
|
* NO_COLOR output comes back as text rather than escape codes,
|
|
75
102
|
* which the model would otherwise have to read past
|
|
103
|
+
* PYTHONUNBUFFERED Python writes as it goes instead of holding output
|
|
104
|
+
* until a buffer fills: `python -m http.server` said
|
|
105
|
+
* "Serving HTTP" to a log that stayed empty, and each
|
|
106
|
+
* start waited out the full 45 seconds as "still starting"
|
|
76
107
|
*/
|
|
77
108
|
export function childEnv(base = process.env) {
|
|
78
109
|
return {
|
|
@@ -89,6 +120,7 @@ export function childEnv(base = process.env) {
|
|
|
89
120
|
NEXT_TELEMETRY_DISABLED: '1',
|
|
90
121
|
NO_COLOR: '1',
|
|
91
122
|
FORCE_COLOR: '0',
|
|
123
|
+
PYTHONUNBUFFERED: '1',
|
|
92
124
|
};
|
|
93
125
|
}
|
|
94
126
|
|
|
@@ -630,13 +662,44 @@ export async function runCommand({ command, cwd, timeout_ms, background }, { onO
|
|
|
630
662
|
});
|
|
631
663
|
}
|
|
632
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
|
+
|
|
675
|
+
if (KILLS_BY_NAME.test(command)) {
|
|
676
|
+
const attempted = `running \`${command.trim().slice(0, 80)}\``;
|
|
677
|
+
try {
|
|
678
|
+
await confirm(
|
|
679
|
+
`stop every matching process: ${command.trim().slice(0, 120)}`,
|
|
680
|
+
'That kills every process with that name on this computer, not only the ones ucode started.',
|
|
681
|
+
'command',
|
|
682
|
+
);
|
|
683
|
+
} catch (err) {
|
|
684
|
+
if (err?.kind !== 'cannot_ask') throw err;
|
|
685
|
+
throw new ToolFailure({
|
|
686
|
+
kind: 'broad_kill',
|
|
687
|
+
attempted,
|
|
688
|
+
failed: 'That kills every process with that name on this computer, not only the ones ucode ' +
|
|
689
|
+
'started, and there is nobody here to approve it.',
|
|
690
|
+
fix: 'Stop the one process by its PID: every server ucode starts reports its PID and the exact ' +
|
|
691
|
+
'command to stop it.',
|
|
692
|
+
});
|
|
693
|
+
}
|
|
694
|
+
}
|
|
695
|
+
|
|
633
696
|
const workdir = cwd
|
|
634
697
|
? resolveIn(cwd, 'run_command', 'cwd')
|
|
635
698
|
: { abs: getRoot(), inside: true, show: '.' };
|
|
636
699
|
await guard(workdir, `run a command in ${workdir.abs}`);
|
|
637
700
|
|
|
638
701
|
const env = childEnv();
|
|
639
|
-
const server =
|
|
702
|
+
const server = startsServer(command);
|
|
640
703
|
|
|
641
704
|
// Anything run where a background install is still going waits for it —
|
|
642
705
|
// two installs in one folder corrupt node_modules, and a build before the
|
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) {
|