ucode-agent 1.33.0 → 1.35.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 +6 -1
- package/src/core/provider.js +40 -2
- package/src/tools/browser.js +114 -1
- package/src/tools/scaffold.js +51 -1
package/package.json
CHANGED
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
|
@@ -765,6 +765,36 @@ async function streamed(request, opts, id) {
|
|
|
765
765
|
* "path", then "content", then either the next file or the end. Splitting on
|
|
766
766
|
* that shape and escaping the stray quotes gets every file back.
|
|
767
767
|
*/
|
|
768
|
+
/**
|
|
769
|
+
* Which calls can be rebuilt out of broken JSON.
|
|
770
|
+
*
|
|
771
|
+
* All three carry file contents — hundreds of lines of HTML, CSS and
|
|
772
|
+
* JavaScript escaped into a JSON string — which is the one argument shape a
|
|
773
|
+
* model gets wrong often enough to matter. Everything else is short enough
|
|
774
|
+
* that a parse failure is a real mistake, worth reporting rather than guessing
|
|
775
|
+
* around.
|
|
776
|
+
*/
|
|
777
|
+
const SALVAGEABLE = new Set(['write_file', 'batch_write', 'create_app']);
|
|
778
|
+
|
|
779
|
+
/**
|
|
780
|
+
* The plain string arguments sitting beside a files array, read off the raw
|
|
781
|
+
* text when the object as a whole will not parse.
|
|
782
|
+
*
|
|
783
|
+
* Only the keys create_app needs, and only from before the files begin, so a
|
|
784
|
+
* "name" belonging to something nested inside a file cannot be mistaken for
|
|
785
|
+
* the app's own.
|
|
786
|
+
*/
|
|
787
|
+
function scalarArgs(text) {
|
|
788
|
+
const at = text.search(/"files"\s*:/);
|
|
789
|
+
const head = at > 0 ? text.slice(0, at) : text;
|
|
790
|
+
const out = {};
|
|
791
|
+
for (const key of ['folder', 'name', 'description', 'template', 'design']) {
|
|
792
|
+
const hit = new RegExp(`"${key}"\\s*:\\s*"((?:[^"\\\\]|\\\\.)*)"`).exec(head);
|
|
793
|
+
if (hit) out[key] = hit[1].replace(/\\(["\\/])/g, '$1');
|
|
794
|
+
}
|
|
795
|
+
return out;
|
|
796
|
+
}
|
|
797
|
+
|
|
768
798
|
export function salvageWrites(text) {
|
|
769
799
|
const heads = [...text.matchAll(/"path"\s*:\s*"((?:[^"\\]|\\.)*)"\s*,\s*"content"\s*:\s*"/g)];
|
|
770
800
|
if (!heads.length) return null;
|
|
@@ -837,9 +867,17 @@ export function readCall({ id, name, raw, cutOff = false }) {
|
|
|
837
867
|
}
|
|
838
868
|
} catch { /* beyond general repair — try the file-write shape next */ }
|
|
839
869
|
|
|
840
|
-
const salvaged = (name
|
|
870
|
+
const salvaged = SALVAGEABLE.has(name) ? salvageWrites(text) : null;
|
|
841
871
|
if (salvaged) {
|
|
842
|
-
|
|
872
|
+
if (name === 'write_file') call.args = salvaged[0];
|
|
873
|
+
// create_app carries the app in the same { path, content } shape, with
|
|
874
|
+
// a few plain strings beside it. Losing the entire call because one of
|
|
875
|
+
// several hundred lines of HTML held a raw newline is how a build ends
|
|
876
|
+
// with no files at all — which is what it did: refused three times,
|
|
877
|
+
// the folder never created, the turn over in sixty-six seconds having
|
|
878
|
+
// produced nothing. The scalars are read back off the same text.
|
|
879
|
+
else if (name === 'create_app') call.args = { ...scalarArgs(text), files: salvaged };
|
|
880
|
+
else call.args = { files: salvaged };
|
|
843
881
|
call.repaired = true;
|
|
844
882
|
return call;
|
|
845
883
|
}
|
package/src/tools/browser.js
CHANGED
|
@@ -179,6 +179,98 @@ 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 — the button that
|
|
242
|
+
// submits, before any other. Taking simply the first button in the document
|
|
243
|
+
// finds the theme toggle in the header, clicks it, and reports a working app
|
|
244
|
+
// as dead because switching to dark mode adds no elements.
|
|
245
|
+
const candidates = [
|
|
246
|
+
page.locator('form button[type="submit"], form input[type="submit"], button[type="submit"], input[type="submit"]'),
|
|
247
|
+
page.locator('form button:not([disabled])'),
|
|
248
|
+
page.locator('button:not([disabled]), [role="button"]'),
|
|
249
|
+
];
|
|
250
|
+
|
|
251
|
+
const pressed = new Set();
|
|
252
|
+
for (const group of candidates) {
|
|
253
|
+
const count = Math.min(await group.count().catch(() => 0), 3);
|
|
254
|
+
for (let i = 0; i < count; i++) {
|
|
255
|
+
const button = group.nth(i);
|
|
256
|
+
const label = (await button.innerText().catch(() => '') || '').trim().replace(/\s+/g, ' ').slice(0, 24);
|
|
257
|
+
if (pressed.has(label || `#${i}`)) continue;
|
|
258
|
+
pressed.add(label || `#${i}`);
|
|
259
|
+
|
|
260
|
+
const ok = await button.click({ timeout: 2_000 }).then(() => true).catch(() => false);
|
|
261
|
+
if (!ok) continue;
|
|
262
|
+
await page.waitForTimeout(300);
|
|
263
|
+
tried.push(`clicked ${label ? `"${label}"` : 'a button'}`);
|
|
264
|
+
|
|
265
|
+
after = await snapshot().catch(() => after);
|
|
266
|
+
if (moved(before, after)) return { tried, worked: true };
|
|
267
|
+
}
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
if (!tried.length) return null;
|
|
271
|
+
return { tried, worked: moved(before, after) };
|
|
272
|
+
}
|
|
273
|
+
|
|
182
274
|
export async function lookAtApp({ url, paths = ['/'] }) {
|
|
183
275
|
const base = String(url ?? '').trim().replace(/\/+$/, '');
|
|
184
276
|
if (!LOCAL.test(`${base}/`)) {
|
|
@@ -217,6 +309,7 @@ export async function lookAtApp({ url, paths = ['/'] }) {
|
|
|
217
309
|
|
|
218
310
|
const target = `${base}${pagePath}`;
|
|
219
311
|
let loadError = null;
|
|
312
|
+
let used = null;
|
|
220
313
|
try {
|
|
221
314
|
// 'load', not 'networkidle': a dev server holds a hot-reload
|
|
222
315
|
// connection open and polls, so the network may never go quiet and
|
|
@@ -246,6 +339,12 @@ export async function lookAtApp({ url, paths = ['/'] }) {
|
|
|
246
339
|
});
|
|
247
340
|
await fs.writeFile(file, buffer);
|
|
248
341
|
shot = { label: `${pagePath} at ${size.width}px (${size.name})`, dataUrl: `data:image/jpeg;base64,${buffer.toString('base64')}` };
|
|
342
|
+
|
|
343
|
+
// Once per look, not once per width: pressing the same button four
|
|
344
|
+
// times says nothing the first press did not, and costs four seconds.
|
|
345
|
+
if (size.name === 'desktop' && pagePath === pages[0]) {
|
|
346
|
+
used = await useTheApp(page).catch(() => null);
|
|
347
|
+
}
|
|
249
348
|
}
|
|
250
349
|
} catch (err) {
|
|
251
350
|
loadError = `the page broke while being checked: ${String(err.message).split('\n')[0]}`;
|
|
@@ -270,11 +369,25 @@ export async function lookAtApp({ url, paths = ['/'] }) {
|
|
|
270
369
|
if (facts?.noAlt) lines.push(`- ${facts.noAlt} image(s) without alt text.`);
|
|
271
370
|
if (facts?.tiny) lines.push(`- ${facts.tiny} tap target(s) smaller than 32px on a phone.`);
|
|
272
371
|
if (facts?.smallText) lines.push(`- ${facts.smallText} text element(s) under 12px.`);
|
|
372
|
+
if (used && !used.worked) {
|
|
373
|
+
lines.push(
|
|
374
|
+
`- NOTHING HAPPENS WHEN YOU USE IT. I ${used.tried.join(', then ')} — and the page`,
|
|
375
|
+
' gained no elements, changed no text and stored nothing. The markup and the styling',
|
|
376
|
+
' are there; the behaviour is not wired to them. Find the listener that was never',
|
|
377
|
+
' attached, or the handler that throws before it does anything, and fix that first:',
|
|
378
|
+
' everything else on this page is decoration until it works.',
|
|
379
|
+
);
|
|
380
|
+
problems++;
|
|
381
|
+
} else if (used) {
|
|
382
|
+
lines.push(`- Core loop works: I ${used.tried.join(', then ')}, and the page responded.`);
|
|
383
|
+
}
|
|
273
384
|
}
|
|
274
385
|
if (errors.length) { lines.push('- Console errors:', ...[...new Set(errors)].slice(0, 6).map((e) => ` - ${e}`)); problems++; }
|
|
275
386
|
if (failed.length) { lines.push('- Failed requests:', ...[...new Set(failed)].slice(0, 6).map((f) => ` - ${f}`)); problems++; }
|
|
276
387
|
if (lines.length === 2 && !loadError) lines.push('- No errors, no overflow, nothing unlabeled.');
|
|
277
|
-
|
|
388
|
+
// A dead core loop counts as broken: a screenshot of an app that does not
|
|
389
|
+
// work is not worth a paragraph on its typography.
|
|
390
|
+
const broken = Boolean(loadError || errors.length || facts?.empty || (used && !used.worked));
|
|
278
391
|
return { section: lines.join('\n'), shot, problems, broken };
|
|
279
392
|
})));
|
|
280
393
|
|
package/src/tools/scaffold.js
CHANGED
|
@@ -131,6 +131,48 @@ const TOKEN_FILE = { 'plain-html': 'styles.css' };
|
|
|
131
131
|
* a stylesheet with no :root variables at all gets the starter's block put
|
|
132
132
|
* back above it, where every rule underneath can reach it.
|
|
133
133
|
*/
|
|
134
|
+
const ASSET_REF = /\b(href|src)=("|')(?!https?:|\/\/|\/|data:|#|mailto:|tel:)([^"']+)\2/g;
|
|
135
|
+
|
|
136
|
+
/**
|
|
137
|
+
* Take the app's own folder back out of its own links.
|
|
138
|
+
*
|
|
139
|
+
* create_app wants paths relative to the project root — "todo/index.html" —
|
|
140
|
+
* because that is where every other file tool works from. The model then
|
|
141
|
+
* carries the same prefix into the markup and writes
|
|
142
|
+
* <link href="todo/styles.css"> inside todo/index.html, where it resolves to
|
|
143
|
+
* todo/todo/styles.css and 404s. The page comes up as bare markup with no
|
|
144
|
+
* stylesheet and no script: no design, no behaviour, nothing in the console
|
|
145
|
+
* but two failed requests.
|
|
146
|
+
*
|
|
147
|
+
* It is the tool's own convention leaking into the file, so the tool takes it
|
|
148
|
+
* back out. Only a leading "<folder>/" on a relative reference, which inside
|
|
149
|
+
* that folder is always wrong — absolute paths, URLs, data: and anchors are
|
|
150
|
+
* left exactly as they are.
|
|
151
|
+
*/
|
|
152
|
+
async function unprefixOwnFolder(appDir, written) {
|
|
153
|
+
const prefix = `${path.basename(appDir)}/`;
|
|
154
|
+
const fixed = [];
|
|
155
|
+
|
|
156
|
+
for (const abs of written) {
|
|
157
|
+
if (!/\.html?$/i.test(abs)) continue;
|
|
158
|
+
const text = await fs.readFile(abs, 'utf8').catch(() => null);
|
|
159
|
+
if (text === null) continue;
|
|
160
|
+
|
|
161
|
+
let hits = 0;
|
|
162
|
+
const next = text.replace(ASSET_REF, (all, attr, quote, value) => {
|
|
163
|
+
if (!value.startsWith(prefix)) return all;
|
|
164
|
+
hits++;
|
|
165
|
+
return `${attr}=${quote}${value.slice(prefix.length)}${quote}`;
|
|
166
|
+
});
|
|
167
|
+
|
|
168
|
+
if (hits) {
|
|
169
|
+
await fs.writeFile(abs, next, 'utf8');
|
|
170
|
+
fixed.push(`${path.relative(appDir, abs).split(path.sep).join('/')} (${hits})`);
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
return fixed;
|
|
174
|
+
}
|
|
175
|
+
|
|
134
176
|
async function keepDesignTokens(appDir, template, written, starterCss) {
|
|
135
177
|
const rel = TOKEN_FILE[template];
|
|
136
178
|
if (!rel || !starterCss) return null;
|
|
@@ -318,7 +360,8 @@ export async function createApp({ folder, name, description, template = 'plain-h
|
|
|
318
360
|
const mine = given;
|
|
319
361
|
const wrote = mine.length ? await batchWrite({ files: mine }) : null;
|
|
320
362
|
const written = new Set(mine.map((f) => resolveIn(f.path, 'create_app', 'files').abs));
|
|
321
|
-
const keptTokens = await keepDesignTokens(target.abs, template, written, starterCss);
|
|
363
|
+
const keptTokens = await keepDesignTokens(target.abs, template, written, starterCss);
|
|
364
|
+
const relinked = await unprefixOwnFolder(target.abs, written);
|
|
322
365
|
|
|
323
366
|
// The starter's own files, in full, so there is never a reason to read them
|
|
324
367
|
// back — and only the ones this call did not already write over. A read is
|
|
@@ -335,6 +378,13 @@ export async function createApp({ folder, name, description, template = 'plain-h
|
|
|
335
378
|
`Created ${target.show} from the ${template} starter — ${copied.length} files, already known to build.\n` +
|
|
336
379
|
(look ? `Design: the ${look.name} preset (${look.summary}), font ${look.fonts?.sans ?? 'Geist'}.\n` : '') +
|
|
337
380
|
(wrote ? `\nYour ${mine.length} file${mine.length === 1 ? '' : 's'}:\n${wrote.content}\n` : '') +
|
|
381
|
+
(relinked.length
|
|
382
|
+
? `\nFixed in ${relinked.join(', ')}: links that began with "${path.basename(target.abs)}/". ` +
|
|
383
|
+
'Paths in "files" are relative to the project root, but a link inside a page is ' +
|
|
384
|
+
'relative to that page — so "index.html" beside "styles.css" links to it as ' +
|
|
385
|
+
'"styles.css", never "app/styles.css". Written that way the stylesheet and the ' +
|
|
386
|
+
'script 404 and the page comes up as bare markup.\n'
|
|
387
|
+
: '') +
|
|
338
388
|
(keptTokens
|
|
339
389
|
? `\nYour ${keptTokens} arrived with no :root block, so the starter's was kept above it — ` +
|
|
340
390
|
'the palette, the spacing scale (--s1 to --s5), the radius and the motion timings. ' +
|