ucode-agent 1.4.0 → 1.6.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 +64 -4
- package/package.json +5 -2
- package/skills/build-app/SKILL.md +19 -1
- package/skills/ui-ux/SKILL.md +2 -2
- package/src/core/doctor.js +122 -0
- package/src/core/loop.js +303 -12
- package/src/core/provider.js +93 -10
- package/src/core/stuck.js +269 -0
- package/src/tools/browser.js +121 -59
- package/src/tools/deploy.js +283 -0
- package/src/tools/files.js +91 -8
- package/src/tools/index.js +65 -8
- package/src/tools/scaffold.js +190 -0
- package/src/tools/shell.js +89 -1
- package/src/ui/activity.js +203 -0
- package/src/ui/plain.js +22 -3
- package/src/ui/screen.js +65 -19
- package/templates/next-shadcn/AGENTS.md +9 -0
- package/templates/next-shadcn/README.md +9 -0
- package/templates/next-shadcn/TEMPLATE.md +83 -0
- package/templates/next-shadcn/_gitignore +41 -0
- package/templates/next-shadcn/_package-lock.json +11465 -0
- package/templates/next-shadcn/components.json +25 -0
- package/templates/next-shadcn/eslint.config.mjs +18 -0
- package/templates/next-shadcn/next-env.d.ts +5 -0
- package/templates/next-shadcn/next.config.ts +8 -0
- package/templates/next-shadcn/package.json +39 -0
- package/templates/next-shadcn/postcss.config.mjs +7 -0
- package/templates/next-shadcn/presets/citrus.json +77 -0
- package/templates/next-shadcn/presets/graphite.json +77 -0
- package/templates/next-shadcn/presets/grove.json +77 -0
- package/templates/next-shadcn/presets/ocean.json +78 -0
- package/templates/next-shadcn/presets/sunset.json +77 -0
- package/templates/next-shadcn/presets/violet.json +77 -0
- package/templates/next-shadcn/src/app/favicon.ico +0 -0
- package/templates/next-shadcn/src/app/globals.css +155 -0
- package/templates/next-shadcn/src/app/layout.tsx +42 -0
- package/templates/next-shadcn/src/app/page.tsx +14 -0
- package/templates/next-shadcn/src/components/theme-provider.tsx +7 -0
- package/templates/next-shadcn/src/components/theme-toggle.tsx +27 -0
- package/templates/next-shadcn/src/components/ui/accordion.tsx +80 -0
- package/templates/next-shadcn/src/components/ui/alert-dialog.tsx +199 -0
- package/templates/next-shadcn/src/components/ui/avatar.tsx +111 -0
- package/templates/next-shadcn/src/components/ui/badge.tsx +48 -0
- package/templates/next-shadcn/src/components/ui/button.tsx +66 -0
- package/templates/next-shadcn/src/components/ui/calendar.tsx +222 -0
- package/templates/next-shadcn/src/components/ui/card.tsx +102 -0
- package/templates/next-shadcn/src/components/ui/checkbox.tsx +32 -0
- package/templates/next-shadcn/src/components/ui/collapsible.tsx +33 -0
- package/templates/next-shadcn/src/components/ui/command.tsx +195 -0
- package/templates/next-shadcn/src/components/ui/dialog.tsx +168 -0
- package/templates/next-shadcn/src/components/ui/dropdown-menu.tsx +268 -0
- package/templates/next-shadcn/src/components/ui/hover-card.tsx +43 -0
- package/templates/next-shadcn/src/components/ui/input-group.tsx +156 -0
- package/templates/next-shadcn/src/components/ui/input.tsx +18 -0
- package/templates/next-shadcn/src/components/ui/label.tsx +23 -0
- package/templates/next-shadcn/src/components/ui/popover.tsx +88 -0
- package/templates/next-shadcn/src/components/ui/progress.tsx +30 -0
- package/templates/next-shadcn/src/components/ui/radio-group.tsx +43 -0
- package/templates/next-shadcn/src/components/ui/scroll-area.tsx +54 -0
- package/templates/next-shadcn/src/components/ui/select.tsx +191 -0
- package/templates/next-shadcn/src/components/ui/separator.tsx +27 -0
- package/templates/next-shadcn/src/components/ui/sheet.tsx +147 -0
- package/templates/next-shadcn/src/components/ui/skeleton.tsx +13 -0
- package/templates/next-shadcn/src/components/ui/slider.tsx +58 -0
- package/templates/next-shadcn/src/components/ui/sonner.tsx +49 -0
- package/templates/next-shadcn/src/components/ui/switch.tsx +32 -0
- package/templates/next-shadcn/src/components/ui/table.tsx +115 -0
- package/templates/next-shadcn/src/components/ui/tabs.tsx +89 -0
- package/templates/next-shadcn/src/components/ui/textarea.tsx +17 -0
- package/templates/next-shadcn/src/components/ui/toggle-group.tsx +89 -0
- package/templates/next-shadcn/src/components/ui/toggle.tsx +46 -0
- package/templates/next-shadcn/src/components/ui/tooltip.tsx +56 -0
- package/templates/next-shadcn/src/lib/utils.ts +6 -0
- package/templates/next-shadcn/tsconfig.json +34 -0
- package/ucode.js +8 -1
|
@@ -0,0 +1,269 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* stuck.js — noticing when the model is going round in circles.
|
|
3
|
+
*
|
|
4
|
+
* Measured on a real build (a tip calculator, 84 model steps): six edits whose
|
|
5
|
+
* old_string and new_string were identical, the same build failing three times
|
|
6
|
+
* running on the same two type errors, and thirty reads of files whose current
|
|
7
|
+
* text was already in the conversation. Every one of those is a whole round
|
|
8
|
+
* trip, and a small model that has started repeating itself rarely stops on
|
|
9
|
+
* its own — the error it keeps getting says what is wrong, but nothing says
|
|
10
|
+
* "you have tried exactly this before".
|
|
11
|
+
*
|
|
12
|
+
* So each finished tool call becomes an event, and the recent events are
|
|
13
|
+
* checked for four patterns:
|
|
14
|
+
*
|
|
15
|
+
* repeat the same call, with the same arguments, failing the same way
|
|
16
|
+
* identical edits refused because old_string and new_string are the same
|
|
17
|
+
* reread reading a file whose unchanged text is still in view
|
|
18
|
+
* build a build failing with the same error text
|
|
19
|
+
*
|
|
20
|
+
* The first time a pattern reaches its threshold, the result that completed it
|
|
21
|
+
* carries a firm, specific note: what was repeated, the error, what to do
|
|
22
|
+
* instead. If the same pattern turns up again after that note, a nudge has not
|
|
23
|
+
* worked, and the caller hands the turn to another model.
|
|
24
|
+
*
|
|
25
|
+
* Everything here is pure: events in, verdicts out. The loop owns the side
|
|
26
|
+
* effects — appending the text and switching the model.
|
|
27
|
+
*/
|
|
28
|
+
|
|
29
|
+
import { createHash } from 'node:crypto';
|
|
30
|
+
|
|
31
|
+
/** How many recent tool calls the patterns are looked for in. */
|
|
32
|
+
export const WINDOW = 20;
|
|
33
|
+
|
|
34
|
+
/** Occurrences inside the window that make a hit. */
|
|
35
|
+
export const THRESHOLDS = { repeat: 3, identical: 2, reread: 1, build: 3 };
|
|
36
|
+
|
|
37
|
+
const hash = (s) => createHash('sha1').update(String(s)).digest('hex').slice(0, 16);
|
|
38
|
+
|
|
39
|
+
/** Argument values compared the way a person would: whitespace runs do not make a call different. */
|
|
40
|
+
function stable(value) {
|
|
41
|
+
if (typeof value === 'string') return value.replace(/\s+/g, ' ').trim();
|
|
42
|
+
if (Array.isArray(value)) return value.map(stable);
|
|
43
|
+
if (value && typeof value === 'object') {
|
|
44
|
+
return Object.fromEntries(Object.keys(value).sort().map((k) => [k, stable(value[k])]));
|
|
45
|
+
}
|
|
46
|
+
return value;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** Name and arguments, reduced to one short string. */
|
|
50
|
+
export function signature(call) {
|
|
51
|
+
return `${call?.name}:${hash(JSON.stringify(stable(call?.args ?? {})))}`;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** Commands whose job is to build or type-check the project. */
|
|
55
|
+
const BUILD = /\b(?:npm|pnpm|yarn|bun)\s+(?:run\s+)?build\b|\bnext\s+build\b|\bvite\s+build\b|\btsc\b(?![^&|;]*--watch)/i;
|
|
56
|
+
|
|
57
|
+
export const isBuild = (command) => BUILD.test(String(command ?? ''));
|
|
58
|
+
|
|
59
|
+
const ERROR_LINE = /\berror\b|Error:|Module not found|Can't resolve|Type error|is not defined|Unterminated/i;
|
|
60
|
+
const GENERIC = /^(?:>\s*)?Build error occurred|Failed to (?:type check|compile)|build worker exited|exiting the build|^exit code:|^What to do:|^- |build failed with \d+ errors?|^Import trace|^\d+ errors? found/i;
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* The part of a build's output that names what is wrong, with the noise that
|
|
64
|
+
* changes from run to run — timings, digests — taken out, so two failures on
|
|
65
|
+
* the same errors compare equal.
|
|
66
|
+
*/
|
|
67
|
+
export function buildErrors(output) {
|
|
68
|
+
const clean = (l) => l
|
|
69
|
+
.replace(/\b\d+(?:\.\d+)?\s?m?s\b/g, '')
|
|
70
|
+
.replace(/digest: '[^']*'/g, '')
|
|
71
|
+
.replace(/\s+/g, ' ')
|
|
72
|
+
.trim();
|
|
73
|
+
const lines = String(output ?? '').split(/\r?\n/).map((l) => l.trim()).filter(Boolean);
|
|
74
|
+
const errors = [...new Set(lines.filter((l) => ERROR_LINE.test(l) && !GENERIC.test(l)).map(clean))];
|
|
75
|
+
return (errors.length ? errors.slice(0, 12) : lines.slice(-5).map(clean)).join('\n');
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* One finished call as the detector sees it.
|
|
80
|
+
*
|
|
81
|
+
* @param {object} call { name, args }
|
|
82
|
+
* @param {object} outcome
|
|
83
|
+
* @param {object} [outcome.out] a tool result ({ content, exitCode? })
|
|
84
|
+
* @param {object} [outcome.err] a ToolFailure
|
|
85
|
+
* @param {string[]} [outcome.rereads] files this call re-read while their text was still in view
|
|
86
|
+
*/
|
|
87
|
+
export function eventFor(call, { out, err, rereads = [] } = {}) {
|
|
88
|
+
const args = call?.args ?? {};
|
|
89
|
+
const event = { tool: call?.name, sig: signature(call), failed: false, rereads: [...rereads] };
|
|
90
|
+
event.path = args.path ?? args.files?.[0]?.path ?? null;
|
|
91
|
+
|
|
92
|
+
if (err) {
|
|
93
|
+
event.failed = true;
|
|
94
|
+
event.kind = err.kind ?? 'error';
|
|
95
|
+
event.error = String(err.failed ?? err.message ?? err);
|
|
96
|
+
event.identical = event.kind === 'bad_args' && /old_string and new_string are identical/.test(event.error);
|
|
97
|
+
return event;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
if (call?.name === 'run_command' && out && out.exitCode !== undefined && out.exitCode !== 0) {
|
|
101
|
+
const command = String(args.command ?? '');
|
|
102
|
+
event.failed = true;
|
|
103
|
+
event.kind = `exit ${out.exitCode}`;
|
|
104
|
+
event.command = command;
|
|
105
|
+
if (isBuild(command)) {
|
|
106
|
+
event.build = true;
|
|
107
|
+
event.error = buildErrors(out.content);
|
|
108
|
+
} else {
|
|
109
|
+
event.error = buildErrors(out.content).slice(0, 400);
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
return event;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* Does the latest event complete a pattern? Returns the hit, or null.
|
|
117
|
+
*
|
|
118
|
+
* Only the newest event can complete one: every earlier event was checked when
|
|
119
|
+
* it arrived, so looking again would report the same hit twice.
|
|
120
|
+
*/
|
|
121
|
+
export function detect(events, { window = WINDOW, thresholds = THRESHOLDS } = {}) {
|
|
122
|
+
const recent = events.slice(-window);
|
|
123
|
+
const last = recent[recent.length - 1];
|
|
124
|
+
if (!last) return null;
|
|
125
|
+
|
|
126
|
+
if (last.identical) {
|
|
127
|
+
const count = recent.filter((e) => e.identical).length;
|
|
128
|
+
return count >= thresholds.identical
|
|
129
|
+
? { pattern: 'identical', key: 'identical', count, tool: last.tool, path: last.path, error: last.error }
|
|
130
|
+
: null;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
if (last.build) {
|
|
134
|
+
const count = recent.filter((e) => e.build && e.error === last.error).length;
|
|
135
|
+
return count >= thresholds.build
|
|
136
|
+
? { pattern: 'build', key: `build:${hash(last.error)}`, count, command: last.command, error: last.error }
|
|
137
|
+
: null;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
if (last.rereads?.length) {
|
|
141
|
+
const count = recent.filter((e) => e.rereads?.length).length;
|
|
142
|
+
return count >= thresholds.reread
|
|
143
|
+
? { pattern: 'reread', key: 'reread', count, paths: last.rereads }
|
|
144
|
+
: null;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
if (last.failed) {
|
|
148
|
+
const same = (e) => e.failed && !e.identical && !e.build && e.sig === last.sig && e.kind === last.kind && e.error === last.error;
|
|
149
|
+
const count = recent.filter(same).length;
|
|
150
|
+
return count >= thresholds.repeat
|
|
151
|
+
? {
|
|
152
|
+
pattern: 'repeat', key: `repeat:${last.sig}:${last.kind}:${hash(last.error)}`, count,
|
|
153
|
+
tool: last.tool, kind: last.kind, error: last.error, path: last.path, command: last.command,
|
|
154
|
+
}
|
|
155
|
+
: null;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
return null;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
/** What each pattern is, as a few words for the user and for the model-switch note. */
|
|
162
|
+
export function describeHit(hit) {
|
|
163
|
+
switch (hit.pattern) {
|
|
164
|
+
case 'identical': return 'making edits that change nothing';
|
|
165
|
+
case 'build': return 'rebuilding on the same errors';
|
|
166
|
+
case 'reread': return 're-reading files it already has';
|
|
167
|
+
default: return `repeating a failing ${hit.tool}`;
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
const nth = (n) => `${n}${n % 10 === 1 && n % 100 !== 11 ? 'st' : n % 10 === 2 && n % 100 !== 12 ? 'nd' : n % 10 === 3 && n % 100 !== 13 ? 'rd' : 'th'}`;
|
|
172
|
+
|
|
173
|
+
/** What to do instead, by the kind of failure being repeated. */
|
|
174
|
+
function adviceFor(hit) {
|
|
175
|
+
if (hit.tool === 'run_command') {
|
|
176
|
+
return 'Read the output above: it names the problem. Change the cause - the code, the ' +
|
|
177
|
+
'command, or its cwd - before running it again, or take a different route.';
|
|
178
|
+
}
|
|
179
|
+
switch (hit.kind) {
|
|
180
|
+
case 'no_match':
|
|
181
|
+
return `Read ${hit.path ?? 'the file'} once, copy old_string from that result exactly as it ` +
|
|
182
|
+
'stands, without the line-number gutter - or, if the change is large, rewrite the file with write_file.';
|
|
183
|
+
case 'ambiguous':
|
|
184
|
+
return 'Add the lines around it to old_string until it matches one place only.';
|
|
185
|
+
case 'not_found':
|
|
186
|
+
return 'That path does not exist. Find the right one with glob or list_dir first.';
|
|
187
|
+
case 'bad_args':
|
|
188
|
+
return 'Fix exactly the argument the error names before calling it again.';
|
|
189
|
+
default:
|
|
190
|
+
return 'Change what the error points at, or take a different route.';
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
/** The note appended to the result that completed a hit. Empty when the result already says it. */
|
|
195
|
+
export function nudge(hit, { switched = false } = {}) {
|
|
196
|
+
const lead = switched
|
|
197
|
+
? 'ucode has handed this turn to another model, because the last one kept ' +
|
|
198
|
+
`${describeHit(hit)} after being told to stop. `
|
|
199
|
+
: '';
|
|
200
|
+
|
|
201
|
+
switch (hit.pattern) {
|
|
202
|
+
case 'identical':
|
|
203
|
+
return `${lead}STOP - that is ${hit.count} edits in a row whose old_string and new_string are ` +
|
|
204
|
+
'identical. An edit like that changes nothing, so it is refused every time. If ' +
|
|
205
|
+
`${hit.path ?? 'the file'} already says what you want, that part is finished: move on to the ` +
|
|
206
|
+
'next thing. If it does not, put the text you actually want in new_string.';
|
|
207
|
+
case 'build':
|
|
208
|
+
return `${lead}STOP - \`${hit.command}\` has now failed ${hit.count} times with the same errors:\n` +
|
|
209
|
+
`${hit.error}\n` +
|
|
210
|
+
'Building again without changing the code those lines point at fails the same way, and each ' +
|
|
211
|
+
'build takes most of a minute. Fix every error listed - in one pass, with edit_files or ' +
|
|
212
|
+
'multi_edit - then build once.';
|
|
213
|
+
case 'reread':
|
|
214
|
+
// The result itself already says the text was not sent again and why;
|
|
215
|
+
// only a model switch has anything to add.
|
|
216
|
+
return switched
|
|
217
|
+
? `${lead}Work from the file text already in this conversation instead of reading it again.`
|
|
218
|
+
: '';
|
|
219
|
+
default:
|
|
220
|
+
return `${lead}STOP - this is the ${nth(hit.count)} time ${hit.tool} has been called with exactly ` +
|
|
221
|
+
`these arguments, and it failed the same way every time (${hit.kind}: ${oneLine(hit.error)}). ` +
|
|
222
|
+
`Calling it again will fail again. ${adviceFor(hit)}`;
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
const oneLine = (s) => {
|
|
227
|
+
const line = String(s ?? '').split('\n')[0];
|
|
228
|
+
return line.length > 200 ? `${line.slice(0, 200)}…` : line;
|
|
229
|
+
};
|
|
230
|
+
|
|
231
|
+
/**
|
|
232
|
+
* The detector with memory: which hits have already had their nudge.
|
|
233
|
+
*
|
|
234
|
+
* observe() returns null, a nudge, or a switch. A nudge is remembered for as
|
|
235
|
+
* long as it is inside the window; the same pattern turning up again while it
|
|
236
|
+
* is remembered means the nudge did not work. After a switch the slate is
|
|
237
|
+
* wiped, so the new model gets a nudge of its own before any further switch.
|
|
238
|
+
*/
|
|
239
|
+
export class StuckWatch {
|
|
240
|
+
constructor({ window = WINDOW, thresholds = THRESHOLDS } = {}) {
|
|
241
|
+
this.window = window;
|
|
242
|
+
this.thresholds = thresholds;
|
|
243
|
+
this.events = [];
|
|
244
|
+
this.seq = 0;
|
|
245
|
+
this.nudged = new Map(); // hit key -> seq of the event that was nudged
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
observe(event) {
|
|
249
|
+
this.seq++;
|
|
250
|
+
this.events.push({ ...event, seq: this.seq });
|
|
251
|
+
if (this.events.length > this.window * 2) this.events.splice(0, this.events.length - this.window);
|
|
252
|
+
|
|
253
|
+
const hit = detect(this.events, { window: this.window, thresholds: this.thresholds });
|
|
254
|
+
if (!hit) return null;
|
|
255
|
+
|
|
256
|
+
const at = this.nudged.get(hit.key);
|
|
257
|
+
if (at !== undefined && this.seq - at < this.window) {
|
|
258
|
+
this.reset();
|
|
259
|
+
return { action: 'switch', hit, text: nudge(hit, { switched: true }) };
|
|
260
|
+
}
|
|
261
|
+
this.nudged.set(hit.key, this.seq);
|
|
262
|
+
return { action: 'nudge', hit, text: nudge(hit) };
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
reset() {
|
|
266
|
+
this.events = [];
|
|
267
|
+
this.nudged.clear();
|
|
268
|
+
}
|
|
269
|
+
}
|
package/src/tools/browser.js
CHANGED
|
@@ -25,6 +25,7 @@ const WIDTHS = [
|
|
|
25
25
|
{ name: 'desktop', width: 1440, height: 900 },
|
|
26
26
|
];
|
|
27
27
|
const LOCAL = /^https?:\/\/(?:localhost|127\.0\.0\.1|0\.0\.0\.0|\[::1\])(?::\d+)?(?:\/|$)/i;
|
|
28
|
+
const MAX_SHOT_HEIGHT = 3000;
|
|
28
29
|
|
|
29
30
|
let browserPromise = null;
|
|
30
31
|
|
|
@@ -131,7 +132,8 @@ async function review(shots) {
|
|
|
131
132
|
role: 'system',
|
|
132
133
|
content:
|
|
133
134
|
'You are a senior product designer reviewing screenshots of a web app, one at a phone width ' +
|
|
134
|
-
'and one at desktop width.
|
|
135
|
+
'and one at desktop width. Each screenshot is the whole page, top to bottom, so anything not ' +
|
|
136
|
+
'in it is genuinely not there. List the concrete visual problems a user would notice, most ' +
|
|
135
137
|
'important first: broken or cramped layout, overflow, misalignment, weak hierarchy (is the ' +
|
|
136
138
|
'most important thing the most prominent?), inconsistent spacing, low contrast, default-looking ' +
|
|
137
139
|
'components, awkward empty states, text that is too small. For each: where it is, what is wrong, ' +
|
|
@@ -144,11 +146,40 @@ async function review(shots) {
|
|
|
144
146
|
images: shots.map((s) => s.dataUrl),
|
|
145
147
|
},
|
|
146
148
|
];
|
|
147
|
-
const reply = await ask(request, [], {
|
|
148
|
-
|
|
149
|
+
const reply = await ask(request, [], {
|
|
150
|
+
model: VISION_MODEL,
|
|
151
|
+
temperature: 0.2,
|
|
152
|
+
// A reasoning model spends its budget thinking before it writes; 900
|
|
153
|
+
// tokens came back as an empty review. Keep the thinking short, and leave
|
|
154
|
+
// room for the answer.
|
|
155
|
+
maxOutputTokens: 4000,
|
|
156
|
+
reasoning: { effort: 'low' },
|
|
157
|
+
// The free vision model is often busy. One try, and a hard cap: a review
|
|
158
|
+
// that cannot run is skipped, never waited on.
|
|
159
|
+
attempts: 1,
|
|
160
|
+
signal: AbortSignal.timeout(REVIEW_BUDGET_MS),
|
|
161
|
+
});
|
|
162
|
+
const text = reply.text.trim();
|
|
163
|
+
if (!text) throw new Error('the vision model returned an empty review');
|
|
164
|
+
return text;
|
|
149
165
|
}
|
|
150
166
|
|
|
151
|
-
|
|
167
|
+
const REVIEW_BUDGET_MS = 60_000;
|
|
168
|
+
|
|
169
|
+
/**
|
|
170
|
+
* The designer's review is the slow part — a reasoning model looking at
|
|
171
|
+
* screenshots, most of a minute — so each app gets one per turn: a look after
|
|
172
|
+
* the fixes only re-runs the fast checks. A review that failed (busy model,
|
|
173
|
+
* empty reply) gets one more try on the next look, then is let go.
|
|
174
|
+
*/
|
|
175
|
+
const reviews = new Map(); // base URL -> { done, tries }
|
|
176
|
+
|
|
177
|
+
/** A new request from the user: the apps may be reviewed afresh. */
|
|
178
|
+
export function forgetReviews() {
|
|
179
|
+
reviews.clear();
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
export async function lookAtApp({ url, paths = ['/'] }) {
|
|
152
183
|
const base = String(url ?? '').trim().replace(/\/+$/, '');
|
|
153
184
|
if (!LOCAL.test(`${base}/`)) {
|
|
154
185
|
throw new ToolFailure({
|
|
@@ -167,76 +198,104 @@ export async function lookAtApp({ url, paths = ['/'], review: wantReview = true
|
|
|
167
198
|
await fs.mkdir(shotsDir, { recursive: true });
|
|
168
199
|
|
|
169
200
|
const b = await browser();
|
|
170
|
-
const sections = [];
|
|
171
|
-
const toReview = [];
|
|
172
|
-
let problems = 0;
|
|
173
201
|
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
202
|
+
// Every page at every width opens at once, each in its own context: the
|
|
203
|
+
// wait is for the slowest one, not the sum of them all.
|
|
204
|
+
const checks = await Promise.all(pages.flatMap((pagePath) => WIDTHS.map(async (size) => {
|
|
205
|
+
let problems = 0;
|
|
206
|
+
let shot = null;
|
|
207
|
+
const context = await b.newContext({ viewport: { width: size.width, height: size.height }, deviceScaleFactor: 1 });
|
|
208
|
+
const page = await context.newPage();
|
|
209
|
+
const errors = [];
|
|
210
|
+
const failed = [];
|
|
211
|
+
page.on('console', (m) => {
|
|
212
|
+
if (m.type() === 'error' && !/devtools|download the react/i.test(m.text())) errors.push(m.text().slice(0, 200));
|
|
213
|
+
});
|
|
214
|
+
page.on('pageerror', (e) => errors.push(`uncaught: ${String(e.message).slice(0, 200)}`));
|
|
215
|
+
page.on('requestfailed', (r) => failed.push(`${r.method()} ${r.url().slice(0, 100)} — ${r.failure()?.errorText ?? 'failed'}`));
|
|
216
|
+
page.on('response', (r) => { if (r.status() >= 400) failed.push(`${r.status()} ${r.url().slice(0, 100)}`); });
|
|
186
217
|
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
}
|
|
197
|
-
|
|
218
|
+
const target = `${base}${pagePath}`;
|
|
219
|
+
let loadError = null;
|
|
220
|
+
try {
|
|
221
|
+
// 'load', not 'networkidle': a dev server holds a hot-reload
|
|
222
|
+
// connection open and polls, so the network may never go quiet and
|
|
223
|
+
// 'networkidle' would wait out its whole timeout on every page, at
|
|
224
|
+
// every width. 'load' covers the first-request compile; the page's own
|
|
225
|
+
// data then gets a short, bounded chance to settle.
|
|
226
|
+
await page.goto(target, { waitUntil: 'load', timeout: 90_000 });
|
|
227
|
+
await page.waitForLoadState('networkidle', { timeout: 1_500 }).catch(() => {});
|
|
228
|
+
} catch (err) {
|
|
229
|
+
loadError = String(err.message).split('\n')[0];
|
|
230
|
+
}
|
|
231
|
+
await page.waitForTimeout(400); // let entrance animations settle
|
|
198
232
|
|
|
199
|
-
|
|
200
|
-
|
|
233
|
+
const file = path.join(shotsDir, `${safeName(pagePath)}-${size.name}.jpg`);
|
|
234
|
+
let facts = null;
|
|
235
|
+
try {
|
|
201
236
|
if (!loadError) {
|
|
202
237
|
facts = await page.evaluate(inspect).catch((err) => ({ error: err.message }));
|
|
203
|
-
|
|
238
|
+
// The whole page, so the reviewer never reports as missing what is
|
|
239
|
+
// only below the fold — capped, so an endless feed stays one image.
|
|
240
|
+
const tall = await page.evaluate(() => document.documentElement.scrollHeight).catch(() => 0);
|
|
241
|
+
const buffer = await page.screenshot({
|
|
242
|
+
type: 'jpeg',
|
|
243
|
+
quality: 70,
|
|
244
|
+
fullPage: true,
|
|
245
|
+
...(tall > MAX_SHOT_HEIGHT ? { clip: { x: 0, y: 0, width: size.width, height: MAX_SHOT_HEIGHT } } : {}),
|
|
246
|
+
});
|
|
204
247
|
await fs.writeFile(file, buffer);
|
|
205
|
-
|
|
248
|
+
shot = { label: `${pagePath} at ${size.width}px (${size.name})`, dataUrl: `data:image/jpeg;base64,${buffer.toString('base64')}` };
|
|
206
249
|
}
|
|
207
|
-
|
|
250
|
+
} catch (err) {
|
|
251
|
+
loadError = `the page broke while being checked: ${String(err.message).split('\n')[0]}`;
|
|
252
|
+
} finally {
|
|
253
|
+
await context.close().catch(() => {});
|
|
254
|
+
}
|
|
208
255
|
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
256
|
+
const lines = [`### ${pagePath} at ${size.width}px (${size.name})`];
|
|
257
|
+
if (loadError) {
|
|
258
|
+
lines.push(`Could not load: ${loadError}`);
|
|
259
|
+
problems++;
|
|
260
|
+
} else {
|
|
261
|
+
lines.push(`Screenshot: ${path.relative(getRoot(), file).split(path.sep).join('/')}`);
|
|
262
|
+
if (facts?.empty) { lines.push('- The page rendered no visible text at all.'); problems++; }
|
|
263
|
+
if (facts?.overflow) {
|
|
264
|
+
lines.push(`- Content is ${facts.overflow}px wider than the screen, so it scrolls sideways:`, ...facts.wide.map((w) => ` - ${w}`));
|
|
212
265
|
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
266
|
}
|
|
227
|
-
if (
|
|
228
|
-
if (
|
|
229
|
-
if (
|
|
230
|
-
|
|
267
|
+
if (facts?.broken?.length) { lines.push(`- Broken images: ${facts.broken.join(', ')}`); problems++; }
|
|
268
|
+
if (facts?.unnamed?.length) { lines.push(`- Buttons or links with no accessible name: ${facts.unnamed.join(', ')}`); problems++; }
|
|
269
|
+
if (facts?.inputsNoLabel) { lines.push(`- ${facts.inputsNoLabel} form field(s) without a label.`); problems++; }
|
|
270
|
+
if (facts?.noAlt) lines.push(`- ${facts.noAlt} image(s) without alt text.`);
|
|
271
|
+
if (facts?.tiny) lines.push(`- ${facts.tiny} tap target(s) smaller than 32px on a phone.`);
|
|
272
|
+
if (facts?.smallText) lines.push(`- ${facts.smallText} text element(s) under 12px.`);
|
|
231
273
|
}
|
|
232
|
-
}
|
|
274
|
+
if (errors.length) { lines.push('- Console errors:', ...[...new Set(errors)].slice(0, 6).map((e) => ` - ${e}`)); problems++; }
|
|
275
|
+
if (failed.length) { lines.push('- Failed requests:', ...[...new Set(failed)].slice(0, 6).map((f) => ` - ${f}`)); problems++; }
|
|
276
|
+
if (lines.length === 2 && !loadError) lines.push('- No errors, no overflow, nothing unlabeled.');
|
|
277
|
+
const broken = Boolean(loadError || errors.length || facts?.empty);
|
|
278
|
+
return { section: lines.join('\n'), shot, problems, broken };
|
|
279
|
+
})));
|
|
280
|
+
|
|
281
|
+
const sections = checks.map((c) => c.section);
|
|
282
|
+
const toReview = checks.map((c) => c.shot).filter(Boolean);
|
|
283
|
+
const problems = checks.reduce((n, c) => n + c.problems, 0);
|
|
284
|
+
const broken = checks.some((c) => c.broken);
|
|
233
285
|
|
|
286
|
+
// A page that crashed or threw is fixed first; reviewing a screenshot of an
|
|
287
|
+
// error overlay is a minute spent on nothing.
|
|
234
288
|
let critique = '';
|
|
235
|
-
|
|
289
|
+
const state = reviews.get(base) ?? { done: false, tries: 0 };
|
|
290
|
+
if (!broken && !state.done && state.tries < 2 && toReview.length) {
|
|
291
|
+
state.tries++;
|
|
292
|
+
reviews.set(base, state);
|
|
236
293
|
try {
|
|
237
294
|
critique = await review(toReview.slice(0, 4));
|
|
295
|
+
state.done = true;
|
|
238
296
|
} catch (err) {
|
|
239
|
-
|
|
297
|
+
const why = String(err.failed ?? err.message).replace(/[.\s]+$/, '');
|
|
298
|
+
critique = `(The visual review could not run: ${why}. The checks above still apply.)`;
|
|
240
299
|
}
|
|
241
300
|
}
|
|
242
301
|
|
|
@@ -246,7 +305,10 @@ export async function lookAtApp({ url, paths = ['/'], review: wantReview = true
|
|
|
246
305
|
'',
|
|
247
306
|
problems
|
|
248
307
|
? 'Fix the problems above, then look again to confirm.'
|
|
249
|
-
:
|
|
308
|
+
: state.done && critique
|
|
309
|
+
? 'The automatic checks found nothing. Weigh the visual review, fix what is worth fixing - ' +
|
|
310
|
+
'the next look re-runs only the fast checks.'
|
|
311
|
+
: 'The automatic checks found nothing.',
|
|
250
312
|
].filter(Boolean).join('\n\n');
|
|
251
313
|
|
|
252
314
|
return result(
|