ucode-agent 1.32.0 → 1.34.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 +5 -5
- package/src/core/loop.js +6 -1
- package/src/core/provider.js +12 -0
- package/src/tools/browser.js +97 -1
- package/src/tools/index.js +5 -1
- package/src/tools/scaffold.js +57 -3
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "ucode-agent",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.34.0",
|
|
4
4
|
"description": "ucode - a terminal coding agent that reads, edits and runs your code, on NVIDIA and Cohere models.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "ucode.js",
|
|
@@ -16,7 +16,7 @@
|
|
|
16
16
|
"scripts": {
|
|
17
17
|
"start": "node ucode.js",
|
|
18
18
|
"test": "node test/run.js",
|
|
19
|
-
"prepublishOnly": "node test/run.js"
|
|
19
|
+
"prepublishOnly": "node scripts/no-bundled-key.js && node test/run.js"
|
|
20
20
|
},
|
|
21
21
|
"repository": {
|
|
22
22
|
"type": "git",
|
|
@@ -26,9 +26,6 @@
|
|
|
26
26
|
"url": "https://github.com/sppideey/ucode-agent/issues"
|
|
27
27
|
},
|
|
28
28
|
"homepage": "https://github.com/sppideey/ucode-agent#readme",
|
|
29
|
-
"publishConfig": {
|
|
30
|
-
"access": "public"
|
|
31
|
-
},
|
|
32
29
|
"engines": {
|
|
33
30
|
"node": ">=22"
|
|
34
31
|
},
|
|
@@ -56,5 +53,8 @@
|
|
|
56
53
|
},
|
|
57
54
|
"devDependencies": {
|
|
58
55
|
"typescript": "^5.9.3"
|
|
56
|
+
},
|
|
57
|
+
"publishConfig": {
|
|
58
|
+
"access": "public"
|
|
59
59
|
}
|
|
60
60
|
}
|
package/src/core/loop.js
CHANGED
|
@@ -1931,7 +1931,12 @@ export class Agent {
|
|
|
1931
1931
|
}
|
|
1932
1932
|
|
|
1933
1933
|
const check = async (label, command, cwd) => {
|
|
1934
|
-
|
|
1934
|
+
// The spinner only, never a transcript line. This pass runs after the
|
|
1935
|
+
// model has finished speaking, so a step logged here is the last thing
|
|
1936
|
+
// left on screen — the session ends on "Checking tasker/app.js" instead
|
|
1937
|
+
// of on the answer, and it reads as though something was still going
|
|
1938
|
+
// when it stopped. A check that passes has nothing to say, and one that
|
|
1939
|
+
// fails goes back to the model, which says it in words.
|
|
1935
1940
|
this.ui.startSpinner(label);
|
|
1936
1941
|
const { out, err } = await this.execute({
|
|
1937
1942
|
id: 'check', name: 'run_command',
|
package/src/core/provider.js
CHANGED
|
@@ -107,6 +107,18 @@ export const FALLBACKS = [
|
|
|
107
107
|
|
|
108
108
|
/** The next model to try after `id`, skipping any already tried this round. */
|
|
109
109
|
export function fallbackFor(id, tried = new Set()) {
|
|
110
|
+
// Off unless UCODE_FALLBACK=1. A build that starts on one model and finishes
|
|
111
|
+
// on another finishes to a different standard, and the swap lands exactly
|
|
112
|
+
// when the user is least placed to work out why the output changed —
|
|
113
|
+
// mid-build, behind a note that scrolls past. Which model to run is the one
|
|
114
|
+
// decision they made before starting; it is not one to take back for them.
|
|
115
|
+
//
|
|
116
|
+
// Every caller already reads "no fallback" as "wait, then try this one
|
|
117
|
+
// again": failover waits a minute and returns to the same model, the
|
|
118
|
+
// stuck-detector simply does not switch, and a worker retries its own. That
|
|
119
|
+
// is why this can be a single gate rather than four.
|
|
120
|
+
if (process.env.UCODE_FALLBACK !== '1') return null;
|
|
121
|
+
|
|
110
122
|
const start = Math.max(0, FALLBACKS.indexOf(id));
|
|
111
123
|
for (let i = 1; i <= FALLBACKS.length; i++) {
|
|
112
124
|
const next = FALLBACKS[(start + i) % FALLBACKS.length];
|
package/src/tools/browser.js
CHANGED
|
@@ -179,6 +179,81 @@ export function forgetReviews() {
|
|
|
179
179
|
reviews.clear();
|
|
180
180
|
}
|
|
181
181
|
|
|
182
|
+
|
|
183
|
+
/**
|
|
184
|
+
* Text typed into the app while checking it. Distinctive enough to recognise
|
|
185
|
+
* in a screenshot, and obviously not something a user wrote.
|
|
186
|
+
*/
|
|
187
|
+
const PROBE_TEXT = 'ucode check';
|
|
188
|
+
|
|
189
|
+
/** Did anything at all happen on the page? */
|
|
190
|
+
const moved = (a, b) => a.nodes !== b.nodes || a.text !== b.text || a.stored !== b.stored;
|
|
191
|
+
|
|
192
|
+
/**
|
|
193
|
+
* Use the app, rather than only looking at it.
|
|
194
|
+
*
|
|
195
|
+
* Everything else here is an inspection: overflow, labels, broken images,
|
|
196
|
+
* console errors on load. All of it passes on an app whose Add button does
|
|
197
|
+
* nothing, because a page with dead JavaScript still renders, still has good
|
|
198
|
+
* contrast and still has no console errors — it simply does not work. Nothing
|
|
199
|
+
* in the harness ever pressed anything, so the model was never told, and never
|
|
200
|
+
* fixed it.
|
|
201
|
+
*
|
|
202
|
+
* So: type into the first text field, press Enter, and if that changed nothing,
|
|
203
|
+
* click the first button. Then look at whether the page has more nodes, more
|
|
204
|
+
* text, or more in localStorage than it did. Any of those moving means the core
|
|
205
|
+
* loop is wired up. None of them moving, on a page that has controls to press,
|
|
206
|
+
* means it is not.
|
|
207
|
+
*
|
|
208
|
+
* Real keyboard and mouse input through the driver, never synthetic DOM events:
|
|
209
|
+
* an implicit form submit does not fire for a dispatched event, which would
|
|
210
|
+
* report a working form as dead.
|
|
211
|
+
*
|
|
212
|
+
* A page with nothing to press — a landing page, a chart, a page of prose — is
|
|
213
|
+
* not exercised and not judged. Returning null there is the difference between
|
|
214
|
+
* a check and a false accusation.
|
|
215
|
+
*/
|
|
216
|
+
async function useTheApp(page) {
|
|
217
|
+
const snapshot = () => page.evaluate(() => ({
|
|
218
|
+
nodes: document.body.querySelectorAll('*').length,
|
|
219
|
+
text: document.body.innerText.replace(/\s+/g, ' ').trim().length,
|
|
220
|
+
stored: (() => { try { return JSON.stringify(localStorage).length; } catch { return 0; } })(),
|
|
221
|
+
}));
|
|
222
|
+
|
|
223
|
+
const before = await snapshot().catch(() => null);
|
|
224
|
+
if (!before) return null;
|
|
225
|
+
const tried = [];
|
|
226
|
+
|
|
227
|
+
// A field and the Enter key: the core loop of most one-page apps.
|
|
228
|
+
const field = page.locator('input[type="text"], input[type="search"], input:not([type]), textarea').first();
|
|
229
|
+
if (await field.count().catch(() => 0)) {
|
|
230
|
+
const ok = await field.fill(PROBE_TEXT, { timeout: 2_000 }).then(() => true).catch(() => false);
|
|
231
|
+
if (ok) {
|
|
232
|
+
await field.press('Enter', { timeout: 2_000 }).catch(() => {});
|
|
233
|
+
await page.waitForTimeout(300);
|
|
234
|
+
tried.push('typed into the first field and pressed Enter');
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
let after = await snapshot().catch(() => before);
|
|
239
|
+
if (tried.length && moved(before, after)) return { tried, worked: true };
|
|
240
|
+
|
|
241
|
+
// Nothing moved, so try the other half of the same pattern.
|
|
242
|
+
const button = page.locator('button:not([disabled]), input[type="submit"], [role="button"]').first();
|
|
243
|
+
if (await button.count().catch(() => 0)) {
|
|
244
|
+
const label = (await button.innerText().catch(() => '') || '').trim().replace(/\s+/g, ' ').slice(0, 24);
|
|
245
|
+
const ok = await button.click({ timeout: 2_000 }).then(() => true).catch(() => false);
|
|
246
|
+
if (ok) {
|
|
247
|
+
await page.waitForTimeout(300);
|
|
248
|
+
tried.push(`clicked ${label ? `"${label}"` : 'the first button'}`);
|
|
249
|
+
after = await snapshot().catch(() => after);
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
if (!tried.length) return null;
|
|
254
|
+
return { tried, worked: moved(before, after) };
|
|
255
|
+
}
|
|
256
|
+
|
|
182
257
|
export async function lookAtApp({ url, paths = ['/'] }) {
|
|
183
258
|
const base = String(url ?? '').trim().replace(/\/+$/, '');
|
|
184
259
|
if (!LOCAL.test(`${base}/`)) {
|
|
@@ -217,6 +292,7 @@ export async function lookAtApp({ url, paths = ['/'] }) {
|
|
|
217
292
|
|
|
218
293
|
const target = `${base}${pagePath}`;
|
|
219
294
|
let loadError = null;
|
|
295
|
+
let used = null;
|
|
220
296
|
try {
|
|
221
297
|
// 'load', not 'networkidle': a dev server holds a hot-reload
|
|
222
298
|
// connection open and polls, so the network may never go quiet and
|
|
@@ -246,6 +322,12 @@ export async function lookAtApp({ url, paths = ['/'] }) {
|
|
|
246
322
|
});
|
|
247
323
|
await fs.writeFile(file, buffer);
|
|
248
324
|
shot = { label: `${pagePath} at ${size.width}px (${size.name})`, dataUrl: `data:image/jpeg;base64,${buffer.toString('base64')}` };
|
|
325
|
+
|
|
326
|
+
// Once per look, not once per width: pressing the same button four
|
|
327
|
+
// times says nothing the first press did not, and costs four seconds.
|
|
328
|
+
if (size.name === 'desktop' && pagePath === pages[0]) {
|
|
329
|
+
used = await useTheApp(page).catch(() => null);
|
|
330
|
+
}
|
|
249
331
|
}
|
|
250
332
|
} catch (err) {
|
|
251
333
|
loadError = `the page broke while being checked: ${String(err.message).split('\n')[0]}`;
|
|
@@ -270,11 +352,25 @@ export async function lookAtApp({ url, paths = ['/'] }) {
|
|
|
270
352
|
if (facts?.noAlt) lines.push(`- ${facts.noAlt} image(s) without alt text.`);
|
|
271
353
|
if (facts?.tiny) lines.push(`- ${facts.tiny} tap target(s) smaller than 32px on a phone.`);
|
|
272
354
|
if (facts?.smallText) lines.push(`- ${facts.smallText} text element(s) under 12px.`);
|
|
355
|
+
if (used && !used.worked) {
|
|
356
|
+
lines.push(
|
|
357
|
+
`- NOTHING HAPPENS WHEN YOU USE IT. I ${used.tried.join(', then ')} — and the page`,
|
|
358
|
+
' gained no elements, changed no text and stored nothing. The markup and the styling',
|
|
359
|
+
' are there; the behaviour is not wired to them. Find the listener that was never',
|
|
360
|
+
' attached, or the handler that throws before it does anything, and fix that first:',
|
|
361
|
+
' everything else on this page is decoration until it works.',
|
|
362
|
+
);
|
|
363
|
+
problems++;
|
|
364
|
+
} else if (used) {
|
|
365
|
+
lines.push(`- Core loop works: I ${used.tried.join(', then ')}, and the page responded.`);
|
|
366
|
+
}
|
|
273
367
|
}
|
|
274
368
|
if (errors.length) { lines.push('- Console errors:', ...[...new Set(errors)].slice(0, 6).map((e) => ` - ${e}`)); problems++; }
|
|
275
369
|
if (failed.length) { lines.push('- Failed requests:', ...[...new Set(failed)].slice(0, 6).map((f) => ` - ${f}`)); problems++; }
|
|
276
370
|
if (lines.length === 2 && !loadError) lines.push('- No errors, no overflow, nothing unlabeled.');
|
|
277
|
-
|
|
371
|
+
// A dead core loop counts as broken: a screenshot of an app that does not
|
|
372
|
+
// work is not worth a paragraph on its typography.
|
|
373
|
+
const broken = Boolean(loadError || errors.length || facts?.empty || (used && !used.worked));
|
|
278
374
|
return { section: lines.join('\n'), shot, problems, broken };
|
|
279
375
|
})));
|
|
280
376
|
|
package/src/tools/index.js
CHANGED
|
@@ -85,7 +85,11 @@ export const tools = [
|
|
|
85
85
|
'Start a new app AND write it, in one call. Pass "files" with the whole app and this ' +
|
|
86
86
|
'is the only call the build needs: the starter lands, your files are written over it, ' +
|
|
87
87
|
'and the result comes back with everything. Two starters. "plain-html" (the default): ' +
|
|
88
|
-
'one index.html, one stylesheet, one ES module — nothing to install, nothing to build, ' +
|
|
88
|
+
'one index.html, one stylesheet, one ES module — nothing to install, nothing to build, ' +
|
|
89
|
+
'and the stylesheet is a design system already: a palette, a spacing scale, radii, ' +
|
|
90
|
+
'motion timings, focus rings and a breakpoint. Re-tint those tokens to suit the app ' +
|
|
91
|
+
'and compose every rule from them. Replacing it with raw pixel values is what an ' +
|
|
92
|
+
'undesigned page is made of. ' +
|
|
89
93
|
'opens straight in a browser, and its three files come back inside this result so there ' +
|
|
90
94
|
'is never a reason to read them. Use it for anything that is one page: a tasks app, a ' +
|
|
91
95
|
'toy, a game, a visualisation, a calculator, a timer. "next-shadcn": Next.js 16, ' +
|
package/src/tools/scaffold.js
CHANGED
|
@@ -108,6 +108,46 @@ async function copyTree(from, to, fill) {
|
|
|
108
108
|
* globals.css and layout.tsx. Apps stop looking like the same default blue.
|
|
109
109
|
* Returns the preset used, or null when the starter has none.
|
|
110
110
|
*/
|
|
111
|
+
/**
|
|
112
|
+
* The file in each starter that carries the design, not just some of the rules.
|
|
113
|
+
*
|
|
114
|
+
* next-shadcn has globals.css, which applyDesign re-tints. plain-html has one
|
|
115
|
+
* stylesheet and it is the whole design system: the palette, a spacing scale,
|
|
116
|
+
* radii, motion timings, focus rings, a reduced-motion rule and a breakpoint.
|
|
117
|
+
*/
|
|
118
|
+
const TOKEN_FILE = { 'plain-html': 'styles.css' };
|
|
119
|
+
|
|
120
|
+
/**
|
|
121
|
+
* Put the starter's token block back when the app wrote over it without one.
|
|
122
|
+
*
|
|
123
|
+
* The stylesheet in plain-html is a design, not a placeholder — but `files`
|
|
124
|
+
* lands straight on top of the starter, so a model passing its own styles.css
|
|
125
|
+
* replaces the scale, the palette and the timings with whatever it typed. What
|
|
126
|
+
* comes back is hand-rolled CSS with no system behind it, and an app built on
|
|
127
|
+
* raw pixel values has uneven spacing everywhere for the rest of its life.
|
|
128
|
+
*
|
|
129
|
+
* A replacement that declares its own custom properties is left alone: that is
|
|
130
|
+
* a model doing the job properly, and second-guessing it would be worse. Only
|
|
131
|
+
* a stylesheet with no :root variables at all gets the starter's block put
|
|
132
|
+
* back above it, where every rule underneath can reach it.
|
|
133
|
+
*/
|
|
134
|
+
async function keepDesignTokens(appDir, template, written, starterCss) {
|
|
135
|
+
const rel = TOKEN_FILE[template];
|
|
136
|
+
if (!rel || !starterCss) return null;
|
|
137
|
+
|
|
138
|
+
const abs = path.join(appDir, rel);
|
|
139
|
+
if (!written.has(abs)) return null; // never overwritten
|
|
140
|
+
|
|
141
|
+
const now = await fs.readFile(abs, 'utf8').catch(() => null);
|
|
142
|
+
if (now === null || /:root\s*\{[^}]*--/.test(now)) return null;
|
|
143
|
+
|
|
144
|
+
const block = /:root\s*\{[\s\S]*?\n\}/.exec(starterCss);
|
|
145
|
+
if (!block) return null;
|
|
146
|
+
|
|
147
|
+
await fs.writeFile(abs, `${block[0]}\n\n${now}`, 'utf8');
|
|
148
|
+
return rel;
|
|
149
|
+
}
|
|
150
|
+
|
|
111
151
|
export async function applyDesign(appDir, design) {
|
|
112
152
|
const dir = path.join(appDir, 'presets');
|
|
113
153
|
const names = (await fs.readdir(dir).catch(() => [])).filter((f) => f.endsWith('.json'));
|
|
@@ -242,7 +282,13 @@ export async function createApp({ folder, name, description, template = 'plain-h
|
|
|
242
282
|
// Next.js serves static files from public/; a plain page has no such place
|
|
243
283
|
// and an empty folder in a three-file app is clutter.
|
|
244
284
|
if (template !== 'plain-html') await fs.mkdir(path.join(target.abs, 'public'), { recursive: true });
|
|
245
|
-
const look = await applyDesign(target.abs, design);
|
|
285
|
+
const look = await applyDesign(target.abs, design);
|
|
286
|
+
|
|
287
|
+
// Read before the app's own files land on top of it, so the tokens can be
|
|
288
|
+
// put back if the replacement arrives without any.
|
|
289
|
+
const starterCss = TOKEN_FILE[template]
|
|
290
|
+
? await fs.readFile(path.join(target.abs, TOKEN_FILE[template]), 'utf8').catch(() => null)
|
|
291
|
+
: null;
|
|
246
292
|
|
|
247
293
|
// The starter has been installed on this machine before: hard-link that
|
|
248
294
|
// tree in, which is seconds where npm is a minute. Otherwise install as
|
|
@@ -271,7 +317,8 @@ export async function createApp({ folder, name, description, template = 'plain-h
|
|
|
271
317
|
// one, and round trips are nearly all of the time a build takes.
|
|
272
318
|
const mine = given;
|
|
273
319
|
const wrote = mine.length ? await batchWrite({ files: mine }) : null;
|
|
274
|
-
const written = new Set(mine.map((f) => resolveIn(f.path, 'create_app', 'files').abs));
|
|
320
|
+
const written = new Set(mine.map((f) => resolveIn(f.path, 'create_app', 'files').abs));
|
|
321
|
+
const keptTokens = await keepDesignTokens(target.abs, template, written, starterCss);
|
|
275
322
|
|
|
276
323
|
// The starter's own files, in full, so there is never a reason to read them
|
|
277
324
|
// back — and only the ones this call did not already write over. A read is
|
|
@@ -287,7 +334,14 @@ export async function createApp({ folder, name, description, template = 'plain-h
|
|
|
287
334
|
const out = result(
|
|
288
335
|
`Created ${target.show} from the ${template} starter — ${copied.length} files, already known to build.\n` +
|
|
289
336
|
(look ? `Design: the ${look.name} preset (${look.summary}), font ${look.fonts?.sans ?? 'Geist'}.\n` : '') +
|
|
290
|
-
(wrote ? `\nYour ${mine.length} file${mine.length === 1 ? '' : 's'}:\n${wrote.content}\n` : '') +
|
|
337
|
+
(wrote ? `\nYour ${mine.length} file${mine.length === 1 ? '' : 's'}:\n${wrote.content}\n` : '') +
|
|
338
|
+
(keptTokens
|
|
339
|
+
? `\nYour ${keptTokens} arrived with no :root block, so the starter's was kept above it — ` +
|
|
340
|
+
'the palette, the spacing scale (--s1 to --s5), the radius and the motion timings. ' +
|
|
341
|
+
'Build the rest of the stylesheet out of those variables: spacing that comes from a ' +
|
|
342
|
+
'scale is the difference between a designed page and an arranged one. Re-tint the ' +
|
|
343
|
+
'values to suit this app; do not go back to raw pixels.\n'
|
|
344
|
+
: '') +
|
|
291
345
|
(linked
|
|
292
346
|
? `Its packages are already in place (${linked.toLocaleString()} files, linked from the starter cache) — ` +
|
|
293
347
|
'nothing to install: build and run straight away.\n'
|