ucode-agent 1.63.0 → 1.64.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/README.md +95 -2
- package/package.json +65 -64
- package/src/core/commands.js +44 -0
- package/src/core/genericcheck.js +147 -0
- package/src/core/headless.js +73 -0
- package/src/core/lessons.js +83 -0
- package/src/core/loop.js +453 -18
- package/src/core/mcp.js +337 -0
- package/src/core/mcpcli.js +52 -0
- package/src/core/provider.js +93 -11
- package/src/core/scope.js +62 -3
- package/src/core/settings.js +148 -0
- package/src/core/snapshot.js +147 -0
- package/src/core/tests.js +14 -1
- package/src/tools/browser.js +2 -1
- package/src/tools/search.js +74 -0
- package/src/tools/shared.js +33 -2
- package/src/tools/shell.js +4 -1
- package/src/ui/plain.js +9 -6
- package/src/ui/screen.js +7 -5
- package/src/ui/theme.js +23 -1
- package/ucode.js +37 -1
package/README.md
CHANGED
|
@@ -365,7 +365,13 @@ Everything after the frontmatter is the instruction.
|
|
|
365
365
|
| `/session delete 2,5` | delete saved conversations by number (or `d d` in the list) |
|
|
366
366
|
| `/new` | save this one and start fresh |
|
|
367
367
|
| `/remember <note>` | add a standing note to this project's `UCODE.md` |
|
|
368
|
-
| `/undo` | put back
|
|
368
|
+
| `/undo [n]` | put the project back as it was before the last turn — or `n` turns — including what commands changed |
|
|
369
|
+
| `/diff` | what has changed this session |
|
|
370
|
+
| `/commit [msg]` | commit the changes, with a message written from the diff if you give none |
|
|
371
|
+
| `/review` | read the uncommitted changes for bugs, changing nothing |
|
|
372
|
+
| `/init` | read the project and write its `UCODE.md` |
|
|
373
|
+
| `/mcp` | connected MCP servers and their tools |
|
|
374
|
+
| `/permissions [ask\|auto]` | ask before every command, or run them; what is always allowed |
|
|
369
375
|
| `/look [url]` | open the running app and report what is on the page |
|
|
370
376
|
| `/deploy [folder]` | put the app online and get its link |
|
|
371
377
|
| `/mic` | say what you want instead of typing it — same as `ctrl+t` |
|
|
@@ -393,6 +399,86 @@ Recording uses what the computer already has: Windows' built-in recorder, `sox`
|
|
|
393
399
|
or `ffmpeg` on macOS (`brew install sox`), `arecord` or `sox` on Linux. If a
|
|
394
400
|
quiet mic is taken for silence, set `UCODE_MIC_QUIET` lower than 800.
|
|
395
401
|
|
|
402
|
+
### Your own commands
|
|
403
|
+
|
|
404
|
+
A file `.ucode/commands/explain.md` (or `~/.ucode/commands/` for every
|
|
405
|
+
project) becomes `/explain`. Its text is the prompt; `$ARGUMENTS` is replaced
|
|
406
|
+
by whatever you type after the command.
|
|
407
|
+
|
|
408
|
+
### MCP servers
|
|
409
|
+
|
|
410
|
+
Connect tools from any MCP server — library docs, GitHub, a database:
|
|
411
|
+
|
|
412
|
+
```
|
|
413
|
+
ucode mcp add context7 npx -y @upstash/context7-mcp
|
|
414
|
+
ucode mcp add github --url https://api.githubcopilot.com/mcp/ --header "Authorization=Bearer ${GITHUB_TOKEN}"
|
|
415
|
+
ucode mcp list
|
|
416
|
+
ucode mcp remove github
|
|
417
|
+
```
|
|
418
|
+
|
|
419
|
+
They are saved in `~/.ucode/mcp.json` (`--project` puts them in this folder's
|
|
420
|
+
`.ucode/mcp.json`). ucode asks before each MCP tool runs; answer `a` to always
|
|
421
|
+
allow that tool. A project's own servers and hooks only run once you approve them.
|
|
422
|
+
|
|
423
|
+
### Permissions and hooks
|
|
424
|
+
|
|
425
|
+
`.ucode/settings.json` (or `~/.ucode/settings.json`):
|
|
426
|
+
|
|
427
|
+
```json
|
|
428
|
+
{
|
|
429
|
+
"commands": "ask",
|
|
430
|
+
"allow": ["npm test", "git status"],
|
|
431
|
+
"hooks": {
|
|
432
|
+
"afterEdit": ["npx prettier --write {files}"],
|
|
433
|
+
"beforeCommand": ["node guard.js"]
|
|
434
|
+
}
|
|
435
|
+
}
|
|
436
|
+
```
|
|
437
|
+
|
|
438
|
+
`"commands": "ask"` puts every command to you first (`/permissions ask`);
|
|
439
|
+
answering `a` adds it to `allow`. A `beforeCommand` hook that exits non-zero
|
|
440
|
+
stops the command; it sees it in `UCODE_COMMAND`.
|
|
441
|
+
|
|
442
|
+
### Run it without a keyboard
|
|
443
|
+
|
|
444
|
+
```
|
|
445
|
+
ucode -p "fix the failing test" prints the answer
|
|
446
|
+
ucode -p "make a quiz app" --json --yes one JSON line: ok, answer, files, steps, requests, time
|
|
447
|
+
```
|
|
448
|
+
|
|
449
|
+
Progress goes to stderr. With no `--yes`, anything that would be asked is declined.
|
|
450
|
+
`npm run eval` runs ten real jobs this way and checks each one — use it before
|
|
451
|
+
a release (it spends about 100 free requests).
|
|
452
|
+
|
|
453
|
+
### Other models
|
|
454
|
+
|
|
455
|
+
Any OpenAI-compatible server works, Ollama on your own computer included —
|
|
456
|
+
free and offline:
|
|
457
|
+
|
|
458
|
+
```
|
|
459
|
+
UCODE_BASE_URL=http://localhost:11434/v1 UCODE_MODEL=qwen2.5-coder ucode
|
|
460
|
+
```
|
|
461
|
+
|
|
462
|
+
## How it thinks
|
|
463
|
+
|
|
464
|
+
- **Thinking levels.** Flash-Lite does not think at all unless asked. ucode
|
|
465
|
+
asks for a little on every step (it costs nothing on a straightforward
|
|
466
|
+
write), more on the first step of a build, and the most when a fix has
|
|
467
|
+
already failed. `UCODE_THINK=0` turns it off.
|
|
468
|
+
- **A design direction for every build** — a tone, two typefaces and an accent —
|
|
469
|
+
so two apps never come out the same, and a check for the generated look
|
|
470
|
+
(the starter's colours, Inter, purple gradients, gradient text, emoji icons)
|
|
471
|
+
that sends it back to be fixed. `UCODE_DESIGN_CHECK=0` turns the check off.
|
|
472
|
+
- **Learns from its mistakes.** Problems ucode keeps catching are counted in
|
|
473
|
+
`~/.ucode/lessons.json`, and the common ones are warned about before the next build.
|
|
474
|
+
- **Tries a different approach** when the same problem survives a fix, and
|
|
475
|
+
offers to hand that fix to Gemini 3.5 Flash — only if you say yes, since it
|
|
476
|
+
has about 20 free requests a day.
|
|
477
|
+
- **Changes to existing code** get their own rules: find the code, read only
|
|
478
|
+
what is involved, make the smallest change in the code's own style.
|
|
479
|
+
- **Stays under the free limit.** Requests are spaced to Google's per-minute
|
|
480
|
+
limit instead of being refused and waited out; `/stats` shows how many were sent.
|
|
481
|
+
|
|
396
482
|
## Options
|
|
397
483
|
|
|
398
484
|
```
|
|
@@ -401,6 +487,9 @@ ucode [options]
|
|
|
401
487
|
-m, --model <id> which model to use
|
|
402
488
|
-C, --cwd <dir> work in another directory
|
|
403
489
|
--plan start in plan mode
|
|
490
|
+
-p, --print <task> do one task with no keyboard, print the answer, exit
|
|
491
|
+
--json with -p: one JSON object about the run
|
|
492
|
+
-y, --yes with -p: say yes to anything that would be asked
|
|
404
493
|
--debug print stack traces when something breaks
|
|
405
494
|
-v, --version print the version
|
|
406
495
|
-h, --help the above
|
|
@@ -420,7 +509,11 @@ Environment overrides: `UCODE_MODEL`, `UCODE_WORKER_MODEL` (a faster model for
|
|
|
420
509
|
parallel workers), `UCODE_WORKER_STEPS`, `UCODE_MAX_CONTEXT_TOKENS`,
|
|
421
510
|
`UCODE_MAX_STEPS`, `UCODE_MAX_TOOL_OUTPUT`, `UCODE_REQUEST_TIMEOUT_MS`,
|
|
422
511
|
`UCODE_STALL_MS` (how long a silent reply is waited on before asking again, 60s),
|
|
423
|
-
`UCODE_BASE_URL`, `UCODE_NO_UPDATE
|
|
512
|
+
`UCODE_BASE_URL`, `UCODE_NO_UPDATE`, `UCODE_THINK=0`, `UCODE_DESIGN_CHECK=0`,
|
|
513
|
+
`UCODE_RPM` (requests a minute before pacing, 0 = off), `UCODE_RIPGREP=0`.
|
|
514
|
+
|
|
515
|
+
Search uses ripgrep (`rg`) when it is installed — much faster on a big
|
|
516
|
+
project — and its own search otherwise.
|
|
424
517
|
|
|
425
518
|
Web search needs a Tavily key — free, 1000 searches a month, no card. Without
|
|
426
519
|
one, ucode answers from what it knows and says that it could not check.
|
package/package.json
CHANGED
|
@@ -1,64 +1,65 @@
|
|
|
1
|
-
{
|
|
2
|
-
"name": "ucode-agent",
|
|
3
|
-
"version": "1.
|
|
4
|
-
"description": "ucode - a terminal coding agent that reads, edits and runs your code, on Google Gemini models.",
|
|
5
|
-
"type": "module",
|
|
6
|
-
"main": "ucode.js",
|
|
7
|
-
"bin": {
|
|
8
|
-
"ucode": "ucode.js"
|
|
9
|
-
},
|
|
10
|
-
"files": [
|
|
11
|
-
"ucode.js",
|
|
12
|
-
"src/",
|
|
13
|
-
"skills/",
|
|
14
|
-
"templates/",
|
|
15
|
-
"THIRD_PARTY_NOTICES.md",
|
|
16
|
-
"LICENSE-APACHE"
|
|
17
|
-
],
|
|
18
|
-
"scripts": {
|
|
19
|
-
"start": "node ucode.js",
|
|
20
|
-
"test": "node test/run.js",
|
|
21
|
-
"prepublishOnly": "node scripts/no-bundled-key.js && node test/run.js",
|
|
22
|
-
"hooks": "node scripts/install-hooks.js"
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
"
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
"
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
"
|
|
38
|
-
"
|
|
39
|
-
"
|
|
40
|
-
"
|
|
41
|
-
"
|
|
42
|
-
"
|
|
43
|
-
"
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
"
|
|
47
|
-
"
|
|
48
|
-
|
|
49
|
-
"
|
|
50
|
-
"
|
|
51
|
-
"
|
|
52
|
-
"
|
|
53
|
-
"marked
|
|
54
|
-
"
|
|
55
|
-
"
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
"
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
}
|
|
1
|
+
{
|
|
2
|
+
"name": "ucode-agent",
|
|
3
|
+
"version": "1.64.0",
|
|
4
|
+
"description": "ucode - a terminal coding agent that reads, edits and runs your code, on Google Gemini models.",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"main": "ucode.js",
|
|
7
|
+
"bin": {
|
|
8
|
+
"ucode": "ucode.js"
|
|
9
|
+
},
|
|
10
|
+
"files": [
|
|
11
|
+
"ucode.js",
|
|
12
|
+
"src/",
|
|
13
|
+
"skills/",
|
|
14
|
+
"templates/",
|
|
15
|
+
"THIRD_PARTY_NOTICES.md",
|
|
16
|
+
"LICENSE-APACHE"
|
|
17
|
+
],
|
|
18
|
+
"scripts": {
|
|
19
|
+
"start": "node ucode.js",
|
|
20
|
+
"test": "node test/run.js",
|
|
21
|
+
"prepublishOnly": "node scripts/no-bundled-key.js && node test/run.js",
|
|
22
|
+
"hooks": "node scripts/install-hooks.js",
|
|
23
|
+
"eval": "node test/evals/run.js"
|
|
24
|
+
},
|
|
25
|
+
"repository": {
|
|
26
|
+
"type": "git",
|
|
27
|
+
"url": "git+https://github.com/sppideey/ucode-agent.git"
|
|
28
|
+
},
|
|
29
|
+
"bugs": {
|
|
30
|
+
"url": "https://github.com/sppideey/ucode-agent/issues"
|
|
31
|
+
},
|
|
32
|
+
"homepage": "https://github.com/sppideey/ucode-agent#readme",
|
|
33
|
+
"engines": {
|
|
34
|
+
"node": ">=22"
|
|
35
|
+
},
|
|
36
|
+
"keywords": [
|
|
37
|
+
"agent",
|
|
38
|
+
"cli",
|
|
39
|
+
"terminal",
|
|
40
|
+
"coding-agent",
|
|
41
|
+
"llm",
|
|
42
|
+
"gemini",
|
|
43
|
+
"google-gemini",
|
|
44
|
+
"ai"
|
|
45
|
+
],
|
|
46
|
+
"author": "om dixit",
|
|
47
|
+
"license": "(MIT OR Apache-2.0)",
|
|
48
|
+
"dependencies": {
|
|
49
|
+
"@babel/parser": "^7.29.9",
|
|
50
|
+
"chalk": "^6.0.0",
|
|
51
|
+
"dotenv": "^18.0.3",
|
|
52
|
+
"jsonrepair": "^3.15.0",
|
|
53
|
+
"marked": "^15.0.12",
|
|
54
|
+
"marked-terminal": "^7.3.0",
|
|
55
|
+
"openai": "^7.23.0",
|
|
56
|
+
"playwright-core": "^1.63.0"
|
|
57
|
+
},
|
|
58
|
+
"devDependencies": {
|
|
59
|
+
"@types/node": "^26.6.2",
|
|
60
|
+
"typescript": "^5.9.3"
|
|
61
|
+
},
|
|
62
|
+
"publishConfig": {
|
|
63
|
+
"access": "public"
|
|
64
|
+
}
|
|
65
|
+
}
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* commands.js — slash commands the user writes themselves.
|
|
3
|
+
*
|
|
4
|
+
* ~/.ucode/commands/<name>.md yours, everywhere
|
|
5
|
+
* <project>/.ucode/commands/<name>.md this project's (wins over yours)
|
|
6
|
+
*
|
|
7
|
+
* The file is a prompt. `/name some words` sends it, with $ARGUMENTS replaced
|
|
8
|
+
* by the words — or the words added at the end when the file has no
|
|
9
|
+
* $ARGUMENTS. Its first line is what /help shows.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import { promises as fs } from 'node:fs';
|
|
13
|
+
import os from 'node:os';
|
|
14
|
+
import path from 'node:path';
|
|
15
|
+
|
|
16
|
+
export const USER_COMMANDS = path.join(os.homedir(), '.ucode', 'commands');
|
|
17
|
+
export const projectCommands = (cwd) => path.join(cwd, '.ucode', 'commands');
|
|
18
|
+
|
|
19
|
+
async function readDir(dir) {
|
|
20
|
+
const found = new Map();
|
|
21
|
+
for (const entry of await fs.readdir(dir, { withFileTypes: true }).catch(() => [])) {
|
|
22
|
+
if (!entry.isFile() || !/\.md$/i.test(entry.name)) continue;
|
|
23
|
+
const name = entry.name.replace(/\.md$/i, '').toLowerCase();
|
|
24
|
+
if (!/^[a-z0-9][\w-]*$/.test(name)) continue;
|
|
25
|
+
const body = await fs.readFile(path.join(dir, entry.name), 'utf8').catch(() => null);
|
|
26
|
+
if (!body?.trim()) continue;
|
|
27
|
+
const first = body.trim().split('\n')[0].replace(/^#+\s*/, '').trim();
|
|
28
|
+
found.set(name, { name, body: body.trim(), description: first.slice(0, 70) });
|
|
29
|
+
}
|
|
30
|
+
return found;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** Every command, by name without the slash. */
|
|
34
|
+
export async function loadCommands(cwd, { userDir = USER_COMMANDS } = {}) {
|
|
35
|
+
const [mine, project] = await Promise.all([readDir(userDir), readDir(projectCommands(cwd))]);
|
|
36
|
+
return new Map([...mine, ...project]);
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** The prompt a command sends, given what was typed after it. */
|
|
40
|
+
export function expandCommand(body, args = '') {
|
|
41
|
+
const words = String(args).trim();
|
|
42
|
+
if (body.includes('$ARGUMENTS')) return body.split('$ARGUMENTS').join(words);
|
|
43
|
+
return words ? `${body}\n\n${words}` : body;
|
|
44
|
+
}
|
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* genericcheck.js — the generated look, caught in code.
|
|
3
|
+
*
|
|
4
|
+
* The rules against it were already written down, in the system prompt and
|
|
5
|
+
* the ui-ux skill: not the starter's palette, not Inter at every size, not a
|
|
6
|
+
* purple-to-blue gradient, not emoji standing in for icons. Flash-Lite reads
|
|
7
|
+
* them and ships the starter's teal anyway. A rule the model can skip is a
|
|
8
|
+
* suggestion; a check that hands the problem back is a rule.
|
|
9
|
+
*
|
|
10
|
+
* Everything here is a few regular expressions over files already on disk —
|
|
11
|
+
* milliseconds, no model call. Only a hit costs anything: one fix round.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import { promises as fs } from 'node:fs';
|
|
15
|
+
import path from 'node:path';
|
|
16
|
+
|
|
17
|
+
/** The starter's own accent, which means nobody chose one. */
|
|
18
|
+
const STARTER = {
|
|
19
|
+
'plain-html': { files: ['styles.css'], token: /--accent\s*:\s*#2dd4bf\b/i },
|
|
20
|
+
'next-shadcn': { files: ['src/app/globals.css'], token: /--primary\s*:\s*oklch\(\s*0\.53\s+0\.2\s+264\s*\)/i },
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
/** Files that carry a Next.js app's look. */
|
|
24
|
+
const LOOK_FILES = {
|
|
25
|
+
'next-shadcn': ['src/app/globals.css', 'src/app/layout.tsx', 'src/app/page.tsx'],
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
const NAMED = {
|
|
29
|
+
purple: 300, violet: 300, indigo: 275, blueviolet: 271, mediumpurple: 260, rebeccapurple: 270,
|
|
30
|
+
darkviolet: 282, slateblue: 248, mediumslateblue: 249, darkslateblue: 248, magenta: 300, fuchsia: 300,
|
|
31
|
+
blue: 240, royalblue: 225, mediumblue: 240, dodgerblue: 210, cornflowerblue: 219,
|
|
32
|
+
};
|
|
33
|
+
|
|
34
|
+
/** Hue in degrees of one colour, or null for a grey or something unreadable. */
|
|
35
|
+
export function hueOf(colour) {
|
|
36
|
+
const c = String(colour).trim().toLowerCase();
|
|
37
|
+
if (NAMED[c] !== undefined) return NAMED[c];
|
|
38
|
+
|
|
39
|
+
let r; let g; let b;
|
|
40
|
+
const hex = /^#([0-9a-f]{3,8})$/.exec(c);
|
|
41
|
+
if (hex) {
|
|
42
|
+
let h = hex[1];
|
|
43
|
+
if (h.length === 3 || h.length === 4) h = [...h.slice(0, 3)].map((x) => x + x).join('');
|
|
44
|
+
[r, g, b] = [0, 2, 4].map((i) => parseInt(h.slice(i, i + 2), 16) / 255);
|
|
45
|
+
}
|
|
46
|
+
const rgb = /^rgba?\(\s*([\d.]+)[\s,]+([\d.]+)[\s,]+([\d.]+)/.exec(c);
|
|
47
|
+
if (rgb) [r, g, b] = rgb.slice(1, 4).map((v) => Number(v) / 255);
|
|
48
|
+
const hsl = /^hsla?\(\s*([\d.]+)(?:deg)?[\s,]+([\d.]+)%/.exec(c);
|
|
49
|
+
if (hsl) return Number(hsl[2]) < 25 ? null : Number(hsl[1]) % 360;
|
|
50
|
+
const lch = /^oklch\(\s*[\d.]+%?\s+([\d.]+)\s+([\d.]+)/.exec(c);
|
|
51
|
+
// oklch puts blue near 264 and purple near 300-310; shift to the same wheel as hsl.
|
|
52
|
+
if (lch) return Number(lch[1]) < 0.06 ? null : (Number(lch[2]) - 25 + 360) % 360;
|
|
53
|
+
if (r === undefined) return null;
|
|
54
|
+
|
|
55
|
+
const max = Math.max(r, g, b);
|
|
56
|
+
const min = Math.min(r, g, b);
|
|
57
|
+
const d = max - min;
|
|
58
|
+
const light = (max + min) / 2;
|
|
59
|
+
const sat = d === 0 ? 0 : d / (1 - Math.abs(2 * light - 1));
|
|
60
|
+
if (sat < 0.25) return null;
|
|
61
|
+
let hue;
|
|
62
|
+
if (max === r) hue = ((g - b) / d) % 6;
|
|
63
|
+
else if (max === g) hue = (b - r) / d + 2;
|
|
64
|
+
else hue = (r - g) / d + 4;
|
|
65
|
+
return Math.round((hue * 60 + 360) % 360);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/** Every gradient's argument list, brackets balanced. */
|
|
69
|
+
function gradients(css) {
|
|
70
|
+
const found = [];
|
|
71
|
+
const re = /(?:linear|radial|conic)-gradient\(/gi;
|
|
72
|
+
let m;
|
|
73
|
+
while ((m = re.exec(css))) {
|
|
74
|
+
let depth = 1;
|
|
75
|
+
let i = re.lastIndex;
|
|
76
|
+
for (; i < css.length && depth; i++) {
|
|
77
|
+
if (css[i] === '(') depth++;
|
|
78
|
+
else if (css[i] === ')') depth--;
|
|
79
|
+
}
|
|
80
|
+
found.push(css.slice(re.lastIndex, i - 1));
|
|
81
|
+
}
|
|
82
|
+
return found;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
const COLOUR = /#[0-9a-f]{3,8}\b|(?:rgba?|hsla?|oklch)\([^)]*\)|\b[a-z]+\b/gi;
|
|
86
|
+
|
|
87
|
+
/** The purple-to-blue gradient: every coloured stop blue-to-purple, at least one of them purple. */
|
|
88
|
+
export function purpleGradient(css) {
|
|
89
|
+
return gradients(css).some((args) => {
|
|
90
|
+
const hues = (args.match(COLOUR) ?? []).map(hueOf).filter((h) => h !== null);
|
|
91
|
+
return hues.length >= 2 && hues.every((h) => h >= 200 && h <= 330) && hues.some((h) => h >= 250);
|
|
92
|
+
});
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* The files that make up the page. For a plain page that is index.html and
|
|
97
|
+
* whatever it actually links: a starter stylesheet left behind, unlinked,
|
|
98
|
+
* says nothing about how the app looks.
|
|
99
|
+
*/
|
|
100
|
+
async function lookFiles(dir, template) {
|
|
101
|
+
if (template !== 'plain-html') return LOOK_FILES[template] ?? [];
|
|
102
|
+
const html = await fs.readFile(path.join(dir, 'index.html'), 'utf8').catch(() => '');
|
|
103
|
+
const linked = [...html.matchAll(/<(?:link|script)\b[^>]*\b(?:href|src)\s*=\s*["']([^"'?#]+)/gi)]
|
|
104
|
+
.map((m) => m[1])
|
|
105
|
+
.filter((f) => !/^(?:[a-z]+:)?\/\//i.test(f) && /\.(?:css|m?js)$/i.test(f));
|
|
106
|
+
return ['index.html', ...new Set(linked)];
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/** Problems with the look of the app in `dir`, as lines for the model. Never throws. */
|
|
110
|
+
export async function genericLook(dir, template = 'plain-html') {
|
|
111
|
+
const files = await lookFiles(dir, template);
|
|
112
|
+
const texts = await Promise.all(files.map((f) => fs.readFile(path.join(dir, f), 'utf8').catch(() => '')));
|
|
113
|
+
const all = texts.join('\n');
|
|
114
|
+
if (!all.trim()) return [];
|
|
115
|
+
|
|
116
|
+
const problems = [];
|
|
117
|
+
const starter = STARTER[template];
|
|
118
|
+
if (starter) {
|
|
119
|
+
const own = starter.files.filter((f) => files.includes(f) || template !== 'plain-html');
|
|
120
|
+
const texts2 = await Promise.all(own.map((f) => fs.readFile(path.join(dir, f), 'utf8').catch(() => '')));
|
|
121
|
+
if (texts2.some((t) => starter.token.test(t))) {
|
|
122
|
+
problems.push('The accent is still the starter\'s own colour, so the app looks like every other one built from it. Pick an accent for this app and set it in the tokens.');
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
if (/font-family\s*:\s*["']?Inter["']?\s*[,;}]|--font-[\w-]+\s*:\s*["']?Inter["']?\s*[,;]|family=Inter(?![+\w])|import\s*\{[^}]*\bInter\b[^}]*\}\s*from\s*["']next\/font\/google/i.test(all)) {
|
|
126
|
+
problems.push('The type is Inter, the default of generated apps. Choose a typeface with a character that fits this app.');
|
|
127
|
+
}
|
|
128
|
+
if (purpleGradient(all)) {
|
|
129
|
+
problems.push('There is a purple-to-blue gradient, the most recognisable mark of a generated design. Use the app\'s own accent, flat or with a quiet tint.');
|
|
130
|
+
}
|
|
131
|
+
if (/(?:-webkit-)?background-clip\s*:\s*text/i.test(all)) {
|
|
132
|
+
problems.push('There is gradient text (background-clip: text). Set headings in a solid colour and let the type carry them.');
|
|
133
|
+
}
|
|
134
|
+
const emoji = all.match(/<(?:button|h[1-6])\b[^>]*>\s*\p{Extended_Pictographic}/gu) ?? [];
|
|
135
|
+
if (emoji.length >= 3) {
|
|
136
|
+
problems.push(`Emoji stand in for icons in ${emoji.length} buttons or headings. Use small inline SVG icons or plain words.`);
|
|
137
|
+
}
|
|
138
|
+
return problems;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/** The fix-round text for a list of look problems. */
|
|
142
|
+
export function genericMessage(problems) {
|
|
143
|
+
return 'ucode checked the design for the generated look and found:\n' +
|
|
144
|
+
problems.map((p) => `- ${p}`).join('\n') +
|
|
145
|
+
'\nFix these in the design tokens and styles only - a new accent, a new typeface. Do not ' +
|
|
146
|
+
'restructure the app or change what it does.';
|
|
147
|
+
}
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* headless.js — `ucode -p "task"`: one job, no keyboard, then exit.
|
|
3
|
+
*
|
|
4
|
+
* For scripts, CI and the eval set. Progress goes to stderr, the answer to
|
|
5
|
+
* stdout (or, with --json, one JSON object describing the run), and the exit
|
|
6
|
+
* code says whether it worked. Nobody is there to approve anything, so every
|
|
7
|
+
* question is answered no — unless --yes says to answer yes.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import { Readable } from 'node:stream';
|
|
11
|
+
import { Plain } from '../ui/plain.js';
|
|
12
|
+
import { Agent } from './loop.js';
|
|
13
|
+
import { requestCount } from './provider.js';
|
|
14
|
+
import { stopServers } from '../tools/shell.js';
|
|
15
|
+
import { closeBrowser } from '../tools/browser.js';
|
|
16
|
+
|
|
17
|
+
export class Headless extends Plain {
|
|
18
|
+
constructor({ cwd, yes = false }) {
|
|
19
|
+
super({ cwd, input: Readable.from([]), output: process.stderr });
|
|
20
|
+
this.yes = yes;
|
|
21
|
+
this.answer = '';
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
assistant(text, opts = {}) {
|
|
25
|
+
super.assistant(text, opts);
|
|
26
|
+
if (!opts.replay && String(text).trim()) this.answer = String(text).trim();
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
confirm({ action }) {
|
|
30
|
+
this.note(`${this.yes ? 'approved' : 'declined'} (no one to ask): ${action}`);
|
|
31
|
+
return Promise.resolve(this.yes);
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** Run one prompt. Resolves to the exit code. */
|
|
36
|
+
export async function runHeadless({ cwd, prompt, json = false, yes = false, plan = false, write = (s) => process.stdout.write(s) }) {
|
|
37
|
+
const ui = new Headless({ cwd, yes });
|
|
38
|
+
const agent = new Agent({ cwd, ui });
|
|
39
|
+
if (plan) ui.mode = 'plan';
|
|
40
|
+
const started = Date.now();
|
|
41
|
+
const sent = requestCount();
|
|
42
|
+
let error = null;
|
|
43
|
+
try {
|
|
44
|
+
await agent.bootstrap();
|
|
45
|
+
agent.startMcp();
|
|
46
|
+
await agent.mcpStarting;
|
|
47
|
+
await agent.turn(prompt);
|
|
48
|
+
} catch (err) {
|
|
49
|
+
error = err;
|
|
50
|
+
ui.error(err);
|
|
51
|
+
} finally {
|
|
52
|
+
stopServers();
|
|
53
|
+
agent.mcp?.close();
|
|
54
|
+
await closeBrowser().catch(() => {});
|
|
55
|
+
await agent.settled?.().catch(() => {});
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
const ok = !error && !agent.endedSilently;
|
|
59
|
+
if (json) {
|
|
60
|
+
write(`${JSON.stringify({
|
|
61
|
+
ok,
|
|
62
|
+
answer: ui.answer,
|
|
63
|
+
files: [...(agent.touched ?? [])],
|
|
64
|
+
steps: agent.stats.steps,
|
|
65
|
+
requests: requestCount() - sent,
|
|
66
|
+
ms: Date.now() - started,
|
|
67
|
+
error: error ? (error.failed ?? error.message ?? String(error)) : null,
|
|
68
|
+
})}\n`);
|
|
69
|
+
} else if (ui.answer) {
|
|
70
|
+
write(`${ui.answer}\n`);
|
|
71
|
+
}
|
|
72
|
+
return ok ? 0 : 1;
|
|
73
|
+
}
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* lessons.js — the mistakes ucode keeps catching, remembered across sessions.
|
|
3
|
+
*
|
|
4
|
+
* Every fix round is ucode finding something wrong with what the model built.
|
|
5
|
+
* The same few things come up build after build: a script that does not parse,
|
|
6
|
+
* a button wired to nothing, a list that forgets itself on reload. Counting
|
|
7
|
+
* them costs nothing, and the ones that keep happening go into the system
|
|
8
|
+
* prompt as a short warning — said before the build, where it is free, instead
|
|
9
|
+
* of after it, where it costs a fix round.
|
|
10
|
+
*
|
|
11
|
+
* The lessons themselves are fixed sentences, not something the model writes:
|
|
12
|
+
* a note in the prompt of every future build should never be a model's guess.
|
|
13
|
+
*
|
|
14
|
+
* Kept in ~/.ucode/lessons.json. Failing to read or write it never matters.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
import { promises as fs } from 'node:fs';
|
|
18
|
+
import os from 'node:os';
|
|
19
|
+
import path from 'node:path';
|
|
20
|
+
|
|
21
|
+
export const LESSONS_FILE = path.join(os.homedir(), '.ucode', 'lessons.json');
|
|
22
|
+
|
|
23
|
+
const KINDS = [
|
|
24
|
+
{ id: 'js-parse', test: /does not parse|SyntaxError|Unexpected token/i,
|
|
25
|
+
lesson: 'Scripts have failed to parse. Before finishing, check every script you wrote for unclosed brackets, braces and template strings.' },
|
|
26
|
+
{ id: 'css-var', test: /custom propert(?:y is|ies are) used and never defined/i,
|
|
27
|
+
lesson: 'Stylesheets have used custom properties that were never defined. Define every token in :root before using it.' },
|
|
28
|
+
{ id: 'dead-button', test: /NOTHING HAPPENS/,
|
|
29
|
+
lesson: 'Buttons have done nothing when clicked. Wire every control to a handler, attached after its element exists.' },
|
|
30
|
+
{ id: 'add-broken', test: /ADDING DOES NOT WORK/,
|
|
31
|
+
lesson: 'Adding an item has failed. The form handler must prevent the default, read the inputs, update the list, render and save.' },
|
|
32
|
+
{ id: 'no-persist', test: /works until you refresh/i,
|
|
33
|
+
lesson: 'Data has been lost on reload. Save to localStorage on every change and load it when the page starts.' },
|
|
34
|
+
{ id: 'console', test: /Console errors/i,
|
|
35
|
+
lesson: 'Pages have thrown console errors. Check that every element you query exists, and wrap JSON.parse of saved data in try/catch.' },
|
|
36
|
+
{ id: 'type-errors', test: /error TS\d+/,
|
|
37
|
+
lesson: 'TypeScript errors have come back. Match prop and function types exactly, and check an import exists before using it.' },
|
|
38
|
+
{ id: 'tests', test: /tests covering your change fail/i,
|
|
39
|
+
lesson: 'Changes have broken existing tests. Read a function\'s tests before changing what it returns.' },
|
|
40
|
+
{ id: 'generic', test: /generated look/i,
|
|
41
|
+
lesson: 'Designs have come out generic: the starter\'s colours, Inter, purple gradients. Choose the accent and typeface first, and use them.' },
|
|
42
|
+
];
|
|
43
|
+
|
|
44
|
+
/** Which kinds of mistake a block of problems text contains. */
|
|
45
|
+
export function kindsIn(problems) {
|
|
46
|
+
const text = String(problems ?? '');
|
|
47
|
+
return KINDS.filter((k) => k.test.test(text)).map((k) => k.id);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
async function readCounts(file) {
|
|
51
|
+
try {
|
|
52
|
+
const data = JSON.parse(await fs.readFile(file, 'utf8'));
|
|
53
|
+
return data && typeof data === 'object' && !Array.isArray(data) ? data : {};
|
|
54
|
+
} catch {
|
|
55
|
+
return {};
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/** Count the mistakes in this fix round. Never throws. */
|
|
60
|
+
export async function noteMistakes(problems, file = LESSONS_FILE) {
|
|
61
|
+
const found = kindsIn(problems);
|
|
62
|
+
if (!found.length || process.env.UCODE_LESSONS === '0') return;
|
|
63
|
+
try {
|
|
64
|
+
const counts = await readCounts(file);
|
|
65
|
+
const next = { ...counts };
|
|
66
|
+
for (const id of found) next[id] = (Number(next[id]) || 0) + 1;
|
|
67
|
+
await fs.mkdir(path.dirname(file), { recursive: true });
|
|
68
|
+
const temp = `${file}.${process.pid}.tmp`;
|
|
69
|
+
await fs.writeFile(temp, JSON.stringify(next, null, 2));
|
|
70
|
+
await fs.rename(temp, file);
|
|
71
|
+
} catch { /* a lesson not saved is a lesson not learned, never a broken turn */ }
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/** The lessons worth repeating: seen at least `min` times, the commonest first. */
|
|
75
|
+
export async function lessonsText(file = LESSONS_FILE, { min = 2, max = 3 } = {}) {
|
|
76
|
+
if (process.env.UCODE_LESSONS === '0') return '';
|
|
77
|
+
const counts = await readCounts(file);
|
|
78
|
+
const top = KINDS
|
|
79
|
+
.filter((k) => (Number(counts[k.id]) || 0) >= min)
|
|
80
|
+
.sort((a, b) => counts[b.id] - counts[a.id])
|
|
81
|
+
.slice(0, max);
|
|
82
|
+
return top.map((k) => `- ${k.lesson}`).join('\n');
|
|
83
|
+
}
|