ucode-agent 1.2.0 → 1.4.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 +73 -17
- package/package.json +3 -2
- package/skills/build-app/SKILL.md +4 -2
- package/skills/ui-ux/SKILL.md +5 -1
- package/src/core/context.js +151 -0
- package/src/core/loop.js +550 -40
- package/src/core/provider.js +58 -15
- package/src/core/updater.js +95 -0
- package/src/tools/browser.js +258 -0
- package/src/tools/files.js +173 -16
- package/src/tools/index.js +472 -394
- package/src/tools/shell.js +149 -1
- package/src/ui/plain.js +330 -325
- package/src/ui/screen.js +27 -7
- package/src/ui/theme.js +18 -0
package/src/core/loop.js
CHANGED
|
@@ -12,18 +12,21 @@
|
|
|
12
12
|
*/
|
|
13
13
|
|
|
14
14
|
import path from 'node:path';
|
|
15
|
-
import { readFile } from 'node:fs/promises';
|
|
15
|
+
import { readFile, access } from 'node:fs/promises';
|
|
16
16
|
import { spawn } from 'node:child_process';
|
|
17
17
|
|
|
18
18
|
import {
|
|
19
19
|
ask, model, setModel, modelName, modelList, contextLimit, rateLimits,
|
|
20
|
-
MODELS, DEFAULT_MODEL, PROVIDER,
|
|
20
|
+
MODELS, DEFAULT_MODEL, PROVIDER, fallbackFor,
|
|
21
21
|
} from './provider.js';
|
|
22
22
|
import {
|
|
23
|
-
tools, runTool, describe, setRoot, setConfirm, PARALLEL_SAFE, WRITES,
|
|
23
|
+
tools, runTool, describe, setRoot, setConfirm, PARALLEL_SAFE, WRITES, FILE_WRITES,
|
|
24
24
|
} from '../tools/index.js';
|
|
25
|
+
import { projectMap, loadMemory, remember, MEMORY_FILE } from './context.js';
|
|
26
|
+
import { autoUpdate } from './updater.js';
|
|
27
|
+
import { closeBrowser } from '../tools/browser.js';
|
|
25
28
|
import {
|
|
26
|
-
newSession, save, load, list, removeAll, titleFrom,
|
|
29
|
+
newSession, save, load, list, remove, removeAll, titleFrom,
|
|
27
30
|
} from './history.js';
|
|
28
31
|
import { fold, usage, tooBig, SUMMARY_PROMPT, forSummary } from './window.js';
|
|
29
32
|
import { loadSkills, catalogue, findSkill, skillMessage, autoLoadFor } from './skills.js';
|
|
@@ -52,7 +55,126 @@ const MAX_ARG_RETRIES = 2;
|
|
|
52
55
|
const MAX_CONTINUATIONS = 3;
|
|
53
56
|
|
|
54
57
|
/** 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']);
|
|
58
|
+
const QUIET = new Set(['read_file', 'read_files', 'list_dir', 'glob', 'grep', 'web_search', 'update_plan']);
|
|
59
|
+
|
|
60
|
+
/** Tools that draw their own line, so they get no "● Doing X" line of their own. */
|
|
61
|
+
const SILENT = new Set(['update_plan']);
|
|
62
|
+
|
|
63
|
+
/** How many rounds of "the type check found errors, fix them" one turn may take. */
|
|
64
|
+
const MAX_FIX_ROUNDS = 3;
|
|
65
|
+
|
|
66
|
+
/** Files worth checking after they change. */
|
|
67
|
+
const CHECKABLE = /\.(?:[cm]?[jt]sx?|py)$/i;
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* Failures that are the provider's and not the model's: busy, slow, down, or
|
|
71
|
+
* unreachable. None of them should end a build — the turn moves to another
|
|
72
|
+
* model and carries on from exactly where it was.
|
|
73
|
+
*/
|
|
74
|
+
const TRANSIENT = new Set(['rate_limit', 'timeout', 'server', 'network', 'no_content']);
|
|
75
|
+
const MAX_FAILOVERS = 8;
|
|
76
|
+
const COOLDOWN = 5 * 60_000;
|
|
77
|
+
|
|
78
|
+
const wait = (ms) => new Promise((r) => setTimeout(r, ms));
|
|
79
|
+
|
|
80
|
+
/** Parallel workers at once, and how many steps each may take. */
|
|
81
|
+
const MAX_WORKERS = 3;
|
|
82
|
+
const WORKER_STEPS = Number(process.env.UCODE_WORKER_STEPS) || 60;
|
|
83
|
+
|
|
84
|
+
/** Workers build; they do not plan, delegate further, or load skills themselves. */
|
|
85
|
+
const WORKER_EXCLUDED = new Set(['delegate', 'update_plan', 'load_skill']);
|
|
86
|
+
|
|
87
|
+
const planTool = {
|
|
88
|
+
name: 'update_plan',
|
|
89
|
+
description:
|
|
90
|
+
'Keep a short checklist the user can see, for work with three or more steps. ' +
|
|
91
|
+
'Send the whole list every time: at most 6 items, a few words each, with done: true ' +
|
|
92
|
+
'on the finished ones. Update it as items finish. Skip it for small tasks.',
|
|
93
|
+
parameters: {
|
|
94
|
+
type: 'object',
|
|
95
|
+
properties: {
|
|
96
|
+
items: {
|
|
97
|
+
type: 'array',
|
|
98
|
+
description: 'The whole plan, in order. At most 6.',
|
|
99
|
+
items: {
|
|
100
|
+
type: 'object',
|
|
101
|
+
properties: {
|
|
102
|
+
text: { type: 'string', description: 'A few words: "Build the upload box".' },
|
|
103
|
+
done: { type: 'boolean', description: 'True once it is finished.' },
|
|
104
|
+
},
|
|
105
|
+
required: ['text'],
|
|
106
|
+
},
|
|
107
|
+
},
|
|
108
|
+
},
|
|
109
|
+
required: ['items'],
|
|
110
|
+
},
|
|
111
|
+
};
|
|
112
|
+
|
|
113
|
+
const delegateTool = {
|
|
114
|
+
name: 'delegate',
|
|
115
|
+
description:
|
|
116
|
+
'Build independent parts in parallel. Up to 3 workers run at once, each with the ' +
|
|
117
|
+
'same tools as you. Use it when the work splits cleanly into parts that touch ' +
|
|
118
|
+
'different files - e.g. the API route, the upload component and the results view. ' +
|
|
119
|
+
'A worker sees only its instructions, so make them complete: the files it owns, ' +
|
|
120
|
+
'what to build, the exact interfaces (props, types, request and response shapes) ' +
|
|
121
|
+
'it must match, and the design rules. Set up shared files (package.json, design ' +
|
|
122
|
+
'tokens, shared types) yourself first. You get back each worker\'s summary and ' +
|
|
123
|
+
'the files it changed; wire the parts together and check the whole afterwards.',
|
|
124
|
+
parameters: {
|
|
125
|
+
type: 'object',
|
|
126
|
+
properties: {
|
|
127
|
+
tasks: {
|
|
128
|
+
type: 'array',
|
|
129
|
+
description: 'Up to 3 independent pieces of work.',
|
|
130
|
+
items: {
|
|
131
|
+
type: 'object',
|
|
132
|
+
properties: {
|
|
133
|
+
name: { type: 'string', description: 'Two or three words: "api route", "upload ui".' },
|
|
134
|
+
instructions: { type: 'string', description: 'Everything the worker needs to do its part completely.' },
|
|
135
|
+
},
|
|
136
|
+
required: ['name', 'instructions'],
|
|
137
|
+
},
|
|
138
|
+
},
|
|
139
|
+
},
|
|
140
|
+
required: ['tasks'],
|
|
141
|
+
},
|
|
142
|
+
};
|
|
143
|
+
|
|
144
|
+
/** The instructions a parallel worker starts with. */
|
|
145
|
+
function workerPrompt({ cwd, name, memory, skills, map }) {
|
|
146
|
+
return [
|
|
147
|
+
`You are a ucode worker called "${name}" - one of several building parts of the same project at the same time.`,
|
|
148
|
+
'',
|
|
149
|
+
`Working directory: ${cwd}`,
|
|
150
|
+
`Platform: ${process.platform}`,
|
|
151
|
+
'',
|
|
152
|
+
'- Do exactly the task you were given. Touch only the files it names, or new files in',
|
|
153
|
+
' the area it owns - other workers are editing the rest of the project right now.',
|
|
154
|
+
'- Read before you edit. read_files for several files, batch_write for several new',
|
|
155
|
+
' files, edit_files for changes across files.',
|
|
156
|
+
'- Nothing has a keyboard: pass non-interactive flags. Do not start dev servers and do',
|
|
157
|
+
' not install packages unless the task says to - say what you need instead.',
|
|
158
|
+
'- Finish what you build: real content, every state handled, no TODOs.',
|
|
159
|
+
'- When done, reply with two or three sentences: what you built, in which files, and',
|
|
160
|
+
' anything the lead has to wire up.',
|
|
161
|
+
'- The files you own may not exist yet - create them. Do not go looking for them',
|
|
162
|
+
' first. The project map below shows what does exist; read only the files whose',
|
|
163
|
+
' interfaces you must match, then start writing within two or three steps.',
|
|
164
|
+
...(memory ? ['', '## Project memory', '', memory] : []),
|
|
165
|
+
...(map ? ['', '## Project map', '', map] : []),
|
|
166
|
+
...(skills ? ['', '## Instructions in force', '', skills] : []),
|
|
167
|
+
].join('\n');
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
/** The files a writing tool call touches. */
|
|
171
|
+
function pathsOf(call) {
|
|
172
|
+
const a = call.args ?? {};
|
|
173
|
+
if (call.name === 'batch_write' || call.name === 'edit_files') return (a.files ?? []).map((f) => f?.path).filter(Boolean);
|
|
174
|
+
return a.path ? [a.path] : [];
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
const exists = (p) => access(p).then(() => true, () => false);
|
|
56
178
|
|
|
57
179
|
/** Skills reach the model as one extra tool, so bodies load only when wanted. */
|
|
58
180
|
const loadSkillTool = {
|
|
@@ -68,7 +190,7 @@ const loadSkillTool = {
|
|
|
68
190
|
},
|
|
69
191
|
};
|
|
70
192
|
|
|
71
|
-
function systemPrompt({ cwd, skills, mode, check }) {
|
|
193
|
+
function systemPrompt({ cwd, skills, mode, check, map, memory }) {
|
|
72
194
|
const list = catalogue(skills);
|
|
73
195
|
|
|
74
196
|
return [
|
|
@@ -76,6 +198,14 @@ function systemPrompt({ cwd, skills, mode, check }) {
|
|
|
76
198
|
'',
|
|
77
199
|
`Working directory: ${cwd}`,
|
|
78
200
|
`Platform: ${process.platform}`,
|
|
201
|
+
...(memory ? [
|
|
202
|
+
'',
|
|
203
|
+
'## Project memory',
|
|
204
|
+
'',
|
|
205
|
+
'Standing instructions from the user. They outrank your defaults.',
|
|
206
|
+
'',
|
|
207
|
+
memory,
|
|
208
|
+
] : []),
|
|
79
209
|
'',
|
|
80
210
|
'## How to work',
|
|
81
211
|
'',
|
|
@@ -111,7 +241,19 @@ function systemPrompt({ cwd, skills, mode, check }) {
|
|
|
111
241
|
'- Put independent calls in the same message — several greps, a glob and a read.',
|
|
112
242
|
' Read-only calls in one message run at the same time.',
|
|
113
243
|
'- batch_write to lay out several new files at once, multi_edit for several changes',
|
|
114
|
-
' to one file.',
|
|
244
|
+
' to one file, edit_files for a change that spans several files.',
|
|
245
|
+
'- For work with three or more steps, keep a short plan with update_plan - at most',
|
|
246
|
+
' six items of a few words - and tick items off as they finish. Skip it for small jobs.',
|
|
247
|
+
'- When a build splits into parts that touch different files (the API route, the',
|
|
248
|
+
' upload component, the results view), set up the shared files yourself, then hand',
|
|
249
|
+
' the parts to delegate so they are built in parallel.',
|
|
250
|
+
'- A package.json you write starts installing in the background immediately; keep',
|
|
251
|
+
' writing files. Running the install yourself afterwards just waits for that one.',
|
|
252
|
+
'- When you finish, ucode type-checks what you changed and hands you the errors, so',
|
|
253
|
+
' there is no need to run tsc yourself.',
|
|
254
|
+
'- Once the dev server is ready, run look_at_app on the pages you built and fix what',
|
|
255
|
+
' it reports - errors, layout that overflows a phone, and the visual review - then',
|
|
256
|
+
' look again. Do not call an interface finished before it has been looked at.',
|
|
115
257
|
'- Nothing you run has a keyboard. Pass the non-interactive flag to anything that',
|
|
116
258
|
' would ask a question, or it fails instead of waiting: create-next-app --yes,',
|
|
117
259
|
' npx shadcn@latest init -d -y, npx shadcn@latest add <names> -y, npm init -y.',
|
|
@@ -164,6 +306,13 @@ function systemPrompt({ cwd, skills, mode, check }) {
|
|
|
164
306
|
'',
|
|
165
307
|
list,
|
|
166
308
|
] : []),
|
|
309
|
+
'',
|
|
310
|
+
'## Project map',
|
|
311
|
+
'',
|
|
312
|
+
'Every file in the project at the start of this turn, with the names each code file',
|
|
313
|
+
'exports. Go straight to the files you need instead of searching for them.',
|
|
314
|
+
'',
|
|
315
|
+
map || '(not available)',
|
|
167
316
|
].join('\n');
|
|
168
317
|
}
|
|
169
318
|
|
|
@@ -211,7 +360,14 @@ export class Agent {
|
|
|
211
360
|
*/
|
|
212
361
|
async bootstrap() {
|
|
213
362
|
setRoot(this.cwd);
|
|
214
|
-
|
|
363
|
+
// Parallel workers can ask at the same moment; the questions queue up and
|
|
364
|
+
// are put to the user one at a time, never on top of each other.
|
|
365
|
+
let asking = Promise.resolve();
|
|
366
|
+
setConfirm((request) => {
|
|
367
|
+
const next = asking.then(() => this.ui.confirm(request));
|
|
368
|
+
asking = next.catch(() => {});
|
|
369
|
+
return next;
|
|
370
|
+
});
|
|
215
371
|
this.skills = await loadSkills({ cwd: this.cwd });
|
|
216
372
|
await this.detectCheck();
|
|
217
373
|
}
|
|
@@ -237,6 +393,14 @@ export class Agent {
|
|
|
237
393
|
|
|
238
394
|
this.showHeader();
|
|
239
395
|
this.installSignals();
|
|
396
|
+
|
|
397
|
+
// Checked in the background; nothing here waits on it.
|
|
398
|
+
autoUpdate({
|
|
399
|
+
onUpdated: (version) => {
|
|
400
|
+
this.ui.setFacts?.({ update: version });
|
|
401
|
+
if (!this.ui.welcoming?.()) this.ui.note(`updated to v${version} — it takes over the next time you start ucode`);
|
|
402
|
+
},
|
|
403
|
+
});
|
|
240
404
|
await this.repl();
|
|
241
405
|
}
|
|
242
406
|
|
|
@@ -349,6 +513,7 @@ export class Agent {
|
|
|
349
513
|
}
|
|
350
514
|
|
|
351
515
|
async shutdown() {
|
|
516
|
+
await closeBrowser().catch(() => {});
|
|
352
517
|
this.ui.stopSpinner();
|
|
353
518
|
if (this.session.messages.length) {
|
|
354
519
|
await this.persist();
|
|
@@ -416,8 +581,21 @@ export class Agent {
|
|
|
416
581
|
}
|
|
417
582
|
|
|
418
583
|
this.autoLoad(input);
|
|
584
|
+
// What the model is told about the project, fresh for this turn.
|
|
585
|
+
[this.map, this.memory] = await Promise.all([
|
|
586
|
+
projectMap(this.cwd).catch(() => ''),
|
|
587
|
+
loadMemory(this.cwd).catch(() => ''),
|
|
588
|
+
]);
|
|
419
589
|
await this.persist();
|
|
420
590
|
|
|
591
|
+
// A busy model was swapped for a fallback earlier; after a few minutes the
|
|
592
|
+
// one the user chose gets another go.
|
|
593
|
+
this.preferred ??= model();
|
|
594
|
+
if (model() !== this.preferred && Date.now() > (this.cooldownUntil ?? 0)) {
|
|
595
|
+
setModel(this.preferred);
|
|
596
|
+
if (this.full) this.showHeader({ clear: false });
|
|
597
|
+
}
|
|
598
|
+
|
|
421
599
|
this.busy = true;
|
|
422
600
|
this.abort = new AbortController();
|
|
423
601
|
|
|
@@ -437,7 +615,7 @@ export class Agent {
|
|
|
437
615
|
|
|
438
616
|
/** The tools the model may see, given the mode. */
|
|
439
617
|
toolsNow() {
|
|
440
|
-
const all = [...tools, loadSkillTool];
|
|
618
|
+
const all = [...tools, loadSkillTool, planTool, delegateTool];
|
|
441
619
|
if (this.ui.mode !== 'plan') return all;
|
|
442
620
|
return all.filter((t) => !WRITES.has(t.name));
|
|
443
621
|
}
|
|
@@ -449,8 +627,12 @@ export class Agent {
|
|
|
449
627
|
let continuations = 0;
|
|
450
628
|
let askedToVerify = false;
|
|
451
629
|
let askedToSpeak = false;
|
|
630
|
+
let fixRounds = 0;
|
|
631
|
+
this.failovers = 0;
|
|
632
|
+
this.tried = new Set([model()]);
|
|
452
633
|
|
|
453
634
|
this.touched = new Set();
|
|
635
|
+
this.sinceCheck = new Set();
|
|
454
636
|
this.ranSomething = false;
|
|
455
637
|
|
|
456
638
|
for (let step = 0; step < MAX_STEPS; step++) {
|
|
@@ -459,6 +641,7 @@ export class Agent {
|
|
|
459
641
|
|
|
460
642
|
let reply;
|
|
461
643
|
let streaming = false;
|
|
644
|
+
this.early = new Map();
|
|
462
645
|
|
|
463
646
|
try {
|
|
464
647
|
const opts = {
|
|
@@ -476,6 +659,14 @@ export class Agent {
|
|
|
476
659
|
}
|
|
477
660
|
this.ui.streamDelta(delta);
|
|
478
661
|
};
|
|
662
|
+
// Read-only calls start the moment they are fully written, while the
|
|
663
|
+
// rest of the reply is still arriving. Nothing that writes or runs is
|
|
664
|
+
// started early: a reply that fails halfway must leave no side effects.
|
|
665
|
+
opts.onToolCall = (call) => {
|
|
666
|
+
if (PARALLEL_SAFE.has(call.name) && !call.parseError && !this.early.has(call.id)) {
|
|
667
|
+
this.early.set(call.id, this.execute(call));
|
|
668
|
+
}
|
|
669
|
+
};
|
|
479
670
|
}
|
|
480
671
|
|
|
481
672
|
reply = await ask(
|
|
@@ -487,6 +678,8 @@ export class Agent {
|
|
|
487
678
|
skills: this.skills,
|
|
488
679
|
mode: this.ui.mode,
|
|
489
680
|
check: this.check,
|
|
681
|
+
map: this.map,
|
|
682
|
+
memory: this.memory,
|
|
490
683
|
}),
|
|
491
684
|
},
|
|
492
685
|
...this.working,
|
|
@@ -514,6 +707,10 @@ export class Agent {
|
|
|
514
707
|
});
|
|
515
708
|
continue;
|
|
516
709
|
}
|
|
710
|
+
|
|
711
|
+
// Busy, slow or down: move to the next model and carry on, rather
|
|
712
|
+
// than ending a half-built app with an error.
|
|
713
|
+
if (TRANSIENT.has(err.kind) && !this.abort.signal.aborted && (await this.failover(err))) continue;
|
|
517
714
|
throw err;
|
|
518
715
|
}
|
|
519
716
|
|
|
@@ -549,6 +746,24 @@ export class Agent {
|
|
|
549
746
|
continue;
|
|
550
747
|
}
|
|
551
748
|
|
|
749
|
+
// Type-check what changed and hand back the errors, a few rounds at most.
|
|
750
|
+
// A turn that ends on a broken build is the most common way an app
|
|
751
|
+
// gets handed over as done when it is not.
|
|
752
|
+
if (fixRounds < MAX_FIX_ROUNDS) {
|
|
753
|
+
const problems = await this.autoCheck();
|
|
754
|
+
if (problems) {
|
|
755
|
+
fixRounds++;
|
|
756
|
+
if (reply.text) this.push({ role: 'assistant', content: reply.text });
|
|
757
|
+
this.push({
|
|
758
|
+
role: 'user',
|
|
759
|
+
content:
|
|
760
|
+
`ucode checked the files you changed and found errors (round ${fixRounds} of ` +
|
|
761
|
+
`${MAX_FIX_ROUNDS}). Fix all of them, then finish.\n\n${problems}`,
|
|
762
|
+
});
|
|
763
|
+
continue;
|
|
764
|
+
}
|
|
765
|
+
}
|
|
766
|
+
|
|
552
767
|
// It changed code and never ran anything. Send it back once.
|
|
553
768
|
if (this.touched.size && !this.ranSomething && this.check && !askedToVerify) {
|
|
554
769
|
askedToVerify = true;
|
|
@@ -656,11 +871,8 @@ export class Agent {
|
|
|
656
871
|
if (this.abort.signal.aborted) return badArgs;
|
|
657
872
|
|
|
658
873
|
const noted = (call) => {
|
|
659
|
-
if (
|
|
660
|
-
this.touched.add(
|
|
661
|
-
}
|
|
662
|
-
if (call.name === 'batch_write') {
|
|
663
|
-
for (const f of call.args?.files ?? []) this.touched.add(f?.path ?? 'a file');
|
|
874
|
+
if (FILE_WRITES.has(call.name)) {
|
|
875
|
+
for (const p of pathsOf(call)) { this.touched.add(p); this.sinceCheck.add(p); }
|
|
664
876
|
}
|
|
665
877
|
if (call.name === 'run_command' || call.name === 'run_commands') this.ranSomething = true;
|
|
666
878
|
};
|
|
@@ -673,10 +885,7 @@ export class Agent {
|
|
|
673
885
|
this.ui.startSpinner(`${group.length} lookups at once`);
|
|
674
886
|
|
|
675
887
|
const settled = await Promise.all(
|
|
676
|
-
group.map((call) => this.
|
|
677
|
-
(out) => ({ call, out }),
|
|
678
|
-
(err) => ({ call, err })
|
|
679
|
-
))
|
|
888
|
+
group.map((call) => (this.early.get(call.id) ?? this.execute(call)).then((r) => ({ call, ...r })))
|
|
680
889
|
);
|
|
681
890
|
|
|
682
891
|
this.ui.stopSpinner();
|
|
@@ -691,18 +900,14 @@ export class Agent {
|
|
|
691
900
|
if (this.abort.signal.aborted) return badArgs;
|
|
692
901
|
|
|
693
902
|
const label = describe(call.name, call.args);
|
|
694
|
-
this.ui.toolCall(label);
|
|
903
|
+
if (!SILENT.has(call.name)) this.ui.toolCall(label);
|
|
695
904
|
this.ui.startSpinner(label);
|
|
696
905
|
noted(call);
|
|
697
906
|
|
|
698
|
-
|
|
699
|
-
|
|
700
|
-
|
|
701
|
-
|
|
702
|
-
} catch (err) {
|
|
703
|
-
this.ui.stopSpinner();
|
|
704
|
-
badArgs = this.reportFailure(call, err) || badArgs;
|
|
705
|
-
}
|
|
907
|
+
const { out, err } = await (this.early.get(call.id) ?? this.execute(call));
|
|
908
|
+
this.ui.stopSpinner();
|
|
909
|
+
if (err) badArgs = this.reportFailure(call, err) || badArgs;
|
|
910
|
+
else this.reportResult(call, out);
|
|
706
911
|
}
|
|
707
912
|
}
|
|
708
913
|
|
|
@@ -745,6 +950,8 @@ export class Agent {
|
|
|
745
950
|
}
|
|
746
951
|
|
|
747
952
|
if (call.name === 'load_skill') return this.loadSkill(call.args?.name);
|
|
953
|
+
if (call.name === 'update_plan') return this.updatePlan(call.args?.items);
|
|
954
|
+
if (call.name === 'delegate') return this.delegate(call.args?.tasks);
|
|
748
955
|
|
|
749
956
|
// Output reaches the screen as the command produces it, so a slow build is
|
|
750
957
|
// something you watch rather than something you sit out in silence.
|
|
@@ -753,6 +960,235 @@ export class Agent {
|
|
|
753
960
|
});
|
|
754
961
|
}
|
|
755
962
|
|
|
963
|
+
/**
|
|
964
|
+
* Switch to the next model after a provider failure. Returns false once
|
|
965
|
+
* there is nothing sensible left to try. When every model is busy at once,
|
|
966
|
+
* it waits a minute and goes round again rather than giving up.
|
|
967
|
+
*/
|
|
968
|
+
async failover(err) {
|
|
969
|
+
if (++this.failovers > MAX_FAILOVERS) return false;
|
|
970
|
+
const from = model();
|
|
971
|
+
let next = fallbackFor(from, this.tried);
|
|
972
|
+
|
|
973
|
+
if (!next) {
|
|
974
|
+
const until = Date.now() + 60_000;
|
|
975
|
+
this.ui.startSpinner('every model is busy');
|
|
976
|
+
while (Date.now() < until && !this.abort?.signal.aborted) {
|
|
977
|
+
this.ui.updateSpinner(`every model is busy — trying again in ${Math.ceil((until - Date.now()) / 1000)}s`);
|
|
978
|
+
await wait(1000);
|
|
979
|
+
}
|
|
980
|
+
this.ui.stopSpinner();
|
|
981
|
+
if (this.abort?.signal.aborted) return false;
|
|
982
|
+
this.tried = new Set();
|
|
983
|
+
next = this.preferred && this.preferred !== from ? this.preferred : fallbackFor(from, this.tried) ?? from;
|
|
984
|
+
}
|
|
985
|
+
|
|
986
|
+
this.tried.add(next);
|
|
987
|
+
setModel(next);
|
|
988
|
+
this.cooldownUntil = Date.now() + COOLDOWN;
|
|
989
|
+
const why = err.kind === 'rate_limit' ? 'busy' : err.kind === 'timeout' ? 'too slow to answer' : 'not answering';
|
|
990
|
+
this.ui.note(`${modelName(from)} is ${why} — carrying on with ${modelName(next)}`);
|
|
991
|
+
if (this.full) this.showHeader({ clear: false });
|
|
992
|
+
return true;
|
|
993
|
+
}
|
|
994
|
+
|
|
995
|
+
/** Run a call and settle to { out } or { err } — never throws. */
|
|
996
|
+
execute(call) {
|
|
997
|
+
return this.dispatch(call).then((out) => ({ out }), (err) => ({ err }));
|
|
998
|
+
}
|
|
999
|
+
|
|
1000
|
+
updatePlan(items) {
|
|
1001
|
+
const list = (Array.isArray(items) ? items : [])
|
|
1002
|
+
.filter((i) => i && String(i.text ?? '').trim())
|
|
1003
|
+
.slice(0, 6);
|
|
1004
|
+
this.ui.plan(list);
|
|
1005
|
+
const done = list.filter((i) => i.done).length;
|
|
1006
|
+
return { content: `Plan updated: ${done} of ${list.length} done.`, summary: `${done}/${list.length}` };
|
|
1007
|
+
}
|
|
1008
|
+
|
|
1009
|
+
/** File writes from parallel workers take turns, so two never interleave. */
|
|
1010
|
+
fileLock(fn) {
|
|
1011
|
+
const run = (this.lockChain ?? Promise.resolve()).then(fn, fn);
|
|
1012
|
+
this.lockChain = run.catch(() => {});
|
|
1013
|
+
return run;
|
|
1014
|
+
}
|
|
1015
|
+
|
|
1016
|
+
/**
|
|
1017
|
+
* Several workers at once, each its own small agent loop with its own
|
|
1018
|
+
* conversation, sharing the tools, the project, and whatever skills are
|
|
1019
|
+
* already in force. Their lines in the transcript carry their name.
|
|
1020
|
+
*/
|
|
1021
|
+
async delegate(tasks) {
|
|
1022
|
+
const list = (Array.isArray(tasks) ? tasks : [])
|
|
1023
|
+
.filter((t) => t && String(t.instructions ?? '').trim())
|
|
1024
|
+
.slice(0, MAX_WORKERS);
|
|
1025
|
+
if (!list.length) {
|
|
1026
|
+
throw new ToolFailure({
|
|
1027
|
+
kind: 'bad_args',
|
|
1028
|
+
attempted: 'starting workers',
|
|
1029
|
+
failed: 'No tasks with instructions were given.',
|
|
1030
|
+
fix: 'Pass tasks as [{ name, instructions }, ...], up to 3.',
|
|
1031
|
+
});
|
|
1032
|
+
}
|
|
1033
|
+
|
|
1034
|
+
const results = await Promise.all(list.map((task, i) => this.runWorker(task, i).catch((err) => ({
|
|
1035
|
+
name: task.name || `worker ${i + 1}`,
|
|
1036
|
+
summary: `Failed: ${err?.failed ?? err?.message ?? err}`,
|
|
1037
|
+
touched: [],
|
|
1038
|
+
}))));
|
|
1039
|
+
|
|
1040
|
+
for (const r of results) for (const f of r.touched) { this.touched.add(f); this.sinceCheck.add(f); }
|
|
1041
|
+
|
|
1042
|
+
// A worker that wrote nothing has not done its part, whatever it said.
|
|
1043
|
+
// The lead builds those itself rather than leaving holes in the app.
|
|
1044
|
+
const empty = results.filter((r) => !r.touched.length).map((r) => r.name);
|
|
1045
|
+
|
|
1046
|
+
return {
|
|
1047
|
+
content: results
|
|
1048
|
+
.map((r) => `## ${r.name}\n${r.summary}\nFiles changed: ${r.touched.join(', ') || 'none'}`)
|
|
1049
|
+
.join('\n\n') +
|
|
1050
|
+
(empty.length
|
|
1051
|
+
? `\n\n${empty.join(', ')} wrote no files. Build ${empty.length === 1 ? 'that part' : 'those parts'} ` +
|
|
1052
|
+
'yourself now, directly - do not delegate them again.'
|
|
1053
|
+
: ''),
|
|
1054
|
+
summary: results.map((r) => `${r.name} · ${r.touched.length} file${r.touched.length === 1 ? '' : 's'}`).join(' '),
|
|
1055
|
+
};
|
|
1056
|
+
}
|
|
1057
|
+
|
|
1058
|
+
async runWorker(task, index) {
|
|
1059
|
+
const name = clip(String(task.name || `worker ${index + 1}`).trim(), 16);
|
|
1060
|
+
const touched = new Set();
|
|
1061
|
+
const skills = this.skills
|
|
1062
|
+
.filter((s) => this.loaded.has(s.name))
|
|
1063
|
+
.map((s) => `--- ${s.name} ---\n${s.body}`)
|
|
1064
|
+
.join('\n\n');
|
|
1065
|
+
const messages = [
|
|
1066
|
+
{ role: 'system', content: workerPrompt({ cwd: this.cwd, name, memory: this.memory, skills, map: this.map }) },
|
|
1067
|
+
{ role: 'user', content: String(task.instructions) },
|
|
1068
|
+
];
|
|
1069
|
+
const available = this.toolsNow().filter((t) => !WORKER_EXCLUDED.has(t.name));
|
|
1070
|
+
const wanted = process.env.UCODE_WORKER_MODEL;
|
|
1071
|
+
let workerModel = wanted && MODELS[wanted] ? wanted : model();
|
|
1072
|
+
const tried = new Set([workerModel]);
|
|
1073
|
+
let failovers = 0;
|
|
1074
|
+
|
|
1075
|
+
// Start a moment apart. Three requests in the same instant is exactly what
|
|
1076
|
+
// trips a free endpoint's rate limit, and the stagger costs a second or two.
|
|
1077
|
+
if (index) await wait(index * 1500);
|
|
1078
|
+
|
|
1079
|
+
for (let step = 0; step < WORKER_STEPS; step++) {
|
|
1080
|
+
if (this.abort?.signal.aborted) break;
|
|
1081
|
+
|
|
1082
|
+
let reply;
|
|
1083
|
+
try {
|
|
1084
|
+
reply = await ask(messages, available, { signal: this.abort?.signal, model: workerModel });
|
|
1085
|
+
} catch (err) {
|
|
1086
|
+
// Same rule as the lead: a busy model is swapped, not a reason to stop.
|
|
1087
|
+
if (TRANSIENT.has(err.kind) && failovers < 6 && !this.abort?.signal.aborted) {
|
|
1088
|
+
failovers++;
|
|
1089
|
+
let next = fallbackFor(workerModel, tried);
|
|
1090
|
+
if (!next) { tried.clear(); await wait(20_000); next = fallbackFor(workerModel, tried) ?? workerModel; }
|
|
1091
|
+
tried.add(next);
|
|
1092
|
+
this.ui.note(`${name}: ${modelName(workerModel)} is busy — switching to ${modelName(next)}`);
|
|
1093
|
+
workerModel = next;
|
|
1094
|
+
step--;
|
|
1095
|
+
continue;
|
|
1096
|
+
}
|
|
1097
|
+
throw err;
|
|
1098
|
+
}
|
|
1099
|
+
this.record(reply.usage);
|
|
1100
|
+
|
|
1101
|
+
if (!reply.toolCalls.length) {
|
|
1102
|
+
this.ui.toolResult(`${name} finished`);
|
|
1103
|
+
return { name, summary: reply.text?.trim() || 'Finished without a summary.', touched: [...touched] };
|
|
1104
|
+
}
|
|
1105
|
+
|
|
1106
|
+
messages.push({ role: 'assistant', content: reply.text || '', toolCalls: reply.toolCalls });
|
|
1107
|
+
for (const call of reply.toolCalls) {
|
|
1108
|
+
this.ui.toolCall(`${name} › ${describe(call.name, call.args)}`);
|
|
1109
|
+
if (FILE_WRITES.has(call.name)) for (const p of pathsOf(call)) touched.add(p);
|
|
1110
|
+
const { out, err } = FILE_WRITES.has(call.name)
|
|
1111
|
+
? await this.fileLock(() => this.execute(call))
|
|
1112
|
+
: await this.execute(call);
|
|
1113
|
+
if (err) {
|
|
1114
|
+
if (!(err instanceof ToolFailure)) throw err;
|
|
1115
|
+
this.ui.toolFailed(`${name}: ${err.kind}: ${err.failed}`);
|
|
1116
|
+
}
|
|
1117
|
+
messages.push({
|
|
1118
|
+
role: 'tool', toolCallId: call.id, name: call.name,
|
|
1119
|
+
content: err ? err.forModel() : out.content,
|
|
1120
|
+
});
|
|
1121
|
+
}
|
|
1122
|
+
}
|
|
1123
|
+
|
|
1124
|
+
return { name, summary: `Stopped after ${WORKER_STEPS} steps without finishing.`, touched: [...touched] };
|
|
1125
|
+
}
|
|
1126
|
+
|
|
1127
|
+
/**
|
|
1128
|
+
* Check the code files changed since the last check, and return the
|
|
1129
|
+
* errors as text for the model — or null when everything is clean.
|
|
1130
|
+
*
|
|
1131
|
+
* TypeScript projects get one `tsc --noEmit` per project that owns a
|
|
1132
|
+
* changed file (an app scaffolded into a subfolder is its own project).
|
|
1133
|
+
* Plain JavaScript gets a syntax check, Python a compile check. Nothing
|
|
1134
|
+
* runs that is not already installed.
|
|
1135
|
+
*/
|
|
1136
|
+
async autoCheck() {
|
|
1137
|
+
const changed = [...this.sinceCheck].filter((f) => CHECKABLE.test(f));
|
|
1138
|
+
this.sinceCheck.clear();
|
|
1139
|
+
if (!changed.length) return null;
|
|
1140
|
+
|
|
1141
|
+
const root = path.resolve(this.cwd);
|
|
1142
|
+
const tsRoots = new Set();
|
|
1143
|
+
const singles = [];
|
|
1144
|
+
|
|
1145
|
+
for (const rel of changed) {
|
|
1146
|
+
const abs = path.resolve(root, rel);
|
|
1147
|
+
if (!(await exists(abs))) continue;
|
|
1148
|
+
if (/\.py$/i.test(rel)) { singles.push({ abs, rel, command: `python -m py_compile "${abs}"` }); continue; }
|
|
1149
|
+
let dir = path.dirname(abs);
|
|
1150
|
+
let owner = null;
|
|
1151
|
+
while (dir.startsWith(root)) {
|
|
1152
|
+
if (await exists(path.join(dir, 'tsconfig.json'))) { owner = dir; break; }
|
|
1153
|
+
const up = path.dirname(dir);
|
|
1154
|
+
if (up === dir) break;
|
|
1155
|
+
dir = up;
|
|
1156
|
+
}
|
|
1157
|
+
if (owner && (await exists(path.join(owner, 'node_modules', 'typescript')))) tsRoots.add(owner);
|
|
1158
|
+
else if (/\.[cm]?js$/i.test(rel)) singles.push({ abs, rel, command: `node --check "${abs}"` });
|
|
1159
|
+
}
|
|
1160
|
+
|
|
1161
|
+
const problems = [];
|
|
1162
|
+
const check = async (label, command, cwd) => {
|
|
1163
|
+
this.ui.toolCall(label);
|
|
1164
|
+
this.ui.startSpinner(label);
|
|
1165
|
+
const { out, err } = await this.execute({
|
|
1166
|
+
id: 'check', name: 'run_command',
|
|
1167
|
+
args: { command, cwd: path.relative(root, cwd) || '.', timeout_ms: 180_000 },
|
|
1168
|
+
});
|
|
1169
|
+
this.ui.stopSpinner();
|
|
1170
|
+
return err ? { exitCode: -1, content: String(err.failed ?? err.message) } : out;
|
|
1171
|
+
};
|
|
1172
|
+
|
|
1173
|
+
for (const dir of tsRoots) {
|
|
1174
|
+
const show = path.relative(root, dir) || '.';
|
|
1175
|
+
const out = await check(`Checking types in ${show}`, 'npx --no-install tsc --noEmit --pretty false', dir);
|
|
1176
|
+
if (out.exitCode === 0) { this.ui.toolResult('types check out'); continue; }
|
|
1177
|
+
const errors = out.content.split('\n').filter((l) => /error TS\d+/.test(l));
|
|
1178
|
+
this.ui.toolFailed(`${errors.length || 'some'} type error${errors.length === 1 ? '' : 's'}`);
|
|
1179
|
+
problems.push(`In ${show} (tsc --noEmit):\n${(errors.length ? errors : out.content.split('\n')).slice(0, 40).join('\n')}`);
|
|
1180
|
+
}
|
|
1181
|
+
|
|
1182
|
+
for (const f of singles) {
|
|
1183
|
+
const out = await check(`Checking ${f.rel}`, f.command, root);
|
|
1184
|
+
if (out.exitCode === 0) { this.ui.toolResult('ok'); continue; }
|
|
1185
|
+
this.ui.toolFailed('does not compile');
|
|
1186
|
+
problems.push(`${f.rel}:\n${out.content.split('\n').slice(0, 20).join('\n')}`);
|
|
1187
|
+
}
|
|
1188
|
+
|
|
1189
|
+
return problems.length ? problems.join('\n\n') : null;
|
|
1190
|
+
}
|
|
1191
|
+
|
|
756
1192
|
loadSkill(name) {
|
|
757
1193
|
const skill = findSkill(this.skills, name);
|
|
758
1194
|
if (!skill) {
|
|
@@ -847,6 +1283,7 @@ export class Agent {
|
|
|
847
1283
|
return this.cmdSessions(arg);
|
|
848
1284
|
|
|
849
1285
|
case '/new': return this.cmdNew();
|
|
1286
|
+
case '/remember': return this.cmdRemember(arg);
|
|
850
1287
|
case '/skills': return this.cmdSkills();
|
|
851
1288
|
case '/clear': this.showHeader(); return;
|
|
852
1289
|
case '/search': return this.cmdSearch(arg);
|
|
@@ -866,6 +1303,7 @@ export class Agent {
|
|
|
866
1303
|
['/model', 'show the models and switch between them'],
|
|
867
1304
|
['/resume', 'pick up an earlier conversation'],
|
|
868
1305
|
['/new', 'save this one and start fresh'],
|
|
1306
|
+
['/remember <note>', `add a standing note to ${MEMORY_FILE}`],
|
|
869
1307
|
['/skills', 'what ucode knows how to do'],
|
|
870
1308
|
['/search <query>', 'look something up on the web'],
|
|
871
1309
|
['/copy', 'copy the last reply to the clipboard'],
|
|
@@ -888,6 +1326,7 @@ export class Agent {
|
|
|
888
1326
|
if (arg) {
|
|
889
1327
|
try {
|
|
890
1328
|
setModel(arg);
|
|
1329
|
+
this.preferred = model();
|
|
891
1330
|
} catch (err) {
|
|
892
1331
|
this.ui.error(err, { debug: this.debug });
|
|
893
1332
|
return;
|
|
@@ -915,6 +1354,7 @@ export class Agent {
|
|
|
915
1354
|
if (chosen === null) return;
|
|
916
1355
|
|
|
917
1356
|
setModel(all[chosen].id);
|
|
1357
|
+
this.preferred = model();
|
|
918
1358
|
this.session.model = model();
|
|
919
1359
|
this.ui.note(`now using ${modelName()}`);
|
|
920
1360
|
this.showHeader({ clear: false });
|
|
@@ -953,7 +1393,7 @@ export class Agent {
|
|
|
953
1393
|
* and how far it got, and the ones from this folder are marked, because that
|
|
954
1394
|
* is nearly always the one being looked for.
|
|
955
1395
|
*/
|
|
956
|
-
describeSession(s, width) {
|
|
1396
|
+
describeSession(s, width, i) {
|
|
957
1397
|
const room = Math.max(24, Math.min(46, width - 34));
|
|
958
1398
|
const mark = s.mine ? blue('●') : dim('○');
|
|
959
1399
|
const when = relativeTime(s.updatedAt).padEnd(9);
|
|
@@ -961,7 +1401,8 @@ export class Agent {
|
|
|
961
1401
|
const where = s.mine ? 'here' : shortenPath(s.cwd, 26);
|
|
962
1402
|
|
|
963
1403
|
return {
|
|
964
|
-
|
|
1404
|
+
// Numbered in the picker, so /session delete 3 has something to point at.
|
|
1405
|
+
label: `${i === undefined ? '' : `${dim(String(i + 1).padStart(2))} `}${mark} ${clip(s.title, room).padEnd(room)} ${dim(when)}${dim(turns)}${dim(where)}`,
|
|
965
1406
|
sub: s.preview ? dim(` ${clip(s.preview, width - 10)}`) : '',
|
|
966
1407
|
};
|
|
967
1408
|
}
|
|
@@ -982,6 +1423,10 @@ export class Agent {
|
|
|
982
1423
|
return;
|
|
983
1424
|
}
|
|
984
1425
|
|
|
1426
|
+
// /session delete 3 or /session delete 2,5,7
|
|
1427
|
+
const del = /^(?:delete|del|rm|remove)\b\s*(.*)$/i.exec(arg ?? '');
|
|
1428
|
+
if (del) return this.deleteSessions(del[1]);
|
|
1429
|
+
|
|
985
1430
|
const sessions = await list({ cwd: this.cwd });
|
|
986
1431
|
if (!sessions.length) {
|
|
987
1432
|
this.ui.note('no saved conversations yet');
|
|
@@ -1004,22 +1449,48 @@ export class Agent {
|
|
|
1004
1449
|
}
|
|
1005
1450
|
index = n - 1;
|
|
1006
1451
|
} else if (this.ui.pick) {
|
|
1007
|
-
|
|
1008
|
-
|
|
1009
|
-
|
|
1010
|
-
|
|
1011
|
-
|
|
1012
|
-
|
|
1013
|
-
|
|
1014
|
-
|
|
1452
|
+
// The picker stays open while you delete, so clearing out several old
|
|
1453
|
+
// conversations is d d, d d, d d — then Enter on the one you want.
|
|
1454
|
+
let active = 0;
|
|
1455
|
+
for (;;) {
|
|
1456
|
+
const here = shown.filter((s) => s.mine).length;
|
|
1457
|
+
const picked = await this.ui.pick(
|
|
1458
|
+
shown.map((s, i) => this.describeSession(s, width, i)),
|
|
1459
|
+
{
|
|
1460
|
+
active,
|
|
1461
|
+
deletable: true,
|
|
1462
|
+
hint:
|
|
1463
|
+
`↑↓ move · enter to continue · d twice to delete · esc to cancel` +
|
|
1464
|
+
(here ? ` — ${here} from this folder` : ''),
|
|
1465
|
+
}
|
|
1466
|
+
);
|
|
1467
|
+
if (picked === null) return;
|
|
1468
|
+
if (typeof picked === 'object' && picked.delete !== undefined) {
|
|
1469
|
+
const doomed = shown[picked.delete];
|
|
1470
|
+
active = picked.delete;
|
|
1471
|
+
if (doomed.id === this.session.id) {
|
|
1472
|
+
this.ui.flash?.('that is the conversation you are in — /new first, then delete it');
|
|
1473
|
+
continue;
|
|
1474
|
+
}
|
|
1475
|
+
await remove(doomed.id);
|
|
1476
|
+
shown.splice(picked.delete, 1);
|
|
1477
|
+
this.ui.flash?.(`deleted · ${clip(doomed.title, 50)}`);
|
|
1478
|
+
if (!shown.length) {
|
|
1479
|
+
this.ui.note('no saved conversations left');
|
|
1480
|
+
return;
|
|
1481
|
+
}
|
|
1482
|
+
active = Math.min(active, shown.length - 1);
|
|
1483
|
+
continue;
|
|
1015
1484
|
}
|
|
1016
|
-
|
|
1017
|
-
|
|
1485
|
+
index = picked;
|
|
1486
|
+
break;
|
|
1487
|
+
}
|
|
1018
1488
|
} else {
|
|
1019
1489
|
this.ui.blank();
|
|
1490
|
+
this.ui.note('/session delete <number> removes one, or several: /session delete 2,5');
|
|
1020
1491
|
index = await this.ui.choose(
|
|
1021
1492
|
'continue which?',
|
|
1022
|
-
shown.map((s) => this.describeSession(s, width).label)
|
|
1493
|
+
shown.map((s, i) => this.describeSession(s, width, i).label)
|
|
1023
1494
|
);
|
|
1024
1495
|
if (index === null) return;
|
|
1025
1496
|
}
|
|
@@ -1031,6 +1502,26 @@ export class Agent {
|
|
|
1031
1502
|
}
|
|
1032
1503
|
}
|
|
1033
1504
|
|
|
1505
|
+
/** /session delete 3, or 2,5,7 — numbers as the session list shows them. */
|
|
1506
|
+
async deleteSessions(spec) {
|
|
1507
|
+
const sessions = (await list({ cwd: this.cwd })).slice(0, 25);
|
|
1508
|
+
const numbers = [...new Set(String(spec).split(/[\s,]+/).filter(Boolean).map(Number))];
|
|
1509
|
+
const bad = numbers.filter((n) => !Number.isInteger(n) || n < 1 || n > sessions.length);
|
|
1510
|
+
if (!numbers.length || bad.length) {
|
|
1511
|
+
this.ui.write(theme.warn(` usage: /session delete <number>[,<number>…] — numbers from 1 to ${sessions.length}`));
|
|
1512
|
+
return;
|
|
1513
|
+
}
|
|
1514
|
+
for (const n of numbers) {
|
|
1515
|
+
const s = sessions[n - 1];
|
|
1516
|
+
if (s.id === this.session.id) {
|
|
1517
|
+
this.ui.note(`skipped ${n} — that is the conversation you are in`);
|
|
1518
|
+
continue;
|
|
1519
|
+
}
|
|
1520
|
+
await remove(s.id);
|
|
1521
|
+
this.ui.note(`deleted ${n} · ${s.title}`);
|
|
1522
|
+
}
|
|
1523
|
+
}
|
|
1524
|
+
|
|
1034
1525
|
async resume(id) {
|
|
1035
1526
|
try {
|
|
1036
1527
|
const loaded = await load(id);
|
|
@@ -1060,6 +1551,25 @@ export class Agent {
|
|
|
1060
1551
|
if (tail.length) this.ui.write(dim(' ── picking up here ──\n'));
|
|
1061
1552
|
}
|
|
1062
1553
|
|
|
1554
|
+
/** Add a line to this project's UCODE.md, read at the start of every turn. */
|
|
1555
|
+
async cmdRemember(note) {
|
|
1556
|
+
if (!note) {
|
|
1557
|
+
this.ui.note(`usage: /remember <something ucode should always know here> — saved to ${MEMORY_FILE}`);
|
|
1558
|
+
return;
|
|
1559
|
+
}
|
|
1560
|
+
try {
|
|
1561
|
+
const file = await remember(this.cwd, note);
|
|
1562
|
+
this.ui.note(`remembered · ${path.relative(this.cwd, file) || MEMORY_FILE}`);
|
|
1563
|
+
} catch (err) {
|
|
1564
|
+
this.ui.error(new Failure({
|
|
1565
|
+
kind: 'memory_unwritable',
|
|
1566
|
+
attempted: `saving to ${MEMORY_FILE}`,
|
|
1567
|
+
failed: err.message,
|
|
1568
|
+
fix: 'Check that this folder is writable.',
|
|
1569
|
+
}), { debug: this.debug });
|
|
1570
|
+
}
|
|
1571
|
+
}
|
|
1572
|
+
|
|
1063
1573
|
async cmdNew() {
|
|
1064
1574
|
if (this.session.messages.length) {
|
|
1065
1575
|
await this.persist();
|