ucode-agent 1.19.0 → 1.20.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/src/core/loop.js CHANGED
@@ -1,2277 +1,2289 @@
1
- /**
2
- * loop.js — the agent itself.
3
- *
4
- * One turn, end to end: take a line, fold the conversation if it has grown too
5
- * big, ask the model, and if it asked for tools, run them and ask again. Repeat
6
- * until it answers with prose instead of a tool call.
7
- *
8
- * Two histories are kept, deliberately:
9
- * session.messages the complete record, written to disk after every step
10
- * working what is actually sent, which may have its older turns
11
- * folded into a summary once the window gets tight
12
- */
13
-
14
- import path from 'node:path';
15
- import os from 'node:os';
16
- import { appendFileSync } from 'node:fs';
17
- import { readFile, access, mkdir } from 'node:fs/promises';
18
- import { testRunnerFor, relatedCommand, summariseFailures } from './tests.js';
19
- import { LogWatch } from './livelog.js';
20
- import { runningServers } from '../tools/shell.js';
21
- import { spawn } from 'node:child_process';
22
-
23
- import {
24
- ask, model, setModel, modelName, modelList, contextLimit, rateLimits,
25
- MODELS, DEFAULT_MODEL, PROVIDER, fallbackFor,
26
- } from './provider.js';
27
- import {
28
- tools, runTool, describe, setRoot, setConfirm, PARALLEL_SAFE, WRITES, FILE_WRITES,
29
- } from '../tools/index.js';
30
- import { projectMap, loadMemory, remember, MEMORY_FILE } from './context.js';
31
- import { autoUpdate } from './updater.js';
32
- import { closeBrowser, forgetReviews } from '../tools/browser.js';
33
- import {
34
- newSession, save, load, list, remove, removeAll, titleFrom,
35
- } from './history.js';
36
- import { fold, usage, tooBig, SUMMARY_PROMPT, forSummary } from './window.js';
37
- import { loadSkills, catalogue, findSkill, skillMessage, autoLoadFor } from './skills.js';
38
- import { Screen, isLabel } from '../ui/screen.js';
39
- import { Plain } from '../ui/plain.js';
40
- import { theme, blue, sky, dim, formatTokens, relativeTime, shortenPath, clip } from '../ui/theme.js';
41
- import { Failure, ToolFailure, Declined } from './failure.js';
42
- import { StuckWatch, eventFor, describeHit } from './stuck.js';
43
- import { serversReadySince } from '../tools/shell.js';
44
- import { formatDuration } from '../ui/activity.js';
45
- import { runDoctor } from './doctor.js';
46
- import { deploy } from '../tools/deploy.js';
47
-
48
- /**
49
- * Tool calls allowed in one turn.
50
- *
51
- * Scaffolding an app is dozens of writes before anything can even be run, so a
52
- * small ceiling stops a real job halfway through which from the outside is
53
- * indistinguishable from the agent giving up for no reason. The runaway-loop
54
- * protection this exists for still works at 250; a loop burns through that
55
- * just as visibly, only later.
56
- */
57
- const MAX_STEPS = Number(process.env.UCODE_MAX_STEPS) || 250;
58
- const MAX_ARG_RETRIES = 2;
59
-
60
- /**
61
- * How many times a reply cut off at the output limit is asked to carry on.
62
- * Three covers any answer a terminal should be printing; past that the model
63
- * is rambling and stopping is the kinder outcome.
64
- */
65
- const MAX_CONTINUATIONS = 3;
66
-
67
- /** Read-only tools whose result line adds nothing — the user saw the output. */
68
- /**
69
- * How many rows a diff adds and removes.
70
- *
71
- * The rows come through as "+12| text" and "-12| text", with a "~" heading
72
- * for each file in a multi-file write and an undecorated note counting what
73
- * was elided. Only the signs are counted.
74
- */
75
- export function countDiff(rows = []) {
76
- let added = 0;
77
- let removed = 0;
78
- for (const row of rows) {
79
- const line = String(row ?? '');
80
- if (line.startsWith('~')) continue;
81
- // "… 218 more removed" / "… 508 more added" stand for rows not shown.
82
- const more = /^\s*[….]+\s*(\d+)\s+more\s+(added|removed)/.exec(line);
83
- if (more) {
84
- if (more[2] === 'added') added += Number(more[1]);
85
- else removed += Number(more[1]);
86
- continue;
87
- }
88
- if (line.startsWith('+')) added++;
89
- else if (line.startsWith('-')) removed++;
90
- }
91
- return { added, removed };
92
- }
93
-
94
- const QUIET = new Set(['read_file', 'read_files', 'list_dir', 'glob', 'grep', 'web_search', 'update_plan']);
95
-
96
- /** Tools that draw their own line, so they get no "● Doing X" line of their own. */
97
- const SILENT = new Set(['update_plan']);
98
-
99
- /** How many rounds of "the type check found errors, fix them" one turn may take. */
100
- const MAX_FIX_ROUNDS = 3;
101
-
102
- /** Files worth checking after they change. */
103
- const CHECKABLE = /\.(?:[cm]?[jt]sx?|py)$/i;
104
-
105
- /** Where TypeScript keeps what it learned, so the next check is a quick one. */
106
- export const TSBUILDINFO = 'node_modules/.cache/ucode/types.tsbuildinfo';
107
-
108
- /** TypeScript before 4.0 rejects --incremental together with --noEmit. */
109
- const NO_INCREMENTAL = /TS5074|TS6304|'--incremental'/;
110
-
111
- /**
112
- * The type check to run. Incremental by default: the first check pays the
113
- * full cost and writes a build info file, and every one after it reads that
114
- * and reports in about a second.
115
- */
116
- export function typeCheckCommand(incremental = true) {
117
- const base = 'npx --no-install tsc --noEmit --pretty false';
118
- return incremental ? `${base} --incremental --tsBuildInfoFile ${TSBUILDINFO}` : base;
119
- }
120
-
121
- /**
122
- * Failures that are the provider's and not the model's: busy, slow, down, or
123
- * unreachable. None of them should end a build the turn moves to another
124
- * model and carries on from exactly where it was.
125
- */
126
- const TRANSIENT = new Set(['rate_limit', 'timeout', 'server', 'network', 'no_content']);
127
- /** Will another model, or a little patience, get past this? Not the daily cap: it covers them all. */
128
- const passing = (err) => TRANSIENT.has(err.kind) && !err.detail?.daily;
129
- const MAX_FAILOVERS = 8;
130
- const COOLDOWN = 5 * 60_000;
131
-
132
- const wait = (ms) => new Promise((r) => setTimeout(r, ms));
133
-
134
- /**
135
- * UCODE_TRACE=1 writes one JSON line per model call and per tool to
136
- * ~/.ucode/trace.jsonl (or to the path UCODE_TRACE names): how long it took,
137
- * tokens in and out, what was called. It is how "it feels slow" becomes a
138
- * number with a cause attached.
139
- */
140
- const TRACE_FILE = process.env.UCODE_TRACE
141
- ? (process.env.UCODE_TRACE === '1' ? path.join(os.homedir(), '.ucode', 'trace.jsonl') : process.env.UCODE_TRACE)
142
- : null;
143
-
144
- /** Tool results worth re-sending in full only while they are recent. */
145
- const THIN_RESULTS = new Set([
146
- 'read_file', 'read_files', 'grep', 'glob', 'list_dir', 'run_command', 'run_commands',
147
- 'look_at_app', 'web_search', 'edit_file', 'multi_edit', 'edit_files',
148
- ]);
149
-
150
- /** Replace long strings in old tool arguments with a note of their size. */
151
- function thinArgs(value) {
152
- if (typeof value === 'string') {
153
- return value.length > 400
154
- ? `[${value.length} characters, already applied — read the file if you need its current text]`
155
- : value;
156
- }
157
- if (Array.isArray(value)) return value.map(thinArgs);
158
- if (value && typeof value === 'object') {
159
- return Object.fromEntries(Object.entries(value).map(([k, v]) => [k, thinArgs(v)]));
160
- }
161
- return value;
162
- }
163
-
164
- /**
165
- * The conversation as sent, with bulky history thinned.
166
- *
167
- * Every file the model writes travels inside its own tool call, so a
168
- * thirty-file app is re-sent in full on every later step tens of thousands
169
- * of tokens the provider has to read before it can answer, growing with
170
- * each file. Beyond the last few steps that text is replaced by a note of its
171
- * size; the files are on disk, and the model re-reads one when it needs it.
172
- * Call ids and results stay paired, and the saved session keeps everything.
173
- */
174
- /** Tools whose result is the contents of one named thing, so re-reads repeat. */
175
- const RE_READ = new Set(['read_file', 'read_files', 'list_dir', 'grep', 'glob']);
176
-
177
- /** The thing a call was about, when two calls for it return the same text. */
178
- function subjectOf(call) {
179
- if (!RE_READ.has(call?.name)) return null;
180
- const a = call.args ?? {};
181
- const what = a.path ?? (Array.isArray(a.paths) ? a.paths.join('|') : null) ?? a.pattern;
182
- if (typeof what !== 'string' || !what) return null;
183
- // grep and glob also depend on what was asked, not only where.
184
- const extra = call.name === 'grep' || call.name === 'glob' ? `|${a.pattern ?? ''}|${a.glob ?? ''}` : '';
185
- return `${call.name}:${what}${extra}`;
186
- }
187
-
188
- /**
189
- * Send each file once.
190
- *
191
- * Reading a file four times over a long task puts four copies of it in the
192
- * conversation, and the first three are worth nothing: the model reads the
193
- * newest and the older ones only cost tokens and invite it to answer from a
194
- * stale copy. Every superseded copy becomes a line saying where the current
195
- * one is. The newest is always kept whole, so nothing the model needs is
196
- * taken away, and the saved session still holds the lot.
197
- */
198
- export function dedupe(messages) {
199
- const subject = new Map(); // toolCallId -> subject
200
- for (const m of messages) {
201
- if (m.role !== 'assistant' || !m.toolCalls?.length) continue;
202
- for (const c of m.toolCalls) {
203
- const s = subjectOf(c);
204
- if (s) subject.set(c.id, s);
205
- }
206
- }
207
- if (!subject.size) return messages;
208
-
209
- const newest = new Map(); // subject -> index of the last result for it
210
- messages.forEach((m, i) => {
211
- if (m.role !== 'tool') return;
212
- const s = subject.get(m.toolCallId);
213
- if (s) newest.set(s, i);
214
- });
215
-
216
- return messages.map((m, i) => {
217
- if (m.role !== 'tool') return m;
218
- const s = subject.get(m.toolCallId);
219
- if (!s || newest.get(s) === i) return m;
220
- // Short results are not worth a note in place of themselves.
221
- if ((m.content?.length ?? 0) < 400) return m;
222
- const what = s.slice(s.indexOf(':') + 1).split('|')[0];
223
- return {
224
- ...m,
225
- content:
226
- `[${m.content.length} characters. This was read again later, and the current ` +
227
- `contents of ${what} are further down this conversation use those, not this.]`,
228
- };
229
- });
230
- }
231
-
232
- export function lean(messages, keep = 3) {
233
- let seen = 0;
234
- let cut = -1;
235
- for (let i = messages.length - 1; i >= 0; i--) {
236
- if (messages[i].role === 'assistant' && messages[i].toolCalls?.length && ++seen === keep) { cut = i; break; }
237
- }
238
- if (cut <= 0) return messages;
239
- return messages.map((m, i) => {
240
- if (i >= cut) return m;
241
- if (m.role === 'assistant' && m.toolCalls?.length) {
242
- return { ...m, toolCalls: m.toolCalls.map((c) => ({ ...c, args: thinArgs(c.args) })) };
243
- }
244
- if (m.role === 'tool' && THIN_RESULTS.has(m.name) && (m.content?.length ?? 0) > 1500) {
245
- return {
246
- ...m,
247
- content: `${m.content.slice(0, 300)}
248
- [${m.content.length} characters from an earlier step, trimmed ` +
249
- 'to keep the conversation fast run the tool again if you need this now]',
250
- };
251
- }
252
- return m;
253
- });
254
- }
255
-
256
- function trace(event) {
257
- if (!TRACE_FILE) return;
258
- try { appendFileSync(TRACE_FILE, `${JSON.stringify({ at: Date.now(), ...event })}
259
- `); } catch { /* never fatal */ }
260
- }
261
-
262
- /** What /stats reports, counted as the session goes. */
263
- function newStats() {
264
- return {
265
- started: Date.now(), workMs: 0, turns: 0, steps: 0, tokensIn: 0, tokensOut: 0,
266
- tools: {}, failed: 0, written: 0, edited: 0, commands: 0, builds: 0, stuck: 0,
267
- };
268
- }
269
-
270
- const BUILD_COMMAND = /\b(?:npm|pnpm|yarn|bun)\s+(?:run\s+)?build\b|\bnext\s+build\b|\bvite\s+build\b/;
271
-
272
- function countTool(stats, call, out, err) {
273
- stats.tools[call.name] = (stats.tools[call.name] ?? 0) + 1;
274
- if (err) { stats.failed++; return; }
275
- const a = call.args ?? {};
276
- if (call.name === 'write_file') stats.written++;
277
- if (call.name === 'batch_write') stats.written += a.files?.length ?? 0;
278
- if (call.name === 'edit_file' || call.name === 'multi_edit') stats.edited++;
279
- if (call.name === 'edit_files') stats.edited += new Set((a.edits ?? []).map((e) => e.path)).size || 1;
280
- if (call.name === 'run_command') {
281
- stats.commands++;
282
- if (BUILD_COMMAND.test(a.command ?? '')) stats.builds++;
283
- }
284
- if (call.name === 'run_commands') stats.commands += a.commands?.length ?? 0;
285
- }
286
-
287
- /** The /stats box. */
288
- export function statsLines(st, messages) {
289
- const n = (v) => Number(v).toLocaleString();
290
- const top = Object.entries(st.tools).sort((x, y) => y[1] - x[1]).slice(0, 6)
291
- .map(([k, v]) => `${k} ${v}`).join(' · ') || 'none yet';
292
- const plural = (v, w) => `${v} ${w}${v === 1 ? '' : 's'}`;
293
- const rows = [
294
- ['Session', `${formatDuration(Date.now() - st.started)} open · ${formatDuration(st.workMs)} working · ${plural(st.turns, 'request')}`],
295
- ['Model', `${n(st.steps)} steps · ${formatTokens(st.tokensIn)} in · ${formatTokens(st.tokensOut)} out`],
296
- ['Tools', top],
297
- ['Files', `${st.written} written · ${st.edited} edited`],
298
- ['Commands', `${st.commands} run · ${plural(st.builds, 'build')}${st.failed ? ` · ${plural(st.failed, 'tool call')} failed` : ''}`],
299
- ['Context', `${messages} messages${st.stuck ? ` · ${plural(st.stuck, 'loop')} caught` : ''}`],
300
- ];
301
- const w = Math.max(...rows.map(([k]) => k.length));
302
- return ['', ` ${blue('Stats')}`, ...rows.map(([k, v]) => ` ${dim(k.padEnd(w))} ${v}`), ''];
303
- }
304
-
305
- /** Parallel workers at once, and how many steps each may take. */
306
- const MAX_WORKERS = 3;
307
- const WORKER_STEPS = Number(process.env.UCODE_WORKER_STEPS) || 60;
308
-
309
- /** Workers build; they do not plan, delegate further, or load skills themselves. */
310
- const WORKER_EXCLUDED = new Set(['delegate', 'update_plan', 'load_skill']);
311
-
312
- const planTool = {
313
- name: 'update_plan',
314
- description:
315
- 'Keep a short checklist the user can see, for work with three or more steps. ' +
316
- 'Send the whole list every time: at most 6 items, a few words each, with done: true ' +
317
- 'on the finished ones. Update it as items finish. Skip it for small tasks.',
318
- parameters: {
319
- type: 'object',
320
- properties: {
321
- items: {
322
- type: 'array',
323
- description: 'The whole plan, in order. At most 6.',
324
- items: {
325
- type: 'object',
326
- properties: {
327
- text: { type: 'string', description: 'A few words: "Build the upload box".' },
328
- done: { type: 'boolean', description: 'True once it is finished.' },
329
- },
330
- required: ['text'],
331
- },
332
- },
333
- },
334
- required: ['items'],
335
- },
336
- };
337
-
338
- const delegateTool = {
339
- name: 'delegate',
340
- description:
341
- 'Build independent parts in parallel. Up to 3 workers run at once, each with the ' +
342
- 'same tools as you. Use it when the work splits cleanly into parts that touch ' +
343
- 'different files - e.g. the API route, the upload component and the results view. ' +
344
- 'A worker sees only its instructions, so make them complete: the files it owns, ' +
345
- 'what to build, the exact interfaces (props, types, request and response shapes) ' +
346
- 'it must match, and the design rules. Set up shared files (package.json, design ' +
347
- 'tokens, shared types) yourself first. You get back each worker\'s summary and ' +
348
- 'the files it changed; wire the parts together and check the whole afterwards.',
349
- parameters: {
350
- type: 'object',
351
- properties: {
352
- tasks: {
353
- type: 'array',
354
- description: 'Up to 3 independent pieces of work.',
355
- items: {
356
- type: 'object',
357
- properties: {
358
- name: { type: 'string', description: 'Two or three words: "api route", "upload ui".' },
359
- instructions: { type: 'string', description: 'Everything the worker needs to do its part completely.' },
360
- },
361
- required: ['name', 'instructions'],
362
- },
363
- },
364
- },
365
- required: ['tasks'],
366
- },
367
- };
368
-
369
- /** The instructions a parallel worker starts with. */
370
- function workerPrompt({ cwd, name, memory, skills, map }) {
371
- return [
372
- `You are a ucode worker called "${name}" - one of several building parts of the same project at the same time.`,
373
- '',
374
- `Working directory: ${cwd}`,
375
- `Platform: ${process.platform}`,
376
- '',
377
- '- Do exactly the task you were given. Touch only the files it names, or new files in',
378
- ' the area it owns - other workers are editing the rest of the project right now.',
379
- '- Read before you edit. read_files for several files, batch_write for several new',
380
- ' files, edit_files for changes across files.',
381
- '- Nothing has a keyboard: pass non-interactive flags. Do not start dev servers and do',
382
- ' not install packages unless the task says to - say what you need instead.',
383
- '- Finish what you build: real content, every state handled, no TODOs.',
384
- '- When done, reply with two or three sentences: what you built, in which files, and',
385
- ' anything the lead has to wire up.',
386
- '- The files you own may not exist yet - create them. Do not go looking for them',
387
- ' first. The project map below shows what does exist; read only the files whose',
388
- ' interfaces you must match, then start writing within two or three steps.',
389
- ...(memory ? ['', '## Project memory', '', memory] : []),
390
- ...(map ? ['', '## Project map', '', map] : []),
391
- ...(skills ? ['', '## Instructions in force', '', skills] : []),
392
- ].join('\n');
393
- }
394
-
395
- /** The files a writing tool call touches. */
396
- function pathsOf(call) {
397
- const a = call.args ?? {};
398
- if (call.name === 'batch_write' || call.name === 'edit_files') return (a.files ?? []).map((f) => f?.path).filter(Boolean);
399
- return a.path ? [a.path] : [];
400
- }
401
-
402
- const exists = (p) => access(p).then(() => true, () => false);
403
-
404
- /** Skills reach the model as one extra tool, so bodies load only when wanted. */
405
- const loadSkillTool = {
406
- name: 'load_skill',
407
- description:
408
- 'Load the full instructions for one of the skills listed in your system prompt. ' +
409
- 'Call it the moment a task matches one before planning, before writing anything ' +
410
- '— then follow what it says.',
411
- parameters: {
412
- type: 'object',
413
- properties: { name: { type: 'string', description: 'The skill name, exactly as listed.' } },
414
- required: ['name'],
415
- },
416
- };
417
-
418
- function systemPrompt({ cwd, skills, mode, check, map, memory }) {
419
- const list = catalogue(skills);
420
-
421
- return [
422
- 'You are ucode, a coding agent working directly in the user\'s terminal.',
423
- '',
424
- `Working directory: ${cwd}`,
425
- `Platform: ${process.platform}`,
426
- ...(memory ? [
427
- '',
428
- '## Project memory',
429
- '',
430
- 'Standing instructions from the user. They outrank your defaults.',
431
- '',
432
- memory,
433
- ] : []),
434
- '',
435
- '## How to work',
436
- '',
437
- 'Before building anything, turn the request into a list of what it must do — every',
438
- 'feature named, and the ones any user would expect whether or not they were named',
439
- '(an empty state, an error state, the keyboard doing the obvious thing, working on',
440
- 'a phone). Keep it as the plan. Build against it, then go through it one item at a',
441
- 'time before you say a word about being finished. Most of what gets missed was',
442
- 'never written down.',
443
- '',
444
- 'Do not put code in your reply. Not a snippet, not "here is the key part", not a',
445
- 'summary of the file. It is already in the file and the user can open it; pasting',
446
- 'it again buries the one or two sentences that actually matter. Say what it does',
447
- 'and what to try.',
448
- '',
449
- 'Do not claim it is done while anything is still running or unchecked. "I have',
450
- 'built it" said before the build finishes is worse than saying nothing: the user',
451
- 'believes you, looks, and finds it broken. Finish, check, then say so and if',
452
- 'something is incomplete, say which part and why.',
453
- '',
454
- 'BE FAST. Every tool call is a round trip, and round trips are nearly all of the',
455
- 'time a build takes. So: write a whole app in ONE batch_write rather than a',
456
- 'write_file per file. Read every file you need in ONE read_files. Never read a',
457
- 'file you just wrote, and never read one back after edit_file the result',
458
- 'already contains it. Do not re-check work the checks have already reported on.',
459
- 'Fast is not sloppy: it is the same work with the waiting taken out.',
460
- '',
461
- 'SPEAK AS "I", NEVER "WE". You are doing this, not a committee: "I will build',
462
- 'Tide as a single HTML file", not "we have created the file".',
463
- '',
464
- 'Say three or four things over a whole build, not one per step:',
465
- ' - Open with what you are going to make and how, in one line, before anything',
466
- ' else: "I will build Tide as a single HTML file - markup, one stylesheet, one',
467
- ' module - with the tasks kept in localStorage."',
468
- ' - One line when you move between the big pieces of work: "The layout is done,',
469
- ' now the animations."',
470
- ' - One line at the end saying what it does and how to try it.',
471
- 'That is all. A line before every tool call is not narration, it is noise: the',
472
- 'steps already show on screen, and repeating them in words buries the few',
473
- 'sentences worth reading.',
474
- '',
475
- 'FIRST, EVERY TIME: write one short line saying what you are about to do, then',
476
- 'make the tool calls. Never open a turn with a tool call and no words. Examples:',
477
- '"Right, the HTML structure first." / "Now the state and the render loop." /',
478
- '"That is the layout done - onto the animations." / "Let me see what is there."',
479
- 'One sentence, your own voice, before the actions - not after them, not instead',
480
- 'of them, and not a restatement of what was asked. The user is watching this',
481
- 'scroll past; without those lines it is a list of file operations and they cannot',
482
- 'tell what you are building. This matters as much as the code.',
483
- '',
484
-
485
- '',
486
- 'Before you guess at an API, ask: type_of gives the exact signature from the',
487
- 'TypeScript this project has installed, and find_symbol says where something is declared without',
488
- 'reading five files to find it. Rename with rename_symbol rather than edit_file — a',
489
- 'find-and-replace that matches too much is the most common broken edit. Reach for',
490
- 'add_block before writing a table, an empty state or a dashboard by hand.',
491
- '',
492
- ...(mode === 'plan' ? [
493
- 'You are in PLAN MODE. Reading, searching and research are available; every tool',
494
- 'that writes a file or runs a command has been withheld. Investigate, then set out',
495
- 'what you would change: which files, which functions, in what order. Never imply',
496
- 'you have made a change you are not able to make.',
497
- '',
498
- ] : []),
499
- ...(check ? [
500
- `This project checks itself with: ${check}`,
501
- 'After changing code, run that and report what actually happened. "It should work"',
502
- 'is not a result.',
503
- '',
504
- ] : []),
505
- '- Read before you write. Never edit a file you have not read this session.',
506
- '- Prefer edit_file to write_file. Rewrite a whole file only when creating it, or',
507
- ' when the change genuinely touches most of it.',
508
- '- old_string must be copied out of what read_file showed you, character for',
509
- ' character, without the line-number gutter, and must appear exactly once. Add',
510
- ' surrounding lines until it does.',
511
- '- read_file returns up to 600 lines. Read the whole file before editing it rather',
512
- ' than editing from a fragment; pass offset to continue a long one.',
513
- '',
514
- '## Going fast',
515
- '',
516
- 'Every tool call is a round trip to you, and the round trip — not the disk, not',
517
- 'the shell is where the time goes. So:',
518
- '',
519
- '- Need more than one file? read_files, all of them in one call. Never read files',
520
- ' one at a time when you already know which ones you want.',
521
- '- An edit result shows the file as it now stands. Do not read a file again after',
522
- ' editing it - you already have its current text.',
523
- '- Put independent calls in the same message — several greps, a glob and a read.',
524
- ' Read-only calls in one message run at the same time.',
525
- '- New Next.js app? create_app - one step, never create-next-app or shadcn init. It',
526
- ' copies a starter that already builds and installs it in the background.',
527
- '- batch_write to lay out several new files at once - but at most about four per',
528
- ' call: one slip in a huge call throws the whole call away.',
529
- '- multi_edit for several changes',
530
- ' to one file, edit_files for a change that spans several files.',
531
- '- For work with three or more steps, keep a short plan with update_plan - at most',
532
- ' six items of a few words - and tick items off as they finish. Skip it for small jobs.',
533
- '- When a build splits into parts that touch different files (the API route, the',
534
- ' upload component, the results view), set up the shared files yourself, then hand',
535
- ' the parts to delegate so they are built in parallel.',
536
- '- A package.json you write starts installing in the background immediately; keep',
537
- ' writing files. Running the install yourself afterwards just waits for that one.',
538
- '- When you finish, ucode type-checks what you changed and hands you the errors, so',
539
- ' there is no need to run tsc yourself.',
540
- '- Do not open or drive a browser. Checking the page in one is something the user',
541
- ' asks for with /look; your job is to leave the app in a state worth looking at.',
542
- ' reports - errors, layout that overflows a phone, the review points worth fixing -',
543
- ' in one pass, then look once more. A clean second look means it is done: report',
544
- ' back instead of polishing in circles. Never call an interface finished unlooked at.',
545
- ' name, handles keys and returns the live link. Build locally first.',
546
- '- Nothing you run has a keyboard. Pass the non-interactive flag to anything that',
547
- ' would ask a question, or it fails instead of waiting: create-next-app --yes,',
548
- ' npx shadcn@latest init -d -y, npx shadcn@latest add <names> -y, npm init -y.',
549
- '- Dev servers start in the background by themselves, and the result tells you the',
550
- ' URL once the server says it is ready. Do not start one twice, do not sleep while',
551
- ' waiting for it, and do not curl it before that result comes back.',
552
- '',
553
- '## When something fails',
554
- '',
555
- '- A failed build names the problem. Fix exactly that, then build again. Never go',
556
- ' exploring inside node_modules: a missing component or package is one install away.',
557
- '- A build takes most of a minute. Fix every error it lists in one pass - multi_edit,',
558
- ' edit_files - before building again, never one error per build.',
559
- '- Never delete an app folder to start over. Fix it where it is - starting again throws',
560
- ' away the install and everything already written.',
561
- '- Run an app\'s commands with cwd set to its folder, and keep paths inside those',
562
- ' commands relative to that folder.',
563
- '',
564
- '## Safety',
565
- '',
566
- '- run_command runs without asking. That is trust rather than licence: never run',
567
- ' anything destructive the user did not ask for.',
568
- '- Paths are relative to the working directory. Anything outside it needs the user',
569
- ' to approve it first.',
570
- '- Verify. After changing code, run the tests or a quick check with run_command.',
571
- '',
572
- '## Saying what you are doing',
573
- '',
574
- '- Before every tool call, write ONE short line naming the file or command:',
575
- ' "Reading tui.js", "Fixing the spinner in loop.js", "Running npm test".',
576
- '- Present tense, under ten words, and no full stop at the end. It is a label on',
577
- ' work happening right now, not a sentence about work that is finished.',
578
- '- That line and nothing else in the message. No preamble, no plan, no bullets —',
579
- ' the user reads it live while the tool runs.',
580
- '- Say the next one when you take the next step, not all of them up front.',
581
- '',
582
- '## Answering',
583
- '',
584
- '- Be short. Two or three sentences is usually the entire answer. This is a',
585
- ' terminal, not a document.',
586
- '- No preamble, no restating the question, no "I will now...". Just answer.',
587
- '- Do not narrate what the tool output already showed. The user watched the diff',
588
- ' and the command output; cover only what is not obvious from them.',
589
- '- Skip closing summaries of work the user just watched you do — but never end',
590
- ' a turn silently. If there is genuinely nothing to add, one short line saying',
591
- ' what changed is the whole answer.',
592
- '- Markdown. Fenced blocks with a language tag get highlighted.',
593
- '- Point at code as path:line so the user can jump straight to it.',
594
- '- Report honestly. If a command failed or you skipped something, say so.',
595
- '- Length tracks the question: a one-line question gets a one-line answer.',
596
- '- Brevity is about your prose and never about your work. What you build is',
597
- ' finished: every control wired, every state handled, no TODO left behind.',
598
- ...(list ? [
599
- '',
600
- '## Skills',
601
- '',
602
- 'These instruction packs are available. When a task matches one, load it with',
603
- 'load_skill as your first step before planning, before writing anything and',
604
- 'then follow it. A skill already in this conversation outranks your own defaults',
605
- 'and is not advisory.',
606
- '',
607
- list,
608
- ] : []),
609
- '',
610
- '## Project map',
611
- '',
612
- 'Every file in the project at the start of this turn, with the names each code file',
613
- 'exports. Go straight to the files you need instead of searching for them.',
614
- '',
615
- map || '(not available)',
616
- ].join('\n');
617
- }
618
-
619
- export class Agent {
620
- constructor({ cwd, debug = false }) {
621
- this.cwd = cwd;
622
- this.debug = debug;
623
- // A full-screen layout only makes sense on a real terminal. Piped input,
624
- // CI and `echo ... | ucode` get the line-based interface instead.
625
- this.full = Boolean(process.stdout.isTTY && process.stdin.isTTY);
626
- this.ui = this.full ? new Screen({ cwd }) : new Plain({ cwd });
627
- this.stats = newStats();
628
- this.skills = [];
629
- this.session = newSession(cwd, model());
630
- this.working = [];
631
- this.loaded = new Set();
632
- this.abort = null;
633
- this.busy = false;
634
- this.check = null;
635
- }
636
-
637
- // -- history -------------------------------------------------------------
638
-
639
- push(message) {
640
- this.session.messages.push(message);
641
- this.working.push(message);
642
- }
643
-
644
- /**
645
- * Answer every tool call a stopped turn never got to.
646
- *
647
- * An assistant message ends by asking for tools, and each of those asks
648
- * needs an answer. Abandon them and the conversation is left mid-sentence,
649
- * so the next time the model reads it the only sensible thing to do is
650
- * carry on where it left off which is exactly what the user pressed stop
651
- * to prevent. Saying "this did not happen" for each one ends the sentence,
652
- * and a line from the user ends the task.
653
- */
654
- closeInterrupted() {
655
- const answered = new Set(this.working.filter((m) => m.role === 'tool').map((m) => m.toolCallId));
656
- const missing = [];
657
- for (const m of this.working) {
658
- if (m.role !== 'assistant' || !m.toolCalls?.length) continue;
659
- for (const call of m.toolCalls) {
660
- if (!answered.has(call.id)) missing.push(call);
661
- }
662
- }
663
- for (const call of missing) {
664
- this.push({
665
- role: 'tool',
666
- toolCallId: call.id,
667
- name: call.name,
668
- content: 'The user stopped the turn before this ran. It did not happen, and it must not be retried.',
669
- });
670
- }
671
- if (missing.length) {
672
- this.push({
673
- role: 'user',
674
- content: 'I stopped that. Drop it and wait for what I ask next — do not pick it back up.',
675
- });
676
- }
677
- return missing.length;
678
- }
679
-
680
- async persist() {
681
- try {
682
- this.session.model = model();
683
- await save(this.session);
684
- } catch (err) {
685
- // Losing the save must not lose the turn.
686
- this.ui.error(err, { debug: this.debug });
687
- }
688
- }
689
-
690
- // -- startup -------------------------------------------------------------
691
-
692
- /**
693
- * Everything a turn needs, minus the terminal.
694
- *
695
- * Split out of start() so another front end could prepare an agent and drive
696
- * turn() itself.
697
- */
698
- async bootstrap() {
699
- setRoot(this.cwd);
700
- // Parallel workers can ask at the same moment; the questions queue up and
701
- // are put to the user one at a time, never on top of each other.
702
- let asking = Promise.resolve();
703
- setConfirm((request) => {
704
- const next = asking.then(() => this.ui.confirm(request));
705
- asking = next.catch(() => {});
706
- return next;
707
- });
708
- this.skills = await loadSkills({ cwd: this.cwd });
709
- await this.detectCheck();
710
- }
711
-
712
- async start() {
713
- await this.bootstrap();
714
-
715
- if (this.full) {
716
- await this.ui.start();
717
- this.ui.onInterrupt = () => {
718
- if (this.busy && this.abort) {
719
- this.abort.abort();
720
- this.ui.stopSpinner();
721
- this.ui.stopTimer?.();
722
- this.ui.note('interrupted');
723
- }
724
- };
725
- this.ui.onModeChange = () => this.showHeader({ clear: false });
726
- }
727
-
728
- for (const problem of this.skills.problems ?? []) {
729
- this.ui.write(theme.warn(` skill not loaded: ${problem}`));
730
- }
731
-
732
- this.showHeader();
733
- this.installSignals();
734
-
735
- // Checked in the background; nothing here waits on it.
736
- autoUpdate({
737
- onUpdated: (version) => {
738
- this.ui.setFacts?.({ update: version });
739
- if (!this.ui.welcoming?.()) this.ui.note(`updated to v${version} — it takes over the next time you start ucode`);
740
- },
741
- });
742
- await this.repl();
743
- }
744
-
745
- showHeader({ clear = true } = {}) {
746
- if (clear && this.full) this.ui.clearScreen();
747
- const stats = usage(this.working, contextLimit());
748
- this.ui.header({
749
- cwd: this.cwd,
750
- model: modelName(),
751
- used: stats.used,
752
- limit: stats.limit,
753
- title: this.session.title === 'Untitled' ? 'new session' : this.session.title,
754
- });
755
- }
756
-
757
- installSignals() {
758
- const flush = async () => {
759
- await save(this.session).catch(() => {});
760
- process.exit(0);
761
- };
762
- process.on('SIGTERM', flush);
763
-
764
- // The full-screen UI reads keys itself, so it owns ctrl+c and esc.
765
- if (this.full) return;
766
-
767
- this.ui.rl.on('SIGINT', () => {
768
- if (this.busy && this.abort) {
769
- this.abort.abort();
770
- this.ui.stopSpinner();
771
- this.ui.write(dim(' interrupted'));
772
- return;
773
- }
774
- this.ui.write(dim(' (ctrl+d or /exit to quit)'));
775
- this.ui.rl.prompt();
776
- });
777
- }
778
-
779
- /**
780
- * How this project verifies itself, worked out once at startup. Null when
781
- * there is genuinely nothing to run verification is only insisted on where
782
- * there is something to insist on.
783
- */
784
- async detectCheck() {
785
- const has = (f) => readFile(path.join(this.cwd, f)).then(() => true, () => false);
786
-
787
- if (await has('package.json')) {
788
- try {
789
- const pkg = JSON.parse(await readFile(path.join(this.cwd, 'package.json'), 'utf8'));
790
- if (pkg.scripts?.test && !/no test specified/i.test(pkg.scripts.test)) {
791
- this.check = 'npm test';
792
- return;
793
- }
794
- } catch { /* an unreadable package.json is not worth failing over */ }
795
- }
796
- if (await has('Cargo.toml')) { this.check = 'cargo test'; return; }
797
- if (await has('go.mod')) { this.check = 'go test ./...'; return; }
798
- if (await has('pyproject.toml') || await has('pytest.ini')) { this.check = 'pytest'; return; }
799
- if (await has('Makefile')) { this.check = 'make test'; return; }
800
- this.check = null;
801
- }
802
-
803
- // -- REPL ----------------------------------------------------------------
804
-
805
- async repl() {
806
- let sawInput = false;
807
-
808
- for (;;) {
809
- const line = await this.ui.ask();
810
- if (line === null) {
811
- // End of input before anything was typed. On Windows this is almost
812
- // always npm's PowerShell shim, which runs the CLI as `$input | node`.
813
- // The pipe makes stdin a non-TTY, readline hits EOF at once, and the
814
- // banner flashes up and vanishes which looks like a crash rather
815
- // than like a program that was never given a keyboard. So say which.
816
- if (!sawInput && !process.stdin.isTTY) this.explainNoKeyboard();
817
- break;
818
- }
819
-
820
- const input = line.trim();
821
- if (input) sawInput = true;
822
- if (!input) continue;
823
-
824
- if (input.startsWith('/')) {
825
- if (await this.command(input) === 'exit') break;
826
- continue;
827
- }
828
-
829
- try {
830
- await this.turn(input);
831
- } catch (err) {
832
- this.ui.error(err, { debug: this.debug });
833
- }
834
- }
835
-
836
- await this.shutdown();
837
- }
838
-
839
- explainNoKeyboard() {
840
- this.ui.blank();
841
- this.ui.write(theme.warn(' ucode could not reach the keyboard, so it stopped.'));
842
- this.ui.blank();
843
- this.ui.write(' That happens when input is piped rather than typed. On Windows it is');
844
- this.ui.write(" usually npm's PowerShell wrapper, which pipes stdin.");
845
- this.ui.blank();
846
- this.ui.write(` ${blue('Any of these work:')}`);
847
- this.ui.write(` ${sky('ucode.cmd')} the cmd shim, which keeps the keyboard`);
848
- this.ui.write(` ${sky('npx ucode-agent')} runs it directly`);
849
- this.ui.write(' or start it from Command Prompt or Windows Terminal');
850
- this.ui.blank();
851
- }
852
-
853
- async shutdown() {
854
- await closeBrowser().catch(() => {});
855
- this.ui.stopSpinner();
856
- if (this.session.messages.length) {
857
- await this.persist();
858
- this.ui.write(dim(`\n saved · ${this.session.title}`));
859
- }
860
- this.ui.close();
861
- }
862
-
863
- // -- one turn ------------------------------------------------------------
864
-
865
- /**
866
- * Pull image paths out of the message and load them, so "what is wrong in
867
- * screenshot.png" works without a separate command for it.
868
- */
869
- async attachImages(input) {
870
- const mentioned = input.match(/[^\s"']+\.(?:png|jpe?g|gif|webp)\b/gi) ?? [];
871
- const images = [];
872
-
873
- for (const name of mentioned) {
874
- const file = path.resolve(this.cwd, name);
875
- try {
876
- const buf = await readFile(file);
877
- if (buf.length > 4 * 1024 * 1024) {
878
- this.ui.note(`${name} is ${(buf.length / 1024 / 1024).toFixed(1)}MB — too big to send, skipped`);
879
- continue;
880
- }
881
- const ext = path.extname(file).toLowerCase().slice(1);
882
- images.push(`data:image/${ext === 'jpg' ? 'jpeg' : ext};base64,${buf.toString('base64')}`);
883
- this.ui.note(`attached ${name}`);
884
- } catch {
885
- // Just a filename mentioned in passing, not a file on disk.
886
- }
887
- }
888
-
889
- return images;
890
- }
891
-
892
- /**
893
- * Skills that this request should arrive with, already loaded.
894
- *
895
- * The load_skill tool asks the model to notice that a task needs a skill,
896
- * and a model in a hurry to be helpful does not always notice. For work
897
- * where the skill *is* the quality bar anything with a user interface in
898
- * it that is not a discovery to make after the app has been built. So the
899
- * request is matched against each skill's trigger words and the body goes in
900
- * before the model takes its first step.
901
- */
902
- autoLoad(input) {
903
- for (const skill of autoLoadFor(this.skills, input)) {
904
- if (this.loaded.has(skill.name)) continue;
905
- this.loaded.add(skill.name);
906
- this.push(skillMessage(skill, { automatic: true }));
907
- this.ui.note(`${skill.name} skill loaded for this`);
908
- }
909
- }
910
-
911
- async turn(input) {
912
- forgetReviews(); // a new request: its apps get a fresh design review
913
- const images = await this.attachImages(input);
914
- this.push(images.length
915
- ? { role: 'user', content: input, images }
916
- : { role: 'user', content: input });
917
-
918
- if (!this.session.title || this.session.title === 'Untitled') {
919
- this.session.title = titleFrom(input);
920
- }
921
-
922
- this.autoLoad(input);
923
- // What the model is told about the project, fresh for this turn.
924
- [this.map, this.memory] = await Promise.all([
925
- projectMap(this.cwd).catch(() => ''),
926
- loadMemory(this.cwd).catch(() => ''),
927
- ]);
928
- await this.persist();
929
-
930
- // A busy model was swapped for a fallback earlier; after a few minutes the
931
- // one the user chose gets another go.
932
- this.preferred ??= model();
933
- if (model() !== this.preferred && Date.now() > (this.cooldownUntil ?? 0)) {
934
- setModel(this.preferred);
935
- if (this.full) this.showHeader({ clear: false });
936
- }
937
-
938
- this.busy = true;
939
- this.abort = new AbortController();
940
- this.endedSilently = false;
941
-
942
- const turnStarted = Date.now();
943
- let finished = false;
944
- this.ui.turnStart?.();
945
- try {
946
- for (;;) {
947
- try {
948
- await this.run();
949
- break;
950
- } catch (err) {
951
- // The daily free cap mid-build: wait for the reset and carry on,
952
- // rather than leaving a half-built app for the user to restart.
953
- if (err?.detail?.daily && !this.abort.signal.aborted && (await this.waitForReset(err))) continue;
954
- throw err;
955
- }
956
- }
957
- finished = true;
958
- } catch (err) {
959
- if (err?.kind === 'aborted' || this.abort.signal.aborted) this.ui.write(dim(' turn cancelled'));
960
- else throw err;
961
- } finally {
962
- trace({ kind: 'turn', ms: Date.now() - turnStarted });
963
- this.stats.workMs += Date.now() - turnStarted;
964
- this.stats.turns++;
965
- if (!finished) this.closeInterrupted();
966
- const ok = finished && !this.endedSilently;
967
- this.busy = false;
968
- this.abort = null;
969
- this.ui.stopSpinner();
970
- this.ui.stopTimer?.();
971
- this.activity = null;
972
- await this.persist();
973
- if (this.full) this.showHeader({ clear: false });
974
- if (finished) this.openWhenReady(turnStarted);
975
- // Last, so "Done" is the last thing that happens rather than the last
976
- // thing said before several more things happen.
977
- this.ui.turnEnd?.({ ok });
978
- }
979
- }
980
-
981
- /** The tools the model may see, given the mode. */
982
- toolsNow() {
983
- const all = [...tools, loadSkillTool, planTool, delegateTool];
984
- if (this.ui.mode !== 'plan') return all;
985
- return all.filter((t) => !WRITES.has(t.name));
986
- }
987
-
988
- /** Model, tools, model, until it answers with prose. */
989
- async run() {
990
- const available = this.toolsNow();
991
- let argRetries = 0;
992
- let continuations = 0;
993
- let askedToVerify = false;
994
- let askedToSpeak = false;
995
- let fixRounds = 0;
996
- this.failovers = 0;
997
- this.tried = new Set([model()]);
998
-
999
- this.stuck = new StuckWatch();
1000
- this.touched = new Set();
1001
- this.sinceCheck = new Set();
1002
- this.logWatch = new LogWatch();
1003
- this.ranSomething = false;
1004
-
1005
- for (let step = 0; step < MAX_STEPS; step++) {
1006
- await this.maybeFold();
1007
- this.ui.startSpinner(step === 0 ? 'thinking' : 'working');
1008
-
1009
- let reply;
1010
- let streaming = false;
1011
- this.early = new Map();
1012
-
1013
- try {
1014
- const opts = {
1015
- signal: this.abort.signal,
1016
- onWait: (text) => this.ui.updateSpinner(text),
1017
- };
1018
- // Only a real terminal has somewhere to stream into.
1019
- if (this.full) {
1020
- opts.onThinking = (delta) => this.ui.thinkingDelta(delta);
1021
- opts.onText = (delta) => {
1022
- if (!streaming) {
1023
- streaming = true;
1024
- this.ui.thinkingEnd();
1025
- this.ui.streamBegin();
1026
- }
1027
- this.ui.streamDelta(delta);
1028
- };
1029
- // Read-only calls start the moment they are fully written, while the
1030
- // rest of the reply is still arriving. Nothing that writes or runs is
1031
- // started early: a reply that fails halfway must leave no side effects.
1032
- opts.onToolCall = (call) => {
1033
- if (PARALLEL_SAFE.has(call.name) && !call.parseError && !this.early.has(call.id)) {
1034
- this.early.set(call.id, this.execute(call));
1035
- }
1036
- };
1037
- }
1038
-
1039
- var asked = Date.now();
1040
- reply = await ask(
1041
- [
1042
- {
1043
- role: 'system',
1044
- content: systemPrompt({
1045
- cwd: this.cwd,
1046
- skills: this.skills,
1047
- mode: this.ui.mode,
1048
- check: this.check,
1049
- map: this.map,
1050
- memory: this.memory,
1051
- }),
1052
- },
1053
- ...dedupe(lean(this.working)),
1054
- ],
1055
- available,
1056
- opts
1057
- );
1058
- } catch (err) {
1059
- this.ui.thinkingEnd();
1060
- if (streaming) this.ui.streamEnd();
1061
-
1062
- // The model invented a tool and the provider rejected the request
1063
- // outright. Tell it what it did and let it try again.
1064
- if (err.kind === 'bad_tool_call' && argRetries < MAX_ARG_RETRIES) {
1065
- argRetries++;
1066
- this.ui.stopSpinner();
1067
- this.ui.toolFailed(
1068
- `${err.detail?.attemptedName ?? 'invalid tool call'} — retrying (${argRetries}/${MAX_ARG_RETRIES})`
1069
- );
1070
- this.push({
1071
- role: 'user',
1072
- content:
1073
- `Your last tool call was rejected. ${err.failed} The only tools that exist ` +
1074
- `are: ${available.map((t) => t.name).join(', ')}. Try again with one of them.`,
1075
- });
1076
- continue;
1077
- }
1078
-
1079
- // Busy, slow or down: move to the next model and carry on, rather
1080
- // than ending a half-built app with an error.
1081
- if (passing(err) && !this.abort.signal.aborted && (await this.failover(err))) continue;
1082
- throw err;
1083
- }
1084
-
1085
- // A reply that is nothing but tool calls never starts a text stream, so
1086
- // the thinking timer has to be closed out here as well.
1087
- this.ui.thinkingEnd();
1088
- this.ui.stopSpinner();
1089
- this.record(reply.usage);
1090
- this.ui.step?.();
1091
- this.stats.steps++;
1092
- this.stats.tokensIn += reply.usage.promptTokens ?? 0;
1093
- this.stats.tokensOut += reply.usage.outputTokens ?? 0;
1094
- trace({
1095
- kind: 'model', who: 'lead', model: model(), ms: Date.now() - asked,
1096
- in: reply.usage.promptTokens, out: reply.usage.outputTokens,
1097
- calls: reply.toolCalls.map((c) => c.name),
1098
- });
1099
-
1100
- // Streamed text is already on screen; turn it into rendered markdown.
1101
- // Text that turns out to be narration ahead of a tool call folds into a
1102
- // status line instead that is where the live commentary comes from.
1103
- const narrating = reply.toolCalls.length > 0;
1104
- if (streaming) this.ui.streamEnd({ asNarration: narrating });
1105
- else if (reply.text && narrating && isLabel(reply.text)) this.ui.narrate(reply.text);
1106
- else if (reply.text) this.ui.assistant(reply.text);
1107
-
1108
- if (reply.toolCalls.length === 0) {
1109
- // The answer stopped at the provider's output cap rather than at the
1110
- // end of a thought, so it is cut mid-word. Ask for the rest instead of
1111
- // handing over half an answer with no sign there was more.
1112
- if (reply.finishReason === 'length' && reply.text && continuations < MAX_CONTINUATIONS) {
1113
- continuations++;
1114
- this.push({ role: 'assistant', content: reply.text });
1115
- this.push({
1116
- role: 'user',
1117
- content:
1118
- 'Your reply stopped at the output limit, mid-sentence. Carry on from exactly ' +
1119
- 'where it broke off. Do not repeat any of it, do not start again, and do not ' +
1120
- 'introduce it just continue.',
1121
- });
1122
- this.ui.note('hit the output limit — asking for the rest');
1123
- continue;
1124
- }
1125
-
1126
- // Type-check what changed and hand back the errors, a few rounds at most.
1127
- // A turn that ends on a broken build is the most common way an app
1128
- // gets handed over as done when it is not.
1129
- if (fixRounds < MAX_FIX_ROUNDS) {
1130
- const problems = await this.autoCheck();
1131
- if (problems) {
1132
- fixRounds++;
1133
- if (reply.text) this.push({ role: 'assistant', content: reply.text });
1134
- this.push({
1135
- role: 'user',
1136
- content:
1137
- `ucode checked the files you changed and found errors (round ${fixRounds} of ` +
1138
- `${MAX_FIX_ROUNDS}). Fix all of them, then finish.\n\n${problems}`,
1139
- });
1140
- continue;
1141
- }
1142
- }
1143
-
1144
- // It changed code and never ran anything. Send it back once.
1145
- if (this.touched.size && !this.ranSomething && this.check && !askedToVerify) {
1146
- askedToVerify = true;
1147
- if (reply.text) this.push({ role: 'assistant', content: reply.text });
1148
- this.push({
1149
- role: 'user',
1150
- content:
1151
- `You changed ${[...this.touched].join(', ')} and did not check it. Run ` +
1152
- `\`${this.check}\` with run_command now, then say what actually happened — ` +
1153
- 'if it failed, show the output rather than claiming it worked. If that is ' +
1154
- 'the wrong way to check this project, run the right one and say which.',
1155
- });
1156
- this.ui.note('verifying the change');
1157
- continue;
1158
- }
1159
-
1160
- /**
1161
- * It did the work and then said nothing at all.
1162
- *
1163
- * Reasoning models do this, and the instruction to skip closing
1164
- * summaries makes it more likely. Silence is fine as a style; it is
1165
- * not fine as an answer, because the user cannot tell it apart from a
1166
- * crash and if they asked what happened, they asked. One nudge,
1167
- * once per turn, and only when there was actually work to report.
1168
- */
1169
- if (!reply.text?.trim() && this.touched.size + (this.ranSomething ? 1 : 0) > 0 && !askedToSpeak) {
1170
- askedToSpeak = true;
1171
- this.push({
1172
- role: 'user',
1173
- content:
1174
- 'You stopped without saying anything. If the thing I asked for is not ' +
1175
- 'built yet, carry on and build it. If it is, tell me in one or two ' +
1176
- 'sentences what it does and how to try it. No preamble, no diffs.',
1177
- });
1178
- continue;
1179
- }
1180
-
1181
- this.push({ role: 'assistant', content: reply.text });
1182
- if (!reply.text?.trim()) {
1183
- // Silence after being asked to speak is not a finished turn. Saying
1184
- // "Done" here is the worst thing available: the user believes it,
1185
- // looks, and finds the thing they asked for was never built.
1186
- this.ui.note('the model stopped without saying anything the work may be unfinished');
1187
- this.endedSilently = true;
1188
- }
1189
- return;
1190
- }
1191
-
1192
- this.push({ role: 'assistant', content: reply.text || '', toolCalls: reply.toolCalls });
1193
- await this.persist();
1194
-
1195
- const badArgs = await this.runCalls(reply.toolCalls);
1196
- if (this.abort.signal.aborted) return;
1197
-
1198
- // Malformed arguments go back to the model, but only so many times.
1199
- if (badArgs) {
1200
- argRetries++;
1201
- if (argRetries > MAX_ARG_RETRIES) {
1202
- throw new Failure({
1203
- kind: 'bad_tool_args',
1204
- attempted: 'running the tools the model asked for',
1205
- failed:
1206
- `${modelName()} produced invalid tool arguments ${argRetries} times running ` +
1207
- 'and could not correct itself.',
1208
- fix:
1209
- 'Say what you want more concretely, or /model to another one — North Mini ' +
1210
- 'Code and Nemotron 3.5 Lightning are both steadier with tool arguments.',
1211
- });
1212
- }
1213
- } else {
1214
- argRetries = 0;
1215
- }
1216
-
1217
- await this.persist();
1218
- }
1219
-
1220
- throw new Failure({
1221
- kind: 'step_limit',
1222
- attempted: 'finishing your request',
1223
- failed: `The model was still calling tools after ${MAX_STEPS} steps.`,
1224
- fix:
1225
- 'Nothing is lost — everything so far is on disk. Say "carry on where you left ' +
1226
- 'off" to continue. If it was repeating one step, it is looping: break the task ' +
1227
- 'up, or /new to reset.',
1228
- });
1229
- }
1230
-
1231
- /**
1232
- * Run one round of tool calls.
1233
- *
1234
- * Consecutive read-only calls go out together — four files read at once
1235
- * rather than four round tripswhile anything that writes or executes runs
1236
- * on its own, in order. Returns whether any call had unusable arguments.
1237
- */
1238
- async runCalls(calls) {
1239
- const groups = [];
1240
- let batch = [];
1241
-
1242
- for (const call of calls) {
1243
- if (PARALLEL_SAFE.has(call.name)) {
1244
- batch.push(call);
1245
- } else {
1246
- if (batch.length) { groups.push(batch); batch = []; }
1247
- groups.push([call]);
1248
- }
1249
- }
1250
- if (batch.length) groups.push(batch);
1251
-
1252
- let badArgs = false;
1253
-
1254
- for (const group of groups) {
1255
- if (this.abort.signal.aborted) return badArgs;
1256
-
1257
- const noted = (call) => {
1258
- if (FILE_WRITES.has(call.name)) {
1259
- for (const p of pathsOf(call)) { this.touched.add(p); this.sinceCheck.add(p); }
1260
- }
1261
- if (call.name === 'run_command' || call.name === 'run_commands') this.ranSomething = true;
1262
- };
1263
-
1264
- if (group.length > 1) {
1265
- for (const call of group) {
1266
- this.ui.toolCall(describe(call.name, call.args));
1267
- noted(call);
1268
- }
1269
- this.ui.startSpinner(`${group.length} lookups at once`);
1270
-
1271
- const settled = await Promise.all(
1272
- group.map((call) => (this.early.get(call.id) ?? this.execute(call)).then((r) => ({ call, ...r })))
1273
- );
1274
-
1275
- this.ui.stopSpinner();
1276
- for (const { call, out, err } of settled) {
1277
- if (err) badArgs = this.reportFailure(call, err) || badArgs;
1278
- else this.reportResult(call, out);
1279
- }
1280
- continue;
1281
- }
1282
-
1283
- for (const call of group) {
1284
- if (this.abort.signal.aborted) return badArgs;
1285
-
1286
- const label = describe(call.name, call.args);
1287
- if (!SILENT.has(call.name)) this.ui.toolCall(label);
1288
- this.ui.startSpinner(label);
1289
- noted(call);
1290
-
1291
- const { out, err } = await (this.early.get(call.id) ?? this.execute(call));
1292
- this.ui.stopSpinner();
1293
- if (err) badArgs = this.reportFailure(call, err) || badArgs;
1294
- else this.reportResult(call, out);
1295
- }
1296
- }
1297
-
1298
- return badArgs;
1299
- }
1300
-
1301
- /**
1302
- * Check a finished call against the stuck patterns (see stuck.js) and return
1303
- * the note to add to its result. A nudge that did not work hands the turn to
1304
- * another model.
1305
- */
1306
- stuckNote(call, outcome) {
1307
- if (!this.stuck) return '';
1308
- const verdict = this.stuck.observe(eventFor(call, outcome));
1309
- if (!verdict) return '';
1310
- this.stats.stuck++;
1311
- if (verdict.action === 'switch') {
1312
- const next = fallbackFor(model(), this.tried ?? new Set([model()]));
1313
- if (next) {
1314
- this.tried?.add(next);
1315
- this.ui.note(`${modelName(model())} kept ${describeHit(verdict.hit)} — handing over to ${modelName(next)}`);
1316
- setModel(next);
1317
- this.cooldownUntil = Date.now() + COOLDOWN;
1318
- if (this.full) this.showHeader({ clear: false });
1319
- }
1320
- }
1321
- return verdict.text ? `\n\n${verdict.text}` : '';
1322
- }
1323
-
1324
- /**
1325
- * The daily free cap was hit mid-turn: keep the session, count down to the
1326
- * reset, and carry on by itself. Esc stops the wait like any other turn.
1327
- */
1328
- async waitForReset(err) {
1329
- const resetAt = err.detail?.resetAt;
1330
- if (!Number.isFinite(resetAt)) return false;
1331
- const at = resetAt + 30_000;
1332
- const clock = new Date(at).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
1333
- this.ui.stopSpinner();
1334
- this.ui.note(`Daily limit reached — ucode carries on by itself at ${clock}. Esc to stop`);
1335
- await this.persist();
1336
- let lastNote = Date.now();
1337
- while (Date.now() < at) {
1338
- if (this.abort?.signal.aborted) return false;
1339
- const left = formatDuration(at - Date.now());
1340
- if (this.full) this.ui.startSpinner(`daily limit · carrying on at ${clock} (in ${left})`);
1341
- else if (Date.now() - lastNote > 30 * 60_000) { this.ui.note(`still waiting for the daily limit ${left} to go`); lastNote = Date.now(); }
1342
- await new Promise((r) => setTimeout(r, Math.min(1000, Math.max(0, at - Date.now()))));
1343
- }
1344
- this.ui.stopSpinner();
1345
- this.ui.note('Daily limit has reset — carrying on');
1346
- return true;
1347
- }
1348
-
1349
- /** A dev server came up during this turn: open it in the browser, once. */
1350
- /**
1351
- * Open the running app in a browser — only when asked.
1352
- *
1353
- * This used to happen on its own whenever a dev server came up. Something
1354
- * seizing the screen mid-thought is startling at the best of times, and
1355
- * during a demo it is worse. UCODE_OPEN=1 brings the old behaviour back for
1356
- * anyone who liked it; otherwise the URL is on screen to click.
1357
- */
1358
- openWhenReady(since) {
1359
- if (!this.full || process.env.UCODE_OPEN !== '1') return;
1360
- const server = serversReadySince(since).at(-1);
1361
- if (!server || (this.opened ??= new Set()).has(server.url)) return;
1362
- this.opened.add(server.url);
1363
- const [cmd, args] = process.platform === 'win32'
1364
- ? ['cmd', ['/c', 'start', '', server.url]]
1365
- : [process.platform === 'darwin' ? 'open' : 'xdg-open', [server.url]];
1366
- try {
1367
- spawn(cmd, args, { stdio: 'ignore', detached: true, windowsHide: true }).unref();
1368
- this.ui.note(`Opened ${server.url} in your browser`);
1369
- } catch { /* no browser to open — the link is in the answer */ }
1370
- }
1371
-
1372
- cmdStats() {
1373
- for (const line of statsLines(this.stats, this.working?.length ?? 0)) this.ui.write(line);
1374
- }
1375
-
1376
- async cmdDoctor() {
1377
- this.ui.startSpinner('checking your setup');
1378
- try {
1379
- const lines = await runDoctor();
1380
- this.ui.stopSpinner();
1381
- for (const line of lines) this.ui.write(line);
1382
- } finally {
1383
- this.ui.stopSpinner();
1384
- }
1385
- }
1386
-
1387
- /** /deploy [folder] — the same tool the model calls, run directly. */
1388
- async cmdDeploy(arg) {
1389
- const folder = (arg ?? '').trim() || '.';
1390
- this.ui.toolCall(`Deploying ${folder}`);
1391
- this.ui.startSpinner('getting ready to deploy');
1392
- try {
1393
- const out = await deploy({ folder }, { onOutput: (lines) => this.ui.updateSpinner(lines.at(-1)) });
1394
- this.ui.stopSpinner();
1395
- this.ui.toolResult(out.summary);
1396
- this.ui.write(out.content.split('\n').map((l) => ` ${l}`).join('\n'));
1397
- } catch (err) {
1398
- this.ui.stopSpinner();
1399
- if (err instanceof ToolFailure) this.ui.error(err);
1400
- else throw err;
1401
- }
1402
- }
1403
-
1404
- reportResult(call, out) {
1405
- // A build or type check that just passed already verified everything
1406
- // changed so far; the automatic check at the end would only repeat it.
1407
- if (call.name === 'run_command' && out.exitCode === 0 &&
1408
- /(?:next build|npm run build|pnpm (?:run )?build|tsc)/.test(call.args?.command ?? '')) {
1409
- this.sinceCheck?.clear();
1410
- }
1411
- if (!QUIET.has(call.name)) this.ui.toolResult(out.summary);
1412
- // The change as its two numbers, not as a copy of the file. The diff rows
1413
- // are still built by the tool the model reads them in the result they
1414
- // simply do not go on screen.
1415
- if (out.diff?.length) this.ui.diffStat?.(countDiff(out.diff));
1416
- this.push({ role: 'tool', toolCallId: call.id, name: call.name, content: out.content + this.stuckNote(call, { out }) });
1417
- }
1418
-
1419
- /** Show a tool failure, hand it to the model, and say if it was bad arguments. */
1420
- reportFailure(call, err) {
1421
- if (!(err instanceof ToolFailure)) throw err;
1422
-
1423
- // Bad arguments are the model talking to itself. "old_string and new_string
1424
- // are identical" is a correction it will make on the next step, and it means
1425
- // nothing to whoever is watching except that something went wrong. It goes
1426
- // to the model, which can act on it, and not to the screen. A refusal the
1427
- // user made, and anything that actually failed, still shows.
1428
- if (err instanceof Declined) this.ui.toolFailed('declined');
1429
- else if (err.kind !== 'bad_args') this.ui.toolFailed(`${err.kind}: ${err.failed}`);
1430
- this.push({
1431
- role: 'tool',
1432
- toolCallId: call.id,
1433
- name: call.name,
1434
- content: err.forModel() + (err instanceof Declined ? '' : this.stuckNote(call, { err })),
1435
- });
1436
- return err.kind === 'bad_args';
1437
- }
1438
-
1439
- async dispatch(call) {
1440
- // The model emitted arguments that were not valid JSON. Hand the parser's
1441
- // own complaint straight back so it can correct itself next step.
1442
- if (call.parseError) {
1443
- throw new ToolFailure({
1444
- kind: 'bad_args',
1445
- attempted: `calling ${call.name}`,
1446
- failed: `The arguments were not valid JSON: ${call.parseError}`,
1447
- fix: `Call ${call.name} again with the arguments as one well-formed JSON object.`,
1448
- });
1449
- }
1450
-
1451
- if (call.name === 'load_skill') return this.loadSkill(call.args?.name);
1452
- if (call.name === 'update_plan') return this.updatePlan(call.args?.items);
1453
- if (call.name === 'delegate') return this.delegate(call.args?.tasks);
1454
-
1455
- // Output reaches the screen as the command produces it, so a slow build is
1456
- // something you watch rather than something you sit out in silence.
1457
- return runTool(call.name, call.args ?? {}, {
1458
- onOutput: (lines) => this.ui.progress(lines),
1459
- });
1460
- }
1461
-
1462
- /**
1463
- * Switch to the next model after a provider failure. Returns false once
1464
- * there is nothing sensible left to try. When every model is busy at once,
1465
- * it waits a minute and goes round again rather than giving up.
1466
- */
1467
- async failover(err) {
1468
- if (++this.failovers > MAX_FAILOVERS) return false;
1469
- const from = model();
1470
- let next = fallbackFor(from, this.tried);
1471
-
1472
- if (!next) {
1473
- const until = Date.now() + 60_000;
1474
- this.ui.startSpinner('every model is busy');
1475
- while (Date.now() < until && !this.abort?.signal.aborted) {
1476
- this.ui.updateSpinner(`every model is busy — trying again in ${Math.ceil((until - Date.now()) / 1000)}s`);
1477
- await wait(1000);
1478
- }
1479
- this.ui.stopSpinner();
1480
- if (this.abort?.signal.aborted) return false;
1481
- this.tried = new Set();
1482
- next = this.preferred && this.preferred !== from ? this.preferred : fallbackFor(from, this.tried) ?? from;
1483
- }
1484
-
1485
- this.tried.add(next);
1486
- setModel(next);
1487
- this.cooldownUntil = Date.now() + COOLDOWN;
1488
- const why = err.kind === 'rate_limit' ? 'busy' : err.kind === 'timeout' ? 'too slow to answer' : 'not answering';
1489
- this.ui.note(`${modelName(from)} is ${why} carrying on with ${modelName(next)}`);
1490
- if (this.full) this.showHeader({ clear: false });
1491
- return true;
1492
- }
1493
-
1494
- /** Run a call and settle to { out } or { err } — never throws. */
1495
- execute(call) {
1496
- const started = Date.now();
1497
- return this.dispatch(call).then(
1498
- (out) => { trace({ kind: 'tool', name: call.name, ms: Date.now() - started }); countTool(this.stats, call, out); return { out }; },
1499
- (err) => { trace({ kind: 'tool', name: call.name, ms: Date.now() - started, err: err?.kind }); countTool(this.stats, call, null, err); return { err }; }
1500
- );
1501
- }
1502
-
1503
- updatePlan(items) {
1504
- const list = (Array.isArray(items) ? items : [])
1505
- .filter((i) => i && String(i.text ?? '').trim())
1506
- .slice(0, 6);
1507
- this.ui.plan(list);
1508
- const done = list.filter((i) => i.done).length;
1509
- return { content: `Plan updated: ${done} of ${list.length} done.`, summary: `${done}/${list.length}` };
1510
- }
1511
-
1512
- /** File writes from parallel workers take turns, so two never interleave. */
1513
- fileLock(fn) {
1514
- const run = (this.lockChain ?? Promise.resolve()).then(fn, fn);
1515
- this.lockChain = run.catch(() => {});
1516
- return run;
1517
- }
1518
-
1519
- /**
1520
- * Several workers at once, each its own small agent loop with its own
1521
- * conversation, sharing the tools, the project, and whatever skills are
1522
- * already in force. Their lines in the transcript carry their name.
1523
- */
1524
- async delegate(tasks) {
1525
- const list = (Array.isArray(tasks) ? tasks : [])
1526
- .filter((t) => t && String(t.instructions ?? '').trim())
1527
- .slice(0, MAX_WORKERS);
1528
- if (!list.length) {
1529
- throw new ToolFailure({
1530
- kind: 'bad_args',
1531
- attempted: 'starting workers',
1532
- failed: 'No tasks with instructions were given.',
1533
- fix: 'Pass tasks as [{ name, instructions }, ...], up to 3.',
1534
- });
1535
- }
1536
-
1537
- const results = await Promise.all(list.map((task, i) => this.runWorker(task, i).catch((err) => ({
1538
- name: task.name || `worker ${i + 1}`,
1539
- summary: `Failed: ${err?.failed ?? err?.message ?? err}`,
1540
- touched: [],
1541
- }))));
1542
-
1543
- for (const r of results) for (const f of r.touched) { this.touched.add(f); this.sinceCheck.add(f); }
1544
-
1545
- // A worker that wrote nothing has not done its part, whatever it said.
1546
- // The lead builds those itself rather than leaving holes in the app.
1547
- const empty = results.filter((r) => !r.touched.length).map((r) => r.name);
1548
-
1549
- return {
1550
- content: results
1551
- .map((r) => `## ${r.name}\n${r.summary}\nFiles changed: ${r.touched.join(', ') || 'none'}`)
1552
- .join('\n\n') +
1553
- (empty.length
1554
- ? `\n\n${empty.join(', ')} wrote no files. Build ${empty.length === 1 ? 'that part' : 'those parts'} ` +
1555
- 'yourself now, directly - do not delegate them again.'
1556
- : ''),
1557
- summary: results.map((r) => `${r.name} · ${r.touched.length} file${r.touched.length === 1 ? '' : 's'}`).join(' '),
1558
- };
1559
- }
1560
-
1561
- async runWorker(task, index) {
1562
- const name = clip(String(task.name || `worker ${index + 1}`).trim(), 16);
1563
- const touched = new Set();
1564
- const skills = this.skills
1565
- .filter((s) => this.loaded.has(s.name))
1566
- .map((s) => `--- ${s.name} ---\n${s.body}`)
1567
- .join('\n\n');
1568
- const messages = [
1569
- { role: 'system', content: workerPrompt({ cwd: this.cwd, name, memory: this.memory, skills, map: this.map }) },
1570
- { role: 'user', content: String(task.instructions) },
1571
- ];
1572
- const available = this.toolsNow().filter((t) => !WORKER_EXCLUDED.has(t.name));
1573
- const wanted = process.env.UCODE_WORKER_MODEL;
1574
- let workerModel = wanted && MODELS[wanted] ? wanted : model();
1575
- const tried = new Set([workerModel]);
1576
- let failovers = 0;
1577
-
1578
- // Start a moment apart. Three requests in the same instant is exactly what
1579
- // trips a free endpoint's rate limit, and the stagger costs a second or two.
1580
- if (index) await wait(index * 1500);
1581
-
1582
- for (let step = 0; step < WORKER_STEPS; step++) {
1583
- if (this.abort?.signal.aborted) break;
1584
-
1585
- let reply;
1586
- const asked = Date.now();
1587
- try {
1588
- reply = await ask(messages, available, { signal: this.abort?.signal, model: workerModel });
1589
- } catch (err) {
1590
- // Same rule as the lead: a busy model is swapped, not a reason to stop.
1591
- if (passing(err) && failovers < 6 && !this.abort?.signal.aborted) {
1592
- failovers++;
1593
- let next = fallbackFor(workerModel, tried);
1594
- if (!next) { tried.clear(); await wait(20_000); next = fallbackFor(workerModel, tried) ?? workerModel; }
1595
- tried.add(next);
1596
- this.ui.note(`${name}: ${modelName(workerModel)} is busy — switching to ${modelName(next)}`);
1597
- workerModel = next;
1598
- step--;
1599
- continue;
1600
- }
1601
- throw err;
1602
- }
1603
- this.record(reply.usage);
1604
- trace({
1605
- kind: 'model', who: name, model: workerModel, ms: Date.now() - asked,
1606
- in: reply.usage.promptTokens, out: reply.usage.outputTokens,
1607
- calls: reply.toolCalls.map((c) => c.name),
1608
- });
1609
-
1610
- if (!reply.toolCalls.length) {
1611
- this.ui.toolResult(`${name} finished`);
1612
- return { name, summary: reply.text?.trim() || 'Finished without a summary.', touched: [...touched] };
1613
- }
1614
-
1615
- messages.push({ role: 'assistant', content: reply.text || '', toolCalls: reply.toolCalls });
1616
- for (const call of reply.toolCalls) {
1617
- this.ui.toolCall(`${name} › ${describe(call.name, call.args)}`);
1618
- if (FILE_WRITES.has(call.name)) for (const p of pathsOf(call)) touched.add(p);
1619
- const { out, err } = FILE_WRITES.has(call.name)
1620
- ? await this.fileLock(() => this.execute(call))
1621
- : await this.execute(call);
1622
- if (err) {
1623
- if (!(err instanceof ToolFailure)) throw err;
1624
- this.ui.toolFailed(`${name}: ${err.kind}: ${err.failed}`);
1625
- }
1626
- messages.push({
1627
- role: 'tool', toolCallId: call.id, name: call.name,
1628
- content: err ? err.forModel() : out.content,
1629
- });
1630
- }
1631
- }
1632
-
1633
- return { name, summary: `Stopped after ${WORKER_STEPS} steps without finishing.`, touched: [...touched] };
1634
- }
1635
-
1636
- /**
1637
- * Check the code files changed since the last check, and return the
1638
- * errors as text for the model or null when everything is clean.
1639
- *
1640
- * The check is incremental: TypeScript writes what it learned to a build
1641
- * info file, so the second check onwards reads that instead of retyping
1642
- * every dependency seconds rather than the best part of a minute. The
1643
- * file sits in node_modules/.cache, which is already ignored by git and is
1644
- * deliberately left out of the starter package cache.
1645
- *
1646
- * TypeScript projects get one `tsc --noEmit` per project that owns a
1647
- * changed file (an app scaffolded into a subfolder is its own project).
1648
- * Plain JavaScript gets a syntax check, Python a compile check. Nothing
1649
- * runs that is not already installed.
1650
- */
1651
- async autoCheck() {
1652
- const changed = [...this.sinceCheck].filter((f) => CHECKABLE.test(f));
1653
- this.sinceCheck.clear();
1654
- if (!changed.length) return null;
1655
-
1656
- const root = path.resolve(this.cwd);
1657
- const tsRoots = new Set();
1658
- const singles = [];
1659
-
1660
- for (const rel of changed) {
1661
- const abs = path.resolve(root, rel);
1662
- if (!(await exists(abs))) continue;
1663
- if (/\.py$/i.test(rel)) { singles.push({ abs, rel, command: `python -m py_compile "${abs}"` }); continue; }
1664
- let dir = path.dirname(abs);
1665
- let owner = null;
1666
- while (dir.startsWith(root)) {
1667
- if (await exists(path.join(dir, 'tsconfig.json'))) { owner = dir; break; }
1668
- const up = path.dirname(dir);
1669
- if (up === dir) break;
1670
- dir = up;
1671
- }
1672
- if (owner && (await exists(path.join(owner, 'node_modules', 'typescript')))) tsRoots.add(owner);
1673
- else if (/\.[cm]?js$/i.test(rel)) singles.push({ abs, rel, command: `node --check "${abs}"` });
1674
- }
1675
-
1676
- const problems = [];
1677
- const check = async (label, command, cwd) => {
1678
- this.ui.toolCall(label);
1679
- this.ui.startSpinner(label);
1680
- const { out, err } = await this.execute({
1681
- id: 'check', name: 'run_command',
1682
- args: { command, cwd: path.relative(root, cwd) || '.', timeout_ms: 180_000 },
1683
- });
1684
- this.ui.stopSpinner();
1685
- return err ? { exitCode: -1, content: String(err.failed ?? err.message) } : out;
1686
- };
1687
-
1688
- for (const dir of tsRoots) {
1689
- const show = path.relative(root, dir) || '.';
1690
- await mkdir(path.join(dir, path.dirname(TSBUILDINFO)), { recursive: true }).catch(() => {});
1691
- let out = await check(`Checking types in ${show}`, typeCheckCommand(true), dir);
1692
- // Older TypeScript refuses --incremental alongside --noEmit. Say so once
1693
- // by simply checking again the slow way, rather than failing the edit.
1694
- if (out.exitCode !== 0 && NO_INCREMENTAL.test(out.content)) {
1695
- out = await check(`Checking types in ${show}`, typeCheckCommand(false), dir);
1696
- }
1697
- if (out.exitCode === 0) { this.ui.toolResult('types check out'); continue; }
1698
- const errors = out.content.split('\n').filter((l) => /error TS\d+/.test(l));
1699
- this.ui.toolFailed(`${errors.length || 'some'} type error${errors.length === 1 ? '' : 's'}`);
1700
- problems.push(`In ${show} (tsc --noEmit):\n${(errors.length ? errors : out.content.split('\n')).slice(0, 40).join('\n')}`);
1701
- }
1702
-
1703
- for (const f of singles) {
1704
- const out = await check(`Checking ${f.rel}`, f.command, root);
1705
- if (out.exitCode === 0) { this.ui.toolResult('ok'); continue; }
1706
- this.ui.toolFailed('does not compile');
1707
- problems.push(`${f.rel}:\n${out.content.split('\n').slice(0, 20).join('\n')}`);
1708
- }
1709
-
1710
- // Only once it compiles: a failing test on code that does not build tells
1711
- // the model nothing it does not already know from the errors above.
1712
- if (!problems.length) {
1713
- const failed = await this.runRelatedTests(root, changed);
1714
- if (failed) problems.push(failed);
1715
- }
1716
-
1717
- const live = await this.liveErrors();
1718
- if (live) problems.push(live);
1719
-
1720
- return problems.length ? problems.join('\n\n') : null;
1721
- }
1722
-
1723
- /**
1724
- * Anything the running app has complained about since the last look. A dev
1725
- * server knows about a broken import the moment it happens; without this
1726
- * nobody reads that until a build, or until the user says the page is blank.
1727
- */
1728
- async liveErrors() {
1729
- try {
1730
- const found = await this.logWatch.since(runningServers());
1731
- if (found) this.ui.toolFailed('the running app reported an error');
1732
- return found;
1733
- } catch {
1734
- return null; // reading a log must never be what breaks a turn
1735
- }
1736
- }
1737
-
1738
- /**
1739
- * Run the tests that reach the files just changed, and return their
1740
- * failures as text — or null when they pass, or when this project has no
1741
- * runner that can be asked which tests matter.
1742
- */
1743
- async runRelatedTests(root, changed) {
1744
- const runner = await testRunnerFor(root);
1745
- if (!runner) return null;
1746
-
1747
- const existing = [];
1748
- for (const rel of changed) if (await exists(path.resolve(root, rel))) existing.push(rel);
1749
- const command = relatedCommand(runner, existing);
1750
- if (!command) return null;
1751
-
1752
- const label = `Running the ${runner} tests that cover this`;
1753
- this.ui.toolCall(label);
1754
- this.ui.startSpinner(label);
1755
- const { out, err } = await this.execute({
1756
- id: 'tests', name: 'run_command',
1757
- args: { command, cwd: '.', timeout_ms: 180_000 },
1758
- });
1759
- this.ui.stopSpinner();
1760
-
1761
- if (err) { this.ui.toolResult('tests skipped'); return null; }
1762
- if (out.exitCode === 0) { this.ui.toolResult('tests pass'); return null; }
1763
-
1764
- // A runner that is not installed is not a failing test; npx says so.
1765
- if (/could not determine executable|not found|Cannot find module/i.test(out.content)) {
1766
- this.ui.toolResult('no test runner installed');
1767
- return null;
1768
- }
1769
-
1770
- this.ui.toolFailed('tests fail');
1771
- return `The tests covering your change fail (${runner}):\n${summariseFailures(runner, out.content)}`;
1772
- }
1773
-
1774
- loadSkill(name) {
1775
- const skill = findSkill(this.skills, name);
1776
- if (!skill) {
1777
- // The available names go in `failed` rather than only in `fix`: the
1778
- // transcript shows the failure line, and a bare "no such skill" leaves
1779
- // the user guessing at what this session actually has.
1780
- const available = this.skills.map((s) => s.name).join(', ') || '(none)';
1781
- throw new ToolFailure({
1782
- kind: 'no_such_skill',
1783
- attempted: `loading the "${name}" skill`,
1784
- failed: `There is no skill called "${name}". This session has: ${available}.`,
1785
- fix: 'Use one of those names, or carry on without one.',
1786
- });
1787
- }
1788
-
1789
- if (this.loaded.has(skill.name)) {
1790
- return { content: `The "${skill.name}" skill is already loaded above. Follow it.`, summary: 'already loaded' };
1791
- }
1792
-
1793
- this.loaded.add(skill.name);
1794
- this.push(skillMessage(skill));
1795
- return {
1796
- content: `Loaded "${skill.name}". Its instructions are in your context now — follow them.`,
1797
- summary: `${skill.name} · ${skill.body.split('\n').length} lines`,
1798
- };
1799
- }
1800
-
1801
- record(u) {
1802
- const total = this.session.usage;
1803
- total.promptTokens += u.promptTokens || 0;
1804
- total.outputTokens += u.outputTokens || 0;
1805
- total.totalTokens += u.totalTokens || 0;
1806
- total.turns += 1;
1807
- }
1808
-
1809
- /** Fold older turns into a summary when the window gets tight. */
1810
- async maybeFold() {
1811
- const limit = contextLimit();
1812
- if (!tooBig(this.working, limit)) return;
1813
-
1814
- this.ui.startSpinner('context is filling up — summarizing earlier turns');
1815
- try {
1816
- const result = await fold(this.working, {
1817
- limit,
1818
- summarize: async (older) => {
1819
- const reply = await ask(
1820
- [
1821
- { role: 'system', content: SUMMARY_PROMPT },
1822
- { role: 'user', content: forSummary(older) },
1823
- ],
1824
- [],
1825
- { signal: this.abort?.signal, temperature: 0 }
1826
- );
1827
- return reply.text;
1828
- },
1829
- });
1830
-
1831
- this.ui.stopSpinner();
1832
- if (result.folded) {
1833
- this.working = result.messages;
1834
- this.ui.note(
1835
- `folded ${result.droppedCount} earlier messages into a summary ` +
1836
- '(the full history is still saved in this session)'
1837
- );
1838
- }
1839
- } catch (err) {
1840
- // If summarizing fails, carry on with the full history and let the API
1841
- // complain — better than silently throwing away the conversation.
1842
- this.ui.stopSpinner();
1843
- this.ui.note(`could not summarize older turns (${err.kind ?? 'error'}); carrying on uncompacted`);
1844
- }
1845
- }
1846
-
1847
- // -- slash commands ------------------------------------------------------
1848
-
1849
- async command(input) {
1850
- const [name, ...rest] = input.split(/\s+/);
1851
- const arg = rest.join(' ').trim();
1852
-
1853
- switch (name) {
1854
- case '/help':
1855
- return this.cmdHelp();
1856
-
1857
- // One thing, one command, however you happen to spell it.
1858
- case '/model':
1859
- case '/models':
1860
- return this.cmdModel(arg);
1861
-
1862
- case '/session':
1863
- case '/sessions':
1864
- case '/resume':
1865
- return this.cmdSessions(arg);
1866
-
1867
- case '/new': return this.cmdNew();
1868
- case '/remember': return this.cmdRemember(arg);
1869
- case '/skills': return this.cmdSkills();
1870
- case '/clear': this.showHeader(); return;
1871
- case '/search': return this.cmdSearch(arg);
1872
- case '/copy': return this.cmdCopy();
1873
- case '/stats': return this.cmdStats();
1874
- case '/doctor': return this.cmdDoctor();
1875
- case '/look': return this.cmdLook(arg);
1876
- case '/deploy': return this.cmdDeploy(arg);
1877
- case '/exit':
1878
- case '/quit': return 'exit';
1879
-
1880
- default:
1881
- this.ui.write(theme.warn(` no such command: ${name}`));
1882
- this.ui.note('/help lists them.');
1883
- }
1884
- }
1885
-
1886
- /**
1887
- * Look at the running app, because the user asked to.
1888
- *
1889
- * This used to happen on its own, which meant a browser being driven while
1890
- * someone was reading, and a window taking the screen mid-thought. It is
1891
- * the same check as before; the difference is who starts it.
1892
- */
1893
- async cmdLook(url) {
1894
- const { lookAtApp } = await import('../tools/browser.js');
1895
- const server = runningServers().at(-1);
1896
- const at = (url ?? '').trim() || server?.url;
1897
- if (!at) {
1898
- this.ui.write(theme.warn(' nothing is running to look at.'));
1899
- this.ui.note('start the app first, or pass a URL: /look http://localhost:3000');
1900
- return;
1901
- }
1902
- this.ui.toolCall(`Looking at ${at}`);
1903
- try {
1904
- const out = await lookAtApp({ url: at });
1905
- this.ui.write(out.content);
1906
- // The model gets it too, so the next thing it says is about what is
1907
- // actually on the page rather than what it believes it built.
1908
- this.push({ role: 'user', content: `I looked at ${at}. This is what is there:
1909
-
1910
- ${out.content}` });
1911
- } catch (err) {
1912
- this.ui.write(theme.error(` ${err.failed ?? err.message}`));
1913
- }
1914
- }
1915
-
1916
- cmdHelp() {
1917
- const rows = [
1918
- ['/help', 'this list'],
1919
- ['/stats', 'time, steps and tokens this session'],
1920
- ['/doctor', 'check that everything ucode needs is working'],
1921
- ['/look [url]', 'open the running app and report what is on the page'],
1922
- ['/deploy [folder]', 'put the app online and get its link'],
1923
- ['/model', 'show the models and switch between them'],
1924
- ['/resume', 'pick up an earlier conversation'],
1925
- ['/new', 'save this one and start fresh'],
1926
- ['/remember <note>', `add a standing note to ${MEMORY_FILE}`],
1927
- ['/skills', 'what ucode knows how to do'],
1928
- ['/search <query>', 'look something up on the web'],
1929
- ['/copy', 'copy the last reply to the clipboard'],
1930
- ['/clear', 'clear the screen, keep the conversation'],
1931
- ['/exit', 'save and quit'],
1932
- ];
1933
-
1934
- this.ui.blank();
1935
- for (const [command, what] of rows) {
1936
- this.ui.write(` ${blue(command.padEnd(18))} ${dim(what)}`);
1937
- }
1938
- this.ui.blank();
1939
- this.ui.write(dim(' /models, /session and /sessions do the same as /model and /resume.'));
1940
- this.ui.write(dim(' ctrl+b swaps plan and build · esc stops a running turn · ctrl+d quits'));
1941
- this.ui.blank();
1942
- }
1943
-
1944
- /** The five models, and this session's spend. */
1945
- async cmdModel(arg) {
1946
- if (arg) {
1947
- try {
1948
- setModel(arg);
1949
- this.preferred = model();
1950
- } catch (err) {
1951
- this.ui.error(err, { debug: this.debug });
1952
- return;
1953
- }
1954
- this.session.model = model();
1955
- this.ui.note(`now using ${modelName()}`);
1956
- this.showHeader({ clear: false });
1957
- return;
1958
- }
1959
-
1960
- const all = modelList();
1961
- const width = Math.max(...all.map((m) => m.name.length));
1962
-
1963
- if (this.ui.pick) {
1964
- const items = all.map((m) => ({
1965
- label:
1966
- `${m.active ? blue('●') : dim('○')} ${m.star ? blue('★') : ' '} ` +
1967
- `${m.name.padEnd(width)} ${dim(`${formatTokens(m.context)} · ${m.note}`)}`,
1968
- }));
1969
-
1970
- const chosen = await this.ui.pick(items, {
1971
- active: Math.max(0, all.findIndex((m) => m.active)),
1972
- hint: '↑↓ move · enter to switch · esc to cancel',
1973
- });
1974
- if (chosen === null) return;
1975
-
1976
- setModel(all[chosen].id);
1977
- this.preferred = model();
1978
- this.session.model = model();
1979
- this.ui.note(`now using ${modelName()}`);
1980
- this.showHeader({ clear: false });
1981
- return;
1982
- }
1983
-
1984
- this.ui.blank();
1985
- for (const m of all) {
1986
- this.ui.write(
1987
- ` ${m.active ? blue('●') : dim('○')} ${m.star ? blue('★') : ' '} ` +
1988
- `${(m.active ? blue : dim)(m.name.padEnd(width))} ${dim(`${formatTokens(m.context)} · ${m.note}`)}`
1989
- );
1990
- this.ui.write(` ${dim(m.id)}`);
1991
- }
1992
-
1993
- const u = this.session.usage;
1994
- const live = rateLimits();
1995
- this.ui.blank();
1996
- this.ui.write(
1997
- ` ${dim('this session')} ${u.turns} turns · ${formatTokens(u.totalTokens)} tokens ` +
1998
- `(${formatTokens(u.promptTokens)} in, ${formatTokens(u.outputTokens)} out)`
1999
- );
2000
- if (live?.requestsRemaining != null && live?.requestsLimit) {
2001
- this.ui.write(` ${dim('requests')} ${live.requestsRemaining} of ${live.requestsLimit} left`);
2002
- }
2003
- this.ui.blank();
2004
- this.ui.write(dim(' /model <id> switches without the picker.'));
2005
- this.ui.blank();
2006
- }
2007
-
2008
- /**
2009
- * One row per saved conversation.
2010
- *
2011
- * A list of titles and timestamps is not enough to recognise your own work
2012
- * by half of them start "Fix the". So each row carries what it was about
2013
- * and how far it got, and the ones from this folder are marked, because that
2014
- * is nearly always the one being looked for.
2015
- */
2016
- describeSession(s, width, i) {
2017
- const room = Math.max(24, Math.min(46, width - 34));
2018
- const mark = s.mine ? blue('●') : dim('○');
2019
- const when = relativeTime(s.updatedAt).padEnd(9);
2020
- const turns = `${s.turns} turn${s.turns === 1 ? '' : 's'}`.padEnd(9);
2021
- const where = s.mine ? 'here' : shortenPath(s.cwd, 26);
2022
-
2023
- return {
2024
- // Numbered in the picker, so /session delete 3 has something to point at.
2025
- label: `${i === undefined ? '' : `${dim(String(i + 1).padStart(2))} `}${mark} ${clip(s.title, room).padEnd(room)} ${dim(when)}${dim(turns)}${dim(where)}`,
2026
- sub: s.preview ? dim(` ${clip(s.preview, width - 10)}`) : '',
2027
- };
2028
- }
2029
-
2030
- async cmdSessions(arg) {
2031
- if (arg === '--clear' || arg === 'clear') {
2032
- const yes = await this.ui.confirm({
2033
- action: 'delete every saved conversation',
2034
- detail: 'This cannot be undone.',
2035
- risk: 'write',
2036
- });
2037
- if (!yes) {
2038
- this.ui.note('cancelled');
2039
- return;
2040
- }
2041
- await removeAll();
2042
- this.ui.note('all sessions deleted');
2043
- return;
2044
- }
2045
-
2046
- // /session delete 3 or /session delete 2,5,7
2047
- const del = /^(?:delete|del|rm|remove)\b\s*(.*)$/i.exec(arg ?? '');
2048
- if (del) return this.deleteSessions(del[1]);
2049
-
2050
- const sessions = await list({ cwd: this.cwd });
2051
- if (!sessions.length) {
2052
- this.ui.note('no saved conversations yet');
2053
- return;
2054
- }
2055
-
2056
- for (const bad of sessions.unreadable ?? []) {
2057
- this.ui.write(theme.warn(` could not read session file: ${bad}`));
2058
- }
2059
-
2060
- const shown = sessions.slice(0, 25);
2061
- const width = this.ui.width ? this.ui.width() : 80;
2062
- let index;
2063
-
2064
- if (arg) {
2065
- const n = Number(arg);
2066
- if (!Number.isInteger(n) || n < 1 || n > shown.length) {
2067
- this.ui.write(theme.warn(` "${arg}" is not one of 1-${shown.length}`));
2068
- return;
2069
- }
2070
- index = n - 1;
2071
- } else if (this.ui.pick) {
2072
- // The picker stays open while you delete, so clearing out several old
2073
- // conversations is d d, d d, d d — then Enter on the one you want.
2074
- let active = 0;
2075
- for (;;) {
2076
- const here = shown.filter((s) => s.mine).length;
2077
- const picked = await this.ui.pick(
2078
- shown.map((s, i) => this.describeSession(s, width, i)),
2079
- {
2080
- active,
2081
- deletable: true,
2082
- hint:
2083
- `↑↓ move · enter to continue · d twice to delete · esc to cancel` +
2084
- (here ? ` ${here} from this folder` : ''),
2085
- }
2086
- );
2087
- if (picked === null) return;
2088
- if (typeof picked === 'object' && picked.delete !== undefined) {
2089
- const doomed = shown[picked.delete];
2090
- active = picked.delete;
2091
- if (doomed.id === this.session.id) {
2092
- this.ui.flash?.('that is the conversation you are in — /new first, then delete it');
2093
- continue;
2094
- }
2095
- await remove(doomed.id);
2096
- shown.splice(picked.delete, 1);
2097
- this.ui.flash?.(`deleted · ${clip(doomed.title, 50)}`);
2098
- if (!shown.length) {
2099
- this.ui.note('no saved conversations left');
2100
- return;
2101
- }
2102
- active = Math.min(active, shown.length - 1);
2103
- continue;
2104
- }
2105
- index = picked;
2106
- break;
2107
- }
2108
- } else {
2109
- this.ui.blank();
2110
- this.ui.note('/session delete <number> removes one, or several: /session delete 2,5');
2111
- index = await this.ui.choose(
2112
- 'continue which?',
2113
- shown.map((s, i) => this.describeSession(s, width, i).label)
2114
- );
2115
- if (index === null) return;
2116
- }
2117
-
2118
- if (this.session.messages.length) await this.persist();
2119
- if (await this.resume(shown[index].id)) {
2120
- this.showHeader();
2121
- this.replayTail();
2122
- }
2123
- }
2124
-
2125
- /** /session delete 3, or 2,5,7 — numbers as the session list shows them. */
2126
- async deleteSessions(spec) {
2127
- const sessions = (await list({ cwd: this.cwd })).slice(0, 25);
2128
- const numbers = [...new Set(String(spec).split(/[\s,]+/).filter(Boolean).map(Number))];
2129
- const bad = numbers.filter((n) => !Number.isInteger(n) || n < 1 || n > sessions.length);
2130
- if (!numbers.length || bad.length) {
2131
- this.ui.write(theme.warn(` usage: /session delete <number>[,<number>…] — numbers from 1 to ${sessions.length}`));
2132
- return;
2133
- }
2134
- for (const n of numbers) {
2135
- const s = sessions[n - 1];
2136
- if (s.id === this.session.id) {
2137
- this.ui.note(`skipped ${n}that is the conversation you are in`);
2138
- continue;
2139
- }
2140
- await remove(s.id);
2141
- this.ui.note(`deleted ${n} · ${s.title}`);
2142
- }
2143
- }
2144
-
2145
- async resume(id) {
2146
- try {
2147
- const loaded = await load(id);
2148
- this.session = loaded;
2149
- this.working = [...loaded.messages];
2150
- this.loaded = new Set(loaded.messages.filter((m) => m.skill).map((m) => m.skill));
2151
- if (loaded.model && MODELS[loaded.model]) setModel(loaded.model);
2152
- return true;
2153
- } catch (err) {
2154
- this.ui.error(err, { debug: this.debug });
2155
- this.ui.note('Starting a fresh one instead.');
2156
- return false;
2157
- }
2158
- }
2159
-
2160
- /** The last few exchanges, so a resumed conversation has visible context. */
2161
- replayTail(count = 4) {
2162
- const tail = this.session.messages
2163
- .filter((m) => (m.role === 'user' || m.role === 'assistant') && m.content)
2164
- .slice(-count);
2165
-
2166
- for (const m of tail) {
2167
- if (m.role !== 'user') this.ui.assistant(m.content);
2168
- else if (this.ui.userMessage) this.ui.userMessage(m.content);
2169
- else this.ui.write(`${blue('›')} ${dim(m.content.split('\n')[0])}`);
2170
- }
2171
- if (tail.length) this.ui.write(dim(' ── picking up here ──\n'));
2172
- }
2173
-
2174
- /** Add a line to this project's UCODE.md, read at the start of every turn. */
2175
- async cmdRemember(note) {
2176
- if (!note) {
2177
- this.ui.note(`usage: /remember <something ucode should always know here> — saved to ${MEMORY_FILE}`);
2178
- return;
2179
- }
2180
- try {
2181
- const file = await remember(this.cwd, note);
2182
- this.ui.note(`remembered · ${path.relative(this.cwd, file) || MEMORY_FILE}`);
2183
- } catch (err) {
2184
- this.ui.error(new Failure({
2185
- kind: 'memory_unwritable',
2186
- attempted: `saving to ${MEMORY_FILE}`,
2187
- failed: err.message,
2188
- fix: 'Check that this folder is writable.',
2189
- }), { debug: this.debug });
2190
- }
2191
- }
2192
-
2193
- async cmdNew() {
2194
- if (this.session.messages.length) {
2195
- await this.persist();
2196
- this.ui.note(`saved · ${this.session.title}`);
2197
- }
2198
- this.session = newSession(this.cwd, model());
2199
- this.working = [];
2200
- this.loaded = new Set();
2201
- this.showHeader();
2202
- }
2203
-
2204
- async cmdSkills() {
2205
- // Re-read from disk. Skills load once at startup, so one written during
2206
- // this session would otherwise stay invisible — and load_skill would fail
2207
- // on a name the user can see in the folder.
2208
- this.skills = await loadSkills({ cwd: this.cwd });
2209
- for (const problem of this.skills.problems ?? []) {
2210
- this.ui.write(theme.warn(` skill not loaded: ${problem}`));
2211
- }
2212
-
2213
- if (!this.skills.length) {
2214
- this.ui.note('no skills found — add a folder with a SKILL.md under .ucode/skills');
2215
- return;
2216
- }
2217
-
2218
- this.ui.blank();
2219
- for (const s of this.skills) {
2220
- const live = this.loaded.has(s.name);
2221
- const auto = s.triggers.length ? dim(' · loads itself') : '';
2222
- this.ui.write(` ${live ? blue('●') : dim('○')} ${blue(s.name)}${auto}`);
2223
- this.ui.write(` ${dim(s.description)}`);
2224
- }
2225
- this.ui.blank();
2226
- this.ui.write(dim(' already loaded here · ucode pulls one in when the task matches'));
2227
- this.ui.blank();
2228
- }
2229
-
2230
- async cmdSearch(query) {
2231
- if (!query) {
2232
- this.ui.note('usage: /search <what you want to look up>');
2233
- return;
2234
- }
2235
- await this.turn(
2236
- `Search the web for: ${query}\n\nUse web_search, then summarise what you found and cite the URLs.`
2237
- );
2238
- }
2239
-
2240
- /** Copy the last reply. Every platform ships a clipboard pipe. */
2241
- async cmdCopy() {
2242
- const last = [...this.session.messages]
2243
- .reverse()
2244
- .find((m) => m.role === 'assistant' && m.content?.trim());
2245
-
2246
- if (!last) {
2247
- this.ui.note('nothing to copy yet');
2248
- return;
2249
- }
2250
-
2251
- const tool = process.platform === 'win32' ? 'clip'
2252
- : process.platform === 'darwin' ? 'pbcopy'
2253
- : 'xclip -selection clipboard';
2254
-
2255
- try {
2256
- await new Promise((resolve, reject) => {
2257
- const child = spawn(tool, { shell: true, windowsHide: true });
2258
- child.on('error', reject);
2259
- child.on('close', (code) => (code === 0 ? resolve() : reject(new Error(`exit ${code}`))));
2260
- child.stdin.end(last.content);
2261
- });
2262
- const lines = last.content.split('\n').length;
2263
- this.ui.note(`copied ${lines} line${lines === 1 ? '' : 's'}`);
2264
- } catch (err) {
2265
- this.ui.error(new Failure({
2266
- kind: 'clipboard_failed',
2267
- attempted: 'copying the last reply',
2268
- failed: `${tool} could not run: ${err.message}`,
2269
- fix: process.platform === 'linux'
2270
- ? 'Install xclip (apt install xclip), or select the text with the mouse.'
2271
- : 'Select the text with the mouse instead.',
2272
- }), { debug: this.debug });
2273
- }
2274
- }
2275
- }
2276
-
2277
- export { DEFAULT_MODEL, PROVIDER };
1
+ /**
2
+ * loop.js — the agent itself.
3
+ *
4
+ * One turn, end to end: take a line, fold the conversation if it has grown too
5
+ * big, ask the model, and if it asked for tools, run them and ask again. Repeat
6
+ * until it answers with prose instead of a tool call.
7
+ *
8
+ * Two histories are kept, deliberately:
9
+ * session.messages the complete record, written to disk after every step
10
+ * working what is actually sent, which may have its older turns
11
+ * folded into a summary once the window gets tight
12
+ */
13
+
14
+ import path from 'node:path';
15
+ import os from 'node:os';
16
+ import { appendFileSync } from 'node:fs';
17
+ import { readFile, access, mkdir } from 'node:fs/promises';
18
+ import { testRunnerFor, relatedCommand, summariseFailures } from './tests.js';
19
+ import { LogWatch } from './livelog.js';
20
+ import { checkHtml } from './htmlcheck.js';
21
+ import { runningServers } from '../tools/shell.js';
22
+ import { spawn } from 'node:child_process';
23
+
24
+ import {
25
+ ask, model, setModel, modelName, modelList, contextLimit, rateLimits,
26
+ MODELS, DEFAULT_MODEL, PROVIDER, fallbackFor,
27
+ } from './provider.js';
28
+ import {
29
+ tools, runTool, describe, setRoot, setConfirm, PARALLEL_SAFE, WRITES, FILE_WRITES,
30
+ } from '../tools/index.js';
31
+ import { projectMap, loadMemory, remember, MEMORY_FILE } from './context.js';
32
+ import { autoUpdate } from './updater.js';
33
+ import { closeBrowser, forgetReviews } from '../tools/browser.js';
34
+ import {
35
+ newSession, save, load, list, remove, removeAll, titleFrom,
36
+ } from './history.js';
37
+ import { fold, usage, tooBig, SUMMARY_PROMPT, forSummary } from './window.js';
38
+ import { loadSkills, catalogue, findSkill, skillMessage, autoLoadFor } from './skills.js';
39
+ import { Screen, isLabel } from '../ui/screen.js';
40
+ import { Plain } from '../ui/plain.js';
41
+ import { theme, blue, sky, dim, formatTokens, relativeTime, shortenPath, clip } from '../ui/theme.js';
42
+ import { Failure, ToolFailure, Declined } from './failure.js';
43
+ import { StuckWatch, eventFor, describeHit } from './stuck.js';
44
+ import { serversReadySince } from '../tools/shell.js';
45
+ import { formatDuration } from '../ui/activity.js';
46
+ import { runDoctor } from './doctor.js';
47
+ import { deploy } from '../tools/deploy.js';
48
+
49
+ /**
50
+ * Tool calls allowed in one turn.
51
+ *
52
+ * Scaffolding an app is dozens of writes before anything can even be run, so a
53
+ * small ceiling stops a real job halfway through which from the outside is
54
+ * indistinguishable from the agent giving up for no reason. The runaway-loop
55
+ * protection this exists for still works at 250; a loop burns through that
56
+ * just as visibly, only later.
57
+ */
58
+ const MAX_STEPS = Number(process.env.UCODE_MAX_STEPS) || 250;
59
+ const MAX_ARG_RETRIES = 2;
60
+
61
+ /**
62
+ * How many times a reply cut off at the output limit is asked to carry on.
63
+ * Three covers any answer a terminal should be printing; past that the model
64
+ * is rambling and stopping is the kinder outcome.
65
+ */
66
+ const MAX_CONTINUATIONS = 3;
67
+
68
+ /** Read-only tools whose result line adds nothing — the user saw the output. */
69
+ /**
70
+ * How many rows a diff adds and removes.
71
+ *
72
+ * The rows come through as "+12| text" and "-12| text", with a "~" heading
73
+ * for each file in a multi-file write and an undecorated note counting what
74
+ * was elided. Only the signs are counted.
75
+ */
76
+ export function countDiff(rows = []) {
77
+ let added = 0;
78
+ let removed = 0;
79
+ for (const row of rows) {
80
+ const line = String(row ?? '');
81
+ if (line.startsWith('~')) continue;
82
+ // "… 218 more removed" / "… 508 more added" stand for rows not shown.
83
+ const more = /^\s*[….]+\s*(\d+)\s+more\s+(added|removed)/.exec(line);
84
+ if (more) {
85
+ if (more[2] === 'added') added += Number(more[1]);
86
+ else removed += Number(more[1]);
87
+ continue;
88
+ }
89
+ if (line.startsWith('+')) added++;
90
+ else if (line.startsWith('-')) removed++;
91
+ }
92
+ return { added, removed };
93
+ }
94
+
95
+ const QUIET = new Set(['read_file', 'read_files', 'list_dir', 'glob', 'grep', 'web_search', 'update_plan']);
96
+
97
+ /** Tools that draw their own line, so they get no "● Doing X" line of their own. */
98
+ const SILENT = new Set(['update_plan']);
99
+
100
+ /** How many rounds of "the type check found errors, fix them" one turn may take. */
101
+ const MAX_FIX_ROUNDS = 3;
102
+
103
+ /** Files worth checking after they change. */
104
+ const CHECKABLE = /\.(?:[cm]?[jt]sx?|py|html?)$/i;
105
+
106
+ /** Where TypeScript keeps what it learned, so the next check is a quick one. */
107
+ export const TSBUILDINFO = 'node_modules/.cache/ucode/types.tsbuildinfo';
108
+
109
+ /** TypeScript before 4.0 rejects --incremental together with --noEmit. */
110
+ const NO_INCREMENTAL = /TS5074|TS6304|'--incremental'/;
111
+
112
+ /**
113
+ * The type check to run. Incremental by default: the first check pays the
114
+ * full cost and writes a build info file, and every one after it reads that
115
+ * and reports in about a second.
116
+ */
117
+ export function typeCheckCommand(incremental = true) {
118
+ const base = 'npx --no-install tsc --noEmit --pretty false';
119
+ return incremental ? `${base} --incremental --tsBuildInfoFile ${TSBUILDINFO}` : base;
120
+ }
121
+
122
+ /**
123
+ * Failures that are the provider's and not the model's: busy, slow, down, or
124
+ * unreachable. None of them should end a build — the turn moves to another
125
+ * model and carries on from exactly where it was.
126
+ */
127
+ const TRANSIENT = new Set(['rate_limit', 'timeout', 'server', 'network', 'no_content']);
128
+ /** Will another model, or a little patience, get past this? Not the daily cap: it covers them all. */
129
+ const passing = (err) => TRANSIENT.has(err.kind) && !err.detail?.daily;
130
+ const MAX_FAILOVERS = 8;
131
+ const COOLDOWN = 5 * 60_000;
132
+
133
+ const wait = (ms) => new Promise((r) => setTimeout(r, ms));
134
+
135
+ /**
136
+ * UCODE_TRACE=1 writes one JSON line per model call and per tool to
137
+ * ~/.ucode/trace.jsonl (or to the path UCODE_TRACE names): how long it took,
138
+ * tokens in and out, what was called. It is how "it feels slow" becomes a
139
+ * number with a cause attached.
140
+ */
141
+ const TRACE_FILE = process.env.UCODE_TRACE
142
+ ? (process.env.UCODE_TRACE === '1' ? path.join(os.homedir(), '.ucode', 'trace.jsonl') : process.env.UCODE_TRACE)
143
+ : null;
144
+
145
+ /** Tool results worth re-sending in full only while they are recent. */
146
+ const THIN_RESULTS = new Set([
147
+ 'read_file', 'read_files', 'grep', 'glob', 'list_dir', 'run_command', 'run_commands',
148
+ 'look_at_app', 'web_search', 'edit_file', 'multi_edit', 'edit_files',
149
+ ]);
150
+
151
+ /** Replace long strings in old tool arguments with a note of their size. */
152
+ function thinArgs(value) {
153
+ if (typeof value === 'string') {
154
+ return value.length > 400
155
+ ? `[${value.length} characters, already applied — read the file if you need its current text]`
156
+ : value;
157
+ }
158
+ if (Array.isArray(value)) return value.map(thinArgs);
159
+ if (value && typeof value === 'object') {
160
+ return Object.fromEntries(Object.entries(value).map(([k, v]) => [k, thinArgs(v)]));
161
+ }
162
+ return value;
163
+ }
164
+
165
+ /**
166
+ * The conversation as sent, with bulky history thinned.
167
+ *
168
+ * Every file the model writes travels inside its own tool call, so a
169
+ * thirty-file app is re-sent in full on every later step tens of thousands
170
+ * of tokens the provider has to read before it can answer, growing with
171
+ * each file. Beyond the last few steps that text is replaced by a note of its
172
+ * size; the files are on disk, and the model re-reads one when it needs it.
173
+ * Call ids and results stay paired, and the saved session keeps everything.
174
+ */
175
+ /** Tools whose result is the contents of one named thing, so re-reads repeat. */
176
+ const RE_READ = new Set(['read_file', 'read_files', 'list_dir', 'grep', 'glob']);
177
+
178
+ /** The thing a call was about, when two calls for it return the same text. */
179
+ function subjectOf(call) {
180
+ if (!RE_READ.has(call?.name)) return null;
181
+ const a = call.args ?? {};
182
+ const what = a.path ?? (Array.isArray(a.paths) ? a.paths.join('|') : null) ?? a.pattern;
183
+ if (typeof what !== 'string' || !what) return null;
184
+ // grep and glob also depend on what was asked, not only where.
185
+ const extra = call.name === 'grep' || call.name === 'glob' ? `|${a.pattern ?? ''}|${a.glob ?? ''}` : '';
186
+ return `${call.name}:${what}${extra}`;
187
+ }
188
+
189
+ /**
190
+ * Send each file once.
191
+ *
192
+ * Reading a file four times over a long task puts four copies of it in the
193
+ * conversation, and the first three are worth nothing: the model reads the
194
+ * newest and the older ones only cost tokens and invite it to answer from a
195
+ * stale copy. Every superseded copy becomes a line saying where the current
196
+ * one is. The newest is always kept whole, so nothing the model needs is
197
+ * taken away, and the saved session still holds the lot.
198
+ */
199
+ export function dedupe(messages) {
200
+ const subject = new Map(); // toolCallId -> subject
201
+ for (const m of messages) {
202
+ if (m.role !== 'assistant' || !m.toolCalls?.length) continue;
203
+ for (const c of m.toolCalls) {
204
+ const s = subjectOf(c);
205
+ if (s) subject.set(c.id, s);
206
+ }
207
+ }
208
+ if (!subject.size) return messages;
209
+
210
+ const newest = new Map(); // subject -> index of the last result for it
211
+ messages.forEach((m, i) => {
212
+ if (m.role !== 'tool') return;
213
+ const s = subject.get(m.toolCallId);
214
+ if (s) newest.set(s, i);
215
+ });
216
+
217
+ return messages.map((m, i) => {
218
+ if (m.role !== 'tool') return m;
219
+ const s = subject.get(m.toolCallId);
220
+ if (!s || newest.get(s) === i) return m;
221
+ // Short results are not worth a note in place of themselves.
222
+ if ((m.content?.length ?? 0) < 400) return m;
223
+ const what = s.slice(s.indexOf(':') + 1).split('|')[0];
224
+ return {
225
+ ...m,
226
+ content:
227
+ `[${m.content.length} characters. This was read again later, and the current ` +
228
+ `contents of ${what} are further down this conversation — use those, not this.]`,
229
+ };
230
+ });
231
+ }
232
+
233
+ export function lean(messages, keep = 3) {
234
+ let seen = 0;
235
+ let cut = -1;
236
+ for (let i = messages.length - 1; i >= 0; i--) {
237
+ if (messages[i].role === 'assistant' && messages[i].toolCalls?.length && ++seen === keep) { cut = i; break; }
238
+ }
239
+ if (cut <= 0) return messages;
240
+ return messages.map((m, i) => {
241
+ if (i >= cut) return m;
242
+ if (m.role === 'assistant' && m.toolCalls?.length) {
243
+ return { ...m, toolCalls: m.toolCalls.map((c) => ({ ...c, args: thinArgs(c.args) })) };
244
+ }
245
+ if (m.role === 'tool' && THIN_RESULTS.has(m.name) && (m.content?.length ?? 0) > 1500) {
246
+ return {
247
+ ...m,
248
+ content: `${m.content.slice(0, 300)}
249
+ [${m.content.length} characters from an earlier step, trimmed ` +
250
+ 'to keep the conversation fast — run the tool again if you need this now]',
251
+ };
252
+ }
253
+ return m;
254
+ });
255
+ }
256
+
257
+ function trace(event) {
258
+ if (!TRACE_FILE) return;
259
+ try { appendFileSync(TRACE_FILE, `${JSON.stringify({ at: Date.now(), ...event })}
260
+ `); } catch { /* never fatal */ }
261
+ }
262
+
263
+ /** What /stats reports, counted as the session goes. */
264
+ function newStats() {
265
+ return {
266
+ started: Date.now(), workMs: 0, turns: 0, steps: 0, tokensIn: 0, tokensOut: 0,
267
+ tools: {}, failed: 0, written: 0, edited: 0, commands: 0, builds: 0, stuck: 0,
268
+ };
269
+ }
270
+
271
+ const BUILD_COMMAND = /\b(?:npm|pnpm|yarn|bun)\s+(?:run\s+)?build\b|\bnext\s+build\b|\bvite\s+build\b/;
272
+
273
+ function countTool(stats, call, out, err) {
274
+ stats.tools[call.name] = (stats.tools[call.name] ?? 0) + 1;
275
+ if (err) { stats.failed++; return; }
276
+ const a = call.args ?? {};
277
+ if (call.name === 'write_file') stats.written++;
278
+ if (call.name === 'batch_write') stats.written += a.files?.length ?? 0;
279
+ if (call.name === 'edit_file' || call.name === 'multi_edit') stats.edited++;
280
+ if (call.name === 'edit_files') stats.edited += new Set((a.edits ?? []).map((e) => e.path)).size || 1;
281
+ if (call.name === 'run_command') {
282
+ stats.commands++;
283
+ if (BUILD_COMMAND.test(a.command ?? '')) stats.builds++;
284
+ }
285
+ if (call.name === 'run_commands') stats.commands += a.commands?.length ?? 0;
286
+ }
287
+
288
+ /** The /stats box. */
289
+ export function statsLines(st, messages) {
290
+ const n = (v) => Number(v).toLocaleString();
291
+ const top = Object.entries(st.tools).sort((x, y) => y[1] - x[1]).slice(0, 6)
292
+ .map(([k, v]) => `${k} ${v}`).join(' · ') || 'none yet';
293
+ const plural = (v, w) => `${v} ${w}${v === 1 ? '' : 's'}`;
294
+ const rows = [
295
+ ['Session', `${formatDuration(Date.now() - st.started)} open · ${formatDuration(st.workMs)} working · ${plural(st.turns, 'request')}`],
296
+ ['Model', `${n(st.steps)} steps · ${formatTokens(st.tokensIn)} in · ${formatTokens(st.tokensOut)} out`],
297
+ ['Tools', top],
298
+ ['Files', `${st.written} written · ${st.edited} edited`],
299
+ ['Commands', `${st.commands} run · ${plural(st.builds, 'build')}${st.failed ? ` · ${plural(st.failed, 'tool call')} failed` : ''}`],
300
+ ['Context', `${messages} messages${st.stuck ? ` · ${plural(st.stuck, 'loop')} caught` : ''}`],
301
+ ];
302
+ const w = Math.max(...rows.map(([k]) => k.length));
303
+ return ['', ` ${blue('Stats')}`, ...rows.map(([k, v]) => ` ${dim(k.padEnd(w))} ${v}`), ''];
304
+ }
305
+
306
+ /** Parallel workers at once, and how many steps each may take. */
307
+ const MAX_WORKERS = 3;
308
+ const WORKER_STEPS = Number(process.env.UCODE_WORKER_STEPS) || 60;
309
+
310
+ /** Workers build; they do not plan, delegate further, or load skills themselves. */
311
+ const WORKER_EXCLUDED = new Set(['delegate', 'update_plan', 'load_skill']);
312
+
313
+ const planTool = {
314
+ name: 'update_plan',
315
+ description:
316
+ 'Keep a short checklist the user can see, for work with three or more steps. ' +
317
+ 'Send the whole list every time: at most 6 items, a few words each, with done: true ' +
318
+ 'on the finished ones. Update it as items finish. Skip it for small tasks.',
319
+ parameters: {
320
+ type: 'object',
321
+ properties: {
322
+ items: {
323
+ type: 'array',
324
+ description: 'The whole plan, in order. At most 6.',
325
+ items: {
326
+ type: 'object',
327
+ properties: {
328
+ text: { type: 'string', description: 'A few words: "Build the upload box".' },
329
+ done: { type: 'boolean', description: 'True once it is finished.' },
330
+ },
331
+ required: ['text'],
332
+ },
333
+ },
334
+ },
335
+ required: ['items'],
336
+ },
337
+ };
338
+
339
+ const delegateTool = {
340
+ name: 'delegate',
341
+ description:
342
+ 'Build independent parts in parallel. Up to 3 workers run at once, each with the ' +
343
+ 'same tools as you. Use it when the work splits cleanly into parts that touch ' +
344
+ 'different files - e.g. the API route, the upload component and the results view. ' +
345
+ 'A worker sees only its instructions, so make them complete: the files it owns, ' +
346
+ 'what to build, the exact interfaces (props, types, request and response shapes) ' +
347
+ 'it must match, and the design rules. Set up shared files (package.json, design ' +
348
+ 'tokens, shared types) yourself first. You get back each worker\'s summary and ' +
349
+ 'the files it changed; wire the parts together and check the whole afterwards.',
350
+ parameters: {
351
+ type: 'object',
352
+ properties: {
353
+ tasks: {
354
+ type: 'array',
355
+ description: 'Up to 3 independent pieces of work.',
356
+ items: {
357
+ type: 'object',
358
+ properties: {
359
+ name: { type: 'string', description: 'Two or three words: "api route", "upload ui".' },
360
+ instructions: { type: 'string', description: 'Everything the worker needs to do its part completely.' },
361
+ },
362
+ required: ['name', 'instructions'],
363
+ },
364
+ },
365
+ },
366
+ required: ['tasks'],
367
+ },
368
+ };
369
+
370
+ /** The instructions a parallel worker starts with. */
371
+ function workerPrompt({ cwd, name, memory, skills, map }) {
372
+ return [
373
+ `You are a ucode worker called "${name}" - one of several building parts of the same project at the same time.`,
374
+ '',
375
+ `Working directory: ${cwd}`,
376
+ `Platform: ${process.platform}`,
377
+ '',
378
+ '- Do exactly the task you were given. Touch only the files it names, or new files in',
379
+ ' the area it owns - other workers are editing the rest of the project right now.',
380
+ '- Read before you edit. read_files for several files, batch_write for several new',
381
+ ' files, edit_files for changes across files.',
382
+ '- Nothing has a keyboard: pass non-interactive flags. Do not start dev servers and do',
383
+ ' not install packages unless the task says to - say what you need instead.',
384
+ '- Finish what you build: real content, every state handled, no TODOs.',
385
+ '- When done, reply with two or three sentences: what you built, in which files, and',
386
+ ' anything the lead has to wire up.',
387
+ '- The files you own may not exist yet - create them. Do not go looking for them',
388
+ ' first. The project map below shows what does exist; read only the files whose',
389
+ ' interfaces you must match, then start writing within two or three steps.',
390
+ ...(memory ? ['', '## Project memory', '', memory] : []),
391
+ ...(map ? ['', '## Project map', '', map] : []),
392
+ ...(skills ? ['', '## Instructions in force', '', skills] : []),
393
+ ].join('\n');
394
+ }
395
+
396
+ /** The files a writing tool call touches. */
397
+ function pathsOf(call) {
398
+ const a = call.args ?? {};
399
+ if (call.name === 'batch_write' || call.name === 'edit_files') return (a.files ?? []).map((f) => f?.path).filter(Boolean);
400
+ return a.path ? [a.path] : [];
401
+ }
402
+
403
+ const exists = (p) => access(p).then(() => true, () => false);
404
+
405
+ /** Skills reach the model as one extra tool, so bodies load only when wanted. */
406
+ const loadSkillTool = {
407
+ name: 'load_skill',
408
+ description:
409
+ 'Load the full instructions for one of the skills listed in your system prompt. ' +
410
+ 'Call it the moment a task matches one before planning, before writing anything ' +
411
+ '— then follow what it says.',
412
+ parameters: {
413
+ type: 'object',
414
+ properties: { name: { type: 'string', description: 'The skill name, exactly as listed.' } },
415
+ required: ['name'],
416
+ },
417
+ };
418
+
419
+ function systemPrompt({ cwd, skills, mode, check, map, memory }) {
420
+ const list = catalogue(skills);
421
+
422
+ return [
423
+ 'You are ucode, a coding agent working directly in the user\'s terminal.',
424
+ '',
425
+ `Working directory: ${cwd}`,
426
+ `Platform: ${process.platform}`,
427
+ ...(memory ? [
428
+ '',
429
+ '## Project memory',
430
+ '',
431
+ 'Standing instructions from the user. They outrank your defaults.',
432
+ '',
433
+ memory,
434
+ ] : []),
435
+ '',
436
+ '## How to work',
437
+ '',
438
+ 'Before building anything, turn the request into a list of what it must do every',
439
+ 'feature named, and the ones any user would expect whether or not they were named',
440
+ '(an empty state, an error state, the keyboard doing the obvious thing, working on',
441
+ 'a phone). Keep it as the plan. Build against it, then go through it one item at a',
442
+ 'time before you say a word about being finished. Most of what gets missed was',
443
+ 'never written down.',
444
+ '',
445
+ 'Do not put code in your reply. Not a snippet, not "here is the key part", not a',
446
+ 'summary of the file. It is already in the file and the user can open it; pasting',
447
+ 'it again buries the one or two sentences that actually matter. Say what it does',
448
+ 'and what to try.',
449
+ '',
450
+ 'Do not claim it is done while anything is still running or unchecked. "I have',
451
+ 'built it" said before the build finishes is worse than saying nothing: the user',
452
+ 'believes you, looks, and finds it broken. Finish, check, then say so and if',
453
+ 'something is incomplete, say which part and why.',
454
+ '',
455
+ 'BE FAST. Every tool call is a round trip, and round trips are nearly all of the',
456
+ 'time a build takes. So: write a whole app in ONE batch_write rather than a',
457
+ 'write_file per file. Read every file you need in ONE read_files. Never read a',
458
+ 'file you just wrote, and never read one back after edit_file the result',
459
+ 'already contains it. Do not re-check work the checks have already reported on.',
460
+ 'Fast is not sloppy: it is the same work with the waiting taken out.',
461
+ '',
462
+ 'SPEAK AS "I", NEVER "WE". You are doing this, not a committee: "I will build',
463
+ 'Tide as a single HTML file", not "we have created the file".',
464
+ '',
465
+ 'Say three or four things over a whole build, not one per step:',
466
+ ' - Open with what you are going to make and how, in one line, before anything',
467
+ ' else: "I will build Tide as a single HTML file - markup, one stylesheet, one',
468
+ ' module - with the tasks kept in localStorage."',
469
+ ' - One line when you move between the big pieces of work: "The layout is done,',
470
+ ' now the animations."',
471
+ ' - One line at the end saying what it does and how to try it.',
472
+ 'That is all. A line before every tool call is not narration, it is noise: the',
473
+ 'steps already show on screen, and repeating them in words buries the few',
474
+ 'sentences worth reading.',
475
+ '',
476
+ 'FIRST, EVERY TIME: write one short line saying what you are about to do, then',
477
+ 'make the tool calls. Never open a turn with a tool call and no words. Examples:',
478
+ '"Right, the HTML structure first." / "Now the state and the render loop." /',
479
+ '"That is the layout done - onto the animations." / "Let me see what is there."',
480
+ 'One sentence, your own voice, before the actions - not after them, not instead',
481
+ 'of them, and not a restatement of what was asked. The user is watching this',
482
+ 'scroll past; without those lines it is a list of file operations and they cannot',
483
+ 'tell what you are building. This matters as much as the code.',
484
+ '',
485
+
486
+ '',
487
+ 'Before you guess at an API, ask: type_of gives the exact signature from the',
488
+ 'TypeScript this project has installed, and find_symbol says where something is declared without',
489
+ 'reading five files to find it. Rename with rename_symbol rather than edit_file — a',
490
+ 'find-and-replace that matches too much is the most common broken edit. Reach for',
491
+ 'add_block before writing a table, an empty state or a dashboard by hand.',
492
+ '',
493
+ ...(mode === 'plan' ? [
494
+ 'You are in PLAN MODE. Reading, searching and research are available; every tool',
495
+ 'that writes a file or runs a command has been withheld. Investigate, then set out',
496
+ 'what you would change: which files, which functions, in what order. Never imply',
497
+ 'you have made a change you are not able to make.',
498
+ '',
499
+ ] : []),
500
+ ...(check ? [
501
+ `This project checks itself with: ${check}`,
502
+ 'After changing code, run that and report what actually happened. "It should work"',
503
+ 'is not a result.',
504
+ '',
505
+ ] : []),
506
+ '- Read before you write. Never edit a file you have not read this session.',
507
+ '- Prefer edit_file to write_file. Rewrite a whole file only when creating it, or',
508
+ ' when the change genuinely touches most of it.',
509
+ '- old_string must be copied out of what read_file showed you, character for',
510
+ ' character, without the line-number gutter, and must appear exactly once. Add',
511
+ ' surrounding lines until it does.',
512
+ '- read_file returns up to 600 lines. Read the whole file before editing it rather',
513
+ ' than editing from a fragment; pass offset to continue a long one.',
514
+ '',
515
+ '## Going fast',
516
+ '',
517
+ 'Every tool call is a round trip to you, and the round trip — not the disk, not',
518
+ 'the shell — is where the time goes. So:',
519
+ '',
520
+ '- Need more than one file? read_files, all of them in one call. Never read files',
521
+ ' one at a time when you already know which ones you want.',
522
+ '- An edit result shows the file as it now stands. Do not read a file again after',
523
+ ' editing it - you already have its current text.',
524
+ '- Put independent calls in the same message several greps, a glob and a read.',
525
+ ' Read-only calls in one message run at the same time.',
526
+ '- New Next.js app? create_app - one step, never create-next-app or shadcn init. It',
527
+ ' copies a starter that already builds and installs it in the background.',
528
+ '- batch_write to lay out several new files at once - but at most about four per',
529
+ ' call: one slip in a huge call throws the whole call away.',
530
+ '- multi_edit for several changes',
531
+ ' to one file, edit_files for a change that spans several files.',
532
+ '- For work with three or more steps, keep a short plan with update_plan - at most',
533
+ ' six items of a few words - and tick items off as they finish. Skip it for small jobs.',
534
+ '- When a build splits into parts that touch different files (the API route, the',
535
+ ' upload component, the results view), set up the shared files yourself, then hand',
536
+ ' the parts to delegate so they are built in parallel.',
537
+ '- A package.json you write starts installing in the background immediately; keep',
538
+ ' writing files. Running the install yourself afterwards just waits for that one.',
539
+ '- When you finish, ucode type-checks what you changed and hands you the errors, so',
540
+ ' there is no need to run tsc yourself.',
541
+ '- Do not open or drive a browser. Checking the page in one is something the user',
542
+ ' asks for with /look; your job is to leave the app in a state worth looking at.',
543
+ ' reports - errors, layout that overflows a phone, the review points worth fixing -',
544
+ ' in one pass, then look once more. A clean second look means it is done: report',
545
+ ' back instead of polishing in circles. Never call an interface finished unlooked at.',
546
+ ' name, handles keys and returns the live link. Build locally first.',
547
+ '- Nothing you run has a keyboard. Pass the non-interactive flag to anything that',
548
+ ' would ask a question, or it fails instead of waiting: create-next-app --yes,',
549
+ ' npx shadcn@latest init -d -y, npx shadcn@latest add <names> -y, npm init -y.',
550
+ '- Dev servers start in the background by themselves, and the result tells you the',
551
+ ' URL once the server says it is ready. Do not start one twice, do not sleep while',
552
+ ' waiting for it, and do not curl it before that result comes back.',
553
+ '',
554
+ '## When something fails',
555
+ '',
556
+ '- A failed build names the problem. Fix exactly that, then build again. Never go',
557
+ ' exploring inside node_modules: a missing component or package is one install away.',
558
+ '- A build takes most of a minute. Fix every error it lists in one pass - multi_edit,',
559
+ ' edit_files - before building again, never one error per build.',
560
+ '- Never delete an app folder to start over. Fix it where it is - starting again throws',
561
+ ' away the install and everything already written.',
562
+ '- Run an app\'s commands with cwd set to its folder, and keep paths inside those',
563
+ ' commands relative to that folder.',
564
+ '',
565
+ '## Safety',
566
+ '',
567
+ '- run_command runs without asking. That is trust rather than licence: never run',
568
+ ' anything destructive the user did not ask for.',
569
+ '- Paths are relative to the working directory. Anything outside it needs the user',
570
+ ' to approve it first.',
571
+ '- Verify. After changing code, run the tests or a quick check with run_command.',
572
+ '',
573
+ '## Saying what you are doing',
574
+ '',
575
+ '- Before every tool call, write ONE short line naming the file or command:',
576
+ ' "Reading tui.js", "Fixing the spinner in loop.js", "Running npm test".',
577
+ '- Present tense, under ten words, and no full stop at the end. It is a label on',
578
+ ' work happening right now, not a sentence about work that is finished.',
579
+ '- That line and nothing else in the message. No preamble, no plan, no bullets —',
580
+ ' the user reads it live while the tool runs.',
581
+ '- Say the next one when you take the next step, not all of them up front.',
582
+ '',
583
+ '## Answering',
584
+ '',
585
+ '- Be short. Two or three sentences is usually the entire answer. This is a',
586
+ ' terminal, not a document.',
587
+ '- No preamble, no restating the question, no "I will now...". Just answer.',
588
+ '- Do not narrate what the tool output already showed. The user watched the diff',
589
+ ' and the command output; cover only what is not obvious from them.',
590
+ '- Skip closing summaries of work the user just watched you do but never end',
591
+ ' a turn silently. If there is genuinely nothing to add, one short line saying',
592
+ ' what changed is the whole answer.',
593
+ '- Markdown. Fenced blocks with a language tag get highlighted.',
594
+ '- Point at code as path:line so the user can jump straight to it.',
595
+ '- Report honestly. If a command failed or you skipped something, say so.',
596
+ '- Length tracks the question: a one-line question gets a one-line answer.',
597
+ '- Brevity is about your prose and never about your work. What you build is',
598
+ ' finished: every control wired, every state handled, no TODO left behind.',
599
+ ...(list ? [
600
+ '',
601
+ '## Skills',
602
+ '',
603
+ 'These instruction packs are available. When a task matches one, load it with',
604
+ 'load_skill as your first step before planning, before writing anything and',
605
+ 'then follow it. A skill already in this conversation outranks your own defaults',
606
+ 'and is not advisory.',
607
+ '',
608
+ list,
609
+ ] : []),
610
+ '',
611
+ '## Project map',
612
+ '',
613
+ 'Every file in the project at the start of this turn, with the names each code file',
614
+ 'exports. Go straight to the files you need instead of searching for them.',
615
+ '',
616
+ map || '(not available)',
617
+ ].join('\n');
618
+ }
619
+
620
+ export class Agent {
621
+ constructor({ cwd, debug = false }) {
622
+ this.cwd = cwd;
623
+ this.debug = debug;
624
+ // A full-screen layout only makes sense on a real terminal. Piped input,
625
+ // CI and `echo ... | ucode` get the line-based interface instead.
626
+ this.full = Boolean(process.stdout.isTTY && process.stdin.isTTY);
627
+ this.ui = this.full ? new Screen({ cwd }) : new Plain({ cwd });
628
+ this.stats = newStats();
629
+ this.skills = [];
630
+ this.session = newSession(cwd, model());
631
+ this.working = [];
632
+ this.loaded = new Set();
633
+ this.abort = null;
634
+ this.busy = false;
635
+ this.check = null;
636
+ }
637
+
638
+ // -- history -------------------------------------------------------------
639
+
640
+ push(message) {
641
+ this.session.messages.push(message);
642
+ this.working.push(message);
643
+ }
644
+
645
+ /**
646
+ * Answer every tool call a stopped turn never got to.
647
+ *
648
+ * An assistant message ends by asking for tools, and each of those asks
649
+ * needs an answer. Abandon them and the conversation is left mid-sentence,
650
+ * so the next time the model reads it the only sensible thing to do is
651
+ * carry on where it left off which is exactly what the user pressed stop
652
+ * to prevent. Saying "this did not happen" for each one ends the sentence,
653
+ * and a line from the user ends the task.
654
+ */
655
+ closeInterrupted() {
656
+ const answered = new Set(this.working.filter((m) => m.role === 'tool').map((m) => m.toolCallId));
657
+ const missing = [];
658
+ for (const m of this.working) {
659
+ if (m.role !== 'assistant' || !m.toolCalls?.length) continue;
660
+ for (const call of m.toolCalls) {
661
+ if (!answered.has(call.id)) missing.push(call);
662
+ }
663
+ }
664
+ for (const call of missing) {
665
+ this.push({
666
+ role: 'tool',
667
+ toolCallId: call.id,
668
+ name: call.name,
669
+ content: 'The user stopped the turn before this ran. It did not happen, and it must not be retried.',
670
+ });
671
+ }
672
+ if (missing.length) {
673
+ this.push({
674
+ role: 'user',
675
+ content: 'I stopped that. Drop it and wait for what I ask next — do not pick it back up.',
676
+ });
677
+ }
678
+ return missing.length;
679
+ }
680
+
681
+ async persist() {
682
+ try {
683
+ this.session.model = model();
684
+ await save(this.session);
685
+ } catch (err) {
686
+ // Losing the save must not lose the turn.
687
+ this.ui.error(err, { debug: this.debug });
688
+ }
689
+ }
690
+
691
+ // -- startup -------------------------------------------------------------
692
+
693
+ /**
694
+ * Everything a turn needs, minus the terminal.
695
+ *
696
+ * Split out of start() so another front end could prepare an agent and drive
697
+ * turn() itself.
698
+ */
699
+ async bootstrap() {
700
+ setRoot(this.cwd);
701
+ // Parallel workers can ask at the same moment; the questions queue up and
702
+ // are put to the user one at a time, never on top of each other.
703
+ let asking = Promise.resolve();
704
+ setConfirm((request) => {
705
+ const next = asking.then(() => this.ui.confirm(request));
706
+ asking = next.catch(() => {});
707
+ return next;
708
+ });
709
+ this.skills = await loadSkills({ cwd: this.cwd });
710
+ await this.detectCheck();
711
+ }
712
+
713
+ async start() {
714
+ await this.bootstrap();
715
+
716
+ if (this.full) {
717
+ await this.ui.start();
718
+ this.ui.onInterrupt = () => {
719
+ if (this.busy && this.abort) {
720
+ this.abort.abort();
721
+ this.ui.stopSpinner();
722
+ this.ui.stopTimer?.();
723
+ this.ui.note('interrupted');
724
+ }
725
+ };
726
+ this.ui.onModeChange = () => this.showHeader({ clear: false });
727
+ }
728
+
729
+ for (const problem of this.skills.problems ?? []) {
730
+ this.ui.write(theme.warn(` skill not loaded: ${problem}`));
731
+ }
732
+
733
+ this.showHeader();
734
+ this.installSignals();
735
+
736
+ // Checked in the background; nothing here waits on it.
737
+ autoUpdate({
738
+ onUpdated: (version) => {
739
+ this.ui.setFacts?.({ update: version });
740
+ if (!this.ui.welcoming?.()) this.ui.note(`updated to v${version} — it takes over the next time you start ucode`);
741
+ },
742
+ });
743
+ await this.repl();
744
+ }
745
+
746
+ showHeader({ clear = true } = {}) {
747
+ if (clear && this.full) this.ui.clearScreen();
748
+ const stats = usage(this.working, contextLimit());
749
+ this.ui.header({
750
+ cwd: this.cwd,
751
+ model: modelName(),
752
+ used: stats.used,
753
+ limit: stats.limit,
754
+ title: this.session.title === 'Untitled' ? 'new session' : this.session.title,
755
+ });
756
+ }
757
+
758
+ installSignals() {
759
+ const flush = async () => {
760
+ await save(this.session).catch(() => {});
761
+ process.exit(0);
762
+ };
763
+ process.on('SIGTERM', flush);
764
+
765
+ // The full-screen UI reads keys itself, so it owns ctrl+c and esc.
766
+ if (this.full) return;
767
+
768
+ this.ui.rl.on('SIGINT', () => {
769
+ if (this.busy && this.abort) {
770
+ this.abort.abort();
771
+ this.ui.stopSpinner();
772
+ this.ui.write(dim(' interrupted'));
773
+ return;
774
+ }
775
+ this.ui.write(dim(' (ctrl+d or /exit to quit)'));
776
+ this.ui.rl.prompt();
777
+ });
778
+ }
779
+
780
+ /**
781
+ * How this project verifies itself, worked out once at startup. Null when
782
+ * there is genuinely nothing to run — verification is only insisted on where
783
+ * there is something to insist on.
784
+ */
785
+ async detectCheck() {
786
+ const has = (f) => readFile(path.join(this.cwd, f)).then(() => true, () => false);
787
+
788
+ if (await has('package.json')) {
789
+ try {
790
+ const pkg = JSON.parse(await readFile(path.join(this.cwd, 'package.json'), 'utf8'));
791
+ if (pkg.scripts?.test && !/no test specified/i.test(pkg.scripts.test)) {
792
+ this.check = 'npm test';
793
+ return;
794
+ }
795
+ } catch { /* an unreadable package.json is not worth failing over */ }
796
+ }
797
+ if (await has('Cargo.toml')) { this.check = 'cargo test'; return; }
798
+ if (await has('go.mod')) { this.check = 'go test ./...'; return; }
799
+ if (await has('pyproject.toml') || await has('pytest.ini')) { this.check = 'pytest'; return; }
800
+ if (await has('Makefile')) { this.check = 'make test'; return; }
801
+ this.check = null;
802
+ }
803
+
804
+ // -- REPL ----------------------------------------------------------------
805
+
806
+ async repl() {
807
+ let sawInput = false;
808
+
809
+ for (;;) {
810
+ const line = await this.ui.ask();
811
+ if (line === null) {
812
+ // End of input before anything was typed. On Windows this is almost
813
+ // always npm's PowerShell shim, which runs the CLI as `$input | node`.
814
+ // The pipe makes stdin a non-TTY, readline hits EOF at once, and the
815
+ // banner flashes up and vanishes which looks like a crash rather
816
+ // than like a program that was never given a keyboard. So say which.
817
+ if (!sawInput && !process.stdin.isTTY) this.explainNoKeyboard();
818
+ break;
819
+ }
820
+
821
+ const input = line.trim();
822
+ if (input) sawInput = true;
823
+ if (!input) continue;
824
+
825
+ if (input.startsWith('/')) {
826
+ if (await this.command(input) === 'exit') break;
827
+ continue;
828
+ }
829
+
830
+ try {
831
+ await this.turn(input);
832
+ } catch (err) {
833
+ this.ui.error(err, { debug: this.debug });
834
+ }
835
+ }
836
+
837
+ await this.shutdown();
838
+ }
839
+
840
+ explainNoKeyboard() {
841
+ this.ui.blank();
842
+ this.ui.write(theme.warn(' ucode could not reach the keyboard, so it stopped.'));
843
+ this.ui.blank();
844
+ this.ui.write(' That happens when input is piped rather than typed. On Windows it is');
845
+ this.ui.write(" usually npm's PowerShell wrapper, which pipes stdin.");
846
+ this.ui.blank();
847
+ this.ui.write(` ${blue('Any of these work:')}`);
848
+ this.ui.write(` ${sky('ucode.cmd')} the cmd shim, which keeps the keyboard`);
849
+ this.ui.write(` ${sky('npx ucode-agent')} runs it directly`);
850
+ this.ui.write(' or start it from Command Prompt or Windows Terminal');
851
+ this.ui.blank();
852
+ }
853
+
854
+ async shutdown() {
855
+ await closeBrowser().catch(() => {});
856
+ this.ui.stopSpinner();
857
+ if (this.session.messages.length) {
858
+ await this.persist();
859
+ this.ui.write(dim(`\n saved · ${this.session.title}`));
860
+ }
861
+ this.ui.close();
862
+ }
863
+
864
+ // -- one turn ------------------------------------------------------------
865
+
866
+ /**
867
+ * Pull image paths out of the message and load them, so "what is wrong in
868
+ * screenshot.png" works without a separate command for it.
869
+ */
870
+ async attachImages(input) {
871
+ const mentioned = input.match(/[^\s"']+\.(?:png|jpe?g|gif|webp)\b/gi) ?? [];
872
+ const images = [];
873
+
874
+ for (const name of mentioned) {
875
+ const file = path.resolve(this.cwd, name);
876
+ try {
877
+ const buf = await readFile(file);
878
+ if (buf.length > 4 * 1024 * 1024) {
879
+ this.ui.note(`${name} is ${(buf.length / 1024 / 1024).toFixed(1)}MB — too big to send, skipped`);
880
+ continue;
881
+ }
882
+ const ext = path.extname(file).toLowerCase().slice(1);
883
+ images.push(`data:image/${ext === 'jpg' ? 'jpeg' : ext};base64,${buf.toString('base64')}`);
884
+ this.ui.note(`attached ${name}`);
885
+ } catch {
886
+ // Just a filename mentioned in passing, not a file on disk.
887
+ }
888
+ }
889
+
890
+ return images;
891
+ }
892
+
893
+ /**
894
+ * Skills that this request should arrive with, already loaded.
895
+ *
896
+ * The load_skill tool asks the model to notice that a task needs a skill,
897
+ * and a model in a hurry to be helpful does not always notice. For work
898
+ * where the skill *is* the quality bar anything with a user interface in
899
+ * it — that is not a discovery to make after the app has been built. So the
900
+ * request is matched against each skill's trigger words and the body goes in
901
+ * before the model takes its first step.
902
+ */
903
+ autoLoad(input) {
904
+ for (const skill of autoLoadFor(this.skills, input)) {
905
+ if (this.loaded.has(skill.name)) continue;
906
+ this.loaded.add(skill.name);
907
+ this.push(skillMessage(skill, { automatic: true }));
908
+ this.ui.note(`${skill.name} skill loaded for this`);
909
+ }
910
+ }
911
+
912
+ async turn(input) {
913
+ forgetReviews(); // a new request: its apps get a fresh design review
914
+ const images = await this.attachImages(input);
915
+ this.push(images.length
916
+ ? { role: 'user', content: input, images }
917
+ : { role: 'user', content: input });
918
+
919
+ if (!this.session.title || this.session.title === 'Untitled') {
920
+ this.session.title = titleFrom(input);
921
+ }
922
+
923
+ this.autoLoad(input);
924
+ // What the model is told about the project, fresh for this turn.
925
+ [this.map, this.memory] = await Promise.all([
926
+ projectMap(this.cwd).catch(() => ''),
927
+ loadMemory(this.cwd).catch(() => ''),
928
+ ]);
929
+ await this.persist();
930
+
931
+ // A busy model was swapped for a fallback earlier; after a few minutes the
932
+ // one the user chose gets another go.
933
+ this.preferred ??= model();
934
+ if (model() !== this.preferred && Date.now() > (this.cooldownUntil ?? 0)) {
935
+ setModel(this.preferred);
936
+ if (this.full) this.showHeader({ clear: false });
937
+ }
938
+
939
+ this.busy = true;
940
+ this.abort = new AbortController();
941
+ this.endedSilently = false;
942
+
943
+ const turnStarted = Date.now();
944
+ let finished = false;
945
+ this.ui.turnStart?.();
946
+ try {
947
+ for (;;) {
948
+ try {
949
+ await this.run();
950
+ break;
951
+ } catch (err) {
952
+ // The daily free cap mid-build: wait for the reset and carry on,
953
+ // rather than leaving a half-built app for the user to restart.
954
+ if (err?.detail?.daily && !this.abort.signal.aborted && (await this.waitForReset(err))) continue;
955
+ throw err;
956
+ }
957
+ }
958
+ finished = true;
959
+ } catch (err) {
960
+ if (err?.kind === 'aborted' || this.abort.signal.aborted) this.ui.write(dim(' turn cancelled'));
961
+ else throw err;
962
+ } finally {
963
+ trace({ kind: 'turn', ms: Date.now() - turnStarted });
964
+ this.stats.workMs += Date.now() - turnStarted;
965
+ this.stats.turns++;
966
+ if (!finished) this.closeInterrupted();
967
+ const ok = finished && !this.endedSilently;
968
+ this.busy = false;
969
+ this.abort = null;
970
+ this.ui.stopSpinner();
971
+ this.ui.stopTimer?.();
972
+ this.activity = null;
973
+ await this.persist();
974
+ if (this.full) this.showHeader({ clear: false });
975
+ if (finished) this.openWhenReady(turnStarted);
976
+ // Last, so "Done" is the last thing that happens rather than the last
977
+ // thing said before several more things happen.
978
+ this.ui.turnEnd?.({ ok });
979
+ }
980
+ }
981
+
982
+ /** The tools the model may see, given the mode. */
983
+ toolsNow() {
984
+ const all = [...tools, loadSkillTool, planTool, delegateTool];
985
+ if (this.ui.mode !== 'plan') return all;
986
+ return all.filter((t) => !WRITES.has(t.name));
987
+ }
988
+
989
+ /** Model, tools, model, until it answers with prose. */
990
+ async run() {
991
+ const available = this.toolsNow();
992
+ let argRetries = 0;
993
+ let continuations = 0;
994
+ let askedToVerify = false;
995
+ let askedToSpeak = false;
996
+ let fixRounds = 0;
997
+ this.failovers = 0;
998
+ this.tried = new Set([model()]);
999
+
1000
+ this.stuck = new StuckWatch();
1001
+ this.touched = new Set();
1002
+ this.sinceCheck = new Set();
1003
+ this.logWatch = new LogWatch();
1004
+ this.ranSomething = false;
1005
+
1006
+ for (let step = 0; step < MAX_STEPS; step++) {
1007
+ await this.maybeFold();
1008
+ this.ui.startSpinner(step === 0 ? 'thinking' : 'working');
1009
+
1010
+ let reply;
1011
+ let streaming = false;
1012
+ this.early = new Map();
1013
+
1014
+ try {
1015
+ const opts = {
1016
+ signal: this.abort.signal,
1017
+ onWait: (text) => this.ui.updateSpinner(text),
1018
+ };
1019
+ // Only a real terminal has somewhere to stream into.
1020
+ if (this.full) {
1021
+ opts.onThinking = (delta) => this.ui.thinkingDelta(delta);
1022
+ opts.onText = (delta) => {
1023
+ if (!streaming) {
1024
+ streaming = true;
1025
+ this.ui.thinkingEnd();
1026
+ this.ui.streamBegin();
1027
+ }
1028
+ this.ui.streamDelta(delta);
1029
+ };
1030
+ // Read-only calls start the moment they are fully written, while the
1031
+ // rest of the reply is still arriving. Nothing that writes or runs is
1032
+ // started early: a reply that fails halfway must leave no side effects.
1033
+ opts.onToolCall = (call) => {
1034
+ if (PARALLEL_SAFE.has(call.name) && !call.parseError && !this.early.has(call.id)) {
1035
+ this.early.set(call.id, this.execute(call));
1036
+ }
1037
+ };
1038
+ }
1039
+
1040
+ var asked = Date.now();
1041
+ reply = await ask(
1042
+ [
1043
+ {
1044
+ role: 'system',
1045
+ content: systemPrompt({
1046
+ cwd: this.cwd,
1047
+ skills: this.skills,
1048
+ mode: this.ui.mode,
1049
+ check: this.check,
1050
+ map: this.map,
1051
+ memory: this.memory,
1052
+ }),
1053
+ },
1054
+ ...dedupe(lean(this.working)),
1055
+ ],
1056
+ available,
1057
+ opts
1058
+ );
1059
+ } catch (err) {
1060
+ this.ui.thinkingEnd();
1061
+ if (streaming) this.ui.streamEnd();
1062
+
1063
+ // The model invented a tool and the provider rejected the request
1064
+ // outright. Tell it what it did and let it try again.
1065
+ if (err.kind === 'bad_tool_call' && argRetries < MAX_ARG_RETRIES) {
1066
+ argRetries++;
1067
+ this.ui.stopSpinner();
1068
+ this.ui.toolFailed(
1069
+ `${err.detail?.attemptedName ?? 'invalid tool call'} — retrying (${argRetries}/${MAX_ARG_RETRIES})`
1070
+ );
1071
+ this.push({
1072
+ role: 'user',
1073
+ content:
1074
+ `Your last tool call was rejected. ${err.failed} The only tools that exist ` +
1075
+ `are: ${available.map((t) => t.name).join(', ')}. Try again with one of them.`,
1076
+ });
1077
+ continue;
1078
+ }
1079
+
1080
+ // Busy, slow or down: move to the next model and carry on, rather
1081
+ // than ending a half-built app with an error.
1082
+ if (passing(err) && !this.abort.signal.aborted && (await this.failover(err))) continue;
1083
+ throw err;
1084
+ }
1085
+
1086
+ // A reply that is nothing but tool calls never starts a text stream, so
1087
+ // the thinking timer has to be closed out here as well.
1088
+ this.ui.thinkingEnd();
1089
+ this.ui.stopSpinner();
1090
+ this.record(reply.usage);
1091
+ this.ui.step?.();
1092
+ this.stats.steps++;
1093
+ this.stats.tokensIn += reply.usage.promptTokens ?? 0;
1094
+ this.stats.tokensOut += reply.usage.outputTokens ?? 0;
1095
+ trace({
1096
+ kind: 'model', who: 'lead', model: model(), ms: Date.now() - asked,
1097
+ in: reply.usage.promptTokens, out: reply.usage.outputTokens,
1098
+ calls: reply.toolCalls.map((c) => c.name),
1099
+ });
1100
+
1101
+ // Streamed text is already on screen; turn it into rendered markdown.
1102
+ // Text that turns out to be narration ahead of a tool call folds into a
1103
+ // status line instead that is where the live commentary comes from.
1104
+ const narrating = reply.toolCalls.length > 0;
1105
+ if (streaming) this.ui.streamEnd({ asNarration: narrating });
1106
+ else if (reply.text && narrating && isLabel(reply.text)) this.ui.narrate(reply.text);
1107
+ else if (reply.text) this.ui.assistant(reply.text);
1108
+
1109
+ if (reply.toolCalls.length === 0) {
1110
+ // The answer stopped at the provider's output cap rather than at the
1111
+ // end of a thought, so it is cut mid-word. Ask for the rest instead of
1112
+ // handing over half an answer with no sign there was more.
1113
+ if (reply.finishReason === 'length' && reply.text && continuations < MAX_CONTINUATIONS) {
1114
+ continuations++;
1115
+ this.push({ role: 'assistant', content: reply.text });
1116
+ this.push({
1117
+ role: 'user',
1118
+ content:
1119
+ 'Your reply stopped at the output limit, mid-sentence. Carry on from exactly ' +
1120
+ 'where it broke off. Do not repeat any of it, do not start again, and do not ' +
1121
+ 'introduce it — just continue.',
1122
+ });
1123
+ this.ui.note('hit the output limit — asking for the rest');
1124
+ continue;
1125
+ }
1126
+
1127
+ // Type-check what changed and hand back the errors, a few rounds at most.
1128
+ // A turn that ends on a broken build is the most common way an app
1129
+ // gets handed over as done when it is not.
1130
+ if (fixRounds < MAX_FIX_ROUNDS) {
1131
+ const problems = await this.autoCheck();
1132
+ if (problems) {
1133
+ fixRounds++;
1134
+ if (reply.text) this.push({ role: 'assistant', content: reply.text });
1135
+ this.push({
1136
+ role: 'user',
1137
+ content:
1138
+ `ucode checked the files you changed and found errors (round ${fixRounds} of ` +
1139
+ `${MAX_FIX_ROUNDS}). Fix all of them, then finish.\n\n${problems}`,
1140
+ });
1141
+ continue;
1142
+ }
1143
+ }
1144
+
1145
+ // It changed code and never ran anything. Send it back once.
1146
+ if (this.touched.size && !this.ranSomething && this.check && !askedToVerify) {
1147
+ askedToVerify = true;
1148
+ if (reply.text) this.push({ role: 'assistant', content: reply.text });
1149
+ this.push({
1150
+ role: 'user',
1151
+ content:
1152
+ `You changed ${[...this.touched].join(', ')} and did not check it. Run ` +
1153
+ `\`${this.check}\` with run_command now, then say what actually happened ` +
1154
+ 'if it failed, show the output rather than claiming it worked. If that is ' +
1155
+ 'the wrong way to check this project, run the right one and say which.',
1156
+ });
1157
+ this.ui.note('verifying the change');
1158
+ continue;
1159
+ }
1160
+
1161
+ /**
1162
+ * It did the work and then said nothing at all.
1163
+ *
1164
+ * Reasoning models do this, and the instruction to skip closing
1165
+ * summaries makes it more likely. Silence is fine as a style; it is
1166
+ * not fine as an answer, because the user cannot tell it apart from a
1167
+ * crash and if they asked what happened, they asked. One nudge,
1168
+ * once per turn, and only when there was actually work to report.
1169
+ */
1170
+ if (!reply.text?.trim() && this.touched.size + (this.ranSomething ? 1 : 0) > 0 && !askedToSpeak) {
1171
+ askedToSpeak = true;
1172
+ this.push({
1173
+ role: 'user',
1174
+ content:
1175
+ 'You stopped without saying anything. If the thing I asked for is not ' +
1176
+ 'built yet, carry on and build it. If it is, tell me in one or two ' +
1177
+ 'sentences what it does and how to try it. No preamble, no diffs.',
1178
+ });
1179
+ continue;
1180
+ }
1181
+
1182
+ this.push({ role: 'assistant', content: reply.text });
1183
+ if (!reply.text?.trim()) {
1184
+ // Silence after being asked to speak is not a finished turn. Saying
1185
+ // "Done" here is the worst thing available: the user believes it,
1186
+ // looks, and finds the thing they asked for was never built.
1187
+ this.ui.note('the model stopped without saying anything — the work may be unfinished');
1188
+ this.endedSilently = true;
1189
+ }
1190
+ return;
1191
+ }
1192
+
1193
+ this.push({ role: 'assistant', content: reply.text || '', toolCalls: reply.toolCalls });
1194
+ await this.persist();
1195
+
1196
+ const badArgs = await this.runCalls(reply.toolCalls);
1197
+ if (this.abort.signal.aborted) return;
1198
+
1199
+ // Malformed arguments go back to the model, but only so many times.
1200
+ if (badArgs) {
1201
+ argRetries++;
1202
+ if (argRetries > MAX_ARG_RETRIES) {
1203
+ throw new Failure({
1204
+ kind: 'bad_tool_args',
1205
+ attempted: 'running the tools the model asked for',
1206
+ failed:
1207
+ `${modelName()} produced invalid tool arguments ${argRetries} times running ` +
1208
+ 'and could not correct itself.',
1209
+ fix:
1210
+ 'Say what you want more concretely, or /model to another one — North Mini ' +
1211
+ 'Code and Nemotron 3.5 Lightning are both steadier with tool arguments.',
1212
+ });
1213
+ }
1214
+ } else {
1215
+ argRetries = 0;
1216
+ }
1217
+
1218
+ await this.persist();
1219
+ }
1220
+
1221
+ throw new Failure({
1222
+ kind: 'step_limit',
1223
+ attempted: 'finishing your request',
1224
+ failed: `The model was still calling tools after ${MAX_STEPS} steps.`,
1225
+ fix:
1226
+ 'Nothing is lost everything so far is on disk. Say "carry on where you left ' +
1227
+ 'off" to continue. If it was repeating one step, it is looping: break the task ' +
1228
+ 'up, or /new to reset.',
1229
+ });
1230
+ }
1231
+
1232
+ /**
1233
+ * Run one round of tool calls.
1234
+ *
1235
+ * Consecutive read-only calls go out together four files read at once
1236
+ * rather than four round trips while anything that writes or executes runs
1237
+ * on its own, in order. Returns whether any call had unusable arguments.
1238
+ */
1239
+ async runCalls(calls) {
1240
+ const groups = [];
1241
+ let batch = [];
1242
+
1243
+ for (const call of calls) {
1244
+ if (PARALLEL_SAFE.has(call.name)) {
1245
+ batch.push(call);
1246
+ } else {
1247
+ if (batch.length) { groups.push(batch); batch = []; }
1248
+ groups.push([call]);
1249
+ }
1250
+ }
1251
+ if (batch.length) groups.push(batch);
1252
+
1253
+ let badArgs = false;
1254
+
1255
+ for (const group of groups) {
1256
+ if (this.abort.signal.aborted) return badArgs;
1257
+
1258
+ const noted = (call) => {
1259
+ if (FILE_WRITES.has(call.name)) {
1260
+ for (const p of pathsOf(call)) { this.touched.add(p); this.sinceCheck.add(p); }
1261
+ }
1262
+ if (call.name === 'run_command' || call.name === 'run_commands') this.ranSomething = true;
1263
+ };
1264
+
1265
+ if (group.length > 1) {
1266
+ for (const call of group) {
1267
+ this.ui.toolCall(describe(call.name, call.args));
1268
+ noted(call);
1269
+ }
1270
+ this.ui.startSpinner(`${group.length} lookups at once`);
1271
+
1272
+ const settled = await Promise.all(
1273
+ group.map((call) => (this.early.get(call.id) ?? this.execute(call)).then((r) => ({ call, ...r })))
1274
+ );
1275
+
1276
+ this.ui.stopSpinner();
1277
+ for (const { call, out, err } of settled) {
1278
+ if (err) badArgs = this.reportFailure(call, err) || badArgs;
1279
+ else this.reportResult(call, out);
1280
+ }
1281
+ continue;
1282
+ }
1283
+
1284
+ for (const call of group) {
1285
+ if (this.abort.signal.aborted) return badArgs;
1286
+
1287
+ const label = describe(call.name, call.args);
1288
+ if (!SILENT.has(call.name)) this.ui.toolCall(label);
1289
+ this.ui.startSpinner(label);
1290
+ noted(call);
1291
+
1292
+ const { out, err } = await (this.early.get(call.id) ?? this.execute(call));
1293
+ this.ui.stopSpinner();
1294
+ if (err) badArgs = this.reportFailure(call, err) || badArgs;
1295
+ else this.reportResult(call, out);
1296
+ }
1297
+ }
1298
+
1299
+ return badArgs;
1300
+ }
1301
+
1302
+ /**
1303
+ * Check a finished call against the stuck patterns (see stuck.js) and return
1304
+ * the note to add to its result. A nudge that did not work hands the turn to
1305
+ * another model.
1306
+ */
1307
+ stuckNote(call, outcome) {
1308
+ if (!this.stuck) return '';
1309
+ const verdict = this.stuck.observe(eventFor(call, outcome));
1310
+ if (!verdict) return '';
1311
+ this.stats.stuck++;
1312
+ if (verdict.action === 'switch') {
1313
+ const next = fallbackFor(model(), this.tried ?? new Set([model()]));
1314
+ if (next) {
1315
+ this.tried?.add(next);
1316
+ this.ui.note(`${modelName(model())} kept ${describeHit(verdict.hit)} — handing over to ${modelName(next)}`);
1317
+ setModel(next);
1318
+ this.cooldownUntil = Date.now() + COOLDOWN;
1319
+ if (this.full) this.showHeader({ clear: false });
1320
+ }
1321
+ }
1322
+ return verdict.text ? `\n\n${verdict.text}` : '';
1323
+ }
1324
+
1325
+ /**
1326
+ * The daily free cap was hit mid-turn: keep the session, count down to the
1327
+ * reset, and carry on by itself. Esc stops the wait like any other turn.
1328
+ */
1329
+ async waitForReset(err) {
1330
+ const resetAt = err.detail?.resetAt;
1331
+ if (!Number.isFinite(resetAt)) return false;
1332
+ const at = resetAt + 30_000;
1333
+ const clock = new Date(at).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
1334
+ this.ui.stopSpinner();
1335
+ this.ui.note(`Daily limit reached — ucode carries on by itself at ${clock}. Esc to stop`);
1336
+ await this.persist();
1337
+ let lastNote = Date.now();
1338
+ while (Date.now() < at) {
1339
+ if (this.abort?.signal.aborted) return false;
1340
+ const left = formatDuration(at - Date.now());
1341
+ if (this.full) this.ui.startSpinner(`daily limit · carrying on at ${clock} (in ${left})`);
1342
+ else if (Date.now() - lastNote > 30 * 60_000) { this.ui.note(`still waiting for the daily limit — ${left} to go`); lastNote = Date.now(); }
1343
+ await new Promise((r) => setTimeout(r, Math.min(1000, Math.max(0, at - Date.now()))));
1344
+ }
1345
+ this.ui.stopSpinner();
1346
+ this.ui.note('Daily limit has reset — carrying on');
1347
+ return true;
1348
+ }
1349
+
1350
+ /** A dev server came up during this turn: open it in the browser, once. */
1351
+ /**
1352
+ * Open the running app in a browser — only when asked.
1353
+ *
1354
+ * This used to happen on its own whenever a dev server came up. Something
1355
+ * seizing the screen mid-thought is startling at the best of times, and
1356
+ * during a demo it is worse. UCODE_OPEN=1 brings the old behaviour back for
1357
+ * anyone who liked it; otherwise the URL is on screen to click.
1358
+ */
1359
+ openWhenReady(since) {
1360
+ if (!this.full || process.env.UCODE_OPEN !== '1') return;
1361
+ const server = serversReadySince(since).at(-1);
1362
+ if (!server || (this.opened ??= new Set()).has(server.url)) return;
1363
+ this.opened.add(server.url);
1364
+ const [cmd, args] = process.platform === 'win32'
1365
+ ? ['cmd', ['/c', 'start', '', server.url]]
1366
+ : [process.platform === 'darwin' ? 'open' : 'xdg-open', [server.url]];
1367
+ try {
1368
+ spawn(cmd, args, { stdio: 'ignore', detached: true, windowsHide: true }).unref();
1369
+ this.ui.note(`Opened ${server.url} in your browser`);
1370
+ } catch { /* no browser to open — the link is in the answer */ }
1371
+ }
1372
+
1373
+ cmdStats() {
1374
+ for (const line of statsLines(this.stats, this.working?.length ?? 0)) this.ui.write(line);
1375
+ }
1376
+
1377
+ async cmdDoctor() {
1378
+ this.ui.startSpinner('checking your setup');
1379
+ try {
1380
+ const lines = await runDoctor();
1381
+ this.ui.stopSpinner();
1382
+ for (const line of lines) this.ui.write(line);
1383
+ } finally {
1384
+ this.ui.stopSpinner();
1385
+ }
1386
+ }
1387
+
1388
+ /** /deploy [folder] — the same tool the model calls, run directly. */
1389
+ async cmdDeploy(arg) {
1390
+ const folder = (arg ?? '').trim() || '.';
1391
+ this.ui.toolCall(`Deploying ${folder}`);
1392
+ this.ui.startSpinner('getting ready to deploy');
1393
+ try {
1394
+ const out = await deploy({ folder }, { onOutput: (lines) => this.ui.updateSpinner(lines.at(-1)) });
1395
+ this.ui.stopSpinner();
1396
+ this.ui.toolResult(out.summary);
1397
+ this.ui.write(out.content.split('\n').map((l) => ` ${l}`).join('\n'));
1398
+ } catch (err) {
1399
+ this.ui.stopSpinner();
1400
+ if (err instanceof ToolFailure) this.ui.error(err);
1401
+ else throw err;
1402
+ }
1403
+ }
1404
+
1405
+ reportResult(call, out) {
1406
+ // A build or type check that just passed already verified everything
1407
+ // changed so far; the automatic check at the end would only repeat it.
1408
+ if (call.name === 'run_command' && out.exitCode === 0 &&
1409
+ /(?:next build|npm run build|pnpm (?:run )?build|tsc)/.test(call.args?.command ?? '')) {
1410
+ this.sinceCheck?.clear();
1411
+ }
1412
+ if (!QUIET.has(call.name)) this.ui.toolResult(out.summary);
1413
+ // The change as its two numbers, not as a copy of the file. The diff rows
1414
+ // are still built by the tool — the model reads them in the result — they
1415
+ // simply do not go on screen.
1416
+ if (out.diff?.length) this.ui.diffStat?.(countDiff(out.diff));
1417
+ this.push({ role: 'tool', toolCallId: call.id, name: call.name, content: out.content + this.stuckNote(call, { out }) });
1418
+ }
1419
+
1420
+ /** Show a tool failure, hand it to the model, and say if it was bad arguments. */
1421
+ reportFailure(call, err) {
1422
+ if (!(err instanceof ToolFailure)) throw err;
1423
+
1424
+ // Bad arguments are the model talking to itself. "old_string and new_string
1425
+ // are identical" is a correction it will make on the next step, and it means
1426
+ // nothing to whoever is watching except that something went wrong. It goes
1427
+ // to the model, which can act on it, and not to the screen. A refusal the
1428
+ // user made, and anything that actually failed, still shows.
1429
+ if (err instanceof Declined) this.ui.toolFailed('declined');
1430
+ else if (err.kind !== 'bad_args') this.ui.toolFailed(`${err.kind}: ${err.failed}`);
1431
+ this.push({
1432
+ role: 'tool',
1433
+ toolCallId: call.id,
1434
+ name: call.name,
1435
+ content: err.forModel() + (err instanceof Declined ? '' : this.stuckNote(call, { err })),
1436
+ });
1437
+ return err.kind === 'bad_args';
1438
+ }
1439
+
1440
+ async dispatch(call) {
1441
+ // The model emitted arguments that were not valid JSON. Hand the parser's
1442
+ // own complaint straight back so it can correct itself next step.
1443
+ if (call.parseError) {
1444
+ throw new ToolFailure({
1445
+ kind: 'bad_args',
1446
+ attempted: `calling ${call.name}`,
1447
+ failed: `The arguments were not valid JSON: ${call.parseError}`,
1448
+ fix: `Call ${call.name} again with the arguments as one well-formed JSON object.`,
1449
+ });
1450
+ }
1451
+
1452
+ if (call.name === 'load_skill') return this.loadSkill(call.args?.name);
1453
+ if (call.name === 'update_plan') return this.updatePlan(call.args?.items);
1454
+ if (call.name === 'delegate') return this.delegate(call.args?.tasks);
1455
+
1456
+ // Output reaches the screen as the command produces it, so a slow build is
1457
+ // something you watch rather than something you sit out in silence.
1458
+ return runTool(call.name, call.args ?? {}, {
1459
+ onOutput: (lines) => this.ui.progress(lines),
1460
+ });
1461
+ }
1462
+
1463
+ /**
1464
+ * Switch to the next model after a provider failure. Returns false once
1465
+ * there is nothing sensible left to try. When every model is busy at once,
1466
+ * it waits a minute and goes round again rather than giving up.
1467
+ */
1468
+ async failover(err) {
1469
+ if (++this.failovers > MAX_FAILOVERS) return false;
1470
+ const from = model();
1471
+ let next = fallbackFor(from, this.tried);
1472
+
1473
+ if (!next) {
1474
+ const until = Date.now() + 60_000;
1475
+ this.ui.startSpinner('every model is busy');
1476
+ while (Date.now() < until && !this.abort?.signal.aborted) {
1477
+ this.ui.updateSpinner(`every model is busy — trying again in ${Math.ceil((until - Date.now()) / 1000)}s`);
1478
+ await wait(1000);
1479
+ }
1480
+ this.ui.stopSpinner();
1481
+ if (this.abort?.signal.aborted) return false;
1482
+ this.tried = new Set();
1483
+ next = this.preferred && this.preferred !== from ? this.preferred : fallbackFor(from, this.tried) ?? from;
1484
+ }
1485
+
1486
+ this.tried.add(next);
1487
+ setModel(next);
1488
+ this.cooldownUntil = Date.now() + COOLDOWN;
1489
+ const why = err.kind === 'rate_limit' ? 'busy' : err.kind === 'timeout' ? 'too slow to answer' : 'not answering';
1490
+ this.ui.note(`${modelName(from)} is ${why} — carrying on with ${modelName(next)}`);
1491
+ if (this.full) this.showHeader({ clear: false });
1492
+ return true;
1493
+ }
1494
+
1495
+ /** Run a call and settle to { out } or { err } — never throws. */
1496
+ execute(call) {
1497
+ const started = Date.now();
1498
+ return this.dispatch(call).then(
1499
+ (out) => { trace({ kind: 'tool', name: call.name, ms: Date.now() - started }); countTool(this.stats, call, out); return { out }; },
1500
+ (err) => { trace({ kind: 'tool', name: call.name, ms: Date.now() - started, err: err?.kind }); countTool(this.stats, call, null, err); return { err }; }
1501
+ );
1502
+ }
1503
+
1504
+ updatePlan(items) {
1505
+ const list = (Array.isArray(items) ? items : [])
1506
+ .filter((i) => i && String(i.text ?? '').trim())
1507
+ .slice(0, 6);
1508
+ this.ui.plan(list);
1509
+ const done = list.filter((i) => i.done).length;
1510
+ return { content: `Plan updated: ${done} of ${list.length} done.`, summary: `${done}/${list.length}` };
1511
+ }
1512
+
1513
+ /** File writes from parallel workers take turns, so two never interleave. */
1514
+ fileLock(fn) {
1515
+ const run = (this.lockChain ?? Promise.resolve()).then(fn, fn);
1516
+ this.lockChain = run.catch(() => {});
1517
+ return run;
1518
+ }
1519
+
1520
+ /**
1521
+ * Several workers at once, each its own small agent loop with its own
1522
+ * conversation, sharing the tools, the project, and whatever skills are
1523
+ * already in force. Their lines in the transcript carry their name.
1524
+ */
1525
+ async delegate(tasks) {
1526
+ const list = (Array.isArray(tasks) ? tasks : [])
1527
+ .filter((t) => t && String(t.instructions ?? '').trim())
1528
+ .slice(0, MAX_WORKERS);
1529
+ if (!list.length) {
1530
+ throw new ToolFailure({
1531
+ kind: 'bad_args',
1532
+ attempted: 'starting workers',
1533
+ failed: 'No tasks with instructions were given.',
1534
+ fix: 'Pass tasks as [{ name, instructions }, ...], up to 3.',
1535
+ });
1536
+ }
1537
+
1538
+ const results = await Promise.all(list.map((task, i) => this.runWorker(task, i).catch((err) => ({
1539
+ name: task.name || `worker ${i + 1}`,
1540
+ summary: `Failed: ${err?.failed ?? err?.message ?? err}`,
1541
+ touched: [],
1542
+ }))));
1543
+
1544
+ for (const r of results) for (const f of r.touched) { this.touched.add(f); this.sinceCheck.add(f); }
1545
+
1546
+ // A worker that wrote nothing has not done its part, whatever it said.
1547
+ // The lead builds those itself rather than leaving holes in the app.
1548
+ const empty = results.filter((r) => !r.touched.length).map((r) => r.name);
1549
+
1550
+ return {
1551
+ content: results
1552
+ .map((r) => `## ${r.name}\n${r.summary}\nFiles changed: ${r.touched.join(', ') || 'none'}`)
1553
+ .join('\n\n') +
1554
+ (empty.length
1555
+ ? `\n\n${empty.join(', ')} wrote no files. Build ${empty.length === 1 ? 'that part' : 'those parts'} ` +
1556
+ 'yourself now, directly - do not delegate them again.'
1557
+ : ''),
1558
+ summary: results.map((r) => `${r.name} · ${r.touched.length} file${r.touched.length === 1 ? '' : 's'}`).join(' '),
1559
+ };
1560
+ }
1561
+
1562
+ async runWorker(task, index) {
1563
+ const name = clip(String(task.name || `worker ${index + 1}`).trim(), 16);
1564
+ const touched = new Set();
1565
+ const skills = this.skills
1566
+ .filter((s) => this.loaded.has(s.name))
1567
+ .map((s) => `--- ${s.name} ---\n${s.body}`)
1568
+ .join('\n\n');
1569
+ const messages = [
1570
+ { role: 'system', content: workerPrompt({ cwd: this.cwd, name, memory: this.memory, skills, map: this.map }) },
1571
+ { role: 'user', content: String(task.instructions) },
1572
+ ];
1573
+ const available = this.toolsNow().filter((t) => !WORKER_EXCLUDED.has(t.name));
1574
+ const wanted = process.env.UCODE_WORKER_MODEL;
1575
+ let workerModel = wanted && MODELS[wanted] ? wanted : model();
1576
+ const tried = new Set([workerModel]);
1577
+ let failovers = 0;
1578
+
1579
+ // Start a moment apart. Three requests in the same instant is exactly what
1580
+ // trips a free endpoint's rate limit, and the stagger costs a second or two.
1581
+ if (index) await wait(index * 1500);
1582
+
1583
+ for (let step = 0; step < WORKER_STEPS; step++) {
1584
+ if (this.abort?.signal.aborted) break;
1585
+
1586
+ let reply;
1587
+ const asked = Date.now();
1588
+ try {
1589
+ reply = await ask(messages, available, { signal: this.abort?.signal, model: workerModel });
1590
+ } catch (err) {
1591
+ // Same rule as the lead: a busy model is swapped, not a reason to stop.
1592
+ if (passing(err) && failovers < 6 && !this.abort?.signal.aborted) {
1593
+ failovers++;
1594
+ let next = fallbackFor(workerModel, tried);
1595
+ if (!next) { tried.clear(); await wait(20_000); next = fallbackFor(workerModel, tried) ?? workerModel; }
1596
+ tried.add(next);
1597
+ this.ui.note(`${name}: ${modelName(workerModel)} is busy — switching to ${modelName(next)}`);
1598
+ workerModel = next;
1599
+ step--;
1600
+ continue;
1601
+ }
1602
+ throw err;
1603
+ }
1604
+ this.record(reply.usage);
1605
+ trace({
1606
+ kind: 'model', who: name, model: workerModel, ms: Date.now() - asked,
1607
+ in: reply.usage.promptTokens, out: reply.usage.outputTokens,
1608
+ calls: reply.toolCalls.map((c) => c.name),
1609
+ });
1610
+
1611
+ if (!reply.toolCalls.length) {
1612
+ this.ui.toolResult(`${name} finished`);
1613
+ return { name, summary: reply.text?.trim() || 'Finished without a summary.', touched: [...touched] };
1614
+ }
1615
+
1616
+ messages.push({ role: 'assistant', content: reply.text || '', toolCalls: reply.toolCalls });
1617
+ for (const call of reply.toolCalls) {
1618
+ this.ui.toolCall(`${name} ${describe(call.name, call.args)}`);
1619
+ if (FILE_WRITES.has(call.name)) for (const p of pathsOf(call)) touched.add(p);
1620
+ const { out, err } = FILE_WRITES.has(call.name)
1621
+ ? await this.fileLock(() => this.execute(call))
1622
+ : await this.execute(call);
1623
+ if (err) {
1624
+ if (!(err instanceof ToolFailure)) throw err;
1625
+ this.ui.toolFailed(`${name}: ${err.kind}: ${err.failed}`);
1626
+ }
1627
+ messages.push({
1628
+ role: 'tool', toolCallId: call.id, name: call.name,
1629
+ content: err ? err.forModel() : out.content,
1630
+ });
1631
+ }
1632
+ }
1633
+
1634
+ return { name, summary: `Stopped after ${WORKER_STEPS} steps without finishing.`, touched: [...touched] };
1635
+ }
1636
+
1637
+ /**
1638
+ * Check the code files changed since the last check, and return the
1639
+ * errors as text for the model — or null when everything is clean.
1640
+ *
1641
+ * The check is incremental: TypeScript writes what it learned to a build
1642
+ * info file, so the second check onwards reads that instead of retyping
1643
+ * every dependency seconds rather than the best part of a minute. The
1644
+ * file sits in node_modules/.cache, which is already ignored by git and is
1645
+ * deliberately left out of the starter package cache.
1646
+ *
1647
+ * TypeScript projects get one `tsc --noEmit` per project that owns a
1648
+ * changed file (an app scaffolded into a subfolder is its own project).
1649
+ * Plain JavaScript gets a syntax check, Python a compile check. Nothing
1650
+ * runs that is not already installed.
1651
+ */
1652
+ async autoCheck() {
1653
+ const changed = [...this.sinceCheck].filter((f) => CHECKABLE.test(f));
1654
+ this.sinceCheck.clear();
1655
+ if (!changed.length) return null;
1656
+
1657
+ const root = path.resolve(this.cwd);
1658
+ const tsRoots = new Set();
1659
+ const singles = [];
1660
+ const problems = [];
1661
+
1662
+ for (const rel of changed) {
1663
+ const abs = path.resolve(root, rel);
1664
+ if (!(await exists(abs))) continue;
1665
+ if (/\.py$/i.test(rel)) { singles.push({ abs, rel, command: `python -m py_compile "${abs}"` }); continue; }
1666
+ // A single-file app keeps all its logic in an inline <script>, which no
1667
+ // other check here ever looks at.
1668
+ if (/\.html?$/i.test(rel)) {
1669
+ const text = await readFile(abs, 'utf8').catch(() => null);
1670
+ const bad = text === null ? [] : checkHtml(text);
1671
+ if (bad.length) {
1672
+ problems.push(`${rel} the script in this page does not parse, so none of it runs:\n` +
1673
+ bad.map((b) => ` line ${b.line}: ${b.message}`).join('\n'));
1674
+ }
1675
+ continue;
1676
+ }
1677
+ let dir = path.dirname(abs);
1678
+ let owner = null;
1679
+ while (dir.startsWith(root)) {
1680
+ if (await exists(path.join(dir, 'tsconfig.json'))) { owner = dir; break; }
1681
+ const up = path.dirname(dir);
1682
+ if (up === dir) break;
1683
+ dir = up;
1684
+ }
1685
+ if (owner && (await exists(path.join(owner, 'node_modules', 'typescript')))) tsRoots.add(owner);
1686
+ else if (/\.[cm]?js$/i.test(rel)) singles.push({ abs, rel, command: `node --check "${abs}"` });
1687
+ }
1688
+
1689
+ const check = async (label, command, cwd) => {
1690
+ this.ui.toolCall(label);
1691
+ this.ui.startSpinner(label);
1692
+ const { out, err } = await this.execute({
1693
+ id: 'check', name: 'run_command',
1694
+ args: { command, cwd: path.relative(root, cwd) || '.', timeout_ms: 180_000 },
1695
+ });
1696
+ this.ui.stopSpinner();
1697
+ return err ? { exitCode: -1, content: String(err.failed ?? err.message) } : out;
1698
+ };
1699
+
1700
+ for (const dir of tsRoots) {
1701
+ const show = path.relative(root, dir) || '.';
1702
+ await mkdir(path.join(dir, path.dirname(TSBUILDINFO)), { recursive: true }).catch(() => {});
1703
+ let out = await check(`Checking types in ${show}`, typeCheckCommand(true), dir);
1704
+ // Older TypeScript refuses --incremental alongside --noEmit. Say so once
1705
+ // by simply checking again the slow way, rather than failing the edit.
1706
+ if (out.exitCode !== 0 && NO_INCREMENTAL.test(out.content)) {
1707
+ out = await check(`Checking types in ${show}`, typeCheckCommand(false), dir);
1708
+ }
1709
+ if (out.exitCode === 0) { this.ui.toolResult('types check out'); continue; }
1710
+ const errors = out.content.split('\n').filter((l) => /error TS\d+/.test(l));
1711
+ this.ui.toolFailed(`${errors.length || 'some'} type error${errors.length === 1 ? '' : 's'}`);
1712
+ problems.push(`In ${show} (tsc --noEmit):\n${(errors.length ? errors : out.content.split('\n')).slice(0, 40).join('\n')}`);
1713
+ }
1714
+
1715
+ for (const f of singles) {
1716
+ const out = await check(`Checking ${f.rel}`, f.command, root);
1717
+ if (out.exitCode === 0) { this.ui.toolResult('ok'); continue; }
1718
+ this.ui.toolFailed('does not compile');
1719
+ problems.push(`${f.rel}:\n${out.content.split('\n').slice(0, 20).join('\n')}`);
1720
+ }
1721
+
1722
+ // Only once it compiles: a failing test on code that does not build tells
1723
+ // the model nothing it does not already know from the errors above.
1724
+ if (!problems.length) {
1725
+ const failed = await this.runRelatedTests(root, changed);
1726
+ if (failed) problems.push(failed);
1727
+ }
1728
+
1729
+ const live = await this.liveErrors();
1730
+ if (live) problems.push(live);
1731
+
1732
+ return problems.length ? problems.join('\n\n') : null;
1733
+ }
1734
+
1735
+ /**
1736
+ * Anything the running app has complained about since the last look. A dev
1737
+ * server knows about a broken import the moment it happens; without this
1738
+ * nobody reads that until a build, or until the user says the page is blank.
1739
+ */
1740
+ async liveErrors() {
1741
+ try {
1742
+ const found = await this.logWatch.since(runningServers());
1743
+ if (found) this.ui.toolFailed('the running app reported an error');
1744
+ return found;
1745
+ } catch {
1746
+ return null; // reading a log must never be what breaks a turn
1747
+ }
1748
+ }
1749
+
1750
+ /**
1751
+ * Run the tests that reach the files just changed, and return their
1752
+ * failures as text or null when they pass, or when this project has no
1753
+ * runner that can be asked which tests matter.
1754
+ */
1755
+ async runRelatedTests(root, changed) {
1756
+ const runner = await testRunnerFor(root);
1757
+ if (!runner) return null;
1758
+
1759
+ const existing = [];
1760
+ for (const rel of changed) if (await exists(path.resolve(root, rel))) existing.push(rel);
1761
+ const command = relatedCommand(runner, existing);
1762
+ if (!command) return null;
1763
+
1764
+ const label = `Running the ${runner} tests that cover this`;
1765
+ this.ui.toolCall(label);
1766
+ this.ui.startSpinner(label);
1767
+ const { out, err } = await this.execute({
1768
+ id: 'tests', name: 'run_command',
1769
+ args: { command, cwd: '.', timeout_ms: 180_000 },
1770
+ });
1771
+ this.ui.stopSpinner();
1772
+
1773
+ if (err) { this.ui.toolResult('tests skipped'); return null; }
1774
+ if (out.exitCode === 0) { this.ui.toolResult('tests pass'); return null; }
1775
+
1776
+ // A runner that is not installed is not a failing test; npx says so.
1777
+ if (/could not determine executable|not found|Cannot find module/i.test(out.content)) {
1778
+ this.ui.toolResult('no test runner installed');
1779
+ return null;
1780
+ }
1781
+
1782
+ this.ui.toolFailed('tests fail');
1783
+ return `The tests covering your change fail (${runner}):\n${summariseFailures(runner, out.content)}`;
1784
+ }
1785
+
1786
+ loadSkill(name) {
1787
+ const skill = findSkill(this.skills, name);
1788
+ if (!skill) {
1789
+ // The available names go in `failed` rather than only in `fix`: the
1790
+ // transcript shows the failure line, and a bare "no such skill" leaves
1791
+ // the user guessing at what this session actually has.
1792
+ const available = this.skills.map((s) => s.name).join(', ') || '(none)';
1793
+ throw new ToolFailure({
1794
+ kind: 'no_such_skill',
1795
+ attempted: `loading the "${name}" skill`,
1796
+ failed: `There is no skill called "${name}". This session has: ${available}.`,
1797
+ fix: 'Use one of those names, or carry on without one.',
1798
+ });
1799
+ }
1800
+
1801
+ if (this.loaded.has(skill.name)) {
1802
+ return { content: `The "${skill.name}" skill is already loaded above. Follow it.`, summary: 'already loaded' };
1803
+ }
1804
+
1805
+ this.loaded.add(skill.name);
1806
+ this.push(skillMessage(skill));
1807
+ return {
1808
+ content: `Loaded "${skill.name}". Its instructions are in your context now — follow them.`,
1809
+ summary: `${skill.name} · ${skill.body.split('\n').length} lines`,
1810
+ };
1811
+ }
1812
+
1813
+ record(u) {
1814
+ const total = this.session.usage;
1815
+ total.promptTokens += u.promptTokens || 0;
1816
+ total.outputTokens += u.outputTokens || 0;
1817
+ total.totalTokens += u.totalTokens || 0;
1818
+ total.turns += 1;
1819
+ }
1820
+
1821
+ /** Fold older turns into a summary when the window gets tight. */
1822
+ async maybeFold() {
1823
+ const limit = contextLimit();
1824
+ if (!tooBig(this.working, limit)) return;
1825
+
1826
+ this.ui.startSpinner('context is filling up — summarizing earlier turns');
1827
+ try {
1828
+ const result = await fold(this.working, {
1829
+ limit,
1830
+ summarize: async (older) => {
1831
+ const reply = await ask(
1832
+ [
1833
+ { role: 'system', content: SUMMARY_PROMPT },
1834
+ { role: 'user', content: forSummary(older) },
1835
+ ],
1836
+ [],
1837
+ { signal: this.abort?.signal, temperature: 0 }
1838
+ );
1839
+ return reply.text;
1840
+ },
1841
+ });
1842
+
1843
+ this.ui.stopSpinner();
1844
+ if (result.folded) {
1845
+ this.working = result.messages;
1846
+ this.ui.note(
1847
+ `folded ${result.droppedCount} earlier messages into a summary ` +
1848
+ '(the full history is still saved in this session)'
1849
+ );
1850
+ }
1851
+ } catch (err) {
1852
+ // If summarizing fails, carry on with the full history and let the API
1853
+ // complain — better than silently throwing away the conversation.
1854
+ this.ui.stopSpinner();
1855
+ this.ui.note(`could not summarize older turns (${err.kind ?? 'error'}); carrying on uncompacted`);
1856
+ }
1857
+ }
1858
+
1859
+ // -- slash commands ------------------------------------------------------
1860
+
1861
+ async command(input) {
1862
+ const [name, ...rest] = input.split(/\s+/);
1863
+ const arg = rest.join(' ').trim();
1864
+
1865
+ switch (name) {
1866
+ case '/help':
1867
+ return this.cmdHelp();
1868
+
1869
+ // One thing, one command, however you happen to spell it.
1870
+ case '/model':
1871
+ case '/models':
1872
+ return this.cmdModel(arg);
1873
+
1874
+ case '/session':
1875
+ case '/sessions':
1876
+ case '/resume':
1877
+ return this.cmdSessions(arg);
1878
+
1879
+ case '/new': return this.cmdNew();
1880
+ case '/remember': return this.cmdRemember(arg);
1881
+ case '/skills': return this.cmdSkills();
1882
+ case '/clear': this.showHeader(); return;
1883
+ case '/search': return this.cmdSearch(arg);
1884
+ case '/copy': return this.cmdCopy();
1885
+ case '/stats': return this.cmdStats();
1886
+ case '/doctor': return this.cmdDoctor();
1887
+ case '/look': return this.cmdLook(arg);
1888
+ case '/deploy': return this.cmdDeploy(arg);
1889
+ case '/exit':
1890
+ case '/quit': return 'exit';
1891
+
1892
+ default:
1893
+ this.ui.write(theme.warn(` no such command: ${name}`));
1894
+ this.ui.note('/help lists them.');
1895
+ }
1896
+ }
1897
+
1898
+ /**
1899
+ * Look at the running app, because the user asked to.
1900
+ *
1901
+ * This used to happen on its own, which meant a browser being driven while
1902
+ * someone was reading, and a window taking the screen mid-thought. It is
1903
+ * the same check as before; the difference is who starts it.
1904
+ */
1905
+ async cmdLook(url) {
1906
+ const { lookAtApp } = await import('../tools/browser.js');
1907
+ const server = runningServers().at(-1);
1908
+ const at = (url ?? '').trim() || server?.url;
1909
+ if (!at) {
1910
+ this.ui.write(theme.warn(' nothing is running to look at.'));
1911
+ this.ui.note('start the app first, or pass a URL: /look http://localhost:3000');
1912
+ return;
1913
+ }
1914
+ this.ui.toolCall(`Looking at ${at}`);
1915
+ try {
1916
+ const out = await lookAtApp({ url: at });
1917
+ this.ui.write(out.content);
1918
+ // The model gets it too, so the next thing it says is about what is
1919
+ // actually on the page rather than what it believes it built.
1920
+ this.push({ role: 'user', content: `I looked at ${at}. This is what is there:
1921
+
1922
+ ${out.content}` });
1923
+ } catch (err) {
1924
+ this.ui.write(theme.error(` ${err.failed ?? err.message}`));
1925
+ }
1926
+ }
1927
+
1928
+ cmdHelp() {
1929
+ const rows = [
1930
+ ['/help', 'this list'],
1931
+ ['/stats', 'time, steps and tokens this session'],
1932
+ ['/doctor', 'check that everything ucode needs is working'],
1933
+ ['/look [url]', 'open the running app and report what is on the page'],
1934
+ ['/deploy [folder]', 'put the app online and get its link'],
1935
+ ['/model', 'show the models and switch between them'],
1936
+ ['/resume', 'pick up an earlier conversation'],
1937
+ ['/new', 'save this one and start fresh'],
1938
+ ['/remember <note>', `add a standing note to ${MEMORY_FILE}`],
1939
+ ['/skills', 'what ucode knows how to do'],
1940
+ ['/search <query>', 'look something up on the web'],
1941
+ ['/copy', 'copy the last reply to the clipboard'],
1942
+ ['/clear', 'clear the screen, keep the conversation'],
1943
+ ['/exit', 'save and quit'],
1944
+ ];
1945
+
1946
+ this.ui.blank();
1947
+ for (const [command, what] of rows) {
1948
+ this.ui.write(` ${blue(command.padEnd(18))} ${dim(what)}`);
1949
+ }
1950
+ this.ui.blank();
1951
+ this.ui.write(dim(' /models, /session and /sessions do the same as /model and /resume.'));
1952
+ this.ui.write(dim(' ctrl+b swaps plan and build · esc stops a running turn · ctrl+d quits'));
1953
+ this.ui.blank();
1954
+ }
1955
+
1956
+ /** The five models, and this session's spend. */
1957
+ async cmdModel(arg) {
1958
+ if (arg) {
1959
+ try {
1960
+ setModel(arg);
1961
+ this.preferred = model();
1962
+ } catch (err) {
1963
+ this.ui.error(err, { debug: this.debug });
1964
+ return;
1965
+ }
1966
+ this.session.model = model();
1967
+ this.ui.note(`now using ${modelName()}`);
1968
+ this.showHeader({ clear: false });
1969
+ return;
1970
+ }
1971
+
1972
+ const all = modelList();
1973
+ const width = Math.max(...all.map((m) => m.name.length));
1974
+
1975
+ if (this.ui.pick) {
1976
+ const items = all.map((m) => ({
1977
+ label:
1978
+ `${m.active ? blue('●') : dim('○')} ${m.star ? blue('★') : ' '} ` +
1979
+ `${m.name.padEnd(width)} ${dim(`${formatTokens(m.context)} · ${m.note}`)}`,
1980
+ }));
1981
+
1982
+ const chosen = await this.ui.pick(items, {
1983
+ active: Math.max(0, all.findIndex((m) => m.active)),
1984
+ hint: '↑↓ move · enter to switch · esc to cancel',
1985
+ });
1986
+ if (chosen === null) return;
1987
+
1988
+ setModel(all[chosen].id);
1989
+ this.preferred = model();
1990
+ this.session.model = model();
1991
+ this.ui.note(`now using ${modelName()}`);
1992
+ this.showHeader({ clear: false });
1993
+ return;
1994
+ }
1995
+
1996
+ this.ui.blank();
1997
+ for (const m of all) {
1998
+ this.ui.write(
1999
+ ` ${m.active ? blue('●') : dim('○')} ${m.star ? blue('★') : ' '} ` +
2000
+ `${(m.active ? blue : dim)(m.name.padEnd(width))} ${dim(`${formatTokens(m.context)} · ${m.note}`)}`
2001
+ );
2002
+ this.ui.write(` ${dim(m.id)}`);
2003
+ }
2004
+
2005
+ const u = this.session.usage;
2006
+ const live = rateLimits();
2007
+ this.ui.blank();
2008
+ this.ui.write(
2009
+ ` ${dim('this session')} ${u.turns} turns · ${formatTokens(u.totalTokens)} tokens ` +
2010
+ `(${formatTokens(u.promptTokens)} in, ${formatTokens(u.outputTokens)} out)`
2011
+ );
2012
+ if (live?.requestsRemaining != null && live?.requestsLimit) {
2013
+ this.ui.write(` ${dim('requests')} ${live.requestsRemaining} of ${live.requestsLimit} left`);
2014
+ }
2015
+ this.ui.blank();
2016
+ this.ui.write(dim(' /model <id> switches without the picker.'));
2017
+ this.ui.blank();
2018
+ }
2019
+
2020
+ /**
2021
+ * One row per saved conversation.
2022
+ *
2023
+ * A list of titles and timestamps is not enough to recognise your own work
2024
+ * by half of them start "Fix the". So each row carries what it was about
2025
+ * and how far it got, and the ones from this folder are marked, because that
2026
+ * is nearly always the one being looked for.
2027
+ */
2028
+ describeSession(s, width, i) {
2029
+ const room = Math.max(24, Math.min(46, width - 34));
2030
+ const mark = s.mine ? blue('●') : dim('○');
2031
+ const when = relativeTime(s.updatedAt).padEnd(9);
2032
+ const turns = `${s.turns} turn${s.turns === 1 ? '' : 's'}`.padEnd(9);
2033
+ const where = s.mine ? 'here' : shortenPath(s.cwd, 26);
2034
+
2035
+ return {
2036
+ // Numbered in the picker, so /session delete 3 has something to point at.
2037
+ label: `${i === undefined ? '' : `${dim(String(i + 1).padStart(2))} `}${mark} ${clip(s.title, room).padEnd(room)} ${dim(when)}${dim(turns)}${dim(where)}`,
2038
+ sub: s.preview ? dim(` ${clip(s.preview, width - 10)}`) : '',
2039
+ };
2040
+ }
2041
+
2042
+ async cmdSessions(arg) {
2043
+ if (arg === '--clear' || arg === 'clear') {
2044
+ const yes = await this.ui.confirm({
2045
+ action: 'delete every saved conversation',
2046
+ detail: 'This cannot be undone.',
2047
+ risk: 'write',
2048
+ });
2049
+ if (!yes) {
2050
+ this.ui.note('cancelled');
2051
+ return;
2052
+ }
2053
+ await removeAll();
2054
+ this.ui.note('all sessions deleted');
2055
+ return;
2056
+ }
2057
+
2058
+ // /session delete 3 or /session delete 2,5,7
2059
+ const del = /^(?:delete|del|rm|remove)\b\s*(.*)$/i.exec(arg ?? '');
2060
+ if (del) return this.deleteSessions(del[1]);
2061
+
2062
+ const sessions = await list({ cwd: this.cwd });
2063
+ if (!sessions.length) {
2064
+ this.ui.note('no saved conversations yet');
2065
+ return;
2066
+ }
2067
+
2068
+ for (const bad of sessions.unreadable ?? []) {
2069
+ this.ui.write(theme.warn(` could not read session file: ${bad}`));
2070
+ }
2071
+
2072
+ const shown = sessions.slice(0, 25);
2073
+ const width = this.ui.width ? this.ui.width() : 80;
2074
+ let index;
2075
+
2076
+ if (arg) {
2077
+ const n = Number(arg);
2078
+ if (!Number.isInteger(n) || n < 1 || n > shown.length) {
2079
+ this.ui.write(theme.warn(` "${arg}" is not one of 1-${shown.length}`));
2080
+ return;
2081
+ }
2082
+ index = n - 1;
2083
+ } else if (this.ui.pick) {
2084
+ // The picker stays open while you delete, so clearing out several old
2085
+ // conversations is d d, d d, d d — then Enter on the one you want.
2086
+ let active = 0;
2087
+ for (;;) {
2088
+ const here = shown.filter((s) => s.mine).length;
2089
+ const picked = await this.ui.pick(
2090
+ shown.map((s, i) => this.describeSession(s, width, i)),
2091
+ {
2092
+ active,
2093
+ deletable: true,
2094
+ hint:
2095
+ `↑↓ move · enter to continue · d twice to delete · esc to cancel` +
2096
+ (here ? ` — ${here} from this folder` : ''),
2097
+ }
2098
+ );
2099
+ if (picked === null) return;
2100
+ if (typeof picked === 'object' && picked.delete !== undefined) {
2101
+ const doomed = shown[picked.delete];
2102
+ active = picked.delete;
2103
+ if (doomed.id === this.session.id) {
2104
+ this.ui.flash?.('that is the conversation you are in — /new first, then delete it');
2105
+ continue;
2106
+ }
2107
+ await remove(doomed.id);
2108
+ shown.splice(picked.delete, 1);
2109
+ this.ui.flash?.(`deleted · ${clip(doomed.title, 50)}`);
2110
+ if (!shown.length) {
2111
+ this.ui.note('no saved conversations left');
2112
+ return;
2113
+ }
2114
+ active = Math.min(active, shown.length - 1);
2115
+ continue;
2116
+ }
2117
+ index = picked;
2118
+ break;
2119
+ }
2120
+ } else {
2121
+ this.ui.blank();
2122
+ this.ui.note('/session delete <number> removes one, or several: /session delete 2,5');
2123
+ index = await this.ui.choose(
2124
+ 'continue which?',
2125
+ shown.map((s, i) => this.describeSession(s, width, i).label)
2126
+ );
2127
+ if (index === null) return;
2128
+ }
2129
+
2130
+ if (this.session.messages.length) await this.persist();
2131
+ if (await this.resume(shown[index].id)) {
2132
+ this.showHeader();
2133
+ this.replayTail();
2134
+ }
2135
+ }
2136
+
2137
+ /** /session delete 3, or 2,5,7 numbers as the session list shows them. */
2138
+ async deleteSessions(spec) {
2139
+ const sessions = (await list({ cwd: this.cwd })).slice(0, 25);
2140
+ const numbers = [...new Set(String(spec).split(/[\s,]+/).filter(Boolean).map(Number))];
2141
+ const bad = numbers.filter((n) => !Number.isInteger(n) || n < 1 || n > sessions.length);
2142
+ if (!numbers.length || bad.length) {
2143
+ this.ui.write(theme.warn(` usage: /session delete <number>[,<number>…] — numbers from 1 to ${sessions.length}`));
2144
+ return;
2145
+ }
2146
+ for (const n of numbers) {
2147
+ const s = sessions[n - 1];
2148
+ if (s.id === this.session.id) {
2149
+ this.ui.note(`skipped ${n} — that is the conversation you are in`);
2150
+ continue;
2151
+ }
2152
+ await remove(s.id);
2153
+ this.ui.note(`deleted ${n} · ${s.title}`);
2154
+ }
2155
+ }
2156
+
2157
+ async resume(id) {
2158
+ try {
2159
+ const loaded = await load(id);
2160
+ this.session = loaded;
2161
+ this.working = [...loaded.messages];
2162
+ this.loaded = new Set(loaded.messages.filter((m) => m.skill).map((m) => m.skill));
2163
+ if (loaded.model && MODELS[loaded.model]) setModel(loaded.model);
2164
+ return true;
2165
+ } catch (err) {
2166
+ this.ui.error(err, { debug: this.debug });
2167
+ this.ui.note('Starting a fresh one instead.');
2168
+ return false;
2169
+ }
2170
+ }
2171
+
2172
+ /** The last few exchanges, so a resumed conversation has visible context. */
2173
+ replayTail(count = 4) {
2174
+ const tail = this.session.messages
2175
+ .filter((m) => (m.role === 'user' || m.role === 'assistant') && m.content)
2176
+ .slice(-count);
2177
+
2178
+ for (const m of tail) {
2179
+ if (m.role !== 'user') this.ui.assistant(m.content);
2180
+ else if (this.ui.userMessage) this.ui.userMessage(m.content);
2181
+ else this.ui.write(`${blue('›')} ${dim(m.content.split('\n')[0])}`);
2182
+ }
2183
+ if (tail.length) this.ui.write(dim(' ── picking up here ──\n'));
2184
+ }
2185
+
2186
+ /** Add a line to this project's UCODE.md, read at the start of every turn. */
2187
+ async cmdRemember(note) {
2188
+ if (!note) {
2189
+ this.ui.note(`usage: /remember <something ucode should always know here> — saved to ${MEMORY_FILE}`);
2190
+ return;
2191
+ }
2192
+ try {
2193
+ const file = await remember(this.cwd, note);
2194
+ this.ui.note(`remembered · ${path.relative(this.cwd, file) || MEMORY_FILE}`);
2195
+ } catch (err) {
2196
+ this.ui.error(new Failure({
2197
+ kind: 'memory_unwritable',
2198
+ attempted: `saving to ${MEMORY_FILE}`,
2199
+ failed: err.message,
2200
+ fix: 'Check that this folder is writable.',
2201
+ }), { debug: this.debug });
2202
+ }
2203
+ }
2204
+
2205
+ async cmdNew() {
2206
+ if (this.session.messages.length) {
2207
+ await this.persist();
2208
+ this.ui.note(`saved · ${this.session.title}`);
2209
+ }
2210
+ this.session = newSession(this.cwd, model());
2211
+ this.working = [];
2212
+ this.loaded = new Set();
2213
+ this.showHeader();
2214
+ }
2215
+
2216
+ async cmdSkills() {
2217
+ // Re-read from disk. Skills load once at startup, so one written during
2218
+ // this session would otherwise stay invisible — and load_skill would fail
2219
+ // on a name the user can see in the folder.
2220
+ this.skills = await loadSkills({ cwd: this.cwd });
2221
+ for (const problem of this.skills.problems ?? []) {
2222
+ this.ui.write(theme.warn(` skill not loaded: ${problem}`));
2223
+ }
2224
+
2225
+ if (!this.skills.length) {
2226
+ this.ui.note('no skills found add a folder with a SKILL.md under .ucode/skills');
2227
+ return;
2228
+ }
2229
+
2230
+ this.ui.blank();
2231
+ for (const s of this.skills) {
2232
+ const live = this.loaded.has(s.name);
2233
+ const auto = s.triggers.length ? dim(' · loads itself') : '';
2234
+ this.ui.write(` ${live ? blue('●') : dim('○')} ${blue(s.name)}${auto}`);
2235
+ this.ui.write(` ${dim(s.description)}`);
2236
+ }
2237
+ this.ui.blank();
2238
+ this.ui.write(dim(' ● already loaded here · ucode pulls one in when the task matches'));
2239
+ this.ui.blank();
2240
+ }
2241
+
2242
+ async cmdSearch(query) {
2243
+ if (!query) {
2244
+ this.ui.note('usage: /search <what you want to look up>');
2245
+ return;
2246
+ }
2247
+ await this.turn(
2248
+ `Search the web for: ${query}\n\nUse web_search, then summarise what you found and cite the URLs.`
2249
+ );
2250
+ }
2251
+
2252
+ /** Copy the last reply. Every platform ships a clipboard pipe. */
2253
+ async cmdCopy() {
2254
+ const last = [...this.session.messages]
2255
+ .reverse()
2256
+ .find((m) => m.role === 'assistant' && m.content?.trim());
2257
+
2258
+ if (!last) {
2259
+ this.ui.note('nothing to copy yet');
2260
+ return;
2261
+ }
2262
+
2263
+ const tool = process.platform === 'win32' ? 'clip'
2264
+ : process.platform === 'darwin' ? 'pbcopy'
2265
+ : 'xclip -selection clipboard';
2266
+
2267
+ try {
2268
+ await new Promise((resolve, reject) => {
2269
+ const child = spawn(tool, { shell: true, windowsHide: true });
2270
+ child.on('error', reject);
2271
+ child.on('close', (code) => (code === 0 ? resolve() : reject(new Error(`exit ${code}`))));
2272
+ child.stdin.end(last.content);
2273
+ });
2274
+ const lines = last.content.split('\n').length;
2275
+ this.ui.note(`copied ${lines} line${lines === 1 ? '' : 's'}`);
2276
+ } catch (err) {
2277
+ this.ui.error(new Failure({
2278
+ kind: 'clipboard_failed',
2279
+ attempted: 'copying the last reply',
2280
+ failed: `${tool} could not run: ${err.message}`,
2281
+ fix: process.platform === 'linux'
2282
+ ? 'Install xclip (apt install xclip), or select the text with the mouse.'
2283
+ : 'Select the text with the mouse instead.',
2284
+ }), { debug: this.debug });
2285
+ }
2286
+ }
2287
+ }
2288
+
2289
+ export { DEFAULT_MODEL, PROVIDER };