ucode-agent 1.2.0 → 1.3.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,7 +12,7 @@
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 {
@@ -20,8 +20,9 @@ import {
20
20
  MODELS, DEFAULT_MODEL, PROVIDER,
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';
25
26
  import {
26
27
  newSession, save, load, list, removeAll, titleFrom,
27
28
  } from './history.js';
@@ -52,7 +53,111 @@ const MAX_ARG_RETRIES = 2;
52
53
  const MAX_CONTINUATIONS = 3;
53
54
 
54
55
  /** 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
+ const QUIET = new Set(['read_file', 'read_files', 'list_dir', 'glob', 'grep', 'web_search', 'update_plan']);
57
+
58
+ /** Tools that draw their own line, so they get no "● Doing X" line of their own. */
59
+ const SILENT = new Set(['update_plan']);
60
+
61
+ /** How many rounds of "the type check found errors, fix them" one turn may take. */
62
+ const MAX_FIX_ROUNDS = 3;
63
+
64
+ /** Files worth checking after they change. */
65
+ const CHECKABLE = /\.(?:[cm]?[jt]sx?|py)$/i;
66
+
67
+ /** Parallel workers at once, and how many steps each may take. */
68
+ const MAX_WORKERS = 3;
69
+ const WORKER_STEPS = Number(process.env.UCODE_WORKER_STEPS) || 60;
70
+
71
+ /** Workers build; they do not plan, delegate further, or load skills themselves. */
72
+ const WORKER_EXCLUDED = new Set(['delegate', 'update_plan', 'load_skill']);
73
+
74
+ const planTool = {
75
+ name: 'update_plan',
76
+ description:
77
+ 'Keep a short checklist the user can see, for work with three or more steps. ' +
78
+ 'Send the whole list every time: at most 6 items, a few words each, with done: true ' +
79
+ 'on the finished ones. Update it as items finish. Skip it for small tasks.',
80
+ parameters: {
81
+ type: 'object',
82
+ properties: {
83
+ items: {
84
+ type: 'array',
85
+ description: 'The whole plan, in order. At most 6.',
86
+ items: {
87
+ type: 'object',
88
+ properties: {
89
+ text: { type: 'string', description: 'A few words: "Build the upload box".' },
90
+ done: { type: 'boolean', description: 'True once it is finished.' },
91
+ },
92
+ required: ['text'],
93
+ },
94
+ },
95
+ },
96
+ required: ['items'],
97
+ },
98
+ };
99
+
100
+ const delegateTool = {
101
+ name: 'delegate',
102
+ description:
103
+ 'Build independent parts in parallel. Up to 3 workers run at once, each with the ' +
104
+ 'same tools as you. Use it when the work splits cleanly into parts that touch ' +
105
+ 'different files - e.g. the API route, the upload component and the results view. ' +
106
+ 'A worker sees only its instructions, so make them complete: the files it owns, ' +
107
+ 'what to build, the exact interfaces (props, types, request and response shapes) ' +
108
+ 'it must match, and the design rules. Set up shared files (package.json, design ' +
109
+ 'tokens, shared types) yourself first. You get back each worker\'s summary and ' +
110
+ 'the files it changed; wire the parts together and check the whole afterwards.',
111
+ parameters: {
112
+ type: 'object',
113
+ properties: {
114
+ tasks: {
115
+ type: 'array',
116
+ description: 'Up to 3 independent pieces of work.',
117
+ items: {
118
+ type: 'object',
119
+ properties: {
120
+ name: { type: 'string', description: 'Two or three words: "api route", "upload ui".' },
121
+ instructions: { type: 'string', description: 'Everything the worker needs to do its part completely.' },
122
+ },
123
+ required: ['name', 'instructions'],
124
+ },
125
+ },
126
+ },
127
+ required: ['tasks'],
128
+ },
129
+ };
130
+
131
+ /** The instructions a parallel worker starts with. */
132
+ function workerPrompt({ cwd, name, memory, skills }) {
133
+ return [
134
+ `You are a ucode worker called "${name}" - one of several building parts of the same project at the same time.`,
135
+ '',
136
+ `Working directory: ${cwd}`,
137
+ `Platform: ${process.platform}`,
138
+ '',
139
+ '- Do exactly the task you were given. Touch only the files it names, or new files in',
140
+ ' the area it owns - other workers are editing the rest of the project right now.',
141
+ '- Read before you edit. read_files for several files, batch_write for several new',
142
+ ' files, edit_files for changes across files.',
143
+ '- Nothing has a keyboard: pass non-interactive flags. Do not start dev servers and do',
144
+ ' not install packages unless the task says to - say what you need instead.',
145
+ '- Finish what you build: real content, every state handled, no TODOs.',
146
+ '- When done, reply with two or three sentences: what you built, in which files, and',
147
+ ' anything the lead has to wire up.',
148
+ ...(memory ? ['', '## Project memory', '', memory] : []),
149
+ ...(skills ? ['', '## Instructions in force', '', skills] : []),
150
+ ].join('\n');
151
+ }
152
+
153
+ /** The files a writing tool call touches. */
154
+ function pathsOf(call) {
155
+ const a = call.args ?? {};
156
+ if (call.name === 'batch_write' || call.name === 'edit_files') return (a.files ?? []).map((f) => f?.path).filter(Boolean);
157
+ return a.path ? [a.path] : [];
158
+ }
159
+
160
+ const exists = (p) => access(p).then(() => true, () => false);
56
161
 
57
162
  /** Skills reach the model as one extra tool, so bodies load only when wanted. */
58
163
  const loadSkillTool = {
@@ -68,7 +173,7 @@ const loadSkillTool = {
68
173
  },
69
174
  };
70
175
 
71
- function systemPrompt({ cwd, skills, mode, check }) {
176
+ function systemPrompt({ cwd, skills, mode, check, map, memory }) {
72
177
  const list = catalogue(skills);
73
178
 
74
179
  return [
@@ -76,6 +181,14 @@ function systemPrompt({ cwd, skills, mode, check }) {
76
181
  '',
77
182
  `Working directory: ${cwd}`,
78
183
  `Platform: ${process.platform}`,
184
+ ...(memory ? [
185
+ '',
186
+ '## Project memory',
187
+ '',
188
+ 'Standing instructions from the user. They outrank your defaults.',
189
+ '',
190
+ memory,
191
+ ] : []),
79
192
  '',
80
193
  '## How to work',
81
194
  '',
@@ -111,7 +224,16 @@ function systemPrompt({ cwd, skills, mode, check }) {
111
224
  '- Put independent calls in the same message — several greps, a glob and a read.',
112
225
  ' Read-only calls in one message run at the same time.',
113
226
  '- batch_write to lay out several new files at once, multi_edit for several changes',
114
- ' to one file.',
227
+ ' to one file, edit_files for a change that spans several files.',
228
+ '- For work with three or more steps, keep a short plan with update_plan - at most',
229
+ ' six items of a few words - and tick items off as they finish. Skip it for small jobs.',
230
+ '- When a build splits into parts that touch different files (the API route, the',
231
+ ' upload component, the results view), set up the shared files yourself, then hand',
232
+ ' the parts to delegate so they are built in parallel.',
233
+ '- A package.json you write starts installing in the background immediately; keep',
234
+ ' writing files. Running the install yourself afterwards just waits for that one.',
235
+ '- When you finish, ucode type-checks what you changed and hands you the errors, so',
236
+ ' there is no need to run tsc yourself.',
115
237
  '- Nothing you run has a keyboard. Pass the non-interactive flag to anything that',
116
238
  ' would ask a question, or it fails instead of waiting: create-next-app --yes,',
117
239
  ' npx shadcn@latest init -d -y, npx shadcn@latest add <names> -y, npm init -y.',
@@ -164,6 +286,13 @@ function systemPrompt({ cwd, skills, mode, check }) {
164
286
  '',
165
287
  list,
166
288
  ] : []),
289
+ '',
290
+ '## Project map',
291
+ '',
292
+ 'Every file in the project at the start of this turn, with the names each code file',
293
+ 'exports. Go straight to the files you need instead of searching for them.',
294
+ '',
295
+ map || '(not available)',
167
296
  ].join('\n');
168
297
  }
169
298
 
@@ -211,7 +340,14 @@ export class Agent {
211
340
  */
212
341
  async bootstrap() {
213
342
  setRoot(this.cwd);
214
- setConfirm((request) => this.ui.confirm(request));
343
+ // Parallel workers can ask at the same moment; the questions queue up and
344
+ // are put to the user one at a time, never on top of each other.
345
+ let asking = Promise.resolve();
346
+ setConfirm((request) => {
347
+ const next = asking.then(() => this.ui.confirm(request));
348
+ asking = next.catch(() => {});
349
+ return next;
350
+ });
215
351
  this.skills = await loadSkills({ cwd: this.cwd });
216
352
  await this.detectCheck();
217
353
  }
@@ -416,6 +552,11 @@ export class Agent {
416
552
  }
417
553
 
418
554
  this.autoLoad(input);
555
+ // What the model is told about the project, fresh for this turn.
556
+ [this.map, this.memory] = await Promise.all([
557
+ projectMap(this.cwd).catch(() => ''),
558
+ loadMemory(this.cwd).catch(() => ''),
559
+ ]);
419
560
  await this.persist();
420
561
 
421
562
  this.busy = true;
@@ -437,7 +578,7 @@ export class Agent {
437
578
 
438
579
  /** The tools the model may see, given the mode. */
439
580
  toolsNow() {
440
- const all = [...tools, loadSkillTool];
581
+ const all = [...tools, loadSkillTool, planTool, delegateTool];
441
582
  if (this.ui.mode !== 'plan') return all;
442
583
  return all.filter((t) => !WRITES.has(t.name));
443
584
  }
@@ -449,8 +590,10 @@ export class Agent {
449
590
  let continuations = 0;
450
591
  let askedToVerify = false;
451
592
  let askedToSpeak = false;
593
+ let fixRounds = 0;
452
594
 
453
595
  this.touched = new Set();
596
+ this.sinceCheck = new Set();
454
597
  this.ranSomething = false;
455
598
 
456
599
  for (let step = 0; step < MAX_STEPS; step++) {
@@ -459,6 +602,7 @@ export class Agent {
459
602
 
460
603
  let reply;
461
604
  let streaming = false;
605
+ this.early = new Map();
462
606
 
463
607
  try {
464
608
  const opts = {
@@ -476,6 +620,14 @@ export class Agent {
476
620
  }
477
621
  this.ui.streamDelta(delta);
478
622
  };
623
+ // Read-only calls start the moment they are fully written, while the
624
+ // rest of the reply is still arriving. Nothing that writes or runs is
625
+ // started early: a reply that fails halfway must leave no side effects.
626
+ opts.onToolCall = (call) => {
627
+ if (PARALLEL_SAFE.has(call.name) && !call.parseError && !this.early.has(call.id)) {
628
+ this.early.set(call.id, this.execute(call));
629
+ }
630
+ };
479
631
  }
480
632
 
481
633
  reply = await ask(
@@ -487,6 +639,8 @@ export class Agent {
487
639
  skills: this.skills,
488
640
  mode: this.ui.mode,
489
641
  check: this.check,
642
+ map: this.map,
643
+ memory: this.memory,
490
644
  }),
491
645
  },
492
646
  ...this.working,
@@ -549,6 +703,24 @@ export class Agent {
549
703
  continue;
550
704
  }
551
705
 
706
+ // Type-check what changed and hand back the errors, a few rounds at most.
707
+ // A turn that ends on a broken build is the most common way an app
708
+ // gets handed over as done when it is not.
709
+ if (fixRounds < MAX_FIX_ROUNDS) {
710
+ const problems = await this.autoCheck();
711
+ if (problems) {
712
+ fixRounds++;
713
+ if (reply.text) this.push({ role: 'assistant', content: reply.text });
714
+ this.push({
715
+ role: 'user',
716
+ content:
717
+ `ucode checked the files you changed and found errors (round ${fixRounds} of ` +
718
+ `${MAX_FIX_ROUNDS}). Fix all of them, then finish.\n\n${problems}`,
719
+ });
720
+ continue;
721
+ }
722
+ }
723
+
552
724
  // It changed code and never ran anything. Send it back once.
553
725
  if (this.touched.size && !this.ranSomething && this.check && !askedToVerify) {
554
726
  askedToVerify = true;
@@ -656,11 +828,8 @@ export class Agent {
656
828
  if (this.abort.signal.aborted) return badArgs;
657
829
 
658
830
  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');
831
+ if (FILE_WRITES.has(call.name)) {
832
+ for (const p of pathsOf(call)) { this.touched.add(p); this.sinceCheck.add(p); }
664
833
  }
665
834
  if (call.name === 'run_command' || call.name === 'run_commands') this.ranSomething = true;
666
835
  };
@@ -673,10 +842,7 @@ export class Agent {
673
842
  this.ui.startSpinner(`${group.length} lookups at once`);
674
843
 
675
844
  const settled = await Promise.all(
676
- group.map((call) => this.dispatch(call).then(
677
- (out) => ({ call, out }),
678
- (err) => ({ call, err })
679
- ))
845
+ group.map((call) => (this.early.get(call.id) ?? this.execute(call)).then((r) => ({ call, ...r })))
680
846
  );
681
847
 
682
848
  this.ui.stopSpinner();
@@ -691,18 +857,14 @@ export class Agent {
691
857
  if (this.abort.signal.aborted) return badArgs;
692
858
 
693
859
  const label = describe(call.name, call.args);
694
- this.ui.toolCall(label);
860
+ if (!SILENT.has(call.name)) this.ui.toolCall(label);
695
861
  this.ui.startSpinner(label);
696
862
  noted(call);
697
863
 
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
- }
864
+ const { out, err } = await (this.early.get(call.id) ?? this.execute(call));
865
+ this.ui.stopSpinner();
866
+ if (err) badArgs = this.reportFailure(call, err) || badArgs;
867
+ else this.reportResult(call, out);
706
868
  }
707
869
  }
708
870
 
@@ -745,6 +907,8 @@ export class Agent {
745
907
  }
746
908
 
747
909
  if (call.name === 'load_skill') return this.loadSkill(call.args?.name);
910
+ if (call.name === 'update_plan') return this.updatePlan(call.args?.items);
911
+ if (call.name === 'delegate') return this.delegate(call.args?.tasks);
748
912
 
749
913
  // Output reaches the screen as the command produces it, so a slow build is
750
914
  // something you watch rather than something you sit out in silence.
@@ -753,6 +917,172 @@ export class Agent {
753
917
  });
754
918
  }
755
919
 
920
+ /** Run a call and settle to { out } or { err } — never throws. */
921
+ execute(call) {
922
+ return this.dispatch(call).then((out) => ({ out }), (err) => ({ err }));
923
+ }
924
+
925
+ updatePlan(items) {
926
+ const list = (Array.isArray(items) ? items : [])
927
+ .filter((i) => i && String(i.text ?? '').trim())
928
+ .slice(0, 6);
929
+ this.ui.plan(list);
930
+ const done = list.filter((i) => i.done).length;
931
+ return { content: `Plan updated: ${done} of ${list.length} done.`, summary: `${done}/${list.length}` };
932
+ }
933
+
934
+ /** File writes from parallel workers take turns, so two never interleave. */
935
+ fileLock(fn) {
936
+ const run = (this.lockChain ?? Promise.resolve()).then(fn, fn);
937
+ this.lockChain = run.catch(() => {});
938
+ return run;
939
+ }
940
+
941
+ /**
942
+ * Several workers at once, each its own small agent loop with its own
943
+ * conversation, sharing the tools, the project, and whatever skills are
944
+ * already in force. Their lines in the transcript carry their name.
945
+ */
946
+ async delegate(tasks) {
947
+ const list = (Array.isArray(tasks) ? tasks : [])
948
+ .filter((t) => t && String(t.instructions ?? '').trim())
949
+ .slice(0, MAX_WORKERS);
950
+ if (!list.length) {
951
+ throw new ToolFailure({
952
+ kind: 'bad_args',
953
+ attempted: 'starting workers',
954
+ failed: 'No tasks with instructions were given.',
955
+ fix: 'Pass tasks as [{ name, instructions }, ...], up to 3.',
956
+ });
957
+ }
958
+
959
+ const results = await Promise.all(list.map((task, i) => this.runWorker(task, i).catch((err) => ({
960
+ name: task.name || `worker ${i + 1}`,
961
+ summary: `Failed: ${err?.failed ?? err?.message ?? err}`,
962
+ touched: [],
963
+ }))));
964
+
965
+ for (const r of results) for (const f of r.touched) { this.touched.add(f); this.sinceCheck.add(f); }
966
+
967
+ return {
968
+ content: results
969
+ .map((r) => `## ${r.name}\n${r.summary}\nFiles changed: ${r.touched.join(', ') || 'none'}`)
970
+ .join('\n\n'),
971
+ summary: results.map((r) => `${r.name} · ${r.touched.length} file${r.touched.length === 1 ? '' : 's'}`).join(' '),
972
+ };
973
+ }
974
+
975
+ async runWorker(task, index) {
976
+ const name = clip(String(task.name || `worker ${index + 1}`).trim(), 16);
977
+ const touched = new Set();
978
+ const skills = this.skills
979
+ .filter((s) => this.loaded.has(s.name))
980
+ .map((s) => `--- ${s.name} ---\n${s.body}`)
981
+ .join('\n\n');
982
+ const messages = [
983
+ { role: 'system', content: workerPrompt({ cwd: this.cwd, name, memory: this.memory, skills }) },
984
+ { role: 'user', content: String(task.instructions) },
985
+ ];
986
+ const available = this.toolsNow().filter((t) => !WORKER_EXCLUDED.has(t.name));
987
+ const wanted = process.env.UCODE_WORKER_MODEL;
988
+ const workerModel = wanted && MODELS[wanted] ? wanted : model();
989
+
990
+ for (let step = 0; step < WORKER_STEPS; step++) {
991
+ if (this.abort?.signal.aborted) break;
992
+ const reply = await ask(messages, available, { signal: this.abort?.signal, model: workerModel });
993
+ this.record(reply.usage);
994
+
995
+ if (!reply.toolCalls.length) {
996
+ this.ui.toolResult(`${name} finished`);
997
+ return { name, summary: reply.text?.trim() || 'Finished without a summary.', touched: [...touched] };
998
+ }
999
+
1000
+ messages.push({ role: 'assistant', content: reply.text || '', toolCalls: reply.toolCalls });
1001
+ for (const call of reply.toolCalls) {
1002
+ this.ui.toolCall(`${name} › ${describe(call.name, call.args)}`);
1003
+ if (FILE_WRITES.has(call.name)) for (const p of pathsOf(call)) touched.add(p);
1004
+ const { out, err } = FILE_WRITES.has(call.name)
1005
+ ? await this.fileLock(() => this.execute(call))
1006
+ : await this.execute(call);
1007
+ if (err) {
1008
+ if (!(err instanceof ToolFailure)) throw err;
1009
+ this.ui.toolFailed(`${name}: ${err.kind}: ${err.failed}`);
1010
+ }
1011
+ messages.push({
1012
+ role: 'tool', toolCallId: call.id, name: call.name,
1013
+ content: err ? err.forModel() : out.content,
1014
+ });
1015
+ }
1016
+ }
1017
+
1018
+ return { name, summary: `Stopped after ${WORKER_STEPS} steps without finishing.`, touched: [...touched] };
1019
+ }
1020
+
1021
+ /**
1022
+ * Check the code files changed since the last check, and return the
1023
+ * errors as text for the model — or null when everything is clean.
1024
+ *
1025
+ * TypeScript projects get one `tsc --noEmit` per project that owns a
1026
+ * changed file (an app scaffolded into a subfolder is its own project).
1027
+ * Plain JavaScript gets a syntax check, Python a compile check. Nothing
1028
+ * runs that is not already installed.
1029
+ */
1030
+ async autoCheck() {
1031
+ const changed = [...this.sinceCheck].filter((f) => CHECKABLE.test(f));
1032
+ this.sinceCheck.clear();
1033
+ if (!changed.length) return null;
1034
+
1035
+ const root = path.resolve(this.cwd);
1036
+ const tsRoots = new Set();
1037
+ const singles = [];
1038
+
1039
+ for (const rel of changed) {
1040
+ const abs = path.resolve(root, rel);
1041
+ if (!(await exists(abs))) continue;
1042
+ if (/\.py$/i.test(rel)) { singles.push({ abs, rel, command: `python -m py_compile "${abs}"` }); continue; }
1043
+ let dir = path.dirname(abs);
1044
+ let owner = null;
1045
+ while (dir.startsWith(root)) {
1046
+ if (await exists(path.join(dir, 'tsconfig.json'))) { owner = dir; break; }
1047
+ const up = path.dirname(dir);
1048
+ if (up === dir) break;
1049
+ dir = up;
1050
+ }
1051
+ if (owner && (await exists(path.join(owner, 'node_modules', 'typescript')))) tsRoots.add(owner);
1052
+ else if (/\.[cm]?js$/i.test(rel)) singles.push({ abs, rel, command: `node --check "${abs}"` });
1053
+ }
1054
+
1055
+ const problems = [];
1056
+ const check = async (label, command, cwd) => {
1057
+ this.ui.toolCall(label);
1058
+ this.ui.startSpinner(label);
1059
+ const { out, err } = await this.execute({
1060
+ id: 'check', name: 'run_command',
1061
+ args: { command, cwd: path.relative(root, cwd) || '.', timeout_ms: 180_000 },
1062
+ });
1063
+ this.ui.stopSpinner();
1064
+ return err ? { exitCode: -1, content: String(err.failed ?? err.message) } : out;
1065
+ };
1066
+
1067
+ for (const dir of tsRoots) {
1068
+ const show = path.relative(root, dir) || '.';
1069
+ const out = await check(`Checking types in ${show}`, 'npx --no-install tsc --noEmit --pretty false', dir);
1070
+ if (out.exitCode === 0) { this.ui.toolResult('types check out'); continue; }
1071
+ const errors = out.content.split('\n').filter((l) => /error TS\d+/.test(l));
1072
+ this.ui.toolFailed(`${errors.length || 'some'} type error${errors.length === 1 ? '' : 's'}`);
1073
+ problems.push(`In ${show} (tsc --noEmit):\n${(errors.length ? errors : out.content.split('\n')).slice(0, 40).join('\n')}`);
1074
+ }
1075
+
1076
+ for (const f of singles) {
1077
+ const out = await check(`Checking ${f.rel}`, f.command, root);
1078
+ if (out.exitCode === 0) { this.ui.toolResult('ok'); continue; }
1079
+ this.ui.toolFailed('does not compile');
1080
+ problems.push(`${f.rel}:\n${out.content.split('\n').slice(0, 20).join('\n')}`);
1081
+ }
1082
+
1083
+ return problems.length ? problems.join('\n\n') : null;
1084
+ }
1085
+
756
1086
  loadSkill(name) {
757
1087
  const skill = findSkill(this.skills, name);
758
1088
  if (!skill) {
@@ -847,6 +1177,7 @@ export class Agent {
847
1177
  return this.cmdSessions(arg);
848
1178
 
849
1179
  case '/new': return this.cmdNew();
1180
+ case '/remember': return this.cmdRemember(arg);
850
1181
  case '/skills': return this.cmdSkills();
851
1182
  case '/clear': this.showHeader(); return;
852
1183
  case '/search': return this.cmdSearch(arg);
@@ -866,6 +1197,7 @@ export class Agent {
866
1197
  ['/model', 'show the models and switch between them'],
867
1198
  ['/resume', 'pick up an earlier conversation'],
868
1199
  ['/new', 'save this one and start fresh'],
1200
+ ['/remember <note>', `add a standing note to ${MEMORY_FILE}`],
869
1201
  ['/skills', 'what ucode knows how to do'],
870
1202
  ['/search <query>', 'look something up on the web'],
871
1203
  ['/copy', 'copy the last reply to the clipboard'],
@@ -1060,6 +1392,25 @@ export class Agent {
1060
1392
  if (tail.length) this.ui.write(dim(' ── picking up here ──\n'));
1061
1393
  }
1062
1394
 
1395
+ /** Add a line to this project's UCODE.md, read at the start of every turn. */
1396
+ async cmdRemember(note) {
1397
+ if (!note) {
1398
+ this.ui.note(`usage: /remember <something ucode should always know here> — saved to ${MEMORY_FILE}`);
1399
+ return;
1400
+ }
1401
+ try {
1402
+ const file = await remember(this.cwd, note);
1403
+ this.ui.note(`remembered · ${path.relative(this.cwd, file) || MEMORY_FILE}`);
1404
+ } catch (err) {
1405
+ this.ui.error(new Failure({
1406
+ kind: 'memory_unwritable',
1407
+ attempted: `saving to ${MEMORY_FILE}`,
1408
+ failed: err.message,
1409
+ fix: 'Check that this folder is writable.',
1410
+ }), { debug: this.debug });
1411
+ }
1412
+ }
1413
+
1063
1414
  async cmdNew() {
1064
1415
  if (this.session.messages.length) {
1065
1416
  await this.persist();
@@ -630,6 +630,8 @@ async function streamed(request, opts, id) {
630
630
  let finishReason = 'stop';
631
631
  let usage = null;
632
632
  const partial = new Map();
633
+ const handed = new Set();
634
+ let highest = -1;
633
635
 
634
636
  for await (const chunk of stream) {
635
637
  if (opts.signal?.aborted) break;
@@ -656,6 +658,20 @@ async function streamed(request, opts, id) {
656
658
  // A tool call's name and arguments arrive across several chunks, keyed by
657
659
  // index, so they are stitched back together here.
658
660
  for (const call of delta.tool_calls ?? []) {
661
+ // Calls arrive one after another, so the first chunk of call N means
662
+ // every call before it is complete. Those are handed over at once, and
663
+ // the caller can start running them while the rest are still being
664
+ // written — the reply streaming and the tools working overlap.
665
+ if (opts.onToolCall && call.index > highest) {
666
+ for (const [index, slot] of partial) {
667
+ if (index < call.index && !handed.has(index)) {
668
+ handed.add(index);
669
+ opts.onToolCall(readCall({ id: slot.id || `call_${index}`, name: slot.name, raw: slot.args }));
670
+ }
671
+ }
672
+ highest = call.index;
673
+ }
674
+
659
675
  const slot = partial.get(call.index) ?? { id: '', name: '', args: '' };
660
676
  if (call.id) slot.id = call.id;
661
677
  if (call.function?.name) slot.name += call.function.name;
@@ -665,9 +681,8 @@ async function streamed(request, opts, id) {
665
681
  }
666
682
 
667
683
  const toolCalls = [];
668
- let n = 0;
669
- for (const slot of partial.values()) {
670
- toolCalls.push(readCall({ id: slot.id || `call_${n++}`, name: slot.name, raw: slot.args }));
684
+ for (const [index, slot] of partial) {
685
+ toolCalls.push(readCall({ id: slot.id || `call_${index}`, name: slot.name, raw: slot.args }));
671
686
  }
672
687
 
673
688
  return {