ucode-agent 1.5.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 +53 -1
- package/package.json +3 -1
- package/skills/ui-ux/SKILL.md +2 -2
- package/src/core/doctor.js +122 -0
- package/src/core/loop.js +301 -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 +44 -10
- package/src/tools/scaffold.js +61 -1
- 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/TEMPLATE.md +53 -9
- package/templates/next-shadcn/_package-lock.json +1335 -148
- package/templates/next-shadcn/components.json +1 -1
- package/templates/next-shadcn/next.config.ts +2 -1
- package/templates/next-shadcn/package.json +4 -2
- 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/components/ui/accordion.tsx +80 -0
- package/templates/next-shadcn/src/components/ui/alert-dialog.tsx +34 -22
- package/templates/next-shadcn/src/components/ui/avatar.tsx +7 -4
- package/templates/next-shadcn/src/components/ui/badge.tsx +15 -18
- package/templates/next-shadcn/src/components/ui/button.tsx +12 -3
- package/templates/next-shadcn/src/components/ui/calendar.tsx +1 -0
- package/templates/next-shadcn/src/components/ui/checkbox.tsx +6 -2
- package/templates/next-shadcn/src/components/ui/collapsible.tsx +33 -0
- package/templates/next-shadcn/src/components/ui/command.tsx +1 -2
- package/templates/next-shadcn/src/components/ui/dialog.tsx +34 -26
- package/templates/next-shadcn/src/components/ui/dropdown-menu.tsx +115 -114
- package/templates/next-shadcn/src/components/ui/hover-card.tsx +43 -0
- package/templates/next-shadcn/src/components/ui/input-group.tsx +2 -4
- package/templates/next-shadcn/src/components/ui/input.tsx +1 -2
- package/templates/next-shadcn/src/components/ui/label.tsx +6 -2
- package/templates/next-shadcn/src/components/ui/popover.tsx +27 -28
- package/templates/next-shadcn/src/components/ui/progress.tsx +11 -63
- package/templates/next-shadcn/src/components/ui/radio-group.tsx +43 -0
- package/templates/next-shadcn/src/components/ui/scroll-area.tsx +6 -6
- package/templates/next-shadcn/src/components/ui/select.tsx +55 -64
- package/templates/next-shadcn/src/components/ui/separator.tsx +6 -3
- package/templates/next-shadcn/src/components/ui/sheet.tsx +35 -26
- package/templates/next-shadcn/src/components/ui/slider.tsx +58 -0
- package/templates/next-shadcn/src/components/ui/switch.tsx +3 -2
- package/templates/next-shadcn/src/components/ui/table.tsx +115 -0
- package/templates/next-shadcn/src/components/ui/tabs.tsx +16 -8
- 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 +24 -33
- package/templates/next-shadcn/src/lib/utils.ts +6 -1
- package/ucode.js +8 -1
package/src/core/loop.js
CHANGED
|
@@ -12,6 +12,8 @@
|
|
|
12
12
|
*/
|
|
13
13
|
|
|
14
14
|
import path from 'node:path';
|
|
15
|
+
import os from 'node:os';
|
|
16
|
+
import { appendFileSync } from 'node:fs';
|
|
15
17
|
import { readFile, access } from 'node:fs/promises';
|
|
16
18
|
import { spawn } from 'node:child_process';
|
|
17
19
|
|
|
@@ -24,7 +26,7 @@ import {
|
|
|
24
26
|
} from '../tools/index.js';
|
|
25
27
|
import { projectMap, loadMemory, remember, MEMORY_FILE } from './context.js';
|
|
26
28
|
import { autoUpdate } from './updater.js';
|
|
27
|
-
import { closeBrowser } from '../tools/browser.js';
|
|
29
|
+
import { closeBrowser, forgetReviews } from '../tools/browser.js';
|
|
28
30
|
import {
|
|
29
31
|
newSession, save, load, list, remove, removeAll, titleFrom,
|
|
30
32
|
} from './history.js';
|
|
@@ -34,6 +36,11 @@ import { Screen, isLabel } from '../ui/screen.js';
|
|
|
34
36
|
import { Plain } from '../ui/plain.js';
|
|
35
37
|
import { theme, blue, sky, dim, formatTokens, relativeTime, shortenPath, clip } from '../ui/theme.js';
|
|
36
38
|
import { Failure, ToolFailure, Declined } from './failure.js';
|
|
39
|
+
import { StuckWatch, eventFor, describeHit } from './stuck.js';
|
|
40
|
+
import { serversReadySince } from '../tools/shell.js';
|
|
41
|
+
import { formatDuration } from '../ui/activity.js';
|
|
42
|
+
import { runDoctor } from './doctor.js';
|
|
43
|
+
import { deploy } from '../tools/deploy.js';
|
|
37
44
|
|
|
38
45
|
/**
|
|
39
46
|
* Tool calls allowed in one turn.
|
|
@@ -72,11 +79,126 @@ const CHECKABLE = /\.(?:[cm]?[jt]sx?|py)$/i;
|
|
|
72
79
|
* model and carries on from exactly where it was.
|
|
73
80
|
*/
|
|
74
81
|
const TRANSIENT = new Set(['rate_limit', 'timeout', 'server', 'network', 'no_content']);
|
|
82
|
+
/** Will another model, or a little patience, get past this? Not the daily cap: it covers them all. */
|
|
83
|
+
const passing = (err) => TRANSIENT.has(err.kind) && !err.detail?.daily;
|
|
75
84
|
const MAX_FAILOVERS = 8;
|
|
76
85
|
const COOLDOWN = 5 * 60_000;
|
|
77
86
|
|
|
78
87
|
const wait = (ms) => new Promise((r) => setTimeout(r, ms));
|
|
79
88
|
|
|
89
|
+
/**
|
|
90
|
+
* UCODE_TRACE=1 writes one JSON line per model call and per tool to
|
|
91
|
+
* ~/.ucode/trace.jsonl (or to the path UCODE_TRACE names): how long it took,
|
|
92
|
+
* tokens in and out, what was called. It is how "it feels slow" becomes a
|
|
93
|
+
* number with a cause attached.
|
|
94
|
+
*/
|
|
95
|
+
const TRACE_FILE = process.env.UCODE_TRACE
|
|
96
|
+
? (process.env.UCODE_TRACE === '1' ? path.join(os.homedir(), '.ucode', 'trace.jsonl') : process.env.UCODE_TRACE)
|
|
97
|
+
: null;
|
|
98
|
+
|
|
99
|
+
/** Tool results worth re-sending in full only while they are recent. */
|
|
100
|
+
const THIN_RESULTS = new Set([
|
|
101
|
+
'read_file', 'read_files', 'grep', 'glob', 'list_dir', 'run_command', 'run_commands',
|
|
102
|
+
'look_at_app', 'web_search', 'edit_file', 'multi_edit', 'edit_files',
|
|
103
|
+
]);
|
|
104
|
+
|
|
105
|
+
/** Replace long strings in old tool arguments with a note of their size. */
|
|
106
|
+
function thinArgs(value) {
|
|
107
|
+
if (typeof value === 'string') {
|
|
108
|
+
return value.length > 400
|
|
109
|
+
? `[${value.length} characters, already applied — read the file if you need its current text]`
|
|
110
|
+
: value;
|
|
111
|
+
}
|
|
112
|
+
if (Array.isArray(value)) return value.map(thinArgs);
|
|
113
|
+
if (value && typeof value === 'object') {
|
|
114
|
+
return Object.fromEntries(Object.entries(value).map(([k, v]) => [k, thinArgs(v)]));
|
|
115
|
+
}
|
|
116
|
+
return value;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* The conversation as sent, with bulky history thinned.
|
|
121
|
+
*
|
|
122
|
+
* Every file the model writes travels inside its own tool call, so a
|
|
123
|
+
* thirty-file app is re-sent in full on every later step — tens of thousands
|
|
124
|
+
* of tokens the provider has to read before it can answer, growing with
|
|
125
|
+
* each file. Beyond the last few steps that text is replaced by a note of its
|
|
126
|
+
* size; the files are on disk, and the model re-reads one when it needs it.
|
|
127
|
+
* Call ids and results stay paired, and the saved session keeps everything.
|
|
128
|
+
*/
|
|
129
|
+
export function lean(messages, keep = 3) {
|
|
130
|
+
let seen = 0;
|
|
131
|
+
let cut = -1;
|
|
132
|
+
for (let i = messages.length - 1; i >= 0; i--) {
|
|
133
|
+
if (messages[i].role === 'assistant' && messages[i].toolCalls?.length && ++seen === keep) { cut = i; break; }
|
|
134
|
+
}
|
|
135
|
+
if (cut <= 0) return messages;
|
|
136
|
+
return messages.map((m, i) => {
|
|
137
|
+
if (i >= cut) return m;
|
|
138
|
+
if (m.role === 'assistant' && m.toolCalls?.length) {
|
|
139
|
+
return { ...m, toolCalls: m.toolCalls.map((c) => ({ ...c, args: thinArgs(c.args) })) };
|
|
140
|
+
}
|
|
141
|
+
if (m.role === 'tool' && THIN_RESULTS.has(m.name) && (m.content?.length ?? 0) > 1500) {
|
|
142
|
+
return {
|
|
143
|
+
...m,
|
|
144
|
+
content: `${m.content.slice(0, 300)}
|
|
145
|
+
… [${m.content.length} characters from an earlier step, trimmed ` +
|
|
146
|
+
'to keep the conversation fast — run the tool again if you need this now]',
|
|
147
|
+
};
|
|
148
|
+
}
|
|
149
|
+
return m;
|
|
150
|
+
});
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
function trace(event) {
|
|
154
|
+
if (!TRACE_FILE) return;
|
|
155
|
+
try { appendFileSync(TRACE_FILE, `${JSON.stringify({ at: Date.now(), ...event })}
|
|
156
|
+
`); } catch { /* never fatal */ }
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/** What /stats reports, counted as the session goes. */
|
|
160
|
+
function newStats() {
|
|
161
|
+
return {
|
|
162
|
+
started: Date.now(), workMs: 0, turns: 0, steps: 0, tokensIn: 0, tokensOut: 0,
|
|
163
|
+
tools: {}, failed: 0, written: 0, edited: 0, commands: 0, builds: 0, stuck: 0,
|
|
164
|
+
};
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
const BUILD_COMMAND = /\b(?:npm|pnpm|yarn|bun)\s+(?:run\s+)?build\b|\bnext\s+build\b|\bvite\s+build\b/;
|
|
168
|
+
|
|
169
|
+
function countTool(stats, call, out, err) {
|
|
170
|
+
stats.tools[call.name] = (stats.tools[call.name] ?? 0) + 1;
|
|
171
|
+
if (err) { stats.failed++; return; }
|
|
172
|
+
const a = call.args ?? {};
|
|
173
|
+
if (call.name === 'write_file') stats.written++;
|
|
174
|
+
if (call.name === 'batch_write') stats.written += a.files?.length ?? 0;
|
|
175
|
+
if (call.name === 'edit_file' || call.name === 'multi_edit') stats.edited++;
|
|
176
|
+
if (call.name === 'edit_files') stats.edited += new Set((a.edits ?? []).map((e) => e.path)).size || 1;
|
|
177
|
+
if (call.name === 'run_command') {
|
|
178
|
+
stats.commands++;
|
|
179
|
+
if (BUILD_COMMAND.test(a.command ?? '')) stats.builds++;
|
|
180
|
+
}
|
|
181
|
+
if (call.name === 'run_commands') stats.commands += a.commands?.length ?? 0;
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
/** The /stats box. */
|
|
185
|
+
export function statsLines(st, messages) {
|
|
186
|
+
const n = (v) => Number(v).toLocaleString();
|
|
187
|
+
const top = Object.entries(st.tools).sort((x, y) => y[1] - x[1]).slice(0, 6)
|
|
188
|
+
.map(([k, v]) => `${k} ${v}`).join(' · ') || 'none yet';
|
|
189
|
+
const plural = (v, w) => `${v} ${w}${v === 1 ? '' : 's'}`;
|
|
190
|
+
const rows = [
|
|
191
|
+
['Session', `${formatDuration(Date.now() - st.started)} open · ${formatDuration(st.workMs)} working · ${plural(st.turns, 'request')}`],
|
|
192
|
+
['Model', `${n(st.steps)} steps · ${formatTokens(st.tokensIn)} in · ${formatTokens(st.tokensOut)} out`],
|
|
193
|
+
['Tools', top],
|
|
194
|
+
['Files', `${st.written} written · ${st.edited} edited`],
|
|
195
|
+
['Commands', `${st.commands} run · ${plural(st.builds, 'build')}${st.failed ? ` · ${plural(st.failed, 'tool call')} failed` : ''}`],
|
|
196
|
+
['Context', `${messages} messages${st.stuck ? ` · ${plural(st.stuck, 'loop')} caught` : ''}`],
|
|
197
|
+
];
|
|
198
|
+
const w = Math.max(...rows.map(([k]) => k.length));
|
|
199
|
+
return ['', ` ${blue('Stats')}`, ...rows.map(([k, v]) => ` ${dim(k.padEnd(w))} ${v}`), ''];
|
|
200
|
+
}
|
|
201
|
+
|
|
80
202
|
/** Parallel workers at once, and how many steps each may take. */
|
|
81
203
|
const MAX_WORKERS = 3;
|
|
82
204
|
const WORKER_STEPS = Number(process.env.UCODE_WORKER_STEPS) || 60;
|
|
@@ -238,11 +360,15 @@ function systemPrompt({ cwd, skills, mode, check, map, memory }) {
|
|
|
238
360
|
'',
|
|
239
361
|
'- Need more than one file? read_files, all of them in one call. Never read files',
|
|
240
362
|
' one at a time when you already know which ones you want.',
|
|
363
|
+
'- An edit result shows the file as it now stands. Do not read a file again after',
|
|
364
|
+
' editing it - you already have its current text.',
|
|
241
365
|
'- Put independent calls in the same message — several greps, a glob and a read.',
|
|
242
366
|
' Read-only calls in one message run at the same time.',
|
|
243
367
|
'- New Next.js app? create_app - one step, never create-next-app or shadcn init. It',
|
|
244
368
|
' copies a starter that already builds and installs it in the background.',
|
|
245
|
-
'- batch_write to lay out several new files at once
|
|
369
|
+
'- batch_write to lay out several new files at once - but at most about four per',
|
|
370
|
+
' call: one slip in a huge call throws the whole call away.',
|
|
371
|
+
'- multi_edit for several changes',
|
|
246
372
|
' to one file, edit_files for a change that spans several files.',
|
|
247
373
|
'- For work with three or more steps, keep a short plan with update_plan - at most',
|
|
248
374
|
' six items of a few words - and tick items off as they finish. Skip it for small jobs.',
|
|
@@ -253,9 +379,12 @@ function systemPrompt({ cwd, skills, mode, check, map, memory }) {
|
|
|
253
379
|
' writing files. Running the install yourself afterwards just waits for that one.',
|
|
254
380
|
'- When you finish, ucode type-checks what you changed and hands you the errors, so',
|
|
255
381
|
' there is no need to run tsc yourself.',
|
|
256
|
-
'- Once the dev server is ready, run look_at_app on the pages you built
|
|
257
|
-
'
|
|
258
|
-
' look
|
|
382
|
+
'- Once the dev server is ready, run look_at_app on the pages you built. Fix what it',
|
|
383
|
+
' reports - errors, layout that overflows a phone, the review points worth fixing -',
|
|
384
|
+
' in one pass, then look once more. A clean second look means it is done: report',
|
|
385
|
+
' back instead of polishing in circles. Never call an interface finished unlooked at.',
|
|
386
|
+
'- Asked to deploy, publish, host or share the app? Use the deploy tool - it picks the',
|
|
387
|
+
' name, handles keys and returns the live link. Build locally first.',
|
|
259
388
|
'- Nothing you run has a keyboard. Pass the non-interactive flag to anything that',
|
|
260
389
|
' would ask a question, or it fails instead of waiting: create-next-app --yes,',
|
|
261
390
|
' npx shadcn@latest init -d -y, npx shadcn@latest add <names> -y, npm init -y.',
|
|
@@ -263,6 +392,17 @@ function systemPrompt({ cwd, skills, mode, check, map, memory }) {
|
|
|
263
392
|
' URL once the server says it is ready. Do not start one twice, do not sleep while',
|
|
264
393
|
' waiting for it, and do not curl it before that result comes back.',
|
|
265
394
|
'',
|
|
395
|
+
'## When something fails',
|
|
396
|
+
'',
|
|
397
|
+
'- A failed build names the problem. Fix exactly that, then build again. Never go',
|
|
398
|
+
' exploring inside node_modules: a missing component or package is one install away.',
|
|
399
|
+
'- A build takes most of a minute. Fix every error it lists in one pass - multi_edit,',
|
|
400
|
+
' edit_files - before building again, never one error per build.',
|
|
401
|
+
'- Never delete an app folder to start over. Fix it where it is - starting again throws',
|
|
402
|
+
' away the install and everything already written.',
|
|
403
|
+
'- Run an app\'s commands with cwd set to its folder, and keep paths inside those',
|
|
404
|
+
' commands relative to that folder.',
|
|
405
|
+
'',
|
|
266
406
|
'## Safety',
|
|
267
407
|
'',
|
|
268
408
|
'- run_command runs without asking. That is trust rather than licence: never run',
|
|
@@ -326,6 +466,7 @@ export class Agent {
|
|
|
326
466
|
// CI and `echo ... | ucode` get the line-based interface instead.
|
|
327
467
|
this.full = Boolean(process.stdout.isTTY && process.stdin.isTTY);
|
|
328
468
|
this.ui = this.full ? new Screen({ cwd }) : new Plain({ cwd });
|
|
469
|
+
this.stats = newStats();
|
|
329
470
|
this.skills = [];
|
|
330
471
|
this.session = newSession(cwd, model());
|
|
331
472
|
this.working = [];
|
|
@@ -573,6 +714,7 @@ export class Agent {
|
|
|
573
714
|
}
|
|
574
715
|
|
|
575
716
|
async turn(input) {
|
|
717
|
+
forgetReviews(); // a new request: its apps get a fresh design review
|
|
576
718
|
const images = await this.attachImages(input);
|
|
577
719
|
this.push(images.length
|
|
578
720
|
? { role: 'user', content: input, images }
|
|
@@ -601,17 +743,36 @@ export class Agent {
|
|
|
601
743
|
this.busy = true;
|
|
602
744
|
this.abort = new AbortController();
|
|
603
745
|
|
|
746
|
+
const turnStarted = Date.now();
|
|
747
|
+
let finished = false;
|
|
748
|
+
this.ui.turnStart?.();
|
|
604
749
|
try {
|
|
605
|
-
|
|
750
|
+
for (;;) {
|
|
751
|
+
try {
|
|
752
|
+
await this.run();
|
|
753
|
+
break;
|
|
754
|
+
} catch (err) {
|
|
755
|
+
// The daily free cap mid-build: wait for the reset and carry on,
|
|
756
|
+
// rather than leaving a half-built app for the user to restart.
|
|
757
|
+
if (err?.detail?.daily && !this.abort.signal.aborted && (await this.waitForReset(err))) continue;
|
|
758
|
+
throw err;
|
|
759
|
+
}
|
|
760
|
+
}
|
|
761
|
+
finished = true;
|
|
606
762
|
} catch (err) {
|
|
607
763
|
if (err?.kind === 'aborted' || this.abort.signal.aborted) this.ui.write(dim(' turn cancelled'));
|
|
608
764
|
else throw err;
|
|
609
765
|
} finally {
|
|
766
|
+
trace({ kind: 'turn', ms: Date.now() - turnStarted });
|
|
767
|
+
this.stats.workMs += Date.now() - turnStarted;
|
|
768
|
+
this.stats.turns++;
|
|
610
769
|
this.busy = false;
|
|
611
770
|
this.abort = null;
|
|
612
771
|
this.ui.stopSpinner();
|
|
772
|
+
this.ui.turnEnd?.({ ok: finished });
|
|
613
773
|
await this.persist();
|
|
614
774
|
if (this.full) this.showHeader({ clear: false });
|
|
775
|
+
if (finished) this.openWhenReady(turnStarted);
|
|
615
776
|
}
|
|
616
777
|
}
|
|
617
778
|
|
|
@@ -633,6 +794,7 @@ export class Agent {
|
|
|
633
794
|
this.failovers = 0;
|
|
634
795
|
this.tried = new Set([model()]);
|
|
635
796
|
|
|
797
|
+
this.stuck = new StuckWatch();
|
|
636
798
|
this.touched = new Set();
|
|
637
799
|
this.sinceCheck = new Set();
|
|
638
800
|
this.ranSomething = false;
|
|
@@ -671,6 +833,7 @@ export class Agent {
|
|
|
671
833
|
};
|
|
672
834
|
}
|
|
673
835
|
|
|
836
|
+
var asked = Date.now();
|
|
674
837
|
reply = await ask(
|
|
675
838
|
[
|
|
676
839
|
{
|
|
@@ -684,7 +847,7 @@ export class Agent {
|
|
|
684
847
|
memory: this.memory,
|
|
685
848
|
}),
|
|
686
849
|
},
|
|
687
|
-
...this.working,
|
|
850
|
+
...lean(this.working),
|
|
688
851
|
],
|
|
689
852
|
available,
|
|
690
853
|
opts
|
|
@@ -712,7 +875,7 @@ export class Agent {
|
|
|
712
875
|
|
|
713
876
|
// Busy, slow or down: move to the next model and carry on, rather
|
|
714
877
|
// than ending a half-built app with an error.
|
|
715
|
-
if (
|
|
878
|
+
if (passing(err) && !this.abort.signal.aborted && (await this.failover(err))) continue;
|
|
716
879
|
throw err;
|
|
717
880
|
}
|
|
718
881
|
|
|
@@ -721,6 +884,15 @@ export class Agent {
|
|
|
721
884
|
this.ui.thinkingEnd();
|
|
722
885
|
this.ui.stopSpinner();
|
|
723
886
|
this.record(reply.usage);
|
|
887
|
+
this.ui.step?.();
|
|
888
|
+
this.stats.steps++;
|
|
889
|
+
this.stats.tokensIn += reply.usage.promptTokens ?? 0;
|
|
890
|
+
this.stats.tokensOut += reply.usage.outputTokens ?? 0;
|
|
891
|
+
trace({
|
|
892
|
+
kind: 'model', who: 'lead', model: model(), ms: Date.now() - asked,
|
|
893
|
+
in: reply.usage.promptTokens, out: reply.usage.outputTokens,
|
|
894
|
+
calls: reply.toolCalls.map((c) => c.name),
|
|
895
|
+
});
|
|
724
896
|
|
|
725
897
|
// Streamed text is already on screen; turn it into rendered markdown.
|
|
726
898
|
// Text that turns out to be narration ahead of a tool call folds into a
|
|
@@ -916,11 +1088,112 @@ export class Agent {
|
|
|
916
1088
|
return badArgs;
|
|
917
1089
|
}
|
|
918
1090
|
|
|
1091
|
+
/**
|
|
1092
|
+
* Check a finished call against the stuck patterns (see stuck.js) and return
|
|
1093
|
+
* the note to add to its result. A nudge that did not work hands the turn to
|
|
1094
|
+
* another model.
|
|
1095
|
+
*/
|
|
1096
|
+
stuckNote(call, outcome) {
|
|
1097
|
+
if (!this.stuck) return '';
|
|
1098
|
+
const verdict = this.stuck.observe(eventFor(call, outcome));
|
|
1099
|
+
if (!verdict) return '';
|
|
1100
|
+
this.stats.stuck++;
|
|
1101
|
+
if (verdict.action === 'switch') {
|
|
1102
|
+
const next = fallbackFor(model(), this.tried ?? new Set([model()]));
|
|
1103
|
+
if (next) {
|
|
1104
|
+
this.tried?.add(next);
|
|
1105
|
+
this.ui.note(`${modelName(model())} kept ${describeHit(verdict.hit)} — handing over to ${modelName(next)}`);
|
|
1106
|
+
setModel(next);
|
|
1107
|
+
this.cooldownUntil = Date.now() + COOLDOWN;
|
|
1108
|
+
if (this.full) this.showHeader({ clear: false });
|
|
1109
|
+
}
|
|
1110
|
+
}
|
|
1111
|
+
return verdict.text ? `\n\n${verdict.text}` : '';
|
|
1112
|
+
}
|
|
1113
|
+
|
|
1114
|
+
/**
|
|
1115
|
+
* The daily free cap was hit mid-turn: keep the session, count down to the
|
|
1116
|
+
* reset, and carry on by itself. Esc stops the wait like any other turn.
|
|
1117
|
+
*/
|
|
1118
|
+
async waitForReset(err) {
|
|
1119
|
+
const resetAt = err.detail?.resetAt;
|
|
1120
|
+
if (!Number.isFinite(resetAt)) return false;
|
|
1121
|
+
const at = resetAt + 30_000;
|
|
1122
|
+
const clock = new Date(at).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
|
|
1123
|
+
this.ui.stopSpinner();
|
|
1124
|
+
this.ui.note(`Daily limit reached — ucode carries on by itself at ${clock}. Esc to stop`);
|
|
1125
|
+
await this.persist();
|
|
1126
|
+
let lastNote = Date.now();
|
|
1127
|
+
while (Date.now() < at) {
|
|
1128
|
+
if (this.abort?.signal.aborted) return false;
|
|
1129
|
+
const left = formatDuration(at - Date.now());
|
|
1130
|
+
if (this.full) this.ui.startSpinner(`daily limit · carrying on at ${clock} (in ${left})`);
|
|
1131
|
+
else if (Date.now() - lastNote > 30 * 60_000) { this.ui.note(`still waiting for the daily limit — ${left} to go`); lastNote = Date.now(); }
|
|
1132
|
+
await new Promise((r) => setTimeout(r, Math.min(1000, Math.max(0, at - Date.now()))));
|
|
1133
|
+
}
|
|
1134
|
+
this.ui.stopSpinner();
|
|
1135
|
+
this.ui.note('Daily limit has reset — carrying on');
|
|
1136
|
+
return true;
|
|
1137
|
+
}
|
|
1138
|
+
|
|
1139
|
+
/** A dev server came up during this turn: open it in the browser, once. */
|
|
1140
|
+
openWhenReady(since) {
|
|
1141
|
+
if (!this.full || process.env.UCODE_OPEN === '0') return;
|
|
1142
|
+
const server = serversReadySince(since).at(-1);
|
|
1143
|
+
if (!server || (this.opened ??= new Set()).has(server.url)) return;
|
|
1144
|
+
this.opened.add(server.url);
|
|
1145
|
+
const [cmd, args] = process.platform === 'win32'
|
|
1146
|
+
? ['cmd', ['/c', 'start', '', server.url]]
|
|
1147
|
+
: [process.platform === 'darwin' ? 'open' : 'xdg-open', [server.url]];
|
|
1148
|
+
try {
|
|
1149
|
+
spawn(cmd, args, { stdio: 'ignore', detached: true, windowsHide: true }).unref();
|
|
1150
|
+
this.ui.note(`Opened ${server.url} in your browser`);
|
|
1151
|
+
} catch { /* no browser to open — the link is in the answer */ }
|
|
1152
|
+
}
|
|
1153
|
+
|
|
1154
|
+
cmdStats() {
|
|
1155
|
+
for (const line of statsLines(this.stats, this.working?.length ?? 0)) this.ui.write(line);
|
|
1156
|
+
}
|
|
1157
|
+
|
|
1158
|
+
async cmdDoctor() {
|
|
1159
|
+
this.ui.startSpinner('checking your setup');
|
|
1160
|
+
try {
|
|
1161
|
+
const lines = await runDoctor();
|
|
1162
|
+
this.ui.stopSpinner();
|
|
1163
|
+
for (const line of lines) this.ui.write(line);
|
|
1164
|
+
} finally {
|
|
1165
|
+
this.ui.stopSpinner();
|
|
1166
|
+
}
|
|
1167
|
+
}
|
|
1168
|
+
|
|
1169
|
+
/** /deploy [folder] — the same tool the model calls, run directly. */
|
|
1170
|
+
async cmdDeploy(arg) {
|
|
1171
|
+
const folder = (arg ?? '').trim() || '.';
|
|
1172
|
+
this.ui.toolCall(`Deploying ${folder}`);
|
|
1173
|
+
this.ui.startSpinner('getting ready to deploy');
|
|
1174
|
+
try {
|
|
1175
|
+
const out = await deploy({ folder }, { onOutput: (lines) => this.ui.updateSpinner(lines.at(-1)) });
|
|
1176
|
+
this.ui.stopSpinner();
|
|
1177
|
+
this.ui.toolResult(out.summary);
|
|
1178
|
+
this.ui.write(out.content.split('\n').map((l) => ` ${l}`).join('\n'));
|
|
1179
|
+
} catch (err) {
|
|
1180
|
+
this.ui.stopSpinner();
|
|
1181
|
+
if (err instanceof ToolFailure) this.ui.error(err);
|
|
1182
|
+
else throw err;
|
|
1183
|
+
}
|
|
1184
|
+
}
|
|
1185
|
+
|
|
919
1186
|
reportResult(call, out) {
|
|
1187
|
+
// A build or type check that just passed already verified everything
|
|
1188
|
+
// changed so far; the automatic check at the end would only repeat it.
|
|
1189
|
+
if (call.name === 'run_command' && out.exitCode === 0 &&
|
|
1190
|
+
/(?:next build|npm run build|pnpm (?:run )?build|tsc)/.test(call.args?.command ?? '')) {
|
|
1191
|
+
this.sinceCheck?.clear();
|
|
1192
|
+
}
|
|
920
1193
|
if (!QUIET.has(call.name)) this.ui.toolResult(out.summary);
|
|
921
1194
|
if (out.diff?.length) this.ui.diff(out.diff);
|
|
922
1195
|
if (out.output?.length) this.ui.commandOutput(out.output);
|
|
923
|
-
this.push({ role: 'tool', toolCallId: call.id, name: call.name, content: out.content });
|
|
1196
|
+
this.push({ role: 'tool', toolCallId: call.id, name: call.name, content: out.content + this.stuckNote(call, { out }) });
|
|
924
1197
|
}
|
|
925
1198
|
|
|
926
1199
|
/** Show a tool failure, hand it to the model, and say if it was bad arguments. */
|
|
@@ -934,7 +1207,7 @@ export class Agent {
|
|
|
934
1207
|
role: 'tool',
|
|
935
1208
|
toolCallId: call.id,
|
|
936
1209
|
name: call.name,
|
|
937
|
-
content: err.forModel(),
|
|
1210
|
+
content: err.forModel() + (err instanceof Declined ? '' : this.stuckNote(call, { err })),
|
|
938
1211
|
});
|
|
939
1212
|
return err.kind === 'bad_args';
|
|
940
1213
|
}
|
|
@@ -996,7 +1269,11 @@ export class Agent {
|
|
|
996
1269
|
|
|
997
1270
|
/** Run a call and settle to { out } or { err } — never throws. */
|
|
998
1271
|
execute(call) {
|
|
999
|
-
|
|
1272
|
+
const started = Date.now();
|
|
1273
|
+
return this.dispatch(call).then(
|
|
1274
|
+
(out) => { trace({ kind: 'tool', name: call.name, ms: Date.now() - started }); countTool(this.stats, call, out); return { out }; },
|
|
1275
|
+
(err) => { trace({ kind: 'tool', name: call.name, ms: Date.now() - started, err: err?.kind }); countTool(this.stats, call, null, err); return { err }; }
|
|
1276
|
+
);
|
|
1000
1277
|
}
|
|
1001
1278
|
|
|
1002
1279
|
updatePlan(items) {
|
|
@@ -1082,11 +1359,12 @@ export class Agent {
|
|
|
1082
1359
|
if (this.abort?.signal.aborted) break;
|
|
1083
1360
|
|
|
1084
1361
|
let reply;
|
|
1362
|
+
const asked = Date.now();
|
|
1085
1363
|
try {
|
|
1086
1364
|
reply = await ask(messages, available, { signal: this.abort?.signal, model: workerModel });
|
|
1087
1365
|
} catch (err) {
|
|
1088
1366
|
// Same rule as the lead: a busy model is swapped, not a reason to stop.
|
|
1089
|
-
if (
|
|
1367
|
+
if (passing(err) && failovers < 6 && !this.abort?.signal.aborted) {
|
|
1090
1368
|
failovers++;
|
|
1091
1369
|
let next = fallbackFor(workerModel, tried);
|
|
1092
1370
|
if (!next) { tried.clear(); await wait(20_000); next = fallbackFor(workerModel, tried) ?? workerModel; }
|
|
@@ -1099,6 +1377,11 @@ export class Agent {
|
|
|
1099
1377
|
throw err;
|
|
1100
1378
|
}
|
|
1101
1379
|
this.record(reply.usage);
|
|
1380
|
+
trace({
|
|
1381
|
+
kind: 'model', who: name, model: workerModel, ms: Date.now() - asked,
|
|
1382
|
+
in: reply.usage.promptTokens, out: reply.usage.outputTokens,
|
|
1383
|
+
calls: reply.toolCalls.map((c) => c.name),
|
|
1384
|
+
});
|
|
1102
1385
|
|
|
1103
1386
|
if (!reply.toolCalls.length) {
|
|
1104
1387
|
this.ui.toolResult(`${name} finished`);
|
|
@@ -1290,6 +1573,9 @@ export class Agent {
|
|
|
1290
1573
|
case '/clear': this.showHeader(); return;
|
|
1291
1574
|
case '/search': return this.cmdSearch(arg);
|
|
1292
1575
|
case '/copy': return this.cmdCopy();
|
|
1576
|
+
case '/stats': return this.cmdStats();
|
|
1577
|
+
case '/doctor': return this.cmdDoctor();
|
|
1578
|
+
case '/deploy': return this.cmdDeploy(arg);
|
|
1293
1579
|
case '/exit':
|
|
1294
1580
|
case '/quit': return 'exit';
|
|
1295
1581
|
|
|
@@ -1302,6 +1588,9 @@ export class Agent {
|
|
|
1302
1588
|
cmdHelp() {
|
|
1303
1589
|
const rows = [
|
|
1304
1590
|
['/help', 'this list'],
|
|
1591
|
+
['/stats', 'time, steps and tokens this session'],
|
|
1592
|
+
['/doctor', 'check that everything ucode needs is working'],
|
|
1593
|
+
['/deploy [folder]', 'put the app online and get its link'],
|
|
1305
1594
|
['/model', 'show the models and switch between them'],
|
|
1306
1595
|
['/resume', 'pick up an earlier conversation'],
|
|
1307
1596
|
['/new', 'save this one and start fresh'],
|
package/src/core/provider.js
CHANGED
|
@@ -22,6 +22,7 @@ import { homedir } from 'node:os';
|
|
|
22
22
|
import { fileURLToPath } from 'node:url';
|
|
23
23
|
import dotenv from 'dotenv';
|
|
24
24
|
import OpenAI from 'openai';
|
|
25
|
+
import { jsonrepair } from 'jsonrepair';
|
|
25
26
|
import { Failure } from './failure.js';
|
|
26
27
|
|
|
27
28
|
const HERE = dirname(fileURLToPath(import.meta.url));
|
|
@@ -395,7 +396,14 @@ export function explain(err, id) {
|
|
|
395
396
|
(Number.isFinite(asNumber) && asNumber > 0 ? asNumber : null) ??
|
|
396
397
|
seconds(/try again in ([\dhms.]+)/i.exec(detail)?.[1]) ??
|
|
397
398
|
null;
|
|
398
|
-
|
|
399
|
+
// The daily cap reads "free-models-per-day-high-balance", with hyphens, and
|
|
400
|
+
// names its source in the metadata. It is one cap across every free model,
|
|
401
|
+
// so it is reported at once rather than waited on model after model.
|
|
402
|
+
const meta = body?.error?.metadata ?? {};
|
|
403
|
+
const daily = /per[- ]day|RPD|TPD|daily/i.test(`${detail} ${meta.limit_source ?? ''}`);
|
|
404
|
+
const resetMs = Number(meta.headers?.['X-RateLimit-Reset'] ?? err?.headers?.get?.('x-ratelimit-reset'));
|
|
405
|
+
const resetAt = daily && Number.isFinite(resetMs) && resetMs > Date.now() ? new Date(resetMs) : null;
|
|
406
|
+
const cap = Number(meta.headers?.['X-RateLimit-Limit']) || null;
|
|
399
407
|
const wait = Number.isFinite(retryAfter) && retryAfter
|
|
400
408
|
? (retryAfter >= 60 ? `${Math.ceil(retryAfter / 60)} min` : `${Math.ceil(retryAfter)}s`)
|
|
401
409
|
: null;
|
|
@@ -404,14 +412,14 @@ export function explain(err, id) {
|
|
|
404
412
|
kind: 'rate_limit',
|
|
405
413
|
attempted,
|
|
406
414
|
failed: daily
|
|
407
|
-
? `
|
|
415
|
+
? `This key's free daily limit${cap ? ` of ${cap} requests` : ''} is used up. It covers every free model, so switching will not help.`
|
|
408
416
|
: `Too many requests for ${modelName(id)} just now${wait ? ` — clear in ${wait}` : ''}.`,
|
|
409
417
|
fix: daily
|
|
410
|
-
?
|
|
411
|
-
'your account
|
|
418
|
+
? `It resets ${resetAt ? `at ${resetAt.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })}` : 'once a day'}. ` +
|
|
419
|
+
'Adding credit to your account raises the limit.'
|
|
412
420
|
: 'ucode waits these out on its own. Free endpoints are shared, so it usually ' +
|
|
413
421
|
'clears in seconds; /model moves to a quieter one.',
|
|
414
|
-
detail: { retryAfter, daily },
|
|
422
|
+
detail: { retryAfter, daily, resetAt: resetAt?.getTime() ?? null },
|
|
415
423
|
cause: err,
|
|
416
424
|
});
|
|
417
425
|
}
|
|
@@ -554,7 +562,7 @@ const pause = (ms) => new Promise((r) => setTimeout(r, ms));
|
|
|
554
562
|
* @param {Array} messages neutral messages
|
|
555
563
|
* @param {Array} [tools] neutral tool definitions
|
|
556
564
|
* @param {object} [opts] { model, temperature, signal, maxOutputTokens,
|
|
557
|
-
* onText, onThinking, onWait }
|
|
565
|
+
* reasoning, attempts, onText, onThinking, onWait }
|
|
558
566
|
*/
|
|
559
567
|
export async function ask(messages, tools = [], opts = {}) {
|
|
560
568
|
const id = opts.model || current;
|
|
@@ -578,8 +586,11 @@ export async function ask(messages, tools = [], opts = {}) {
|
|
|
578
586
|
}
|
|
579
587
|
if (opts.temperature !== undefined) request.temperature = opts.temperature;
|
|
580
588
|
if (opts.maxOutputTokens) request.max_tokens = opts.maxOutputTokens;
|
|
589
|
+
if (opts.reasoning) request.reasoning = opts.reasoning;
|
|
581
590
|
|
|
582
|
-
|
|
591
|
+
// A side call (the design review) passes fewer: it is better skipped than
|
|
592
|
+
// waited on through a string of rate-limit pauses.
|
|
593
|
+
const attempts = opts.attempts ?? 4;
|
|
583
594
|
let problem;
|
|
584
595
|
|
|
585
596
|
// Text already on screen cannot be unprinted, so a stream is only safe to
|
|
@@ -710,7 +721,7 @@ async function streamed(request, opts, id) {
|
|
|
710
721
|
|
|
711
722
|
const toolCalls = [];
|
|
712
723
|
for (const [index, slot] of partial) {
|
|
713
|
-
toolCalls.push(readCall({ id: slot.id || `call_${index}`, name: slot.name, raw: slot.args }));
|
|
724
|
+
toolCalls.push(readCall({ id: slot.id || `call_${index}`, name: slot.name, raw: slot.args, cutOff: finishReason === 'length' }));
|
|
714
725
|
}
|
|
715
726
|
|
|
716
727
|
return {
|
|
@@ -727,6 +738,56 @@ async function streamed(request, opts, id) {
|
|
|
727
738
|
};
|
|
728
739
|
}
|
|
729
740
|
|
|
741
|
+
/**
|
|
742
|
+
* Recover the files from a file-write call whose JSON will not parse.
|
|
743
|
+
*
|
|
744
|
+
* The usual cause is a double quote inside the code that the model forgot to
|
|
745
|
+
* escape — `className="flex"` — which ends the JSON string early. No general
|
|
746
|
+
* repair can know which quote was meant, but a file write has a fixed shape:
|
|
747
|
+
* "path", then "content", then either the next file or the end. Splitting on
|
|
748
|
+
* that shape and escaping the stray quotes gets every file back.
|
|
749
|
+
*/
|
|
750
|
+
export function salvageWrites(text) {
|
|
751
|
+
const heads = [...text.matchAll(/"path"\s*:\s*"((?:[^"\\]|\\.)*)"\s*,\s*"content"\s*:\s*"/g)];
|
|
752
|
+
if (!heads.length) return null;
|
|
753
|
+
|
|
754
|
+
const files = [];
|
|
755
|
+
for (let i = 0; i < heads.length; i++) {
|
|
756
|
+
const from = heads[i].index + heads[i][0].length;
|
|
757
|
+
const to = i + 1 < heads.length ? heads[i + 1].index : text.length;
|
|
758
|
+
// The string ends at the last quote that is followed by nothing but JSON
|
|
759
|
+
// punctuation — `"}, {`, or `"}}, {` when the model added a brace, or `"}]}`.
|
|
760
|
+
const body = text.slice(from, to).replace(/"[\s,{}[\]]*$/, '');
|
|
761
|
+
const content = unescapeLoose(body);
|
|
762
|
+
const pathValue = unescapeLoose(heads[i][1]);
|
|
763
|
+
if (!pathValue || !content) return null; // not the shape we thought — leave it an honest error
|
|
764
|
+
files.push({ path: pathValue, content });
|
|
765
|
+
}
|
|
766
|
+
return files.length ? files : null;
|
|
767
|
+
}
|
|
768
|
+
|
|
769
|
+
/**
|
|
770
|
+
* Decode a JSON string body the forgiving way: the standard escapes are
|
|
771
|
+
* honoured, and everything JSON would reject — a raw line break, a tab, a stray
|
|
772
|
+
* quote, an escape JSON does not know — is kept as the character it plainly is.
|
|
773
|
+
*/
|
|
774
|
+
function unescapeLoose(s) {
|
|
775
|
+
const simple = { n: '\n', t: '\t', r: '\r', b: '\b', f: '\f', '"': '"', '\\': '\\', '/': '/' };
|
|
776
|
+
let out = '';
|
|
777
|
+
for (let i = 0; i < s.length; i++) {
|
|
778
|
+
const c = s[i];
|
|
779
|
+
if (c !== '\\' || i === s.length - 1) { out += c; continue; }
|
|
780
|
+
const next = s[++i];
|
|
781
|
+
if (next === 'u' && /^[0-9a-fA-F]{4}$/.test(s.slice(i + 1, i + 5))) {
|
|
782
|
+
out += String.fromCharCode(parseInt(s.slice(i + 1, i + 5), 16));
|
|
783
|
+
i += 4;
|
|
784
|
+
} else {
|
|
785
|
+
out += simple[next] ?? next;
|
|
786
|
+
}
|
|
787
|
+
}
|
|
788
|
+
return out;
|
|
789
|
+
}
|
|
790
|
+
|
|
730
791
|
/**
|
|
731
792
|
* Parse one tool call's arguments.
|
|
732
793
|
*
|
|
@@ -734,7 +795,7 @@ async function streamed(request, opts, id) {
|
|
|
734
795
|
* back to the model, which usually fixes its own JSON on the next step —
|
|
735
796
|
* cheaper than failing the whole turn over a stray comma.
|
|
736
797
|
*/
|
|
737
|
-
function readCall({ id, name, raw }) {
|
|
798
|
+
export function readCall({ id, name, raw, cutOff = false }) {
|
|
738
799
|
const call = { id, name, args: {} };
|
|
739
800
|
const text = String(raw ?? '').trim();
|
|
740
801
|
if (!text) return call;
|
|
@@ -743,6 +804,28 @@ function readCall({ id, name, raw }) {
|
|
|
743
804
|
if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) call.args = parsed;
|
|
744
805
|
else call.parseError = `arguments must be a JSON object, got ${Array.isArray(parsed) ? 'array' : typeof parsed}`;
|
|
745
806
|
} catch (err) {
|
|
807
|
+
// A missing comma or a stray control character in a 3,000-token
|
|
808
|
+
// batch_write used to throw the whole step away — half a minute of output
|
|
809
|
+
// discarded over one character. Repair the usual slips instead. Never when
|
|
810
|
+
// the reply was cut off at the output limit, though: repairing that would
|
|
811
|
+
// close the string and quietly write half a file.
|
|
812
|
+
if (!cutOff) {
|
|
813
|
+
try {
|
|
814
|
+
const fixed = JSON.parse(jsonrepair(text));
|
|
815
|
+
if (fixed && typeof fixed === 'object' && !Array.isArray(fixed)) {
|
|
816
|
+
call.args = fixed;
|
|
817
|
+
call.repaired = true;
|
|
818
|
+
return call;
|
|
819
|
+
}
|
|
820
|
+
} catch { /* beyond general repair — try the file-write shape next */ }
|
|
821
|
+
|
|
822
|
+
const salvaged = (name === 'write_file' || name === 'batch_write') ? salvageWrites(text) : null;
|
|
823
|
+
if (salvaged) {
|
|
824
|
+
call.args = name === 'write_file' ? salvaged[0] : { files: salvaged };
|
|
825
|
+
call.repaired = true;
|
|
826
|
+
return call;
|
|
827
|
+
}
|
|
828
|
+
}
|
|
746
829
|
call.parseError = `${err.message} — the raw arguments were: ${text.slice(0, 300)}`;
|
|
747
830
|
}
|
|
748
831
|
return call;
|
|
@@ -753,7 +836,7 @@ function normalize(data, id) {
|
|
|
753
836
|
const message = choice?.message ?? {};
|
|
754
837
|
|
|
755
838
|
const toolCalls = (message.tool_calls ?? []).map((c) =>
|
|
756
|
-
readCall({ id: c.id, name: c.function?.name, raw: c.function?.arguments })
|
|
839
|
+
readCall({ id: c.id, name: c.function?.name, raw: c.function?.arguments, cutOff: choice?.finish_reason === 'length' })
|
|
757
840
|
);
|
|
758
841
|
|
|
759
842
|
const u = data?.usage ?? {};
|