ucode-agent 1.60.2 → 1.61.1
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/doctor.js +6 -17
- package/src/core/loop.js +71 -15
- package/src/tools/browser.js +92 -1
- package/src/tools/deploy.js +19 -4
package/package.json
CHANGED
package/src/core/doctor.js
CHANGED
|
@@ -12,7 +12,7 @@ import os from 'node:os';
|
|
|
12
12
|
import path from 'node:path';
|
|
13
13
|
import { theme, dim, blue } from '../ui/theme.js';
|
|
14
14
|
import { VERSION } from './version.js';
|
|
15
|
-
import {
|
|
15
|
+
import { ENV_FILE, BASE_URL, providerKey } from './provider.js';
|
|
16
16
|
import { newer } from './updater.js';
|
|
17
17
|
|
|
18
18
|
const DEADLINE = 6000;
|
|
@@ -27,22 +27,11 @@ async function checkKey() {
|
|
|
27
27
|
const key = providerKey();
|
|
28
28
|
if (!key) return { ok: false, name: 'API key', detail: 'not set', fix: `Add GEMINI_API_KEY=... to ${ENV_FILE} (free at aistudio.google.com/apikey)` };
|
|
29
29
|
try {
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
});
|
|
36
|
-
if (r.status === 401 || r.status === 403) return { ok: false, name: 'API key', detail: 'rejected', fix: 'Check the key for typos, or make a new one' };
|
|
37
|
-
const body = await r.json().catch(() => ({}));
|
|
38
|
-
const meta = body?.error?.metadata?.headers ?? {};
|
|
39
|
-
const left = r.headers.get('x-ratelimit-remaining') ?? meta['X-RateLimit-Remaining'];
|
|
40
|
-
const reset = Number(r.headers.get('x-ratelimit-reset') ?? meta['X-RateLimit-Reset']);
|
|
41
|
-
if (r.status === 429 && /per[- ]day|daily/i.test(`${body?.error?.message} ${body?.error?.metadata?.limit_source}`)) {
|
|
42
|
-
const at = Number.isFinite(reset) ? new Date(reset).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }) : 'tomorrow';
|
|
43
|
-
return { ok: false, name: 'Requests today', detail: 'daily free limit used up', fix: `It resets at ${at}; adding credit raises the limit` };
|
|
44
|
-
}
|
|
45
|
-
return { ok: true, name: 'API key', detail: left != null ? `works · ${left} free requests left today` : 'works' };
|
|
30
|
+
// Listing models checks the key in under a second; a chat request can queue for half a minute.
|
|
31
|
+
const r = await fetch(`${BASE_URL}/models`, { headers: { Authorization: `Bearer ${key}` }, signal: timed() });
|
|
32
|
+
if ([400, 401, 403].includes(r.status)) return { ok: false, name: 'API key', detail: 'rejected', fix: 'Check the key for typos, or make a new one' };
|
|
33
|
+
if (!r.ok) return { ok: false, name: 'API key', detail: `could not check (HTTP ${r.status})`, fix: 'Try again in a minute' };
|
|
34
|
+
return { ok: true, name: 'API key', detail: 'works' };
|
|
46
35
|
} catch (err) {
|
|
47
36
|
return { ok: false, name: 'API key', detail: `could not check (${err.name === 'TimeoutError' ? 'no answer' : err.message})`, fix: 'Check your internet connection' };
|
|
48
37
|
}
|
package/src/core/loop.js
CHANGED
|
@@ -14,7 +14,7 @@
|
|
|
14
14
|
import path from 'node:path';
|
|
15
15
|
import os from 'node:os';
|
|
16
16
|
import { pathToFileURL } from 'node:url';
|
|
17
|
-
import { appendFileSync, existsSync } from 'node:fs';
|
|
17
|
+
import { appendFileSync, existsSync, statSync } from 'node:fs';
|
|
18
18
|
import { readFile, readdir, access, mkdir } from 'node:fs/promises';
|
|
19
19
|
import { testRunnerFor, relatedCommand, summariseFailures } from './tests.js';
|
|
20
20
|
import { LogWatch } from './livelog.js';
|
|
@@ -1773,23 +1773,57 @@ export class Agent {
|
|
|
1773
1773
|
}
|
|
1774
1774
|
}
|
|
1775
1775
|
|
|
1776
|
-
/**
|
|
1776
|
+
/**
|
|
1777
|
+
* /deploy puts an app online — only when the user types it.
|
|
1778
|
+
*
|
|
1779
|
+
* /deploy the app made most recently (this session, else the newest folder here)
|
|
1780
|
+
* /deploy <folder> that one
|
|
1781
|
+
* /deploy all every app in this folder, one link each
|
|
1782
|
+
*/
|
|
1777
1783
|
async cmdDeploy(arg) {
|
|
1778
|
-
const
|
|
1779
|
-
this.
|
|
1780
|
-
|
|
1781
|
-
|
|
1782
|
-
|
|
1783
|
-
|
|
1784
|
-
this.
|
|
1785
|
-
this.
|
|
1786
|
-
}
|
|
1787
|
-
|
|
1788
|
-
|
|
1789
|
-
|
|
1784
|
+
const wanted = (arg ?? '').trim();
|
|
1785
|
+
const apps = await this.appFolders();
|
|
1786
|
+
let folders;
|
|
1787
|
+
if (wanted === 'all') folders = apps;
|
|
1788
|
+
else if (wanted) folders = [wanted];
|
|
1789
|
+
else {
|
|
1790
|
+
const made = this.apps?.at(-1);
|
|
1791
|
+
folders = [made ? path.relative(this.cwd, made).split(path.sep).join('/') || '.' : (apps[0] ?? '.')];
|
|
1792
|
+
}
|
|
1793
|
+
if (!folders.length) {
|
|
1794
|
+
this.ui.note('no apps here to deploy — build one first, or /deploy <folder>');
|
|
1795
|
+
return;
|
|
1796
|
+
}
|
|
1797
|
+
for (const folder of folders) {
|
|
1798
|
+
this.ui.toolCall(`Deploying ${folder}`);
|
|
1799
|
+
this.ui.startSpinner('getting ready to deploy');
|
|
1800
|
+
try {
|
|
1801
|
+
const out = await deploy({ folder }, { onOutput: (lines) => this.ui.updateSpinner(lines.at(-1)) });
|
|
1802
|
+
this.ui.stopSpinner();
|
|
1803
|
+
this.ui.toolResult(out.summary);
|
|
1804
|
+
this.ui.write(out.content.split('\n').map((l) => ` ${l}`).join('\n'));
|
|
1805
|
+
} catch (err) {
|
|
1806
|
+
this.ui.stopSpinner();
|
|
1807
|
+
if (err instanceof ToolFailure) this.ui.error(err);
|
|
1808
|
+
else throw err;
|
|
1809
|
+
}
|
|
1790
1810
|
}
|
|
1791
1811
|
}
|
|
1792
1812
|
|
|
1813
|
+
/** App folders here, newest first: the folder itself if it is one, else its subfolders with a page or package.json. */
|
|
1814
|
+
async appFolders() {
|
|
1815
|
+
if (await exists(path.join(this.cwd, 'index.html'))) return ['.'];
|
|
1816
|
+
const found = [];
|
|
1817
|
+
for (const entry of await readdir(this.cwd, { withFileTypes: true }).catch(() => [])) {
|
|
1818
|
+
if (!entry.isDirectory() || entry.name.startsWith('.') || entry.name === 'node_modules') continue;
|
|
1819
|
+
const dir = path.join(this.cwd, entry.name);
|
|
1820
|
+
if ((await exists(path.join(dir, 'index.html'))) || (await exists(path.join(dir, 'package.json')))) {
|
|
1821
|
+
found.push({ name: entry.name, at: statSync(dir).mtimeMs });
|
|
1822
|
+
}
|
|
1823
|
+
}
|
|
1824
|
+
return found.sort((a, b) => b.at - a.at).map((f) => f.name);
|
|
1825
|
+
}
|
|
1826
|
+
|
|
1793
1827
|
reportResult(call, out) {
|
|
1794
1828
|
// A build or type check that just passed already verified everything
|
|
1795
1829
|
// changed so far; the automatic check at the end would only repeat it.
|
|
@@ -1912,6 +1946,26 @@ export class Agent {
|
|
|
1912
1946
|
.map((f) => ({ abs: path.resolve(this.cwd, f.path), size: f.content.length, path: f.path }));
|
|
1913
1947
|
}
|
|
1914
1948
|
|
|
1949
|
+
/**
|
|
1950
|
+
* A model that has just made todo-app/ sometimes writes app.js beside it
|
|
1951
|
+
* instead of in it: the page never gets its script, and the hand-over, which
|
|
1952
|
+
* watches the app folder, never fires. A bare name the app already has, and
|
|
1953
|
+
* this folder does not, goes into the app.
|
|
1954
|
+
*/
|
|
1955
|
+
intoApp(call) {
|
|
1956
|
+
const app = this.apps?.at(-1);
|
|
1957
|
+
if (!app) return;
|
|
1958
|
+
const fix = (p) => {
|
|
1959
|
+
if (typeof p !== 'string' || path.isAbsolute(p) || existsSync(path.resolve(this.cwd, p))) return p;
|
|
1960
|
+
const inside = path.join(app, p);
|
|
1961
|
+
if (path.relative(app, inside).startsWith('..') || !existsSync(inside)) return p;
|
|
1962
|
+
return path.relative(this.cwd, inside).split(path.sep).join('/');
|
|
1963
|
+
};
|
|
1964
|
+
const a = call.args ?? {};
|
|
1965
|
+
if (call.name === 'write_file') a.path = fix(a.path);
|
|
1966
|
+
else if (call.name === 'batch_write') a.files = normaliseFiles(a.files).map((f) => (f && typeof f === 'object' ? { ...f, path: fix(f.path) } : f));
|
|
1967
|
+
}
|
|
1968
|
+
|
|
1915
1969
|
async dispatch(call, offered = this.offering) {
|
|
1916
1970
|
// The model emitted arguments that were not valid JSON. Hand the parser's
|
|
1917
1971
|
// own complaint straight back so it can correct itself next step.
|
|
@@ -2019,6 +2073,8 @@ export class Agent {
|
|
|
2019
2073
|
}
|
|
2020
2074
|
}
|
|
2021
2075
|
|
|
2076
|
+
if (call.name === 'write_file' || call.name === 'batch_write') this.intoApp(call);
|
|
2077
|
+
|
|
2022
2078
|
if (call.name === 'load_skill') return this.loadSkill(call.args?.name);
|
|
2023
2079
|
if (call.name === 'update_plan') return this.updatePlan(call.args?.items);
|
|
2024
2080
|
if (call.name === 'delegate') return this.delegate(call.args?.tasks);
|
|
@@ -2525,7 +2581,7 @@ export class Agent {
|
|
|
2525
2581
|
}
|
|
2526
2582
|
|
|
2527
2583
|
const content = String(out?.content ?? '');
|
|
2528
|
-
if (/Console errors|Failed requests|NOTHING HAPPENS|does not parse/i.test(content)) {
|
|
2584
|
+
if (/Console errors|Failed requests|NOTHING HAPPENS|ADDING DOES NOT WORK|works until you refresh|does not parse/i.test(content)) {
|
|
2529
2585
|
this.handOverPending = true;
|
|
2530
2586
|
this.ui.runStat?.('needs a fix');
|
|
2531
2587
|
return { problems: `I opened the app and looked at it:\n\n${content}` };
|
package/src/tools/browser.js
CHANGED
|
@@ -218,7 +218,90 @@ const moved = (a, b) => a.nodes !== b.nodes || a.html !== b.html || a.stored !==
|
|
|
218
218
|
* not exercised and not judged. Returning null there is the difference between
|
|
219
219
|
* a check and a false accusation.
|
|
220
220
|
*/
|
|
221
|
+
/**
|
|
222
|
+
* Add something, the way a person would, and see it land on the page.
|
|
223
|
+
*
|
|
224
|
+
* Typing into "the first field" found a todo app's search box: the search
|
|
225
|
+
* filtered the empty list, the page changed, and an app whose Add did nothing
|
|
226
|
+
* at all was reported as working. So for anything shaped like a list — an
|
|
227
|
+
* input that is not a search, or a New / Add button — the probe text is added
|
|
228
|
+
* and must then appear in the page's visible text. Returns null for pages with
|
|
229
|
+
* nothing to add to, so a calculator or a weather lookup is judged as before.
|
|
230
|
+
*/
|
|
231
|
+
const NOT_ADD = /search|filter|find|sort|query/i;
|
|
232
|
+
const LIST_HINT = /task|todo|to-do|item|note|add|new|entry|expense|habit|title|what needs|remind/i;
|
|
233
|
+
const ADD_BUTTON = /^\s*(?:\+|add|new|create|save|submit|post|done)\b|\b(?:add|create|save)\s/i;
|
|
234
|
+
|
|
235
|
+
async function tryAdd(page) {
|
|
236
|
+
const shows = () => page.evaluate((t) => document.body.innerText.includes(t), PROBE_TEXT).catch(() => false);
|
|
237
|
+
const settle = () => page.waitForTimeout(400);
|
|
238
|
+
const hintOf = (el) => el.evaluate((e) => [e.placeholder, e.getAttribute('aria-label'), e.name, e.id]
|
|
239
|
+
.filter(Boolean).join(' ')).catch(() => '');
|
|
240
|
+
const addField = async () => {
|
|
241
|
+
const inputs = page.locator('input[type="text"], input:not([type]), textarea');
|
|
242
|
+
const n = Math.min(await inputs.count().catch(() => 0), 10);
|
|
243
|
+
for (let i = 0; i < n; i++) {
|
|
244
|
+
const el = inputs.nth(i);
|
|
245
|
+
if (!(await el.isVisible().catch(() => false))) continue;
|
|
246
|
+
const hint = await hintOf(el);
|
|
247
|
+
if (!NOT_ADD.test(hint)) return { el, hint };
|
|
248
|
+
}
|
|
249
|
+
return null;
|
|
250
|
+
};
|
|
251
|
+
const addButton = async (words = ADD_BUTTON) => {
|
|
252
|
+
const buttons = page.locator('button:not([disabled]), input[type="submit"], [role="button"]');
|
|
253
|
+
const n = Math.min(await buttons.count().catch(() => 0), 30);
|
|
254
|
+
for (let i = 0; i < n; i++) {
|
|
255
|
+
const b = buttons.nth(i);
|
|
256
|
+
const label = ((await b.innerText().catch(() => '')) || (await b.getAttribute('value').catch(() => '')) ||
|
|
257
|
+
(await b.getAttribute('aria-label').catch(() => '')) || '').trim().replace(/\s+/g, ' ');
|
|
258
|
+
if (words.test(label) && (await b.isVisible().catch(() => false))) return { b, label: label.slice(0, 24) };
|
|
259
|
+
}
|
|
260
|
+
return null;
|
|
261
|
+
};
|
|
262
|
+
|
|
263
|
+
const tried = [];
|
|
264
|
+
let field = await addField();
|
|
265
|
+
let listApp = Boolean(field && LIST_HINT.test(field.hint));
|
|
266
|
+
if (!field) {
|
|
267
|
+
// No field yet: the form may be behind a "New task" / "+" button.
|
|
268
|
+
// Words only: a calculator's "+" opens nothing.
|
|
269
|
+
const opener = await addButton(/\b(?:add|new|create)\b/i);
|
|
270
|
+
if (!opener) return null;
|
|
271
|
+
listApp = true;
|
|
272
|
+
await opener.b.click({ timeout: 2_000 }).catch(() => {});
|
|
273
|
+
await settle();
|
|
274
|
+
tried.push(`clicked "${opener.label}"`);
|
|
275
|
+
field = await addField();
|
|
276
|
+
// No field appeared: that button was not a form opener ("Add one" on a
|
|
277
|
+
// counter), so this is not a list app, and the normal check judges it.
|
|
278
|
+
if (!field) return { tried, added: false, listApp: false };
|
|
279
|
+
}
|
|
280
|
+
const ok = await field.el.fill(PROBE_TEXT, { timeout: 2_000 }).then(() => true).catch(() => false);
|
|
281
|
+
if (!ok) return tried.length ? { tried, added: false, listApp } : null;
|
|
282
|
+
await field.el.press('Enter', { timeout: 2_000 }).catch(() => {});
|
|
283
|
+
await settle();
|
|
284
|
+
tried.push(`typed "${PROBE_TEXT}" into ${field.hint ? `"${field.hint.slice(0, 30)}"` : 'the field'} and pressed Enter`);
|
|
285
|
+
if (await shows()) return { tried, added: true, listApp };
|
|
286
|
+
|
|
287
|
+
const button = await addButton();
|
|
288
|
+
if (button) {
|
|
289
|
+
listApp = true;
|
|
290
|
+
await button.b.click({ timeout: 2_000 }).catch(() => {});
|
|
291
|
+
await settle();
|
|
292
|
+
tried.push(`clicked "${button.label}"`);
|
|
293
|
+
if (await shows()) return { tried, added: true, listApp };
|
|
294
|
+
}
|
|
295
|
+
return { tried, added: false, listApp };
|
|
296
|
+
}
|
|
297
|
+
|
|
221
298
|
async function useTheApp(page) {
|
|
299
|
+
// A list app is judged by whether adding works, not by whether anything moved.
|
|
300
|
+
const add = await tryAdd(page).catch(() => null);
|
|
301
|
+
if (add?.added) return { tried: add.tried, worked: true };
|
|
302
|
+
if (add?.listApp) return { tried: add.tried, worked: false, addFailed: true };
|
|
303
|
+
if (add?.tried.length) await page.reload({ waitUntil: 'load', timeout: 20_000 }).catch(() => {});
|
|
304
|
+
|
|
222
305
|
const snapshot = () => page.evaluate(() => ({
|
|
223
306
|
nodes: document.body.querySelectorAll('*').length,
|
|
224
307
|
text: document.body.innerText.replace(/\s+/g, ' ').trim().length,
|
|
@@ -487,7 +570,15 @@ export async function lookAtApp({ url, paths = ['/'], review: withReview = true
|
|
|
487
570
|
if (f.smallText) lines.push(`- ${at}: ${f.smallText} text element(s) under 12px.`);
|
|
488
571
|
}
|
|
489
572
|
|
|
490
|
-
if (used
|
|
573
|
+
if (used?.addFailed) {
|
|
574
|
+
lines.push(
|
|
575
|
+
`- ADDING DOES NOT WORK. I ${used.tried.join(', then ')} — and "${PROBE_TEXT}" never`,
|
|
576
|
+
' appeared on the page. Adding an item is the whole app. Wire the add input and its',
|
|
577
|
+
' button to code that pushes the item, saves it and re-renders the list, and make',
|
|
578
|
+
' sure no error stops that code before it runs.',
|
|
579
|
+
);
|
|
580
|
+
problems++;
|
|
581
|
+
} else if (used && !used.worked) {
|
|
491
582
|
lines.push(
|
|
492
583
|
`- NOTHING HAPPENS WHEN YOU USE IT. I ${used.tried.join(', then ')} — and the page`,
|
|
493
584
|
' gained no elements, changed no text and stored nothing. The markup and the styling',
|
package/src/tools/deploy.js
CHANGED
|
@@ -231,7 +231,7 @@ export async function deploy({ folder = '.', name } = {}, { onOutput } = {}) {
|
|
|
231
231
|
if (staticSite) {
|
|
232
232
|
const config = path.join(dir, 'vercel.json');
|
|
233
233
|
if (!existsSync(config)) {
|
|
234
|
-
await fs.writeFile(config, JSON.stringify({ buildCommand: null, outputDirectory: '.' }, null, 2) + '\n');
|
|
234
|
+
await fs.writeFile(config, JSON.stringify({ buildCommand: null, outputDirectory: '.' }, null, 2) + '\n');
|
|
235
235
|
}
|
|
236
236
|
const sending = (await fs.readdir(dir, { withFileTypes: true }))
|
|
237
237
|
.filter((e) => e.isFile() && !e.name.startsWith('.') && e.name !== 'vercel.json')
|
|
@@ -249,6 +249,12 @@ export async function deploy({ folder = '.', name } = {}, { onOutput } = {}) {
|
|
|
249
249
|
});
|
|
250
250
|
}
|
|
251
251
|
|
|
252
|
+
// Vercel turns on "Vercel Authentication" for new projects, which puts every
|
|
253
|
+
// .vercel.app address behind a Vercel login: the link worked for the account
|
|
254
|
+
// owner and asked everyone else to sign in. /deploy is asking for a public
|
|
255
|
+
// site, so the project is made public.
|
|
256
|
+
await api(`/v9/projects/${link.projectId}`, { method: 'PATCH', body: { ssoProtection: null } }).catch(() => {});
|
|
257
|
+
|
|
252
258
|
say('Uploading and building on Vercel');
|
|
253
259
|
const started = Date.now();
|
|
254
260
|
const { code, output } = await new Promise((resolve) => {
|
|
@@ -284,9 +290,18 @@ export async function deploy({ folder = '.', name } = {}, { onOutput } = {}) {
|
|
|
284
290
|
});
|
|
285
291
|
}
|
|
286
292
|
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
293
|
+
// The address people can open is the project's production alias, which is
|
|
294
|
+
// not always <name>.vercel.app: a taken name gets a suffix ("-eta"). Guessing
|
|
295
|
+
// it handed out a 404, and falling back to the deployment's own URL handed
|
|
296
|
+
// out one that sits behind a Vercel login. Ask Vercel for the real one.
|
|
297
|
+
const project = await api(`/v9/projects/${link.projectId}`).catch(() => ({ json: {} }));
|
|
298
|
+
const aliases = (project.json?.targets?.production?.alias ?? []).slice().sort((a, b) => a.length - b.length);
|
|
299
|
+
const opens = (u) => fetchImpl(u, { redirect: 'manual', signal: AbortSignal.timeout(15_000) })
|
|
300
|
+
.then((r) => r.status < 300).catch(() => false);
|
|
301
|
+
let url = `https://${link.projectName}.vercel.app`;
|
|
302
|
+
for (const alias of aliases) {
|
|
303
|
+
if (await opens(`https://${alias}`)) { url = `https://${alias}`; break; }
|
|
304
|
+
}
|
|
290
305
|
return result(
|
|
291
306
|
[
|
|
292
307
|
`Live at ${url}`,
|