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/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
- setConfirm((request) => this.ui.confirm(request));
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 (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');
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.dispatch(call).then(
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
- 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
- }
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
- label: `${mark} ${clip(s.title, room).padEnd(room)} ${dim(when)}${dim(turns)}${dim(where)}`,
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
- 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` : ''),
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
- if (index === null) return;
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();