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
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,9 +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',
|
|
368
|
+
' copies a starter that already builds and installs it in the background.',
|
|
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',
|
|
244
372
|
' to one file, edit_files for a change that spans several files.',
|
|
245
373
|
'- For work with three or more steps, keep a short plan with update_plan - at most',
|
|
246
374
|
' six items of a few words - and tick items off as they finish. Skip it for small jobs.',
|
|
@@ -251,9 +379,12 @@ function systemPrompt({ cwd, skills, mode, check, map, memory }) {
|
|
|
251
379
|
' writing files. Running the install yourself afterwards just waits for that one.',
|
|
252
380
|
'- When you finish, ucode type-checks what you changed and hands you the errors, so',
|
|
253
381
|
' there is no need to run tsc yourself.',
|
|
254
|
-
'- Once the dev server is ready, run look_at_app on the pages you built
|
|
255
|
-
'
|
|
256
|
-
' 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.',
|
|
257
388
|
'- Nothing you run has a keyboard. Pass the non-interactive flag to anything that',
|
|
258
389
|
' would ask a question, or it fails instead of waiting: create-next-app --yes,',
|
|
259
390
|
' npx shadcn@latest init -d -y, npx shadcn@latest add <names> -y, npm init -y.',
|
|
@@ -261,6 +392,17 @@ function systemPrompt({ cwd, skills, mode, check, map, memory }) {
|
|
|
261
392
|
' URL once the server says it is ready. Do not start one twice, do not sleep while',
|
|
262
393
|
' waiting for it, and do not curl it before that result comes back.',
|
|
263
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
|
+
'',
|
|
264
406
|
'## Safety',
|
|
265
407
|
'',
|
|
266
408
|
'- run_command runs without asking. That is trust rather than licence: never run',
|
|
@@ -324,6 +466,7 @@ export class Agent {
|
|
|
324
466
|
// CI and `echo ... | ucode` get the line-based interface instead.
|
|
325
467
|
this.full = Boolean(process.stdout.isTTY && process.stdin.isTTY);
|
|
326
468
|
this.ui = this.full ? new Screen({ cwd }) : new Plain({ cwd });
|
|
469
|
+
this.stats = newStats();
|
|
327
470
|
this.skills = [];
|
|
328
471
|
this.session = newSession(cwd, model());
|
|
329
472
|
this.working = [];
|
|
@@ -571,6 +714,7 @@ export class Agent {
|
|
|
571
714
|
}
|
|
572
715
|
|
|
573
716
|
async turn(input) {
|
|
717
|
+
forgetReviews(); // a new request: its apps get a fresh design review
|
|
574
718
|
const images = await this.attachImages(input);
|
|
575
719
|
this.push(images.length
|
|
576
720
|
? { role: 'user', content: input, images }
|
|
@@ -599,17 +743,36 @@ export class Agent {
|
|
|
599
743
|
this.busy = true;
|
|
600
744
|
this.abort = new AbortController();
|
|
601
745
|
|
|
746
|
+
const turnStarted = Date.now();
|
|
747
|
+
let finished = false;
|
|
748
|
+
this.ui.turnStart?.();
|
|
602
749
|
try {
|
|
603
|
-
|
|
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;
|
|
604
762
|
} catch (err) {
|
|
605
763
|
if (err?.kind === 'aborted' || this.abort.signal.aborted) this.ui.write(dim(' turn cancelled'));
|
|
606
764
|
else throw err;
|
|
607
765
|
} finally {
|
|
766
|
+
trace({ kind: 'turn', ms: Date.now() - turnStarted });
|
|
767
|
+
this.stats.workMs += Date.now() - turnStarted;
|
|
768
|
+
this.stats.turns++;
|
|
608
769
|
this.busy = false;
|
|
609
770
|
this.abort = null;
|
|
610
771
|
this.ui.stopSpinner();
|
|
772
|
+
this.ui.turnEnd?.({ ok: finished });
|
|
611
773
|
await this.persist();
|
|
612
774
|
if (this.full) this.showHeader({ clear: false });
|
|
775
|
+
if (finished) this.openWhenReady(turnStarted);
|
|
613
776
|
}
|
|
614
777
|
}
|
|
615
778
|
|
|
@@ -631,6 +794,7 @@ export class Agent {
|
|
|
631
794
|
this.failovers = 0;
|
|
632
795
|
this.tried = new Set([model()]);
|
|
633
796
|
|
|
797
|
+
this.stuck = new StuckWatch();
|
|
634
798
|
this.touched = new Set();
|
|
635
799
|
this.sinceCheck = new Set();
|
|
636
800
|
this.ranSomething = false;
|
|
@@ -669,6 +833,7 @@ export class Agent {
|
|
|
669
833
|
};
|
|
670
834
|
}
|
|
671
835
|
|
|
836
|
+
var asked = Date.now();
|
|
672
837
|
reply = await ask(
|
|
673
838
|
[
|
|
674
839
|
{
|
|
@@ -682,7 +847,7 @@ export class Agent {
|
|
|
682
847
|
memory: this.memory,
|
|
683
848
|
}),
|
|
684
849
|
},
|
|
685
|
-
...this.working,
|
|
850
|
+
...lean(this.working),
|
|
686
851
|
],
|
|
687
852
|
available,
|
|
688
853
|
opts
|
|
@@ -710,7 +875,7 @@ export class Agent {
|
|
|
710
875
|
|
|
711
876
|
// Busy, slow or down: move to the next model and carry on, rather
|
|
712
877
|
// than ending a half-built app with an error.
|
|
713
|
-
if (
|
|
878
|
+
if (passing(err) && !this.abort.signal.aborted && (await this.failover(err))) continue;
|
|
714
879
|
throw err;
|
|
715
880
|
}
|
|
716
881
|
|
|
@@ -719,6 +884,15 @@ export class Agent {
|
|
|
719
884
|
this.ui.thinkingEnd();
|
|
720
885
|
this.ui.stopSpinner();
|
|
721
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
|
+
});
|
|
722
896
|
|
|
723
897
|
// Streamed text is already on screen; turn it into rendered markdown.
|
|
724
898
|
// Text that turns out to be narration ahead of a tool call folds into a
|
|
@@ -914,11 +1088,112 @@ export class Agent {
|
|
|
914
1088
|
return badArgs;
|
|
915
1089
|
}
|
|
916
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
|
+
|
|
917
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
|
+
}
|
|
918
1193
|
if (!QUIET.has(call.name)) this.ui.toolResult(out.summary);
|
|
919
1194
|
if (out.diff?.length) this.ui.diff(out.diff);
|
|
920
1195
|
if (out.output?.length) this.ui.commandOutput(out.output);
|
|
921
|
-
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 }) });
|
|
922
1197
|
}
|
|
923
1198
|
|
|
924
1199
|
/** Show a tool failure, hand it to the model, and say if it was bad arguments. */
|
|
@@ -932,7 +1207,7 @@ export class Agent {
|
|
|
932
1207
|
role: 'tool',
|
|
933
1208
|
toolCallId: call.id,
|
|
934
1209
|
name: call.name,
|
|
935
|
-
content: err.forModel(),
|
|
1210
|
+
content: err.forModel() + (err instanceof Declined ? '' : this.stuckNote(call, { err })),
|
|
936
1211
|
});
|
|
937
1212
|
return err.kind === 'bad_args';
|
|
938
1213
|
}
|
|
@@ -994,7 +1269,11 @@ export class Agent {
|
|
|
994
1269
|
|
|
995
1270
|
/** Run a call and settle to { out } or { err } — never throws. */
|
|
996
1271
|
execute(call) {
|
|
997
|
-
|
|
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
|
+
);
|
|
998
1277
|
}
|
|
999
1278
|
|
|
1000
1279
|
updatePlan(items) {
|
|
@@ -1080,11 +1359,12 @@ export class Agent {
|
|
|
1080
1359
|
if (this.abort?.signal.aborted) break;
|
|
1081
1360
|
|
|
1082
1361
|
let reply;
|
|
1362
|
+
const asked = Date.now();
|
|
1083
1363
|
try {
|
|
1084
1364
|
reply = await ask(messages, available, { signal: this.abort?.signal, model: workerModel });
|
|
1085
1365
|
} catch (err) {
|
|
1086
1366
|
// Same rule as the lead: a busy model is swapped, not a reason to stop.
|
|
1087
|
-
if (
|
|
1367
|
+
if (passing(err) && failovers < 6 && !this.abort?.signal.aborted) {
|
|
1088
1368
|
failovers++;
|
|
1089
1369
|
let next = fallbackFor(workerModel, tried);
|
|
1090
1370
|
if (!next) { tried.clear(); await wait(20_000); next = fallbackFor(workerModel, tried) ?? workerModel; }
|
|
@@ -1097,6 +1377,11 @@ export class Agent {
|
|
|
1097
1377
|
throw err;
|
|
1098
1378
|
}
|
|
1099
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
|
+
});
|
|
1100
1385
|
|
|
1101
1386
|
if (!reply.toolCalls.length) {
|
|
1102
1387
|
this.ui.toolResult(`${name} finished`);
|
|
@@ -1288,6 +1573,9 @@ export class Agent {
|
|
|
1288
1573
|
case '/clear': this.showHeader(); return;
|
|
1289
1574
|
case '/search': return this.cmdSearch(arg);
|
|
1290
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);
|
|
1291
1579
|
case '/exit':
|
|
1292
1580
|
case '/quit': return 'exit';
|
|
1293
1581
|
|
|
@@ -1300,6 +1588,9 @@ export class Agent {
|
|
|
1300
1588
|
cmdHelp() {
|
|
1301
1589
|
const rows = [
|
|
1302
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'],
|
|
1303
1594
|
['/model', 'show the models and switch between them'],
|
|
1304
1595
|
['/resume', 'pick up an earlier conversation'],
|
|
1305
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 ?? {};
|