ucode-agent 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +240 -0
- package/package.json +54 -0
- package/skills/build-app/SKILL.md +81 -0
- package/skills/code-review/SKILL.md +36 -0
- package/skills/debug/SKILL.md +47 -0
- package/skills/ui-ux/SKILL.md +237 -0
- package/skills/write-tests/SKILL.md +47 -0
- package/src/core/failure.js +70 -0
- package/src/core/history.js +278 -0
- package/src/core/loop.js +1146 -0
- package/src/core/provider.js +740 -0
- package/src/core/skills.js +165 -0
- package/src/core/window.js +127 -0
- package/src/tools/files.js +466 -0
- package/src/tools/index.js +394 -0
- package/src/tools/search.js +192 -0
- package/src/tools/shared.js +343 -0
- package/src/tools/shell.js +553 -0
- package/src/tools/web.js +96 -0
- package/src/ui/markdown.js +64 -0
- package/src/ui/plain.js +325 -0
- package/src/ui/screen.js +1067 -0
- package/src/ui/theme.js +256 -0
- package/ucode.js +118 -0
package/src/core/loop.js
ADDED
|
@@ -0,0 +1,1146 @@
|
|
|
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 { readFile } from 'node:fs/promises';
|
|
16
|
+
import { spawn } from 'node:child_process';
|
|
17
|
+
|
|
18
|
+
import {
|
|
19
|
+
ask, model, setModel, modelName, modelList, contextLimit, rateLimits,
|
|
20
|
+
MODELS, DEFAULT_MODEL, PROVIDER,
|
|
21
|
+
} from './provider.js';
|
|
22
|
+
import {
|
|
23
|
+
tools, runTool, describe, setRoot, setConfirm, PARALLEL_SAFE, WRITES,
|
|
24
|
+
} from '../tools/index.js';
|
|
25
|
+
import {
|
|
26
|
+
newSession, save, load, list, removeAll, titleFrom,
|
|
27
|
+
} from './history.js';
|
|
28
|
+
import { fold, usage, tooBig, SUMMARY_PROMPT, forSummary } from './window.js';
|
|
29
|
+
import { loadSkills, catalogue, findSkill, skillMessage, autoLoadFor } from './skills.js';
|
|
30
|
+
import { Screen, isLabel } from '../ui/screen.js';
|
|
31
|
+
import { Plain } from '../ui/plain.js';
|
|
32
|
+
import { theme, blue, sky, dim, formatTokens, relativeTime, shortenPath, clip } from '../ui/theme.js';
|
|
33
|
+
import { Failure, ToolFailure, Declined } from './failure.js';
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Tool calls allowed in one turn.
|
|
37
|
+
*
|
|
38
|
+
* Scaffolding an app is dozens of writes before anything can even be run, so a
|
|
39
|
+
* small ceiling stops a real job halfway through — which from the outside is
|
|
40
|
+
* indistinguishable from the agent giving up for no reason. The runaway-loop
|
|
41
|
+
* protection this exists for still works at 250; a loop burns through that
|
|
42
|
+
* just as visibly, only later.
|
|
43
|
+
*/
|
|
44
|
+
const MAX_STEPS = Number(process.env.UCODE_MAX_STEPS) || 250;
|
|
45
|
+
const MAX_ARG_RETRIES = 2;
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* How many times a reply cut off at the output limit is asked to carry on.
|
|
49
|
+
* Three covers any answer a terminal should be printing; past that the model
|
|
50
|
+
* is rambling and stopping is the kinder outcome.
|
|
51
|
+
*/
|
|
52
|
+
const MAX_CONTINUATIONS = 3;
|
|
53
|
+
|
|
54
|
+
/** Read-only tools whose result line adds nothing — the user saw the output. */
|
|
55
|
+
const QUIET = new Set(['read_file', 'read_files', 'list_dir', 'glob', 'grep', 'web_search']);
|
|
56
|
+
|
|
57
|
+
/** Skills reach the model as one extra tool, so bodies load only when wanted. */
|
|
58
|
+
const loadSkillTool = {
|
|
59
|
+
name: 'load_skill',
|
|
60
|
+
description:
|
|
61
|
+
'Load the full instructions for one of the skills listed in your system prompt. ' +
|
|
62
|
+
'Call it the moment a task matches one — before planning, before writing anything ' +
|
|
63
|
+
'— then follow what it says.',
|
|
64
|
+
parameters: {
|
|
65
|
+
type: 'object',
|
|
66
|
+
properties: { name: { type: 'string', description: 'The skill name, exactly as listed.' } },
|
|
67
|
+
required: ['name'],
|
|
68
|
+
},
|
|
69
|
+
};
|
|
70
|
+
|
|
71
|
+
function systemPrompt({ cwd, skills, mode, check }) {
|
|
72
|
+
const list = catalogue(skills);
|
|
73
|
+
|
|
74
|
+
return [
|
|
75
|
+
'You are ucode, a coding agent working directly in the user\'s terminal.',
|
|
76
|
+
'',
|
|
77
|
+
`Working directory: ${cwd}`,
|
|
78
|
+
`Platform: ${process.platform}`,
|
|
79
|
+
'',
|
|
80
|
+
'## How to work',
|
|
81
|
+
'',
|
|
82
|
+
...(mode === 'plan' ? [
|
|
83
|
+
'You are in PLAN MODE. Reading, searching and research are available; every tool',
|
|
84
|
+
'that writes a file or runs a command has been withheld. Investigate, then set out',
|
|
85
|
+
'what you would change: which files, which functions, in what order. Never imply',
|
|
86
|
+
'you have made a change you are not able to make.',
|
|
87
|
+
'',
|
|
88
|
+
] : []),
|
|
89
|
+
...(check ? [
|
|
90
|
+
`This project checks itself with: ${check}`,
|
|
91
|
+
'After changing code, run that and report what actually happened. "It should work"',
|
|
92
|
+
'is not a result.',
|
|
93
|
+
'',
|
|
94
|
+
] : []),
|
|
95
|
+
'- Read before you write. Never edit a file you have not read this session.',
|
|
96
|
+
'- Prefer edit_file to write_file. Rewrite a whole file only when creating it, or',
|
|
97
|
+
' when the change genuinely touches most of it.',
|
|
98
|
+
'- old_string must be copied out of what read_file showed you, character for',
|
|
99
|
+
' character, without the line-number gutter, and must appear exactly once. Add',
|
|
100
|
+
' surrounding lines until it does.',
|
|
101
|
+
'- read_file returns up to 600 lines. Read the whole file before editing it rather',
|
|
102
|
+
' than editing from a fragment; pass offset to continue a long one.',
|
|
103
|
+
'',
|
|
104
|
+
'## Going fast',
|
|
105
|
+
'',
|
|
106
|
+
'Every tool call is a round trip to you, and the round trip — not the disk, not',
|
|
107
|
+
'the shell — is where the time goes. So:',
|
|
108
|
+
'',
|
|
109
|
+
'- Need more than one file? read_files, all of them in one call. Never read files',
|
|
110
|
+
' one at a time when you already know which ones you want.',
|
|
111
|
+
'- Put independent calls in the same message — several greps, a glob and a read.',
|
|
112
|
+
' Read-only calls in one message run at the same time.',
|
|
113
|
+
'- batch_write to lay out several new files at once, multi_edit for several changes',
|
|
114
|
+
' to one file.',
|
|
115
|
+
'- Nothing you run has a keyboard. Pass the non-interactive flag to anything that',
|
|
116
|
+
' would ask a question, or it fails instead of waiting: create-next-app --yes,',
|
|
117
|
+
' npx shadcn@latest init -d -y, npx shadcn@latest add <names> -y, npm init -y.',
|
|
118
|
+
'- Dev servers start in the background by themselves, and the result tells you the',
|
|
119
|
+
' URL once the server says it is ready. Do not start one twice, do not sleep while',
|
|
120
|
+
' waiting for it, and do not curl it before that result comes back.',
|
|
121
|
+
'',
|
|
122
|
+
'## Safety',
|
|
123
|
+
'',
|
|
124
|
+
'- run_command runs without asking. That is trust rather than licence: never run',
|
|
125
|
+
' anything destructive the user did not ask for.',
|
|
126
|
+
'- Paths are relative to the working directory. Anything outside it needs the user',
|
|
127
|
+
' to approve it first.',
|
|
128
|
+
'- Verify. After changing code, run the tests or a quick check with run_command.',
|
|
129
|
+
'',
|
|
130
|
+
'## Saying what you are doing',
|
|
131
|
+
'',
|
|
132
|
+
'- Before every tool call, write ONE short line naming the file or command:',
|
|
133
|
+
' "Reading tui.js", "Fixing the spinner in loop.js", "Running npm test".',
|
|
134
|
+
'- Present tense, under ten words, and no full stop at the end. It is a label on',
|
|
135
|
+
' work happening right now, not a sentence about work that is finished.',
|
|
136
|
+
'- That line and nothing else in the message. No preamble, no plan, no bullets —',
|
|
137
|
+
' the user reads it live while the tool runs.',
|
|
138
|
+
'- Say the next one when you take the next step, not all of them up front.',
|
|
139
|
+
'',
|
|
140
|
+
'## Answering',
|
|
141
|
+
'',
|
|
142
|
+
'- Be short. Two or three sentences is usually the entire answer. This is a',
|
|
143
|
+
' terminal, not a document.',
|
|
144
|
+
'- No preamble, no restating the question, no "I will now...". Just answer.',
|
|
145
|
+
'- Do not narrate what the tool output already showed. The user watched the diff',
|
|
146
|
+
' and the command output; cover only what is not obvious from them.',
|
|
147
|
+
'- Skip closing summaries of work the user just watched you do — but never end',
|
|
148
|
+
' a turn silently. If there is genuinely nothing to add, one short line saying',
|
|
149
|
+
' what changed is the whole answer.',
|
|
150
|
+
'- Markdown. Fenced blocks with a language tag get highlighted.',
|
|
151
|
+
'- Point at code as path:line so the user can jump straight to it.',
|
|
152
|
+
'- Report honestly. If a command failed or you skipped something, say so.',
|
|
153
|
+
'- Length tracks the question: a one-line question gets a one-line answer.',
|
|
154
|
+
'- Brevity is about your prose and never about your work. What you build is',
|
|
155
|
+
' finished: every control wired, every state handled, no TODO left behind.',
|
|
156
|
+
...(list ? [
|
|
157
|
+
'',
|
|
158
|
+
'## Skills',
|
|
159
|
+
'',
|
|
160
|
+
'These instruction packs are available. When a task matches one, load it with',
|
|
161
|
+
'load_skill as your first step — before planning, before writing anything — and',
|
|
162
|
+
'then follow it. A skill already in this conversation outranks your own defaults',
|
|
163
|
+
'and is not advisory.',
|
|
164
|
+
'',
|
|
165
|
+
list,
|
|
166
|
+
] : []),
|
|
167
|
+
].join('\n');
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
export class Agent {
|
|
171
|
+
constructor({ cwd, debug = false }) {
|
|
172
|
+
this.cwd = cwd;
|
|
173
|
+
this.debug = debug;
|
|
174
|
+
// A full-screen layout only makes sense on a real terminal. Piped input,
|
|
175
|
+
// CI and `echo ... | ucode` get the line-based interface instead.
|
|
176
|
+
this.full = Boolean(process.stdout.isTTY && process.stdin.isTTY);
|
|
177
|
+
this.ui = this.full ? new Screen({ cwd }) : new Plain({ cwd });
|
|
178
|
+
this.skills = [];
|
|
179
|
+
this.session = newSession(cwd, model());
|
|
180
|
+
this.working = [];
|
|
181
|
+
this.loaded = new Set();
|
|
182
|
+
this.abort = null;
|
|
183
|
+
this.busy = false;
|
|
184
|
+
this.check = null;
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
// -- history -------------------------------------------------------------
|
|
188
|
+
|
|
189
|
+
push(message) {
|
|
190
|
+
this.session.messages.push(message);
|
|
191
|
+
this.working.push(message);
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
async persist() {
|
|
195
|
+
try {
|
|
196
|
+
this.session.model = model();
|
|
197
|
+
await save(this.session);
|
|
198
|
+
} catch (err) {
|
|
199
|
+
// Losing the save must not lose the turn.
|
|
200
|
+
this.ui.error(err, { debug: this.debug });
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
// -- startup -------------------------------------------------------------
|
|
205
|
+
|
|
206
|
+
/**
|
|
207
|
+
* Everything a turn needs, minus the terminal.
|
|
208
|
+
*
|
|
209
|
+
* Split out of start() so another front end could prepare an agent and drive
|
|
210
|
+
* turn() itself.
|
|
211
|
+
*/
|
|
212
|
+
async bootstrap() {
|
|
213
|
+
setRoot(this.cwd);
|
|
214
|
+
setConfirm((request) => this.ui.confirm(request));
|
|
215
|
+
this.skills = await loadSkills({ cwd: this.cwd });
|
|
216
|
+
await this.detectCheck();
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
async start() {
|
|
220
|
+
await this.bootstrap();
|
|
221
|
+
|
|
222
|
+
if (this.full) {
|
|
223
|
+
await this.ui.start();
|
|
224
|
+
this.ui.onInterrupt = () => {
|
|
225
|
+
if (this.busy && this.abort) {
|
|
226
|
+
this.abort.abort();
|
|
227
|
+
this.ui.stopSpinner();
|
|
228
|
+
this.ui.note('interrupted');
|
|
229
|
+
}
|
|
230
|
+
};
|
|
231
|
+
this.ui.onModeChange = () => this.showHeader({ clear: false });
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
for (const problem of this.skills.problems ?? []) {
|
|
235
|
+
this.ui.write(theme.warn(` skill not loaded: ${problem}`));
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
this.showHeader();
|
|
239
|
+
this.installSignals();
|
|
240
|
+
await this.repl();
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
showHeader({ clear = true } = {}) {
|
|
244
|
+
if (clear && this.full) this.ui.clearScreen();
|
|
245
|
+
const stats = usage(this.working, contextLimit());
|
|
246
|
+
this.ui.header({
|
|
247
|
+
cwd: this.cwd,
|
|
248
|
+
model: modelName(),
|
|
249
|
+
used: stats.used,
|
|
250
|
+
limit: stats.limit,
|
|
251
|
+
title: this.session.title === 'Untitled' ? 'new session' : this.session.title,
|
|
252
|
+
});
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
installSignals() {
|
|
256
|
+
const flush = async () => {
|
|
257
|
+
await save(this.session).catch(() => {});
|
|
258
|
+
process.exit(0);
|
|
259
|
+
};
|
|
260
|
+
process.on('SIGTERM', flush);
|
|
261
|
+
|
|
262
|
+
// The full-screen UI reads keys itself, so it owns ctrl+c and esc.
|
|
263
|
+
if (this.full) return;
|
|
264
|
+
|
|
265
|
+
this.ui.rl.on('SIGINT', () => {
|
|
266
|
+
if (this.busy && this.abort) {
|
|
267
|
+
this.abort.abort();
|
|
268
|
+
this.ui.stopSpinner();
|
|
269
|
+
this.ui.write(dim(' interrupted'));
|
|
270
|
+
return;
|
|
271
|
+
}
|
|
272
|
+
this.ui.write(dim(' (ctrl+d or /exit to quit)'));
|
|
273
|
+
this.ui.rl.prompt();
|
|
274
|
+
});
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
/**
|
|
278
|
+
* How this project verifies itself, worked out once at startup. Null when
|
|
279
|
+
* there is genuinely nothing to run — verification is only insisted on where
|
|
280
|
+
* there is something to insist on.
|
|
281
|
+
*/
|
|
282
|
+
async detectCheck() {
|
|
283
|
+
const has = (f) => readFile(path.join(this.cwd, f)).then(() => true, () => false);
|
|
284
|
+
|
|
285
|
+
if (await has('package.json')) {
|
|
286
|
+
try {
|
|
287
|
+
const pkg = JSON.parse(await readFile(path.join(this.cwd, 'package.json'), 'utf8'));
|
|
288
|
+
if (pkg.scripts?.test && !/no test specified/i.test(pkg.scripts.test)) {
|
|
289
|
+
this.check = 'npm test';
|
|
290
|
+
return;
|
|
291
|
+
}
|
|
292
|
+
} catch { /* an unreadable package.json is not worth failing over */ }
|
|
293
|
+
}
|
|
294
|
+
if (await has('Cargo.toml')) { this.check = 'cargo test'; return; }
|
|
295
|
+
if (await has('go.mod')) { this.check = 'go test ./...'; return; }
|
|
296
|
+
if (await has('pyproject.toml') || await has('pytest.ini')) { this.check = 'pytest'; return; }
|
|
297
|
+
if (await has('Makefile')) { this.check = 'make test'; return; }
|
|
298
|
+
this.check = null;
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
// -- REPL ----------------------------------------------------------------
|
|
302
|
+
|
|
303
|
+
async repl() {
|
|
304
|
+
let sawInput = false;
|
|
305
|
+
|
|
306
|
+
for (;;) {
|
|
307
|
+
const line = await this.ui.ask();
|
|
308
|
+
if (line === null) {
|
|
309
|
+
// End of input before anything was typed. On Windows this is almost
|
|
310
|
+
// always npm's PowerShell shim, which runs the CLI as `$input | node`.
|
|
311
|
+
// The pipe makes stdin a non-TTY, readline hits EOF at once, and the
|
|
312
|
+
// banner flashes up and vanishes — which looks like a crash rather
|
|
313
|
+
// than like a program that was never given a keyboard. So say which.
|
|
314
|
+
if (!sawInput && !process.stdin.isTTY) this.explainNoKeyboard();
|
|
315
|
+
break;
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
const input = line.trim();
|
|
319
|
+
if (input) sawInput = true;
|
|
320
|
+
if (!input) continue;
|
|
321
|
+
|
|
322
|
+
if (input.startsWith('/')) {
|
|
323
|
+
if (await this.command(input) === 'exit') break;
|
|
324
|
+
continue;
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
try {
|
|
328
|
+
await this.turn(input);
|
|
329
|
+
} catch (err) {
|
|
330
|
+
this.ui.error(err, { debug: this.debug });
|
|
331
|
+
}
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
await this.shutdown();
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
explainNoKeyboard() {
|
|
338
|
+
this.ui.blank();
|
|
339
|
+
this.ui.write(theme.warn(' ucode could not reach the keyboard, so it stopped.'));
|
|
340
|
+
this.ui.blank();
|
|
341
|
+
this.ui.write(' That happens when input is piped rather than typed. On Windows it is');
|
|
342
|
+
this.ui.write(" usually npm's PowerShell wrapper, which pipes stdin.");
|
|
343
|
+
this.ui.blank();
|
|
344
|
+
this.ui.write(` ${blue('Any of these work:')}`);
|
|
345
|
+
this.ui.write(` ${sky('ucode.cmd')} the cmd shim, which keeps the keyboard`);
|
|
346
|
+
this.ui.write(` ${sky('npx ucode-agent')} runs it directly`);
|
|
347
|
+
this.ui.write(' or start it from Command Prompt or Windows Terminal');
|
|
348
|
+
this.ui.blank();
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
async shutdown() {
|
|
352
|
+
this.ui.stopSpinner();
|
|
353
|
+
if (this.session.messages.length) {
|
|
354
|
+
await this.persist();
|
|
355
|
+
this.ui.write(dim(`\n saved · ${this.session.title}`));
|
|
356
|
+
}
|
|
357
|
+
this.ui.close();
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
// -- one turn ------------------------------------------------------------
|
|
361
|
+
|
|
362
|
+
/**
|
|
363
|
+
* Pull image paths out of the message and load them, so "what is wrong in
|
|
364
|
+
* screenshot.png" works without a separate command for it.
|
|
365
|
+
*/
|
|
366
|
+
async attachImages(input) {
|
|
367
|
+
const mentioned = input.match(/[^\s"']+\.(?:png|jpe?g|gif|webp)\b/gi) ?? [];
|
|
368
|
+
const images = [];
|
|
369
|
+
|
|
370
|
+
for (const name of mentioned) {
|
|
371
|
+
const file = path.resolve(this.cwd, name);
|
|
372
|
+
try {
|
|
373
|
+
const buf = await readFile(file);
|
|
374
|
+
if (buf.length > 4 * 1024 * 1024) {
|
|
375
|
+
this.ui.note(`${name} is ${(buf.length / 1024 / 1024).toFixed(1)}MB — too big to send, skipped`);
|
|
376
|
+
continue;
|
|
377
|
+
}
|
|
378
|
+
const ext = path.extname(file).toLowerCase().slice(1);
|
|
379
|
+
images.push(`data:image/${ext === 'jpg' ? 'jpeg' : ext};base64,${buf.toString('base64')}`);
|
|
380
|
+
this.ui.note(`attached ${name}`);
|
|
381
|
+
} catch {
|
|
382
|
+
// Just a filename mentioned in passing, not a file on disk.
|
|
383
|
+
}
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
return images;
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
/**
|
|
390
|
+
* Skills that this request should arrive with, already loaded.
|
|
391
|
+
*
|
|
392
|
+
* The load_skill tool asks the model to notice that a task needs a skill,
|
|
393
|
+
* and a model in a hurry to be helpful does not always notice. For work
|
|
394
|
+
* where the skill *is* the quality bar — anything with a user interface in
|
|
395
|
+
* it — that is not a discovery to make after the app has been built. So the
|
|
396
|
+
* request is matched against each skill's trigger words and the body goes in
|
|
397
|
+
* before the model takes its first step.
|
|
398
|
+
*/
|
|
399
|
+
autoLoad(input) {
|
|
400
|
+
for (const skill of autoLoadFor(this.skills, input)) {
|
|
401
|
+
if (this.loaded.has(skill.name)) continue;
|
|
402
|
+
this.loaded.add(skill.name);
|
|
403
|
+
this.push(skillMessage(skill, { automatic: true }));
|
|
404
|
+
this.ui.note(`${skill.name} skill loaded for this`);
|
|
405
|
+
}
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
async turn(input) {
|
|
409
|
+
const images = await this.attachImages(input);
|
|
410
|
+
this.push(images.length
|
|
411
|
+
? { role: 'user', content: input, images }
|
|
412
|
+
: { role: 'user', content: input });
|
|
413
|
+
|
|
414
|
+
if (!this.session.title || this.session.title === 'Untitled') {
|
|
415
|
+
this.session.title = titleFrom(input);
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
this.autoLoad(input);
|
|
419
|
+
await this.persist();
|
|
420
|
+
|
|
421
|
+
this.busy = true;
|
|
422
|
+
this.abort = new AbortController();
|
|
423
|
+
|
|
424
|
+
try {
|
|
425
|
+
await this.run();
|
|
426
|
+
} catch (err) {
|
|
427
|
+
if (err?.kind === 'aborted' || this.abort.signal.aborted) this.ui.write(dim(' turn cancelled'));
|
|
428
|
+
else throw err;
|
|
429
|
+
} finally {
|
|
430
|
+
this.busy = false;
|
|
431
|
+
this.abort = null;
|
|
432
|
+
this.ui.stopSpinner();
|
|
433
|
+
await this.persist();
|
|
434
|
+
if (this.full) this.showHeader({ clear: false });
|
|
435
|
+
}
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
/** The tools the model may see, given the mode. */
|
|
439
|
+
toolsNow() {
|
|
440
|
+
const all = [...tools, loadSkillTool];
|
|
441
|
+
if (this.ui.mode !== 'plan') return all;
|
|
442
|
+
return all.filter((t) => !WRITES.has(t.name));
|
|
443
|
+
}
|
|
444
|
+
|
|
445
|
+
/** Model, tools, model, until it answers with prose. */
|
|
446
|
+
async run() {
|
|
447
|
+
const available = this.toolsNow();
|
|
448
|
+
let argRetries = 0;
|
|
449
|
+
let continuations = 0;
|
|
450
|
+
let askedToVerify = false;
|
|
451
|
+
let askedToSpeak = false;
|
|
452
|
+
|
|
453
|
+
this.touched = new Set();
|
|
454
|
+
this.ranSomething = false;
|
|
455
|
+
|
|
456
|
+
for (let step = 0; step < MAX_STEPS; step++) {
|
|
457
|
+
await this.maybeFold();
|
|
458
|
+
this.ui.startSpinner(step === 0 ? 'thinking' : 'working');
|
|
459
|
+
|
|
460
|
+
let reply;
|
|
461
|
+
let streaming = false;
|
|
462
|
+
|
|
463
|
+
try {
|
|
464
|
+
const opts = {
|
|
465
|
+
signal: this.abort.signal,
|
|
466
|
+
onWait: (text) => this.ui.updateSpinner(text),
|
|
467
|
+
};
|
|
468
|
+
// Only a real terminal has somewhere to stream into.
|
|
469
|
+
if (this.full) {
|
|
470
|
+
opts.onThinking = () => this.ui.thinkingDelta();
|
|
471
|
+
opts.onText = (delta) => {
|
|
472
|
+
if (!streaming) {
|
|
473
|
+
streaming = true;
|
|
474
|
+
this.ui.thinkingEnd();
|
|
475
|
+
this.ui.streamBegin();
|
|
476
|
+
}
|
|
477
|
+
this.ui.streamDelta(delta);
|
|
478
|
+
};
|
|
479
|
+
}
|
|
480
|
+
|
|
481
|
+
reply = await ask(
|
|
482
|
+
[
|
|
483
|
+
{
|
|
484
|
+
role: 'system',
|
|
485
|
+
content: systemPrompt({
|
|
486
|
+
cwd: this.cwd,
|
|
487
|
+
skills: this.skills,
|
|
488
|
+
mode: this.ui.mode,
|
|
489
|
+
check: this.check,
|
|
490
|
+
}),
|
|
491
|
+
},
|
|
492
|
+
...this.working,
|
|
493
|
+
],
|
|
494
|
+
available,
|
|
495
|
+
opts
|
|
496
|
+
);
|
|
497
|
+
} catch (err) {
|
|
498
|
+
this.ui.thinkingEnd();
|
|
499
|
+
if (streaming) this.ui.streamEnd();
|
|
500
|
+
|
|
501
|
+
// The model invented a tool and the provider rejected the request
|
|
502
|
+
// outright. Tell it what it did and let it try again.
|
|
503
|
+
if (err.kind === 'bad_tool_call' && argRetries < MAX_ARG_RETRIES) {
|
|
504
|
+
argRetries++;
|
|
505
|
+
this.ui.stopSpinner();
|
|
506
|
+
this.ui.toolFailed(
|
|
507
|
+
`${err.detail?.attemptedName ?? 'invalid tool call'} — retrying (${argRetries}/${MAX_ARG_RETRIES})`
|
|
508
|
+
);
|
|
509
|
+
this.push({
|
|
510
|
+
role: 'user',
|
|
511
|
+
content:
|
|
512
|
+
`Your last tool call was rejected. ${err.failed} The only tools that exist ` +
|
|
513
|
+
`are: ${available.map((t) => t.name).join(', ')}. Try again with one of them.`,
|
|
514
|
+
});
|
|
515
|
+
continue;
|
|
516
|
+
}
|
|
517
|
+
throw err;
|
|
518
|
+
}
|
|
519
|
+
|
|
520
|
+
// A reply that is nothing but tool calls never starts a text stream, so
|
|
521
|
+
// the thinking timer has to be closed out here as well.
|
|
522
|
+
this.ui.thinkingEnd();
|
|
523
|
+
this.ui.stopSpinner();
|
|
524
|
+
this.record(reply.usage);
|
|
525
|
+
|
|
526
|
+
// Streamed text is already on screen; turn it into rendered markdown.
|
|
527
|
+
// Text that turns out to be narration ahead of a tool call folds into a
|
|
528
|
+
// status line instead — that is where the live commentary comes from.
|
|
529
|
+
const narrating = reply.toolCalls.length > 0;
|
|
530
|
+
if (streaming) this.ui.streamEnd({ asNarration: narrating });
|
|
531
|
+
else if (reply.text && narrating && isLabel(reply.text)) this.ui.narrate(reply.text);
|
|
532
|
+
else if (reply.text) this.ui.assistant(reply.text);
|
|
533
|
+
|
|
534
|
+
if (reply.toolCalls.length === 0) {
|
|
535
|
+
// The answer stopped at the provider's output cap rather than at the
|
|
536
|
+
// end of a thought, so it is cut mid-word. Ask for the rest instead of
|
|
537
|
+
// handing over half an answer with no sign there was more.
|
|
538
|
+
if (reply.finishReason === 'length' && reply.text && continuations < MAX_CONTINUATIONS) {
|
|
539
|
+
continuations++;
|
|
540
|
+
this.push({ role: 'assistant', content: reply.text });
|
|
541
|
+
this.push({
|
|
542
|
+
role: 'user',
|
|
543
|
+
content:
|
|
544
|
+
'Your reply stopped at the output limit, mid-sentence. Carry on from exactly ' +
|
|
545
|
+
'where it broke off. Do not repeat any of it, do not start again, and do not ' +
|
|
546
|
+
'introduce it — just continue.',
|
|
547
|
+
});
|
|
548
|
+
this.ui.note('hit the output limit — asking for the rest');
|
|
549
|
+
continue;
|
|
550
|
+
}
|
|
551
|
+
|
|
552
|
+
// It changed code and never ran anything. Send it back once.
|
|
553
|
+
if (this.touched.size && !this.ranSomething && this.check && !askedToVerify) {
|
|
554
|
+
askedToVerify = true;
|
|
555
|
+
if (reply.text) this.push({ role: 'assistant', content: reply.text });
|
|
556
|
+
this.push({
|
|
557
|
+
role: 'user',
|
|
558
|
+
content:
|
|
559
|
+
`You changed ${[...this.touched].join(', ')} and did not check it. Run ` +
|
|
560
|
+
`\`${this.check}\` with run_command now, then say what actually happened — ` +
|
|
561
|
+
'if it failed, show the output rather than claiming it worked. If that is ' +
|
|
562
|
+
'the wrong way to check this project, run the right one and say which.',
|
|
563
|
+
});
|
|
564
|
+
this.ui.note('verifying the change');
|
|
565
|
+
continue;
|
|
566
|
+
}
|
|
567
|
+
|
|
568
|
+
/**
|
|
569
|
+
* It did the work and then said nothing at all.
|
|
570
|
+
*
|
|
571
|
+
* Reasoning models do this, and the instruction to skip closing
|
|
572
|
+
* summaries makes it more likely. Silence is fine as a style; it is
|
|
573
|
+
* not fine as an answer, because the user cannot tell it apart from a
|
|
574
|
+
* crash — and if they asked what happened, they asked. One nudge,
|
|
575
|
+
* once per turn, and only when there was actually work to report.
|
|
576
|
+
*/
|
|
577
|
+
if (!reply.text?.trim() && this.touched.size + (this.ranSomething ? 1 : 0) > 0 && !askedToSpeak) {
|
|
578
|
+
askedToSpeak = true;
|
|
579
|
+
this.push({
|
|
580
|
+
role: 'user',
|
|
581
|
+
content:
|
|
582
|
+
'You finished without saying anything. In one or two sentences: what did ' +
|
|
583
|
+
'you change, and does it work? No preamble, no repeating the diffs.',
|
|
584
|
+
});
|
|
585
|
+
continue;
|
|
586
|
+
}
|
|
587
|
+
|
|
588
|
+
this.push({ role: 'assistant', content: reply.text });
|
|
589
|
+
if (!reply.text?.trim()) this.ui.note('the model ended the turn without a reply');
|
|
590
|
+
return;
|
|
591
|
+
}
|
|
592
|
+
|
|
593
|
+
this.push({ role: 'assistant', content: reply.text || '', toolCalls: reply.toolCalls });
|
|
594
|
+
await this.persist();
|
|
595
|
+
|
|
596
|
+
const badArgs = await this.runCalls(reply.toolCalls);
|
|
597
|
+
if (this.abort.signal.aborted) return;
|
|
598
|
+
|
|
599
|
+
// Malformed arguments go back to the model, but only so many times.
|
|
600
|
+
if (badArgs) {
|
|
601
|
+
argRetries++;
|
|
602
|
+
if (argRetries > MAX_ARG_RETRIES) {
|
|
603
|
+
throw new Failure({
|
|
604
|
+
kind: 'bad_tool_args',
|
|
605
|
+
attempted: 'running the tools the model asked for',
|
|
606
|
+
failed:
|
|
607
|
+
`${modelName()} produced invalid tool arguments ${argRetries} times running ` +
|
|
608
|
+
'and could not correct itself.',
|
|
609
|
+
fix:
|
|
610
|
+
'Say what you want more concretely, or /model to another one — North Mini ' +
|
|
611
|
+
'Code and Nemotron 3.5 Lightning are both steadier with tool arguments.',
|
|
612
|
+
});
|
|
613
|
+
}
|
|
614
|
+
} else {
|
|
615
|
+
argRetries = 0;
|
|
616
|
+
}
|
|
617
|
+
|
|
618
|
+
await this.persist();
|
|
619
|
+
}
|
|
620
|
+
|
|
621
|
+
throw new Failure({
|
|
622
|
+
kind: 'step_limit',
|
|
623
|
+
attempted: 'finishing your request',
|
|
624
|
+
failed: `The model was still calling tools after ${MAX_STEPS} steps.`,
|
|
625
|
+
fix:
|
|
626
|
+
'Nothing is lost — everything so far is on disk. Say "carry on where you left ' +
|
|
627
|
+
'off" to continue. If it was repeating one step, it is looping: break the task ' +
|
|
628
|
+
'up, or /new to reset.',
|
|
629
|
+
});
|
|
630
|
+
}
|
|
631
|
+
|
|
632
|
+
/**
|
|
633
|
+
* Run one round of tool calls.
|
|
634
|
+
*
|
|
635
|
+
* Consecutive read-only calls go out together — four files read at once
|
|
636
|
+
* rather than four round trips — while anything that writes or executes runs
|
|
637
|
+
* on its own, in order. Returns whether any call had unusable arguments.
|
|
638
|
+
*/
|
|
639
|
+
async runCalls(calls) {
|
|
640
|
+
const groups = [];
|
|
641
|
+
let batch = [];
|
|
642
|
+
|
|
643
|
+
for (const call of calls) {
|
|
644
|
+
if (PARALLEL_SAFE.has(call.name)) {
|
|
645
|
+
batch.push(call);
|
|
646
|
+
} else {
|
|
647
|
+
if (batch.length) { groups.push(batch); batch = []; }
|
|
648
|
+
groups.push([call]);
|
|
649
|
+
}
|
|
650
|
+
}
|
|
651
|
+
if (batch.length) groups.push(batch);
|
|
652
|
+
|
|
653
|
+
let badArgs = false;
|
|
654
|
+
|
|
655
|
+
for (const group of groups) {
|
|
656
|
+
if (this.abort.signal.aborted) return badArgs;
|
|
657
|
+
|
|
658
|
+
const noted = (call) => {
|
|
659
|
+
if (call.name === 'write_file' || call.name === 'edit_file' || call.name === 'multi_edit') {
|
|
660
|
+
this.touched.add(call.args?.path ?? 'a file');
|
|
661
|
+
}
|
|
662
|
+
if (call.name === 'batch_write') {
|
|
663
|
+
for (const f of call.args?.files ?? []) this.touched.add(f?.path ?? 'a file');
|
|
664
|
+
}
|
|
665
|
+
if (call.name === 'run_command' || call.name === 'run_commands') this.ranSomething = true;
|
|
666
|
+
};
|
|
667
|
+
|
|
668
|
+
if (group.length > 1) {
|
|
669
|
+
for (const call of group) {
|
|
670
|
+
this.ui.toolCall(describe(call.name, call.args));
|
|
671
|
+
noted(call);
|
|
672
|
+
}
|
|
673
|
+
this.ui.startSpinner(`${group.length} lookups at once`);
|
|
674
|
+
|
|
675
|
+
const settled = await Promise.all(
|
|
676
|
+
group.map((call) => this.dispatch(call).then(
|
|
677
|
+
(out) => ({ call, out }),
|
|
678
|
+
(err) => ({ call, err })
|
|
679
|
+
))
|
|
680
|
+
);
|
|
681
|
+
|
|
682
|
+
this.ui.stopSpinner();
|
|
683
|
+
for (const { call, out, err } of settled) {
|
|
684
|
+
if (err) badArgs = this.reportFailure(call, err) || badArgs;
|
|
685
|
+
else this.reportResult(call, out);
|
|
686
|
+
}
|
|
687
|
+
continue;
|
|
688
|
+
}
|
|
689
|
+
|
|
690
|
+
for (const call of group) {
|
|
691
|
+
if (this.abort.signal.aborted) return badArgs;
|
|
692
|
+
|
|
693
|
+
const label = describe(call.name, call.args);
|
|
694
|
+
this.ui.toolCall(label);
|
|
695
|
+
this.ui.startSpinner(label);
|
|
696
|
+
noted(call);
|
|
697
|
+
|
|
698
|
+
try {
|
|
699
|
+
const out = await this.dispatch(call);
|
|
700
|
+
this.ui.stopSpinner();
|
|
701
|
+
this.reportResult(call, out);
|
|
702
|
+
} catch (err) {
|
|
703
|
+
this.ui.stopSpinner();
|
|
704
|
+
badArgs = this.reportFailure(call, err) || badArgs;
|
|
705
|
+
}
|
|
706
|
+
}
|
|
707
|
+
}
|
|
708
|
+
|
|
709
|
+
return badArgs;
|
|
710
|
+
}
|
|
711
|
+
|
|
712
|
+
reportResult(call, out) {
|
|
713
|
+
if (!QUIET.has(call.name)) this.ui.toolResult(out.summary);
|
|
714
|
+
if (out.diff?.length) this.ui.diff(out.diff);
|
|
715
|
+
if (out.output?.length) this.ui.commandOutput(out.output);
|
|
716
|
+
this.push({ role: 'tool', toolCallId: call.id, name: call.name, content: out.content });
|
|
717
|
+
}
|
|
718
|
+
|
|
719
|
+
/** Show a tool failure, hand it to the model, and say if it was bad arguments. */
|
|
720
|
+
reportFailure(call, err) {
|
|
721
|
+
if (!(err instanceof ToolFailure)) throw err;
|
|
722
|
+
|
|
723
|
+
this.ui.toolFailed(
|
|
724
|
+
err instanceof Declined ? 'declined' : `${err.kind}: ${err.failed}`
|
|
725
|
+
);
|
|
726
|
+
this.push({
|
|
727
|
+
role: 'tool',
|
|
728
|
+
toolCallId: call.id,
|
|
729
|
+
name: call.name,
|
|
730
|
+
content: err.forModel(),
|
|
731
|
+
});
|
|
732
|
+
return err.kind === 'bad_args';
|
|
733
|
+
}
|
|
734
|
+
|
|
735
|
+
async dispatch(call) {
|
|
736
|
+
// The model emitted arguments that were not valid JSON. Hand the parser's
|
|
737
|
+
// own complaint straight back so it can correct itself next step.
|
|
738
|
+
if (call.parseError) {
|
|
739
|
+
throw new ToolFailure({
|
|
740
|
+
kind: 'bad_args',
|
|
741
|
+
attempted: `calling ${call.name}`,
|
|
742
|
+
failed: `The arguments were not valid JSON: ${call.parseError}`,
|
|
743
|
+
fix: `Call ${call.name} again with the arguments as one well-formed JSON object.`,
|
|
744
|
+
});
|
|
745
|
+
}
|
|
746
|
+
|
|
747
|
+
if (call.name === 'load_skill') return this.loadSkill(call.args?.name);
|
|
748
|
+
|
|
749
|
+
// Output reaches the screen as the command produces it, so a slow build is
|
|
750
|
+
// something you watch rather than something you sit out in silence.
|
|
751
|
+
return runTool(call.name, call.args ?? {}, {
|
|
752
|
+
onOutput: (lines) => this.ui.progress(lines),
|
|
753
|
+
});
|
|
754
|
+
}
|
|
755
|
+
|
|
756
|
+
loadSkill(name) {
|
|
757
|
+
const skill = findSkill(this.skills, name);
|
|
758
|
+
if (!skill) {
|
|
759
|
+
// The available names go in `failed` rather than only in `fix`: the
|
|
760
|
+
// transcript shows the failure line, and a bare "no such skill" leaves
|
|
761
|
+
// the user guessing at what this session actually has.
|
|
762
|
+
const available = this.skills.map((s) => s.name).join(', ') || '(none)';
|
|
763
|
+
throw new ToolFailure({
|
|
764
|
+
kind: 'no_such_skill',
|
|
765
|
+
attempted: `loading the "${name}" skill`,
|
|
766
|
+
failed: `There is no skill called "${name}". This session has: ${available}.`,
|
|
767
|
+
fix: 'Use one of those names, or carry on without one.',
|
|
768
|
+
});
|
|
769
|
+
}
|
|
770
|
+
|
|
771
|
+
if (this.loaded.has(skill.name)) {
|
|
772
|
+
return { content: `The "${skill.name}" skill is already loaded above. Follow it.`, summary: 'already loaded' };
|
|
773
|
+
}
|
|
774
|
+
|
|
775
|
+
this.loaded.add(skill.name);
|
|
776
|
+
this.push(skillMessage(skill));
|
|
777
|
+
return {
|
|
778
|
+
content: `Loaded "${skill.name}". Its instructions are in your context now — follow them.`,
|
|
779
|
+
summary: `${skill.name} · ${skill.body.split('\n').length} lines`,
|
|
780
|
+
};
|
|
781
|
+
}
|
|
782
|
+
|
|
783
|
+
record(u) {
|
|
784
|
+
const total = this.session.usage;
|
|
785
|
+
total.promptTokens += u.promptTokens || 0;
|
|
786
|
+
total.outputTokens += u.outputTokens || 0;
|
|
787
|
+
total.totalTokens += u.totalTokens || 0;
|
|
788
|
+
total.turns += 1;
|
|
789
|
+
}
|
|
790
|
+
|
|
791
|
+
/** Fold older turns into a summary when the window gets tight. */
|
|
792
|
+
async maybeFold() {
|
|
793
|
+
const limit = contextLimit();
|
|
794
|
+
if (!tooBig(this.working, limit)) return;
|
|
795
|
+
|
|
796
|
+
this.ui.startSpinner('context is filling up — summarizing earlier turns');
|
|
797
|
+
try {
|
|
798
|
+
const result = await fold(this.working, {
|
|
799
|
+
limit,
|
|
800
|
+
summarize: async (older) => {
|
|
801
|
+
const reply = await ask(
|
|
802
|
+
[
|
|
803
|
+
{ role: 'system', content: SUMMARY_PROMPT },
|
|
804
|
+
{ role: 'user', content: forSummary(older) },
|
|
805
|
+
],
|
|
806
|
+
[],
|
|
807
|
+
{ signal: this.abort?.signal, temperature: 0 }
|
|
808
|
+
);
|
|
809
|
+
return reply.text;
|
|
810
|
+
},
|
|
811
|
+
});
|
|
812
|
+
|
|
813
|
+
this.ui.stopSpinner();
|
|
814
|
+
if (result.folded) {
|
|
815
|
+
this.working = result.messages;
|
|
816
|
+
this.ui.note(
|
|
817
|
+
`folded ${result.droppedCount} earlier messages into a summary ` +
|
|
818
|
+
'(the full history is still saved in this session)'
|
|
819
|
+
);
|
|
820
|
+
}
|
|
821
|
+
} catch (err) {
|
|
822
|
+
// If summarizing fails, carry on with the full history and let the API
|
|
823
|
+
// complain — better than silently throwing away the conversation.
|
|
824
|
+
this.ui.stopSpinner();
|
|
825
|
+
this.ui.note(`could not summarize older turns (${err.kind ?? 'error'}); carrying on uncompacted`);
|
|
826
|
+
}
|
|
827
|
+
}
|
|
828
|
+
|
|
829
|
+
// -- slash commands ------------------------------------------------------
|
|
830
|
+
|
|
831
|
+
async command(input) {
|
|
832
|
+
const [name, ...rest] = input.split(/\s+/);
|
|
833
|
+
const arg = rest.join(' ').trim();
|
|
834
|
+
|
|
835
|
+
switch (name) {
|
|
836
|
+
case '/help':
|
|
837
|
+
return this.cmdHelp();
|
|
838
|
+
|
|
839
|
+
// One thing, one command, however you happen to spell it.
|
|
840
|
+
case '/model':
|
|
841
|
+
case '/models':
|
|
842
|
+
return this.cmdModel(arg);
|
|
843
|
+
|
|
844
|
+
case '/session':
|
|
845
|
+
case '/sessions':
|
|
846
|
+
case '/resume':
|
|
847
|
+
return this.cmdSessions(arg);
|
|
848
|
+
|
|
849
|
+
case '/new': return this.cmdNew();
|
|
850
|
+
case '/skills': return this.cmdSkills();
|
|
851
|
+
case '/clear': this.showHeader(); return;
|
|
852
|
+
case '/search': return this.cmdSearch(arg);
|
|
853
|
+
case '/copy': return this.cmdCopy();
|
|
854
|
+
case '/exit':
|
|
855
|
+
case '/quit': return 'exit';
|
|
856
|
+
|
|
857
|
+
default:
|
|
858
|
+
this.ui.write(theme.warn(` no such command: ${name}`));
|
|
859
|
+
this.ui.note('/help lists them.');
|
|
860
|
+
}
|
|
861
|
+
}
|
|
862
|
+
|
|
863
|
+
cmdHelp() {
|
|
864
|
+
const rows = [
|
|
865
|
+
['/help', 'this list'],
|
|
866
|
+
['/model', 'show the models and switch between them'],
|
|
867
|
+
['/resume', 'pick up an earlier conversation'],
|
|
868
|
+
['/new', 'save this one and start fresh'],
|
|
869
|
+
['/skills', 'what ucode knows how to do'],
|
|
870
|
+
['/search <query>', 'look something up on the web'],
|
|
871
|
+
['/copy', 'copy the last reply to the clipboard'],
|
|
872
|
+
['/clear', 'clear the screen, keep the conversation'],
|
|
873
|
+
['/exit', 'save and quit'],
|
|
874
|
+
];
|
|
875
|
+
|
|
876
|
+
this.ui.blank();
|
|
877
|
+
for (const [command, what] of rows) {
|
|
878
|
+
this.ui.write(` ${blue(command.padEnd(18))} ${dim(what)}`);
|
|
879
|
+
}
|
|
880
|
+
this.ui.blank();
|
|
881
|
+
this.ui.write(dim(' /models, /session and /sessions do the same as /model and /resume.'));
|
|
882
|
+
this.ui.write(dim(' ctrl+b swaps plan and build · esc stops a running turn · ctrl+d quits'));
|
|
883
|
+
this.ui.blank();
|
|
884
|
+
}
|
|
885
|
+
|
|
886
|
+
/** The five models, and this session's spend. */
|
|
887
|
+
async cmdModel(arg) {
|
|
888
|
+
if (arg) {
|
|
889
|
+
try {
|
|
890
|
+
setModel(arg);
|
|
891
|
+
} catch (err) {
|
|
892
|
+
this.ui.error(err, { debug: this.debug });
|
|
893
|
+
return;
|
|
894
|
+
}
|
|
895
|
+
this.session.model = model();
|
|
896
|
+
this.ui.note(`now using ${modelName()}`);
|
|
897
|
+
this.showHeader({ clear: false });
|
|
898
|
+
return;
|
|
899
|
+
}
|
|
900
|
+
|
|
901
|
+
const all = modelList();
|
|
902
|
+
const width = Math.max(...all.map((m) => m.name.length));
|
|
903
|
+
|
|
904
|
+
if (this.ui.pick) {
|
|
905
|
+
const items = all.map((m) => ({
|
|
906
|
+
label:
|
|
907
|
+
`${m.active ? blue('●') : dim('○')} ${m.star ? blue('★') : ' '} ` +
|
|
908
|
+
`${m.name.padEnd(width)} ${dim(`${formatTokens(m.context)} · ${m.note}`)}`,
|
|
909
|
+
}));
|
|
910
|
+
|
|
911
|
+
const chosen = await this.ui.pick(items, {
|
|
912
|
+
active: Math.max(0, all.findIndex((m) => m.active)),
|
|
913
|
+
hint: '↑↓ move · enter to switch · esc to cancel',
|
|
914
|
+
});
|
|
915
|
+
if (chosen === null) return;
|
|
916
|
+
|
|
917
|
+
setModel(all[chosen].id);
|
|
918
|
+
this.session.model = model();
|
|
919
|
+
this.ui.note(`now using ${modelName()}`);
|
|
920
|
+
this.showHeader({ clear: false });
|
|
921
|
+
return;
|
|
922
|
+
}
|
|
923
|
+
|
|
924
|
+
this.ui.blank();
|
|
925
|
+
for (const m of all) {
|
|
926
|
+
this.ui.write(
|
|
927
|
+
` ${m.active ? blue('●') : dim('○')} ${m.star ? blue('★') : ' '} ` +
|
|
928
|
+
`${(m.active ? blue : dim)(m.name.padEnd(width))} ${dim(`${formatTokens(m.context)} · ${m.note}`)}`
|
|
929
|
+
);
|
|
930
|
+
this.ui.write(` ${dim(m.id)}`);
|
|
931
|
+
}
|
|
932
|
+
|
|
933
|
+
const u = this.session.usage;
|
|
934
|
+
const live = rateLimits();
|
|
935
|
+
this.ui.blank();
|
|
936
|
+
this.ui.write(
|
|
937
|
+
` ${dim('this session')} ${u.turns} turns · ${formatTokens(u.totalTokens)} tokens ` +
|
|
938
|
+
`(${formatTokens(u.promptTokens)} in, ${formatTokens(u.outputTokens)} out)`
|
|
939
|
+
);
|
|
940
|
+
if (live?.requestsRemaining != null && live?.requestsLimit) {
|
|
941
|
+
this.ui.write(` ${dim('requests')} ${live.requestsRemaining} of ${live.requestsLimit} left`);
|
|
942
|
+
}
|
|
943
|
+
this.ui.blank();
|
|
944
|
+
this.ui.write(dim(' /model <id> switches without the picker.'));
|
|
945
|
+
this.ui.blank();
|
|
946
|
+
}
|
|
947
|
+
|
|
948
|
+
/**
|
|
949
|
+
* One row per saved conversation.
|
|
950
|
+
*
|
|
951
|
+
* A list of titles and timestamps is not enough to recognise your own work
|
|
952
|
+
* by — half of them start "Fix the". So each row carries what it was about
|
|
953
|
+
* and how far it got, and the ones from this folder are marked, because that
|
|
954
|
+
* is nearly always the one being looked for.
|
|
955
|
+
*/
|
|
956
|
+
describeSession(s, width) {
|
|
957
|
+
const room = Math.max(24, Math.min(46, width - 34));
|
|
958
|
+
const mark = s.mine ? blue('●') : dim('○');
|
|
959
|
+
const when = relativeTime(s.updatedAt).padEnd(9);
|
|
960
|
+
const turns = `${s.turns} turn${s.turns === 1 ? '' : 's'}`.padEnd(9);
|
|
961
|
+
const where = s.mine ? 'here' : shortenPath(s.cwd, 26);
|
|
962
|
+
|
|
963
|
+
return {
|
|
964
|
+
label: `${mark} ${clip(s.title, room).padEnd(room)} ${dim(when)}${dim(turns)}${dim(where)}`,
|
|
965
|
+
sub: s.preview ? dim(` ${clip(s.preview, width - 10)}`) : '',
|
|
966
|
+
};
|
|
967
|
+
}
|
|
968
|
+
|
|
969
|
+
async cmdSessions(arg) {
|
|
970
|
+
if (arg === '--clear' || arg === 'clear') {
|
|
971
|
+
const yes = await this.ui.confirm({
|
|
972
|
+
action: 'delete every saved conversation',
|
|
973
|
+
detail: 'This cannot be undone.',
|
|
974
|
+
risk: 'write',
|
|
975
|
+
});
|
|
976
|
+
if (!yes) {
|
|
977
|
+
this.ui.note('cancelled');
|
|
978
|
+
return;
|
|
979
|
+
}
|
|
980
|
+
await removeAll();
|
|
981
|
+
this.ui.note('all sessions deleted');
|
|
982
|
+
return;
|
|
983
|
+
}
|
|
984
|
+
|
|
985
|
+
const sessions = await list({ cwd: this.cwd });
|
|
986
|
+
if (!sessions.length) {
|
|
987
|
+
this.ui.note('no saved conversations yet');
|
|
988
|
+
return;
|
|
989
|
+
}
|
|
990
|
+
|
|
991
|
+
for (const bad of sessions.unreadable ?? []) {
|
|
992
|
+
this.ui.write(theme.warn(` could not read session file: ${bad}`));
|
|
993
|
+
}
|
|
994
|
+
|
|
995
|
+
const shown = sessions.slice(0, 25);
|
|
996
|
+
const width = this.ui.width ? this.ui.width() : 80;
|
|
997
|
+
let index;
|
|
998
|
+
|
|
999
|
+
if (arg) {
|
|
1000
|
+
const n = Number(arg);
|
|
1001
|
+
if (!Number.isInteger(n) || n < 1 || n > shown.length) {
|
|
1002
|
+
this.ui.write(theme.warn(` "${arg}" is not one of 1-${shown.length}`));
|
|
1003
|
+
return;
|
|
1004
|
+
}
|
|
1005
|
+
index = n - 1;
|
|
1006
|
+
} else if (this.ui.pick) {
|
|
1007
|
+
const here = shown.filter((s) => s.mine).length;
|
|
1008
|
+
index = await this.ui.pick(
|
|
1009
|
+
shown.map((s) => this.describeSession(s, width)),
|
|
1010
|
+
{
|
|
1011
|
+
hint:
|
|
1012
|
+
`↑↓ move · enter to continue · esc to cancel` +
|
|
1013
|
+
(here ? ` — ${here} from this folder` : '') +
|
|
1014
|
+
(sessions.length > shown.length ? ` · ${sessions.length - shown.length} older not shown` : ''),
|
|
1015
|
+
}
|
|
1016
|
+
);
|
|
1017
|
+
if (index === null) return;
|
|
1018
|
+
} else {
|
|
1019
|
+
this.ui.blank();
|
|
1020
|
+
index = await this.ui.choose(
|
|
1021
|
+
'continue which?',
|
|
1022
|
+
shown.map((s) => this.describeSession(s, width).label)
|
|
1023
|
+
);
|
|
1024
|
+
if (index === null) return;
|
|
1025
|
+
}
|
|
1026
|
+
|
|
1027
|
+
if (this.session.messages.length) await this.persist();
|
|
1028
|
+
if (await this.resume(shown[index].id)) {
|
|
1029
|
+
this.showHeader();
|
|
1030
|
+
this.replayTail();
|
|
1031
|
+
}
|
|
1032
|
+
}
|
|
1033
|
+
|
|
1034
|
+
async resume(id) {
|
|
1035
|
+
try {
|
|
1036
|
+
const loaded = await load(id);
|
|
1037
|
+
this.session = loaded;
|
|
1038
|
+
this.working = [...loaded.messages];
|
|
1039
|
+
this.loaded = new Set(loaded.messages.filter((m) => m.skill).map((m) => m.skill));
|
|
1040
|
+
if (loaded.model && MODELS[loaded.model]) setModel(loaded.model);
|
|
1041
|
+
return true;
|
|
1042
|
+
} catch (err) {
|
|
1043
|
+
this.ui.error(err, { debug: this.debug });
|
|
1044
|
+
this.ui.note('Starting a fresh one instead.');
|
|
1045
|
+
return false;
|
|
1046
|
+
}
|
|
1047
|
+
}
|
|
1048
|
+
|
|
1049
|
+
/** The last few exchanges, so a resumed conversation has visible context. */
|
|
1050
|
+
replayTail(count = 4) {
|
|
1051
|
+
const tail = this.session.messages
|
|
1052
|
+
.filter((m) => (m.role === 'user' || m.role === 'assistant') && m.content)
|
|
1053
|
+
.slice(-count);
|
|
1054
|
+
|
|
1055
|
+
for (const m of tail) {
|
|
1056
|
+
if (m.role === 'user') this.ui.write(`${blue('›')} ${dim(m.content.split('\n')[0])}`);
|
|
1057
|
+
else this.ui.assistant(m.content);
|
|
1058
|
+
}
|
|
1059
|
+
if (tail.length) this.ui.write(dim(' ── picking up here ──\n'));
|
|
1060
|
+
}
|
|
1061
|
+
|
|
1062
|
+
async cmdNew() {
|
|
1063
|
+
if (this.session.messages.length) {
|
|
1064
|
+
await this.persist();
|
|
1065
|
+
this.ui.note(`saved · ${this.session.title}`);
|
|
1066
|
+
}
|
|
1067
|
+
this.session = newSession(this.cwd, model());
|
|
1068
|
+
this.working = [];
|
|
1069
|
+
this.loaded = new Set();
|
|
1070
|
+
this.showHeader();
|
|
1071
|
+
}
|
|
1072
|
+
|
|
1073
|
+
async cmdSkills() {
|
|
1074
|
+
// Re-read from disk. Skills load once at startup, so one written during
|
|
1075
|
+
// this session would otherwise stay invisible — and load_skill would fail
|
|
1076
|
+
// on a name the user can see in the folder.
|
|
1077
|
+
this.skills = await loadSkills({ cwd: this.cwd });
|
|
1078
|
+
for (const problem of this.skills.problems ?? []) {
|
|
1079
|
+
this.ui.write(theme.warn(` skill not loaded: ${problem}`));
|
|
1080
|
+
}
|
|
1081
|
+
|
|
1082
|
+
if (!this.skills.length) {
|
|
1083
|
+
this.ui.note('no skills found — add a folder with a SKILL.md under .ucode/skills');
|
|
1084
|
+
return;
|
|
1085
|
+
}
|
|
1086
|
+
|
|
1087
|
+
this.ui.blank();
|
|
1088
|
+
for (const s of this.skills) {
|
|
1089
|
+
const live = this.loaded.has(s.name);
|
|
1090
|
+
const auto = s.triggers.length ? dim(' · loads itself') : '';
|
|
1091
|
+
this.ui.write(` ${live ? blue('●') : dim('○')} ${blue(s.name)}${auto}`);
|
|
1092
|
+
this.ui.write(` ${dim(s.description)}`);
|
|
1093
|
+
}
|
|
1094
|
+
this.ui.blank();
|
|
1095
|
+
this.ui.write(dim(' ● already loaded here · ucode pulls one in when the task matches'));
|
|
1096
|
+
this.ui.blank();
|
|
1097
|
+
}
|
|
1098
|
+
|
|
1099
|
+
async cmdSearch(query) {
|
|
1100
|
+
if (!query) {
|
|
1101
|
+
this.ui.note('usage: /search <what you want to look up>');
|
|
1102
|
+
return;
|
|
1103
|
+
}
|
|
1104
|
+
await this.turn(
|
|
1105
|
+
`Search the web for: ${query}\n\nUse web_search, then summarise what you found and cite the URLs.`
|
|
1106
|
+
);
|
|
1107
|
+
}
|
|
1108
|
+
|
|
1109
|
+
/** Copy the last reply. Every platform ships a clipboard pipe. */
|
|
1110
|
+
async cmdCopy() {
|
|
1111
|
+
const last = [...this.session.messages]
|
|
1112
|
+
.reverse()
|
|
1113
|
+
.find((m) => m.role === 'assistant' && m.content?.trim());
|
|
1114
|
+
|
|
1115
|
+
if (!last) {
|
|
1116
|
+
this.ui.note('nothing to copy yet');
|
|
1117
|
+
return;
|
|
1118
|
+
}
|
|
1119
|
+
|
|
1120
|
+
const tool = process.platform === 'win32' ? 'clip'
|
|
1121
|
+
: process.platform === 'darwin' ? 'pbcopy'
|
|
1122
|
+
: 'xclip -selection clipboard';
|
|
1123
|
+
|
|
1124
|
+
try {
|
|
1125
|
+
await new Promise((resolve, reject) => {
|
|
1126
|
+
const child = spawn(tool, { shell: true, windowsHide: true });
|
|
1127
|
+
child.on('error', reject);
|
|
1128
|
+
child.on('close', (code) => (code === 0 ? resolve() : reject(new Error(`exit ${code}`))));
|
|
1129
|
+
child.stdin.end(last.content);
|
|
1130
|
+
});
|
|
1131
|
+
const lines = last.content.split('\n').length;
|
|
1132
|
+
this.ui.note(`copied ${lines} line${lines === 1 ? '' : 's'}`);
|
|
1133
|
+
} catch (err) {
|
|
1134
|
+
this.ui.error(new Failure({
|
|
1135
|
+
kind: 'clipboard_failed',
|
|
1136
|
+
attempted: 'copying the last reply',
|
|
1137
|
+
failed: `${tool} could not run: ${err.message}`,
|
|
1138
|
+
fix: process.platform === 'linux'
|
|
1139
|
+
? 'Install xclip (apt install xclip), or select the text with the mouse.'
|
|
1140
|
+
: 'Select the text with the mouse instead.',
|
|
1141
|
+
}), { debug: this.debug });
|
|
1142
|
+
}
|
|
1143
|
+
}
|
|
1144
|
+
}
|
|
1145
|
+
|
|
1146
|
+
export { DEFAULT_MODEL, PROVIDER };
|