ucode-agent 1.3.0 → 1.4.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 +33 -11
- package/package.json +3 -2
- package/skills/build-app/SKILL.md +4 -2
- package/skills/ui-ux/SKILL.md +5 -1
- package/src/core/loop.js +179 -20
- package/src/core/provider.js +40 -12
- package/src/core/updater.js +95 -0
- package/src/tools/browser.js +258 -0
- package/src/tools/index.js +27 -0
- package/src/tools/shell.js +8 -1
- package/src/ui/screen.js +20 -6
package/README.md
CHANGED
|
@@ -19,7 +19,7 @@ corner:
|
|
|
19
19
|
╭──────────────────────────────────────────────────────────────────────────────╮
|
|
20
20
|
│ › Ask anything… │
|
|
21
21
|
│ │
|
|
22
|
-
│ ◆ Build ·
|
|
22
|
+
│ ◆ Build · North Mini Code 0% │
|
|
23
23
|
╰──────────────────────────────────────────────────────────────────────────────╯
|
|
24
24
|
|
|
25
25
|
|
|
@@ -44,7 +44,7 @@ the input, so your own words are easy to find in a long session:
|
|
|
44
44
|
╭──────────────────────────────────────────────────────────────────────────────────╮
|
|
45
45
|
│ › now add a dark mode toggle │
|
|
46
46
|
│ │
|
|
47
|
-
│ ◆ Build ·
|
|
47
|
+
│ ◆ Build · North Mini Code 4% │
|
|
48
48
|
╰──────────────────────────────────────────────────────────────────────────────────╯
|
|
49
49
|
```
|
|
50
50
|
|
|
@@ -88,24 +88,32 @@ properly, which is the thing an agent actually depends on.
|
|
|
88
88
|
|
|
89
89
|
| Model | Context | For |
|
|
90
90
|
| --- | --- | --- |
|
|
91
|
-
|
|
|
91
|
+
| Nemotron 3 Ultra | 1M | deepest reasoning, slowest to first token |
|
|
92
92
|
| Nemotron 3.5 Lightning | 1M | the same enormous window, answers much sooner |
|
|
93
93
|
| Nemotron 3 Super | 262k | strong all-rounder, quick to start |
|
|
94
94
|
| Nemotron 3 Nano Omni | 256k | small, fast, reasoning tuned |
|
|
95
|
-
| **North Mini Code** ★ | 256k |
|
|
95
|
+
| **North Mini Code** ★ | 256k | the default — built for code and interface work, quick to answer |
|
|
96
96
|
|
|
97
97
|
`/model` shows them and switches. `ucode -m cohere/north-mini-code:free`
|
|
98
98
|
starts on one.
|
|
99
99
|
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
100
|
+
North Mini Code is the default: it is built for code and interfaces, which is most
|
|
101
|
+
of what ucode is asked to do, and it answers far sooner than the big reasoning
|
|
102
|
+
models. Switch to Ultra when a problem needs the million-token window more than
|
|
103
|
+
the speed.
|
|
104
|
+
|
|
105
|
+
**A busy model never stops a build.** Free endpoints are shared, and "too many
|
|
106
|
+
requests" is routine. ucode waits it out with growing pauses, and if the model
|
|
107
|
+
stays busy it carries on with the next one — North Mini Code, then Nemotron 3.5
|
|
108
|
+
Lightning, Super, Ultra — from exactly where it was, and tells you it switched.
|
|
109
|
+
If every model is busy at once it waits a minute and goes round again. Your
|
|
110
|
+
chosen model gets another go a few minutes later.
|
|
103
111
|
|
|
104
112
|
## What it does
|
|
105
113
|
|
|
106
|
-
**
|
|
114
|
+
**Fourteen tools.** `read_file`, `read_files`, `write_file`, `batch_write`,
|
|
107
115
|
`edit_file`, `multi_edit`, `edit_files`, `list_dir`, `glob`, `grep`,
|
|
108
|
-
`run_command`, `run_commands`, `web_search`. Read-only calls run in parallel,
|
|
116
|
+
`run_command`, `run_commands`, `look_at_app`, `web_search`. Read-only calls run in parallel,
|
|
109
117
|
and start the moment the model finishes writing them — while the rest of its
|
|
110
118
|
reply is still arriving. Anything that writes runs on its own, in order.
|
|
111
119
|
|
|
@@ -119,6 +127,14 @@ written, its install starts in the background while the rest of the app is
|
|
|
119
127
|
still being written. An install the model asks for later waits for that one
|
|
120
128
|
instead of running twice, and anything run in that folder waits for it too.
|
|
121
129
|
|
|
130
|
+
**It looks at what it built.** `look_at_app` opens the running app in a real
|
|
131
|
+
browser — the Edge or Chrome already on your machine, so there is nothing extra
|
|
132
|
+
to download — at 375px and 1440px. It reports console errors, failed requests,
|
|
133
|
+
content that spills off a phone screen, broken images and unlabeled controls,
|
|
134
|
+
saves screenshots to `.ucode/screenshots`, and has Nemotron Nano Omni review them
|
|
135
|
+
the way a designer would. The model fixes what it finds before calling the app
|
|
136
|
+
done.
|
|
137
|
+
|
|
122
138
|
**Errors fixed before you see them.** When the model says it is done, ucode
|
|
123
139
|
type-checks every file it changed — `tsc --noEmit` for TypeScript projects,
|
|
124
140
|
a syntax check for JavaScript and Python — and hands any errors back to fix,
|
|
@@ -154,7 +170,12 @@ them; the chip inside the input box says which is live.
|
|
|
154
170
|
|
|
155
171
|
**Sessions.** Everything is on disk under `~/.ucode/sessions`, saved after every
|
|
156
172
|
step. `/resume` lists them with what each one was actually about, the ones from
|
|
157
|
-
this folder first.
|
|
173
|
+
this folder first. Press `d` twice on one to delete it — the list stays open, so
|
|
174
|
+
clearing out several is quick — or `/session delete 2,5`.
|
|
175
|
+
|
|
176
|
+
**It updates itself.** Each launch checks npm in the background and, if there is
|
|
177
|
+
a newer version, installs it while you work. The next launch is the new one.
|
|
178
|
+
Set `UCODE_NO_UPDATE=1` to turn that off.
|
|
158
179
|
|
|
159
180
|
**A context window that folds rather than forgets.** Past 75% the oldest turns
|
|
160
181
|
are summarised instead of dropped, never cutting between a tool call and its
|
|
@@ -207,6 +228,7 @@ Everything after the frontmatter is the instruction.
|
|
|
207
228
|
| `/help` | the list |
|
|
208
229
|
| `/model` | show the models and switch — `/models` does the same |
|
|
209
230
|
| `/resume` | pick up an earlier conversation — `/session`, `/sessions` too |
|
|
231
|
+
| `/session delete 2,5` | delete saved conversations by number (or `d d` in the list) |
|
|
210
232
|
| `/new` | save this one and start fresh |
|
|
211
233
|
| `/remember <note>` | add a standing note to this project's `UCODE.md` |
|
|
212
234
|
| `/skills` | what it knows how to do, and what is loaded |
|
|
@@ -245,7 +267,7 @@ ucode [options]
|
|
|
245
267
|
Environment overrides: `UCODE_MODEL`, `UCODE_WORKER_MODEL` (a faster model for
|
|
246
268
|
parallel workers), `UCODE_WORKER_STEPS`, `UCODE_MAX_CONTEXT_TOKENS`,
|
|
247
269
|
`UCODE_MAX_STEPS`, `UCODE_MAX_TOOL_OUTPUT`, `UCODE_REQUEST_TIMEOUT_MS`,
|
|
248
|
-
`UCODE_BASE_URL`.
|
|
270
|
+
`UCODE_BASE_URL`, `UCODE_NO_UPDATE`.
|
|
249
271
|
|
|
250
272
|
Web search needs a Tavily key — free, 1000 searches a month, no card. Without
|
|
251
273
|
one, ucode answers from what it knows and says that it could not check.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "ucode-agent",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.4.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",
|
|
@@ -48,6 +48,7 @@
|
|
|
48
48
|
"dotenv": "^17.4.2",
|
|
49
49
|
"marked": "^15.0.12",
|
|
50
50
|
"marked-terminal": "^7.3.0",
|
|
51
|
-
"openai": "^7.4.0"
|
|
51
|
+
"openai": "^7.4.0",
|
|
52
|
+
"playwright-core": "^1.63.0"
|
|
52
53
|
}
|
|
53
54
|
}
|
|
@@ -141,8 +141,10 @@ Use `batch_write` for the skeleton — one call, every file.
|
|
|
141
141
|
- `npm run build` — it type-checks and lints; a build that fails is not done.
|
|
142
142
|
- Start it: `npm run dev` goes to the background on its own and comes back with
|
|
143
143
|
the URL once ready. Do not start it twice.
|
|
144
|
-
- Exercise it: `curl` the API route with real input,
|
|
145
|
-
|
|
144
|
+
- Exercise it: `curl` the API route with real input, then `look_at_app` on every
|
|
145
|
+
page — it loads them in a real browser at phone and desktop width and reports
|
|
146
|
+
errors, overflow and a visual review. A clean build proves it compiles, not
|
|
147
|
+
that it works.
|
|
146
148
|
- Fix what you find and check again.
|
|
147
149
|
|
|
148
150
|
## 7. Definition of done
|
package/skills/ui-ux/SKILL.md
CHANGED
|
@@ -283,7 +283,11 @@ because it was first to hand means you were not deciding.
|
|
|
283
283
|
|
|
284
284
|
## 9. Look at it, then report
|
|
285
285
|
|
|
286
|
-
Run it (`npm run dev` starts in the background and returns the URL)
|
|
286
|
+
Run it (`npm run dev` starts in the background and returns the URL), then call
|
|
287
|
+
`look_at_app` with that URL. It opens the app in a real browser at 375px and
|
|
288
|
+
1440px, reports console errors, failed requests, overflow, broken images and
|
|
289
|
+
unlabeled controls, and returns a designer's review of the screenshots. Fix what
|
|
290
|
+
it finds and look again. Then check:
|
|
287
291
|
|
|
288
292
|
1. **375px wide** — no horizontal scroll, nothing overlapping or clipped. If you
|
|
289
293
|
wrote no responsive rules at all, you have not done this.
|
package/src/core/loop.js
CHANGED
|
@@ -17,14 +17,16 @@ import { spawn } from 'node:child_process';
|
|
|
17
17
|
|
|
18
18
|
import {
|
|
19
19
|
ask, model, setModel, modelName, modelList, contextLimit, rateLimits,
|
|
20
|
-
MODELS, DEFAULT_MODEL, PROVIDER,
|
|
20
|
+
MODELS, DEFAULT_MODEL, PROVIDER, fallbackFor,
|
|
21
21
|
} from './provider.js';
|
|
22
22
|
import {
|
|
23
23
|
tools, runTool, describe, setRoot, setConfirm, PARALLEL_SAFE, WRITES, FILE_WRITES,
|
|
24
24
|
} from '../tools/index.js';
|
|
25
25
|
import { projectMap, loadMemory, remember, MEMORY_FILE } from './context.js';
|
|
26
|
+
import { autoUpdate } from './updater.js';
|
|
27
|
+
import { closeBrowser } from '../tools/browser.js';
|
|
26
28
|
import {
|
|
27
|
-
newSession, save, load, list, removeAll, titleFrom,
|
|
29
|
+
newSession, save, load, list, remove, removeAll, titleFrom,
|
|
28
30
|
} from './history.js';
|
|
29
31
|
import { fold, usage, tooBig, SUMMARY_PROMPT, forSummary } from './window.js';
|
|
30
32
|
import { loadSkills, catalogue, findSkill, skillMessage, autoLoadFor } from './skills.js';
|
|
@@ -64,6 +66,17 @@ const MAX_FIX_ROUNDS = 3;
|
|
|
64
66
|
/** Files worth checking after they change. */
|
|
65
67
|
const CHECKABLE = /\.(?:[cm]?[jt]sx?|py)$/i;
|
|
66
68
|
|
|
69
|
+
/**
|
|
70
|
+
* Failures that are the provider's and not the model's: busy, slow, down, or
|
|
71
|
+
* unreachable. None of them should end a build — the turn moves to another
|
|
72
|
+
* model and carries on from exactly where it was.
|
|
73
|
+
*/
|
|
74
|
+
const TRANSIENT = new Set(['rate_limit', 'timeout', 'server', 'network', 'no_content']);
|
|
75
|
+
const MAX_FAILOVERS = 8;
|
|
76
|
+
const COOLDOWN = 5 * 60_000;
|
|
77
|
+
|
|
78
|
+
const wait = (ms) => new Promise((r) => setTimeout(r, ms));
|
|
79
|
+
|
|
67
80
|
/** Parallel workers at once, and how many steps each may take. */
|
|
68
81
|
const MAX_WORKERS = 3;
|
|
69
82
|
const WORKER_STEPS = Number(process.env.UCODE_WORKER_STEPS) || 60;
|
|
@@ -129,7 +142,7 @@ const delegateTool = {
|
|
|
129
142
|
};
|
|
130
143
|
|
|
131
144
|
/** The instructions a parallel worker starts with. */
|
|
132
|
-
function workerPrompt({ cwd, name, memory, skills }) {
|
|
145
|
+
function workerPrompt({ cwd, name, memory, skills, map }) {
|
|
133
146
|
return [
|
|
134
147
|
`You are a ucode worker called "${name}" - one of several building parts of the same project at the same time.`,
|
|
135
148
|
'',
|
|
@@ -145,7 +158,11 @@ function workerPrompt({ cwd, name, memory, skills }) {
|
|
|
145
158
|
'- Finish what you build: real content, every state handled, no TODOs.',
|
|
146
159
|
'- When done, reply with two or three sentences: what you built, in which files, and',
|
|
147
160
|
' anything the lead has to wire up.',
|
|
161
|
+
'- The files you own may not exist yet - create them. Do not go looking for them',
|
|
162
|
+
' first. The project map below shows what does exist; read only the files whose',
|
|
163
|
+
' interfaces you must match, then start writing within two or three steps.',
|
|
148
164
|
...(memory ? ['', '## Project memory', '', memory] : []),
|
|
165
|
+
...(map ? ['', '## Project map', '', map] : []),
|
|
149
166
|
...(skills ? ['', '## Instructions in force', '', skills] : []),
|
|
150
167
|
].join('\n');
|
|
151
168
|
}
|
|
@@ -234,6 +251,9 @@ function systemPrompt({ cwd, skills, mode, check, map, memory }) {
|
|
|
234
251
|
' writing files. Running the install yourself afterwards just waits for that one.',
|
|
235
252
|
'- When you finish, ucode type-checks what you changed and hands you the errors, so',
|
|
236
253
|
' there is no need to run tsc yourself.',
|
|
254
|
+
'- Once the dev server is ready, run look_at_app on the pages you built and fix what',
|
|
255
|
+
' it reports - errors, layout that overflows a phone, and the visual review - then',
|
|
256
|
+
' look again. Do not call an interface finished before it has been looked at.',
|
|
237
257
|
'- Nothing you run has a keyboard. Pass the non-interactive flag to anything that',
|
|
238
258
|
' would ask a question, or it fails instead of waiting: create-next-app --yes,',
|
|
239
259
|
' npx shadcn@latest init -d -y, npx shadcn@latest add <names> -y, npm init -y.',
|
|
@@ -373,6 +393,14 @@ export class Agent {
|
|
|
373
393
|
|
|
374
394
|
this.showHeader();
|
|
375
395
|
this.installSignals();
|
|
396
|
+
|
|
397
|
+
// Checked in the background; nothing here waits on it.
|
|
398
|
+
autoUpdate({
|
|
399
|
+
onUpdated: (version) => {
|
|
400
|
+
this.ui.setFacts?.({ update: version });
|
|
401
|
+
if (!this.ui.welcoming?.()) this.ui.note(`updated to v${version} — it takes over the next time you start ucode`);
|
|
402
|
+
},
|
|
403
|
+
});
|
|
376
404
|
await this.repl();
|
|
377
405
|
}
|
|
378
406
|
|
|
@@ -485,6 +513,7 @@ export class Agent {
|
|
|
485
513
|
}
|
|
486
514
|
|
|
487
515
|
async shutdown() {
|
|
516
|
+
await closeBrowser().catch(() => {});
|
|
488
517
|
this.ui.stopSpinner();
|
|
489
518
|
if (this.session.messages.length) {
|
|
490
519
|
await this.persist();
|
|
@@ -559,6 +588,14 @@ export class Agent {
|
|
|
559
588
|
]);
|
|
560
589
|
await this.persist();
|
|
561
590
|
|
|
591
|
+
// A busy model was swapped for a fallback earlier; after a few minutes the
|
|
592
|
+
// one the user chose gets another go.
|
|
593
|
+
this.preferred ??= model();
|
|
594
|
+
if (model() !== this.preferred && Date.now() > (this.cooldownUntil ?? 0)) {
|
|
595
|
+
setModel(this.preferred);
|
|
596
|
+
if (this.full) this.showHeader({ clear: false });
|
|
597
|
+
}
|
|
598
|
+
|
|
562
599
|
this.busy = true;
|
|
563
600
|
this.abort = new AbortController();
|
|
564
601
|
|
|
@@ -591,6 +628,8 @@ export class Agent {
|
|
|
591
628
|
let askedToVerify = false;
|
|
592
629
|
let askedToSpeak = false;
|
|
593
630
|
let fixRounds = 0;
|
|
631
|
+
this.failovers = 0;
|
|
632
|
+
this.tried = new Set([model()]);
|
|
594
633
|
|
|
595
634
|
this.touched = new Set();
|
|
596
635
|
this.sinceCheck = new Set();
|
|
@@ -668,6 +707,10 @@ export class Agent {
|
|
|
668
707
|
});
|
|
669
708
|
continue;
|
|
670
709
|
}
|
|
710
|
+
|
|
711
|
+
// Busy, slow or down: move to the next model and carry on, rather
|
|
712
|
+
// than ending a half-built app with an error.
|
|
713
|
+
if (TRANSIENT.has(err.kind) && !this.abort.signal.aborted && (await this.failover(err))) continue;
|
|
671
714
|
throw err;
|
|
672
715
|
}
|
|
673
716
|
|
|
@@ -917,6 +960,38 @@ export class Agent {
|
|
|
917
960
|
});
|
|
918
961
|
}
|
|
919
962
|
|
|
963
|
+
/**
|
|
964
|
+
* Switch to the next model after a provider failure. Returns false once
|
|
965
|
+
* there is nothing sensible left to try. When every model is busy at once,
|
|
966
|
+
* it waits a minute and goes round again rather than giving up.
|
|
967
|
+
*/
|
|
968
|
+
async failover(err) {
|
|
969
|
+
if (++this.failovers > MAX_FAILOVERS) return false;
|
|
970
|
+
const from = model();
|
|
971
|
+
let next = fallbackFor(from, this.tried);
|
|
972
|
+
|
|
973
|
+
if (!next) {
|
|
974
|
+
const until = Date.now() + 60_000;
|
|
975
|
+
this.ui.startSpinner('every model is busy');
|
|
976
|
+
while (Date.now() < until && !this.abort?.signal.aborted) {
|
|
977
|
+
this.ui.updateSpinner(`every model is busy — trying again in ${Math.ceil((until - Date.now()) / 1000)}s`);
|
|
978
|
+
await wait(1000);
|
|
979
|
+
}
|
|
980
|
+
this.ui.stopSpinner();
|
|
981
|
+
if (this.abort?.signal.aborted) return false;
|
|
982
|
+
this.tried = new Set();
|
|
983
|
+
next = this.preferred && this.preferred !== from ? this.preferred : fallbackFor(from, this.tried) ?? from;
|
|
984
|
+
}
|
|
985
|
+
|
|
986
|
+
this.tried.add(next);
|
|
987
|
+
setModel(next);
|
|
988
|
+
this.cooldownUntil = Date.now() + COOLDOWN;
|
|
989
|
+
const why = err.kind === 'rate_limit' ? 'busy' : err.kind === 'timeout' ? 'too slow to answer' : 'not answering';
|
|
990
|
+
this.ui.note(`${modelName(from)} is ${why} — carrying on with ${modelName(next)}`);
|
|
991
|
+
if (this.full) this.showHeader({ clear: false });
|
|
992
|
+
return true;
|
|
993
|
+
}
|
|
994
|
+
|
|
920
995
|
/** Run a call and settle to { out } or { err } — never throws. */
|
|
921
996
|
execute(call) {
|
|
922
997
|
return this.dispatch(call).then((out) => ({ out }), (err) => ({ err }));
|
|
@@ -964,10 +1039,18 @@ export class Agent {
|
|
|
964
1039
|
|
|
965
1040
|
for (const r of results) for (const f of r.touched) { this.touched.add(f); this.sinceCheck.add(f); }
|
|
966
1041
|
|
|
1042
|
+
// A worker that wrote nothing has not done its part, whatever it said.
|
|
1043
|
+
// The lead builds those itself rather than leaving holes in the app.
|
|
1044
|
+
const empty = results.filter((r) => !r.touched.length).map((r) => r.name);
|
|
1045
|
+
|
|
967
1046
|
return {
|
|
968
1047
|
content: results
|
|
969
1048
|
.map((r) => `## ${r.name}\n${r.summary}\nFiles changed: ${r.touched.join(', ') || 'none'}`)
|
|
970
|
-
.join('\n\n')
|
|
1049
|
+
.join('\n\n') +
|
|
1050
|
+
(empty.length
|
|
1051
|
+
? `\n\n${empty.join(', ')} wrote no files. Build ${empty.length === 1 ? 'that part' : 'those parts'} ` +
|
|
1052
|
+
'yourself now, directly - do not delegate them again.'
|
|
1053
|
+
: ''),
|
|
971
1054
|
summary: results.map((r) => `${r.name} · ${r.touched.length} file${r.touched.length === 1 ? '' : 's'}`).join(' '),
|
|
972
1055
|
};
|
|
973
1056
|
}
|
|
@@ -980,16 +1063,39 @@ export class Agent {
|
|
|
980
1063
|
.map((s) => `--- ${s.name} ---\n${s.body}`)
|
|
981
1064
|
.join('\n\n');
|
|
982
1065
|
const messages = [
|
|
983
|
-
{ role: 'system', content: workerPrompt({ cwd: this.cwd, name, memory: this.memory, skills }) },
|
|
1066
|
+
{ role: 'system', content: workerPrompt({ cwd: this.cwd, name, memory: this.memory, skills, map: this.map }) },
|
|
984
1067
|
{ role: 'user', content: String(task.instructions) },
|
|
985
1068
|
];
|
|
986
1069
|
const available = this.toolsNow().filter((t) => !WORKER_EXCLUDED.has(t.name));
|
|
987
1070
|
const wanted = process.env.UCODE_WORKER_MODEL;
|
|
988
|
-
|
|
1071
|
+
let workerModel = wanted && MODELS[wanted] ? wanted : model();
|
|
1072
|
+
const tried = new Set([workerModel]);
|
|
1073
|
+
let failovers = 0;
|
|
1074
|
+
|
|
1075
|
+
// Start a moment apart. Three requests in the same instant is exactly what
|
|
1076
|
+
// trips a free endpoint's rate limit, and the stagger costs a second or two.
|
|
1077
|
+
if (index) await wait(index * 1500);
|
|
989
1078
|
|
|
990
1079
|
for (let step = 0; step < WORKER_STEPS; step++) {
|
|
991
1080
|
if (this.abort?.signal.aborted) break;
|
|
992
|
-
|
|
1081
|
+
|
|
1082
|
+
let reply;
|
|
1083
|
+
try {
|
|
1084
|
+
reply = await ask(messages, available, { signal: this.abort?.signal, model: workerModel });
|
|
1085
|
+
} catch (err) {
|
|
1086
|
+
// Same rule as the lead: a busy model is swapped, not a reason to stop.
|
|
1087
|
+
if (TRANSIENT.has(err.kind) && failovers < 6 && !this.abort?.signal.aborted) {
|
|
1088
|
+
failovers++;
|
|
1089
|
+
let next = fallbackFor(workerModel, tried);
|
|
1090
|
+
if (!next) { tried.clear(); await wait(20_000); next = fallbackFor(workerModel, tried) ?? workerModel; }
|
|
1091
|
+
tried.add(next);
|
|
1092
|
+
this.ui.note(`${name}: ${modelName(workerModel)} is busy — switching to ${modelName(next)}`);
|
|
1093
|
+
workerModel = next;
|
|
1094
|
+
step--;
|
|
1095
|
+
continue;
|
|
1096
|
+
}
|
|
1097
|
+
throw err;
|
|
1098
|
+
}
|
|
993
1099
|
this.record(reply.usage);
|
|
994
1100
|
|
|
995
1101
|
if (!reply.toolCalls.length) {
|
|
@@ -1220,6 +1326,7 @@ export class Agent {
|
|
|
1220
1326
|
if (arg) {
|
|
1221
1327
|
try {
|
|
1222
1328
|
setModel(arg);
|
|
1329
|
+
this.preferred = model();
|
|
1223
1330
|
} catch (err) {
|
|
1224
1331
|
this.ui.error(err, { debug: this.debug });
|
|
1225
1332
|
return;
|
|
@@ -1247,6 +1354,7 @@ export class Agent {
|
|
|
1247
1354
|
if (chosen === null) return;
|
|
1248
1355
|
|
|
1249
1356
|
setModel(all[chosen].id);
|
|
1357
|
+
this.preferred = model();
|
|
1250
1358
|
this.session.model = model();
|
|
1251
1359
|
this.ui.note(`now using ${modelName()}`);
|
|
1252
1360
|
this.showHeader({ clear: false });
|
|
@@ -1285,7 +1393,7 @@ export class Agent {
|
|
|
1285
1393
|
* and how far it got, and the ones from this folder are marked, because that
|
|
1286
1394
|
* is nearly always the one being looked for.
|
|
1287
1395
|
*/
|
|
1288
|
-
describeSession(s, width) {
|
|
1396
|
+
describeSession(s, width, i) {
|
|
1289
1397
|
const room = Math.max(24, Math.min(46, width - 34));
|
|
1290
1398
|
const mark = s.mine ? blue('●') : dim('○');
|
|
1291
1399
|
const when = relativeTime(s.updatedAt).padEnd(9);
|
|
@@ -1293,7 +1401,8 @@ export class Agent {
|
|
|
1293
1401
|
const where = s.mine ? 'here' : shortenPath(s.cwd, 26);
|
|
1294
1402
|
|
|
1295
1403
|
return {
|
|
1296
|
-
|
|
1404
|
+
// Numbered in the picker, so /session delete 3 has something to point at.
|
|
1405
|
+
label: `${i === undefined ? '' : `${dim(String(i + 1).padStart(2))} `}${mark} ${clip(s.title, room).padEnd(room)} ${dim(when)}${dim(turns)}${dim(where)}`,
|
|
1297
1406
|
sub: s.preview ? dim(` ${clip(s.preview, width - 10)}`) : '',
|
|
1298
1407
|
};
|
|
1299
1408
|
}
|
|
@@ -1314,6 +1423,10 @@ export class Agent {
|
|
|
1314
1423
|
return;
|
|
1315
1424
|
}
|
|
1316
1425
|
|
|
1426
|
+
// /session delete 3 or /session delete 2,5,7
|
|
1427
|
+
const del = /^(?:delete|del|rm|remove)\b\s*(.*)$/i.exec(arg ?? '');
|
|
1428
|
+
if (del) return this.deleteSessions(del[1]);
|
|
1429
|
+
|
|
1317
1430
|
const sessions = await list({ cwd: this.cwd });
|
|
1318
1431
|
if (!sessions.length) {
|
|
1319
1432
|
this.ui.note('no saved conversations yet');
|
|
@@ -1336,22 +1449,48 @@ export class Agent {
|
|
|
1336
1449
|
}
|
|
1337
1450
|
index = n - 1;
|
|
1338
1451
|
} else if (this.ui.pick) {
|
|
1339
|
-
|
|
1340
|
-
|
|
1341
|
-
|
|
1342
|
-
|
|
1343
|
-
|
|
1344
|
-
|
|
1345
|
-
|
|
1346
|
-
|
|
1452
|
+
// The picker stays open while you delete, so clearing out several old
|
|
1453
|
+
// conversations is d d, d d, d d — then Enter on the one you want.
|
|
1454
|
+
let active = 0;
|
|
1455
|
+
for (;;) {
|
|
1456
|
+
const here = shown.filter((s) => s.mine).length;
|
|
1457
|
+
const picked = await this.ui.pick(
|
|
1458
|
+
shown.map((s, i) => this.describeSession(s, width, i)),
|
|
1459
|
+
{
|
|
1460
|
+
active,
|
|
1461
|
+
deletable: true,
|
|
1462
|
+
hint:
|
|
1463
|
+
`↑↓ move · enter to continue · d twice to delete · esc to cancel` +
|
|
1464
|
+
(here ? ` — ${here} from this folder` : ''),
|
|
1465
|
+
}
|
|
1466
|
+
);
|
|
1467
|
+
if (picked === null) return;
|
|
1468
|
+
if (typeof picked === 'object' && picked.delete !== undefined) {
|
|
1469
|
+
const doomed = shown[picked.delete];
|
|
1470
|
+
active = picked.delete;
|
|
1471
|
+
if (doomed.id === this.session.id) {
|
|
1472
|
+
this.ui.flash?.('that is the conversation you are in — /new first, then delete it');
|
|
1473
|
+
continue;
|
|
1474
|
+
}
|
|
1475
|
+
await remove(doomed.id);
|
|
1476
|
+
shown.splice(picked.delete, 1);
|
|
1477
|
+
this.ui.flash?.(`deleted · ${clip(doomed.title, 50)}`);
|
|
1478
|
+
if (!shown.length) {
|
|
1479
|
+
this.ui.note('no saved conversations left');
|
|
1480
|
+
return;
|
|
1481
|
+
}
|
|
1482
|
+
active = Math.min(active, shown.length - 1);
|
|
1483
|
+
continue;
|
|
1347
1484
|
}
|
|
1348
|
-
|
|
1349
|
-
|
|
1485
|
+
index = picked;
|
|
1486
|
+
break;
|
|
1487
|
+
}
|
|
1350
1488
|
} else {
|
|
1351
1489
|
this.ui.blank();
|
|
1490
|
+
this.ui.note('/session delete <number> removes one, or several: /session delete 2,5');
|
|
1352
1491
|
index = await this.ui.choose(
|
|
1353
1492
|
'continue which?',
|
|
1354
|
-
shown.map((s) => this.describeSession(s, width).label)
|
|
1493
|
+
shown.map((s, i) => this.describeSession(s, width, i).label)
|
|
1355
1494
|
);
|
|
1356
1495
|
if (index === null) return;
|
|
1357
1496
|
}
|
|
@@ -1363,6 +1502,26 @@ export class Agent {
|
|
|
1363
1502
|
}
|
|
1364
1503
|
}
|
|
1365
1504
|
|
|
1505
|
+
/** /session delete 3, or 2,5,7 — numbers as the session list shows them. */
|
|
1506
|
+
async deleteSessions(spec) {
|
|
1507
|
+
const sessions = (await list({ cwd: this.cwd })).slice(0, 25);
|
|
1508
|
+
const numbers = [...new Set(String(spec).split(/[\s,]+/).filter(Boolean).map(Number))];
|
|
1509
|
+
const bad = numbers.filter((n) => !Number.isInteger(n) || n < 1 || n > sessions.length);
|
|
1510
|
+
if (!numbers.length || bad.length) {
|
|
1511
|
+
this.ui.write(theme.warn(` usage: /session delete <number>[,<number>…] — numbers from 1 to ${sessions.length}`));
|
|
1512
|
+
return;
|
|
1513
|
+
}
|
|
1514
|
+
for (const n of numbers) {
|
|
1515
|
+
const s = sessions[n - 1];
|
|
1516
|
+
if (s.id === this.session.id) {
|
|
1517
|
+
this.ui.note(`skipped ${n} — that is the conversation you are in`);
|
|
1518
|
+
continue;
|
|
1519
|
+
}
|
|
1520
|
+
await remove(s.id);
|
|
1521
|
+
this.ui.note(`deleted ${n} · ${s.title}`);
|
|
1522
|
+
}
|
|
1523
|
+
}
|
|
1524
|
+
|
|
1366
1525
|
async resume(id) {
|
|
1367
1526
|
try {
|
|
1368
1527
|
const loaded = await load(id);
|
package/src/core/provider.js
CHANGED
|
@@ -58,7 +58,7 @@ export const MODELS = {
|
|
|
58
58
|
name: 'Nemotron 3 Ultra',
|
|
59
59
|
context: 1_000_000,
|
|
60
60
|
star: true,
|
|
61
|
-
note: 'deepest reasoning, 1M context —
|
|
61
|
+
note: 'deepest reasoning, 1M context — slowest to answer',
|
|
62
62
|
},
|
|
63
63
|
'nvidia/nemotron-3.5-lightning:free': {
|
|
64
64
|
name: 'Nemotron 3.5 Lightning',
|
|
@@ -79,18 +79,43 @@ export const MODELS = {
|
|
|
79
79
|
name: 'North Mini Code',
|
|
80
80
|
context: 256_000,
|
|
81
81
|
star: true,
|
|
82
|
-
note: '
|
|
82
|
+
note: 'the default — built for code and interface work, quick to answer',
|
|
83
83
|
},
|
|
84
84
|
};
|
|
85
85
|
|
|
86
86
|
/**
|
|
87
|
-
*
|
|
88
|
-
*
|
|
89
|
-
*
|
|
90
|
-
*
|
|
91
|
-
* to Lightning or North Mini Code when the wait stops being worth it.
|
|
87
|
+
* North Mini Code is the default: it is built for code and interface work,
|
|
88
|
+
* which is what ucode is mostly asked to do, and it answers far sooner than
|
|
89
|
+
* the big reasoning models. /model moves to Ultra when a problem needs the
|
|
90
|
+
* million-token window and the long think more than it needs the speed.
|
|
92
91
|
*/
|
|
93
|
-
export const DEFAULT_MODEL = '
|
|
92
|
+
export const DEFAULT_MODEL = 'cohere/north-mini-code:free';
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* Where to go when a model is busy, in order of preference. Each is served by
|
|
96
|
+
* a different upstream, so a rate limit on one rarely means a limit on the
|
|
97
|
+
* next — which is what lets a long build keep going instead of stopping at
|
|
98
|
+
* the first "too many requests".
|
|
99
|
+
*/
|
|
100
|
+
export const FALLBACKS = [
|
|
101
|
+
'cohere/north-mini-code:free',
|
|
102
|
+
'nvidia/nemotron-3.5-lightning:free',
|
|
103
|
+
'nvidia/nemotron-3-super-120b-a12b:free',
|
|
104
|
+
'nvidia/nemotron-3-ultra-550b-a55b:free',
|
|
105
|
+
];
|
|
106
|
+
|
|
107
|
+
/** The next model to try after `id`, skipping any already tried this round. */
|
|
108
|
+
export function fallbackFor(id, tried = new Set()) {
|
|
109
|
+
const start = Math.max(0, FALLBACKS.indexOf(id));
|
|
110
|
+
for (let i = 1; i <= FALLBACKS.length; i++) {
|
|
111
|
+
const next = FALLBACKS[(start + i) % FALLBACKS.length];
|
|
112
|
+
if (next !== id && !tried.has(next)) return next;
|
|
113
|
+
}
|
|
114
|
+
return null;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/** Seconds to wait on successive rate limits that come with no retry-after. */
|
|
118
|
+
const RATE_LIMIT_BACKOFF = [5, 10, 20];
|
|
94
119
|
|
|
95
120
|
let current = process.env.UCODE_MODEL || DEFAULT_MODEL;
|
|
96
121
|
let client = null;
|
|
@@ -578,12 +603,15 @@ export async function ask(messages, tools = [], opts = {}) {
|
|
|
578
603
|
problem = explain(err, id);
|
|
579
604
|
|
|
580
605
|
// A per-minute limit is a wait, not a failure. Sit it out rather than
|
|
581
|
-
// making the user retype their message.
|
|
582
|
-
|
|
606
|
+
// making the user retype their message. Free endpoints often refuse
|
|
607
|
+
// without saying how long to wait, so when there is no retry-after the
|
|
608
|
+
// pauses grow on their own — 5s, 10s, 20s — and only then does the
|
|
609
|
+
// error go up to the loop, which moves to another model.
|
|
610
|
+
const told = problem.detail?.retryAfter;
|
|
611
|
+
const wait = Number.isFinite(told) && told > 0 && told <= 90 ? told : RATE_LIMIT_BACKOFF[attempt - 1];
|
|
583
612
|
if (
|
|
584
613
|
problem.kind === 'rate_limit' && !problem.detail?.daily &&
|
|
585
|
-
|
|
586
|
-
attempt < attempts && !opts.signal?.aborted
|
|
614
|
+
wait && attempt < attempts && printed === 0 && !opts.signal?.aborted
|
|
587
615
|
) {
|
|
588
616
|
const until = Date.now() + wait * 1000;
|
|
589
617
|
while (Date.now() < until && !opts.signal?.aborted) {
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* updater.js — staying current without anyone running npm by hand.
|
|
3
|
+
*
|
|
4
|
+
* On every launch ucode asks the registry, in the background, whether a newer
|
|
5
|
+
* version exists. If one does, it installs it globally, detached, while you
|
|
6
|
+
* work. The version you are running carries on untouched; the next launch is
|
|
7
|
+
* the new one. Nothing about starting ucode waits on any of this.
|
|
8
|
+
*
|
|
9
|
+
* It stays out of the way in three cases: a development checkout (updating
|
|
10
|
+
* would overwrite the `npm link` that points at your working copy), when
|
|
11
|
+
* UCODE_NO_UPDATE is set, and when another ucode is already updating.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import { spawn } from 'node:child_process';
|
|
15
|
+
import { existsSync, promises as fs } from 'node:fs';
|
|
16
|
+
import os from 'node:os';
|
|
17
|
+
import path from 'node:path';
|
|
18
|
+
import { fileURLToPath } from 'node:url';
|
|
19
|
+
import { VERSION } from './version.js';
|
|
20
|
+
|
|
21
|
+
const PACKAGE = 'ucode-agent';
|
|
22
|
+
const PACKAGE_ROOT = path.join(path.dirname(fileURLToPath(import.meta.url)), '..', '..');
|
|
23
|
+
const HOME = path.join(os.homedir(), '.ucode');
|
|
24
|
+
const LOCK = path.join(HOME, 'update.lock');
|
|
25
|
+
const LOG = path.join(HOME, 'update.log');
|
|
26
|
+
const LOCK_TTL = 10 * 60_000;
|
|
27
|
+
|
|
28
|
+
/** "1.10.0" > "1.9.3" — numeric, part by part. */
|
|
29
|
+
export function newer(a, b) {
|
|
30
|
+
const pa = String(a).split('.').map((n) => parseInt(n, 10) || 0);
|
|
31
|
+
const pb = String(b).split('.').map((n) => parseInt(n, 10) || 0);
|
|
32
|
+
for (let i = 0; i < Math.max(pa.length, pb.length); i++) {
|
|
33
|
+
if ((pa[i] ?? 0) !== (pb[i] ?? 0)) return (pa[i] ?? 0) > (pb[i] ?? 0);
|
|
34
|
+
}
|
|
35
|
+
return false;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function isDevCheckout() {
|
|
39
|
+
return existsSync(path.join(PACKAGE_ROOT, '.git'));
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
async function latestVersion() {
|
|
43
|
+
const res = await fetch(`https://registry.npmjs.org/${PACKAGE}/latest`, {
|
|
44
|
+
signal: AbortSignal.timeout(5_000),
|
|
45
|
+
headers: { accept: 'application/json' },
|
|
46
|
+
});
|
|
47
|
+
if (!res.ok) return null;
|
|
48
|
+
return (await res.json())?.version ?? null;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
async function takeLock() {
|
|
52
|
+
try {
|
|
53
|
+
const stat = await fs.stat(LOCK);
|
|
54
|
+
if (Date.now() - stat.mtimeMs < LOCK_TTL) return false; // someone else is on it
|
|
55
|
+
} catch { /* no lock — good */ }
|
|
56
|
+
await fs.mkdir(HOME, { recursive: true });
|
|
57
|
+
await fs.writeFile(LOCK, String(process.pid));
|
|
58
|
+
return true;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Check, and install if there is something newer.
|
|
63
|
+
*
|
|
64
|
+
* @param {object} o
|
|
65
|
+
* @param {(v: string) => void} [o.onUpdated] called when the install finishes
|
|
66
|
+
*/
|
|
67
|
+
export async function autoUpdate({ onUpdated } = {}) {
|
|
68
|
+
try {
|
|
69
|
+
if (process.env.UCODE_NO_UPDATE || isDevCheckout() || !VERSION) return;
|
|
70
|
+
const latest = await latestVersion();
|
|
71
|
+
if (!latest || !newer(latest, VERSION)) return;
|
|
72
|
+
if (!(await takeLock())) return;
|
|
73
|
+
|
|
74
|
+
const log = await fs.open(LOG, 'w');
|
|
75
|
+
const child = spawn(`npm install -g ${PACKAGE}@${latest} --no-audit --no-fund`, {
|
|
76
|
+
shell: true,
|
|
77
|
+
detached: true,
|
|
78
|
+
windowsHide: true,
|
|
79
|
+
stdio: ['ignore', log.fd, log.fd],
|
|
80
|
+
});
|
|
81
|
+
await log.close();
|
|
82
|
+
child.unref();
|
|
83
|
+
|
|
84
|
+
child.on('exit', async (code) => {
|
|
85
|
+
await fs.rm(LOCK, { force: true }).catch(() => {});
|
|
86
|
+
if (code === 0) onUpdated?.(latest);
|
|
87
|
+
});
|
|
88
|
+
child.on('error', async () => {
|
|
89
|
+
await fs.rm(LOCK, { force: true }).catch(() => {});
|
|
90
|
+
});
|
|
91
|
+
} catch {
|
|
92
|
+
// An update check must never be the reason ucode misbehaves. Offline,
|
|
93
|
+
// registry down, no permission to install globally: all silently skipped.
|
|
94
|
+
}
|
|
95
|
+
}
|
|
@@ -0,0 +1,258 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* browser.js — looking at the app the way a person would.
|
|
3
|
+
*
|
|
4
|
+
* A build that passes and a page that works are different claims. This opens
|
|
5
|
+
* the running app in a real browser at a phone width and a desktop width, and
|
|
6
|
+
* reports what a person would run into: errors in the console, requests that
|
|
7
|
+
* failed, a layout that spills off the side of a phone, broken images,
|
|
8
|
+
* controls with no name. It saves a screenshot of each, and has the one model
|
|
9
|
+
* in the set that can see — Nemotron Nano Omni — review them as a designer
|
|
10
|
+
* would. The model building the app then has something concrete to fix.
|
|
11
|
+
*
|
|
12
|
+
* It drives the browser already on the machine (Edge or Chrome) through
|
|
13
|
+
* playwright-core, so there is no separate 150 MB browser download.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
import { promises as fs } from 'node:fs';
|
|
17
|
+
import path from 'node:path';
|
|
18
|
+
import { ToolFailure } from '../core/failure.js';
|
|
19
|
+
import { ask } from '../core/provider.js';
|
|
20
|
+
import { getRoot, result } from './shared.js';
|
|
21
|
+
|
|
22
|
+
const VISION_MODEL = 'nvidia/nemotron-3-nano-omni-30b-a3b-reasoning:free';
|
|
23
|
+
const WIDTHS = [
|
|
24
|
+
{ name: 'phone', width: 375, height: 812 },
|
|
25
|
+
{ name: 'desktop', width: 1440, height: 900 },
|
|
26
|
+
];
|
|
27
|
+
const LOCAL = /^https?:\/\/(?:localhost|127\.0\.0\.1|0\.0\.0\.0|\[::1\])(?::\d+)?(?:\/|$)/i;
|
|
28
|
+
|
|
29
|
+
let browserPromise = null;
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* One browser for the whole session, started on first use. The installed
|
|
33
|
+
* Edge or Chrome is tried first; Playwright's own Chromium only if it happens
|
|
34
|
+
* to be installed.
|
|
35
|
+
*/
|
|
36
|
+
async function browser() {
|
|
37
|
+
if (browserPromise) return browserPromise;
|
|
38
|
+
browserPromise = (async () => {
|
|
39
|
+
let chromium;
|
|
40
|
+
try {
|
|
41
|
+
({ chromium } = await import('playwright-core'));
|
|
42
|
+
} catch (err) {
|
|
43
|
+
throw new ToolFailure({
|
|
44
|
+
kind: 'no_playwright',
|
|
45
|
+
attempted: 'starting a browser',
|
|
46
|
+
failed: `playwright-core could not be loaded: ${err.message}`,
|
|
47
|
+
fix: 'Reinstall ucode (npm install -g ucode-agent). Carry on without looking at the app, and say so.',
|
|
48
|
+
});
|
|
49
|
+
}
|
|
50
|
+
const tried = [];
|
|
51
|
+
for (const channel of ['msedge', 'chrome', undefined]) {
|
|
52
|
+
try {
|
|
53
|
+
return await chromium.launch({ channel, headless: true });
|
|
54
|
+
} catch (err) {
|
|
55
|
+
tried.push(`${channel ?? 'bundled chromium'}: ${String(err.message).split('\n')[0]}`);
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
throw new ToolFailure({
|
|
59
|
+
kind: 'no_browser',
|
|
60
|
+
attempted: 'starting a browser',
|
|
61
|
+
failed: `No browser could be started. Tried ${tried.join('; ')}.`,
|
|
62
|
+
fix: 'Install Google Chrome or Microsoft Edge. Carry on without looking at the app, and say so.',
|
|
63
|
+
});
|
|
64
|
+
})();
|
|
65
|
+
browserPromise.catch(() => { browserPromise = null; });
|
|
66
|
+
return browserPromise;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/** Close the shared browser, if one was started. Called when ucode exits. */
|
|
70
|
+
export async function closeBrowser() {
|
|
71
|
+
if (!browserPromise) return;
|
|
72
|
+
try { await (await browserPromise).close(); } catch { /* already gone */ }
|
|
73
|
+
browserPromise = null;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/** Layout and accessibility checks run inside the page. */
|
|
77
|
+
function inspect() {
|
|
78
|
+
const vw = window.innerWidth;
|
|
79
|
+
const describeEl = (el) => {
|
|
80
|
+
const id = el.id ? `#${el.id}` : '';
|
|
81
|
+
const cls = typeof el.className === 'string' && el.className.trim()
|
|
82
|
+
? `.${el.className.trim().split(/\s+/).slice(0, 2).join('.')}` : '';
|
|
83
|
+
const text = (el.innerText || el.getAttribute('aria-label') || '').trim().replace(/\s+/g, ' ').slice(0, 40);
|
|
84
|
+
return `<${el.tagName.toLowerCase()}${id}${cls}>${text ? ` "${text}"` : ''}`;
|
|
85
|
+
};
|
|
86
|
+
|
|
87
|
+
const overflow = document.documentElement.scrollWidth - vw;
|
|
88
|
+
const wide = [];
|
|
89
|
+
if (overflow > 1) {
|
|
90
|
+
for (const el of document.querySelectorAll('body *')) {
|
|
91
|
+
const r = el.getBoundingClientRect();
|
|
92
|
+
if (r.width > 0 && r.right > vw + 1 && getComputedStyle(el).position !== 'fixed') {
|
|
93
|
+
wide.push(`${describeEl(el)} reaches ${Math.round(r.right)}px`);
|
|
94
|
+
if (wide.length >= 5) break;
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
const broken = [...document.images].filter((i) => i.complete && i.naturalWidth === 0).map((i) => i.src.slice(0, 80));
|
|
100
|
+
const noAlt = [...document.images].filter((i) => !i.hasAttribute('alt')).length;
|
|
101
|
+
const unnamed = [...document.querySelectorAll('button, a[href], [role="button"]')]
|
|
102
|
+
.filter((el) => !(el.innerText || '').trim() && !el.getAttribute('aria-label') && !el.getAttribute('title')
|
|
103
|
+
&& !el.querySelector('[aria-label], title, img[alt]:not([alt=""])'))
|
|
104
|
+
.slice(0, 5).map(describeEl);
|
|
105
|
+
const inputsNoLabel = [...document.querySelectorAll('input:not([type="hidden"]), textarea, select')]
|
|
106
|
+
.filter((el) => !(el.id && document.querySelector(`label[for="${el.id}"]`)) && !el.closest('label')
|
|
107
|
+
&& !el.getAttribute('aria-label') && !el.getAttribute('aria-labelledby'))
|
|
108
|
+
.length;
|
|
109
|
+
const tiny = vw < 600
|
|
110
|
+
? [...document.querySelectorAll('button, a[href], [role="button"], input, select')]
|
|
111
|
+
.filter((el) => { const r = el.getBoundingClientRect(); return r.width > 0 && (r.height < 32 || r.width < 32); })
|
|
112
|
+
.length
|
|
113
|
+
: 0;
|
|
114
|
+
const smallText = [...document.querySelectorAll('p, li, span, a, button, label, td')]
|
|
115
|
+
.filter((el) => el.childElementCount === 0 && (el.innerText || '').trim() && parseFloat(getComputedStyle(el).fontSize) < 12)
|
|
116
|
+
.length;
|
|
117
|
+
|
|
118
|
+
return {
|
|
119
|
+
title: document.title,
|
|
120
|
+
overflow: overflow > 1 ? Math.round(overflow) : 0,
|
|
121
|
+
wide, broken, noAlt, unnamed, inputsNoLabel, tiny, smallText,
|
|
122
|
+
empty: !(document.body.innerText || '').trim(),
|
|
123
|
+
};
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
const safeName = (p) => (p === '/' ? 'home' : p.replace(/^\/+|\/+$/g, '').replace(/[^\w-]+/g, '_')) || 'page';
|
|
127
|
+
|
|
128
|
+
async function review(shots) {
|
|
129
|
+
const request = [
|
|
130
|
+
{
|
|
131
|
+
role: 'system',
|
|
132
|
+
content:
|
|
133
|
+
'You are a senior product designer reviewing screenshots of a web app, one at a phone width ' +
|
|
134
|
+
'and one at desktop width. List the concrete visual problems a user would notice, most ' +
|
|
135
|
+
'important first: broken or cramped layout, overflow, misalignment, weak hierarchy (is the ' +
|
|
136
|
+
'most important thing the most prominent?), inconsistent spacing, low contrast, default-looking ' +
|
|
137
|
+
'components, awkward empty states, text that is too small. For each: where it is, what is wrong, ' +
|
|
138
|
+
'and the specific fix. At most 8 points, one or two lines each. If it genuinely looks polished, ' +
|
|
139
|
+
'say so in one line and name the one thing that would improve it most. No preamble.',
|
|
140
|
+
},
|
|
141
|
+
{
|
|
142
|
+
role: 'user',
|
|
143
|
+
content: shots.map((s) => `${s.label}`).join(' and ') + '.',
|
|
144
|
+
images: shots.map((s) => s.dataUrl),
|
|
145
|
+
},
|
|
146
|
+
];
|
|
147
|
+
const reply = await ask(request, [], { model: VISION_MODEL, temperature: 0.2, maxOutputTokens: 900 });
|
|
148
|
+
return reply.text.trim();
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
export async function lookAtApp({ url, paths = ['/'], review: wantReview = true }) {
|
|
152
|
+
const base = String(url ?? '').trim().replace(/\/+$/, '');
|
|
153
|
+
if (!LOCAL.test(`${base}/`)) {
|
|
154
|
+
throw new ToolFailure({
|
|
155
|
+
kind: 'bad_args',
|
|
156
|
+
attempted: 'looking at the app',
|
|
157
|
+
failed: `"${url}" is not a local address. This only opens apps running on this machine.`,
|
|
158
|
+
fix: 'Pass the URL the dev server reported, e.g. http://localhost:3000',
|
|
159
|
+
});
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
const pages = (Array.isArray(paths) && paths.length ? paths : ['/'])
|
|
163
|
+
.map((p) => `/${String(p).trim().replace(/^\/+/, '')}`)
|
|
164
|
+
.slice(0, 4);
|
|
165
|
+
|
|
166
|
+
const shotsDir = path.join(getRoot(), '.ucode', 'screenshots');
|
|
167
|
+
await fs.mkdir(shotsDir, { recursive: true });
|
|
168
|
+
|
|
169
|
+
const b = await browser();
|
|
170
|
+
const sections = [];
|
|
171
|
+
const toReview = [];
|
|
172
|
+
let problems = 0;
|
|
173
|
+
|
|
174
|
+
for (const pagePath of pages) {
|
|
175
|
+
for (const size of WIDTHS) {
|
|
176
|
+
const context = await b.newContext({ viewport: { width: size.width, height: size.height }, deviceScaleFactor: 1 });
|
|
177
|
+
const page = await context.newPage();
|
|
178
|
+
const errors = [];
|
|
179
|
+
const failed = [];
|
|
180
|
+
page.on('console', (m) => {
|
|
181
|
+
if (m.type() === 'error' && !/devtools|download the react/i.test(m.text())) errors.push(m.text().slice(0, 200));
|
|
182
|
+
});
|
|
183
|
+
page.on('pageerror', (e) => errors.push(`uncaught: ${String(e.message).slice(0, 200)}`));
|
|
184
|
+
page.on('requestfailed', (r) => failed.push(`${r.method()} ${r.url().slice(0, 100)} — ${r.failure()?.errorText ?? 'failed'}`));
|
|
185
|
+
page.on('response', (r) => { if (r.status() >= 400) failed.push(`${r.status()} ${r.url().slice(0, 100)}`); });
|
|
186
|
+
|
|
187
|
+
const target = `${base}${pagePath}`;
|
|
188
|
+
let loadError = null;
|
|
189
|
+
try {
|
|
190
|
+
// A dev server compiles a page on its first request, which can take a
|
|
191
|
+
// while; networkidle then waits for the page's own data to arrive.
|
|
192
|
+
await page.goto(target, { waitUntil: 'networkidle', timeout: 60_000 });
|
|
193
|
+
} catch (err) {
|
|
194
|
+
try { await page.goto(target, { waitUntil: 'load', timeout: 30_000 }); }
|
|
195
|
+
catch (err2) { loadError = String(err2.message).split('\n')[0]; }
|
|
196
|
+
}
|
|
197
|
+
await page.waitForTimeout(600); // let entrance animations settle
|
|
198
|
+
|
|
199
|
+
const file = path.join(shotsDir, `${safeName(pagePath)}-${size.name}.jpg`);
|
|
200
|
+
let facts = null;
|
|
201
|
+
if (!loadError) {
|
|
202
|
+
facts = await page.evaluate(inspect).catch((err) => ({ error: err.message }));
|
|
203
|
+
const buffer = await page.screenshot({ type: 'jpeg', quality: 70, fullPage: false });
|
|
204
|
+
await fs.writeFile(file, buffer);
|
|
205
|
+
toReview.push({ label: `${pagePath} at ${size.width}px (${size.name})`, dataUrl: `data:image/jpeg;base64,${buffer.toString('base64')}` });
|
|
206
|
+
}
|
|
207
|
+
await context.close();
|
|
208
|
+
|
|
209
|
+
const lines = [`### ${pagePath} at ${size.width}px (${size.name})`];
|
|
210
|
+
if (loadError) {
|
|
211
|
+
lines.push(`Could not load: ${loadError}`);
|
|
212
|
+
problems++;
|
|
213
|
+
} else {
|
|
214
|
+
lines.push(`Screenshot: ${path.relative(getRoot(), file).split(path.sep).join('/')}`);
|
|
215
|
+
if (facts?.empty) { lines.push('- The page rendered no visible text at all.'); problems++; }
|
|
216
|
+
if (facts?.overflow) {
|
|
217
|
+
lines.push(`- Content is ${facts.overflow}px wider than the screen, so it scrolls sideways:`, ...facts.wide.map((w) => ` - ${w}`));
|
|
218
|
+
problems++;
|
|
219
|
+
}
|
|
220
|
+
if (facts?.broken?.length) { lines.push(`- Broken images: ${facts.broken.join(', ')}`); problems++; }
|
|
221
|
+
if (facts?.unnamed?.length) { lines.push(`- Buttons or links with no accessible name: ${facts.unnamed.join(', ')}`); problems++; }
|
|
222
|
+
if (facts?.inputsNoLabel) { lines.push(`- ${facts.inputsNoLabel} form field(s) without a label.`); problems++; }
|
|
223
|
+
if (facts?.noAlt) lines.push(`- ${facts.noAlt} image(s) without alt text.`);
|
|
224
|
+
if (facts?.tiny) lines.push(`- ${facts.tiny} tap target(s) smaller than 32px on a phone.`);
|
|
225
|
+
if (facts?.smallText) lines.push(`- ${facts.smallText} text element(s) under 12px.`);
|
|
226
|
+
}
|
|
227
|
+
if (errors.length) { lines.push('- Console errors:', ...[...new Set(errors)].slice(0, 6).map((e) => ` - ${e}`)); problems++; }
|
|
228
|
+
if (failed.length) { lines.push('- Failed requests:', ...[...new Set(failed)].slice(0, 6).map((f) => ` - ${f}`)); problems++; }
|
|
229
|
+
if (lines.length === 2 && !loadError) lines.push('- No errors, no overflow, nothing unlabeled.');
|
|
230
|
+
sections.push(lines.join('\n'));
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
let critique = '';
|
|
235
|
+
if (wantReview !== false && toReview.length) {
|
|
236
|
+
try {
|
|
237
|
+
critique = await review(toReview.slice(0, 4));
|
|
238
|
+
} catch (err) {
|
|
239
|
+
critique = `(The visual review could not run: ${err.failed ?? err.message}. The checks above still apply.)`;
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
const body = [
|
|
244
|
+
...sections,
|
|
245
|
+
critique ? `## Visual review\n${critique}` : '',
|
|
246
|
+
'',
|
|
247
|
+
problems
|
|
248
|
+
? 'Fix the problems above, then look again to confirm.'
|
|
249
|
+
: 'The automatic checks found nothing. Weigh the visual review, fix what is worth fixing.',
|
|
250
|
+
].filter(Boolean).join('\n\n');
|
|
251
|
+
|
|
252
|
+
return result(
|
|
253
|
+
body,
|
|
254
|
+
problems
|
|
255
|
+
? `${problems} problem${problems === 1 ? '' : 's'} found · screenshots in .ucode/screenshots`
|
|
256
|
+
: 'no errors · screenshots in .ucode/screenshots'
|
|
257
|
+
);
|
|
258
|
+
}
|
package/src/tools/index.js
CHANGED
|
@@ -8,6 +8,7 @@ import { readFile, readFiles, writeFile, batchWrite, editFile, multiEdit, editFi
|
|
|
8
8
|
import { listDir, glob, grep } from './search.js';
|
|
9
9
|
import { runCommand, runCommands } from './shell.js';
|
|
10
10
|
import { webSearch } from './web.js';
|
|
11
|
+
import { lookAtApp } from './browser.js';
|
|
11
12
|
import { clip, READ_LINES } from './shared.js';
|
|
12
13
|
|
|
13
14
|
export { setRoot, setConfirm, getRoot } from './shared.js';
|
|
@@ -267,6 +268,29 @@ export const tools = [
|
|
|
267
268
|
required: ['commands'],
|
|
268
269
|
},
|
|
269
270
|
},
|
|
271
|
+
{
|
|
272
|
+
name: 'look_at_app',
|
|
273
|
+
description:
|
|
274
|
+
'Open the running app in a real browser at a phone width (375px) and a desktop width ' +
|
|
275
|
+
'(1440px) and report what a person would run into: console errors, failed requests, ' +
|
|
276
|
+
'content that spills off the side of the screen, broken images, unlabeled buttons and ' +
|
|
277
|
+
'fields - plus a designer-style review of the screenshots. Use it once the dev server ' +
|
|
278
|
+
'is ready, and again after visual changes, then fix what it reports. Screenshots are ' +
|
|
279
|
+
'saved under .ucode/screenshots.',
|
|
280
|
+
parameters: {
|
|
281
|
+
type: 'object',
|
|
282
|
+
properties: {
|
|
283
|
+
url: str('The local URL the dev server reported, e.g. http://localhost:3000'),
|
|
284
|
+
paths: {
|
|
285
|
+
type: 'array',
|
|
286
|
+
description: 'Pages to open, e.g. ["/", "/settings"]. Defaults to ["/"]. Up to 4.',
|
|
287
|
+
items: { type: 'string' },
|
|
288
|
+
},
|
|
289
|
+
review: bool('Include the visual design review of the screenshots. Defaults to true.'),
|
|
290
|
+
},
|
|
291
|
+
required: ['url'],
|
|
292
|
+
},
|
|
293
|
+
},
|
|
270
294
|
{
|
|
271
295
|
name: 'web_search',
|
|
272
296
|
description:
|
|
@@ -299,6 +323,7 @@ const run = {
|
|
|
299
323
|
run_command: runCommand,
|
|
300
324
|
run_commands: runCommands,
|
|
301
325
|
web_search: webSearch,
|
|
326
|
+
look_at_app: lookAtApp,
|
|
302
327
|
};
|
|
303
328
|
|
|
304
329
|
/** Tools that change the project or execute code. */
|
|
@@ -435,6 +460,8 @@ export function describe(name, args = {}) {
|
|
|
435
460
|
return `Running ${clip(args.command, 70)}${args.background ? ' in the background' : ''}`;
|
|
436
461
|
case 'run_commands':
|
|
437
462
|
return `Running ${args.commands?.length ?? 0} commands together`;
|
|
463
|
+
case 'look_at_app':
|
|
464
|
+
return `Looking at ${clip(args.url, 40)} on a phone and a desktop`;
|
|
438
465
|
case 'web_search':
|
|
439
466
|
return `Searching the web for ${clip(args.query, 60)}`;
|
|
440
467
|
case 'load_skill':
|
package/src/tools/shell.js
CHANGED
|
@@ -200,7 +200,14 @@ function startServer(command, workdir, { env } = {}) {
|
|
|
200
200
|
cwd: workdir.abs,
|
|
201
201
|
shell: true,
|
|
202
202
|
windowsHide: true,
|
|
203
|
-
detached
|
|
203
|
+
// Not detached on Windows, and this is load-bearing. A detached process
|
|
204
|
+
// there has no console, and programs launched under it write nothing
|
|
205
|
+
// to a redirected file — measured: every one of node, npm and next
|
|
206
|
+
// produced an empty log, so a server's "ready" line never arrived and
|
|
207
|
+
// every start waited out the full timer. Attached, the output lands.
|
|
208
|
+
// The server itself still outlives ucode: only this shell is tied to
|
|
209
|
+
// ucode's job object, and the job lets grandchildren break away.
|
|
210
|
+
detached: process.platform !== 'win32',
|
|
204
211
|
stdio: ['ignore', fd, fd],
|
|
205
212
|
env,
|
|
206
213
|
});
|
package/src/ui/screen.js
CHANGED
|
@@ -39,7 +39,7 @@ import chalk from 'chalk';
|
|
|
39
39
|
import {
|
|
40
40
|
theme, blue, sky, deep, dim, edge, ADDED, REMOVED, BANNER, BANNER_WIDTH, SPINNER,
|
|
41
41
|
boxTop, boxBottom, boxRow, visLen, padVis, clip, wrapAnsi,
|
|
42
|
-
shortenPath, asLabel, ensureColour, planLine,
|
|
42
|
+
shortenPath, asLabel, ensureColour, planLine, bare,
|
|
43
43
|
} from './theme.js';
|
|
44
44
|
import { renderer, render, polish } from './markdown.js';
|
|
45
45
|
import { VERSION } from '../core/version.js';
|
|
@@ -773,11 +773,15 @@ export class Screen {
|
|
|
773
773
|
* cannot be given up permanently, because under alternate scroll the mouse
|
|
774
774
|
* wheel arrives as arrow keys.
|
|
775
775
|
*/
|
|
776
|
-
pick(items, { active = 0, hint = 'enter to choose · esc to cancel' } = {}) {
|
|
776
|
+
pick(items, { active = 0, hint = 'enter to choose · esc to cancel', deletable = false } = {}) {
|
|
777
777
|
this.picker = {
|
|
778
778
|
items,
|
|
779
779
|
index: Math.min(Math.max(0, active), Math.max(0, items.length - 1)),
|
|
780
780
|
hint,
|
|
781
|
+
// With deletable, `d` twice on a row resolves { delete: index }. Twice,
|
|
782
|
+
// because a single stray keypress should never cost a conversation.
|
|
783
|
+
deletable,
|
|
784
|
+
armed: null,
|
|
781
785
|
};
|
|
782
786
|
this.render();
|
|
783
787
|
return new Promise((resolve) => { this.pickerResolve = resolve; });
|
|
@@ -799,7 +803,7 @@ export class Screen {
|
|
|
799
803
|
* about instead of a column of near-identical titles.
|
|
800
804
|
*/
|
|
801
805
|
pickerLines(height) {
|
|
802
|
-
const { items, index, hint } = this.picker;
|
|
806
|
+
const { items, index, hint, armed } = this.picker;
|
|
803
807
|
const room = Math.max(1, height - 2);
|
|
804
808
|
|
|
805
809
|
// Rows per item, so the window can be sized in rows rather than in items.
|
|
@@ -821,12 +825,15 @@ export class Screen {
|
|
|
821
825
|
for (let i = first; i <= last; i++) {
|
|
822
826
|
const item = items[i];
|
|
823
827
|
const body = typeof item === 'string' ? item : item.label;
|
|
824
|
-
|
|
828
|
+
if (i === armed) out.push(`${theme.warn('✗')} ${theme.warn(bare(body))}`);
|
|
829
|
+
else out.push(i === index ? `${blue('❯')} ${chalk.bold.white(body)}` : ` ${dim(body)}`);
|
|
825
830
|
if (typeof item !== 'string' && item.sub) out.push(` ${item.sub}`);
|
|
826
831
|
}
|
|
827
832
|
|
|
828
833
|
out.push('');
|
|
829
|
-
out.push(
|
|
834
|
+
out.push(armed !== null && armed !== undefined
|
|
835
|
+
? theme.warn(' press d again to delete this conversation · any other key keeps it')
|
|
836
|
+
: dim(` ${hint}`));
|
|
830
837
|
return out;
|
|
831
838
|
}
|
|
832
839
|
|
|
@@ -932,6 +939,13 @@ export class Screen {
|
|
|
932
939
|
// An open picker owns the keyboard until it closes.
|
|
933
940
|
if (this.picker) {
|
|
934
941
|
const last = this.picker.items.length - 1;
|
|
942
|
+
if (this.picker.deletable && (key === 'd' || key === 'D' || key === `${ESC}[3~`)) {
|
|
943
|
+
if (this.picker.armed === this.picker.index) { this.closePicker({ delete: this.picker.index }); return; }
|
|
944
|
+
this.picker.armed = this.picker.index;
|
|
945
|
+
this.render();
|
|
946
|
+
return;
|
|
947
|
+
}
|
|
948
|
+
this.picker.armed = null; // any other key takes the delete back
|
|
935
949
|
if (key === `${ESC}[A`) { this.picker.index = Math.max(0, this.picker.index - 1); this.render(); return; }
|
|
936
950
|
if (key === `${ESC}[B`) { this.picker.index = Math.min(last, this.picker.index + 1); this.render(); return; }
|
|
937
951
|
if (key === '\r' || key === '\n') { this.closePicker(this.picker.index); return; }
|
|
@@ -1149,7 +1163,7 @@ export class Screen {
|
|
|
1149
1163
|
|
|
1150
1164
|
// The version, in the corner, and nothing else on the screen.
|
|
1151
1165
|
if (VERSION) {
|
|
1152
|
-
const tag = dim(`v${VERSION}`);
|
|
1166
|
+
const tag = dim(this.facts.update ? `v${VERSION} · v${this.facts.update} installed, starts next time` : `v${VERSION}`);
|
|
1153
1167
|
frame[this.rows - 1] = ' '.repeat(Math.max(0, g.cols - visLen(tag) - 2)) + tag;
|
|
1154
1168
|
}
|
|
1155
1169
|
|