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 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 every file the last turn changed |
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.63.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
- },
24
- "repository": {
25
- "type": "git",
26
- "url": "git+https://github.com/sppideey/ucode-agent.git"
27
- },
28
- "bugs": {
29
- "url": "https://github.com/sppideey/ucode-agent/issues"
30
- },
31
- "homepage": "https://github.com/sppideey/ucode-agent#readme",
32
- "engines": {
33
- "node": ">=22"
34
- },
35
- "keywords": [
36
- "agent",
37
- "cli",
38
- "terminal",
39
- "coding-agent",
40
- "llm",
41
- "gemini",
42
- "google-gemini",
43
- "ai"
44
- ],
45
- "author": "om dixit",
46
- "license": "(MIT OR Apache-2.0)",
47
- "dependencies": {
48
- "@babel/parser": "^7.29.9",
49
- "chalk": "^6.0.0",
50
- "dotenv": "^18.0.3",
51
- "jsonrepair": "^3.15.0",
52
- "marked": "^15.0.12",
53
- "marked-terminal": "^7.3.0",
54
- "openai": "^7.23.0",
55
- "playwright-core": "^1.63.0"
56
- },
57
- "devDependencies": {
58
- "@types/node": "^26.6.2",
59
- "typescript": "^5.9.3"
60
- },
61
- "publishConfig": {
62
- "access": "public"
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
+ }