pi-optchat 0.6.7 → 0.6.8

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 CHANGED
@@ -1,3 +1,5 @@
1
+ ![Pi-OptChat: persistent memory for Pi](https://raw.githubusercontent.com/jonaslsaa/pi-optchat/main/docs/banner.jpg)
2
+
1
3
  # pi-optchat
2
4
 
3
5
  A Pi extension that implements [Victor Taelin's OptChat recipe](https://gist.github.com/VictorTaelin/91837951a5ce5b38f341ec1ba1df6449): one endless chat per profile, remembered through a summary tree instead of compaction.
@@ -41,6 +43,7 @@ For headless use, pass `--optchat-profile work`.
41
43
  | --- | --- |
42
44
  | `/optchat` | Status and actions menu. |
43
45
  | `/optchat profile` | Select or create a profile. Switching starts a fresh Pi session. |
46
+ | `/optchat settings` | This profile's settings: models, subagent levels and limits, previous exchange, summary size tolerance. |
44
47
  | `/optchat model` | Compactor model and effort for this profile. |
45
48
  | `/optchat agents` | Live agent tree and saved run history. |
46
49
  | `/optchat agents model` | Subagent model and effort for this profile. |
@@ -63,6 +66,26 @@ Subagent and compactor settings are saved per profile and do not follow the main
63
66
 
64
67
  Compression and subagents make extra model requests with your provider credentials.
65
68
 
69
+ ## Settings
70
+
71
+ `/optchat settings` opens this profile's settings. Each row shows its value and default, the selected row says what it does and when a change applies, and a change is saved right away to the profile's `config.json`. Defaults follow Victor's recipe, except Previous exchange and Summary size tolerance, which keep OptChat's earlier behaviour.
72
+
73
+ | Setting | Default | What it does |
74
+ | --- | --- | --- |
75
+ | Compactor model | Sonnet 5.5, medium | Same as `/optchat model`. Applies to the next summary. |
76
+ | Subagent model | Opus 5.5, high | Same as `/optchat agents model`. Applies to new subagents. |
77
+ | Subagent levels | 1 | 1: only the main agent starts subagents (the recipe). 2 or more: subagents may start their own, that many levels deep. Applies to subagents started or resumed after the change. |
78
+ | Max active agents | 8 | Subagents running at once in the profile, all levels together, so it also caps how deep a chain can go. |
79
+ | Previous exchange | on | Replays your last request and answer in full with the next turn (see below). Off is the recipe. |
80
+ | Previous exchange limit | 16 KB | A larger last exchange is left out. |
81
+ | Summary size tolerance | 640 bytes | The compactor is always asked for 512-byte lines; a longer line up to this size is kept instead of retried. 512 is the recipe's strict rule. |
82
+
83
+ Numbers must be whole numbers of at least 1 (512 for the summary size tolerance). Missing keys in an older `config.json` take their defaults.
84
+
85
+ **Upgrading from 0.6.x:** subagent levels used to be fixed at 3 and now default to 1, so subagents no longer start their own subagents until you set Subagent levels to 2 or 3.
86
+
87
+ ![Settings page](docs/screenshots/settings.png)
88
+
66
89
  ## Subagents
67
90
 
68
91
  Ask in plain words, for example: "Spawn an agent to investigate this repository and report back."
@@ -72,9 +95,9 @@ Ask in plain words, for example: "Spawn an agent to investigate this repository
72
95
  - Each child reports back on its own when it finishes. The parent stays alive to receive reports; it never polls.
73
96
  - In the main chat, subagent messages and reports appear in a dark grey box labelled `↳ subagent <id> · still running` or `· report`, so they don't look like something you typed. The model still receives them as ordinary user messages. (One exception: reports recovered at startup, before your first message in the session, still show as plain user messages.)
74
97
  - The parent can send a running child guidance with `tell`, and the child can message its parent mid-run with `tell_parent` (a question, an early finding). It reaches the parent like a report, marked "still running": between tool calls if the parent is busy, or waking it if it's waiting.
75
- - `tell` to a finished child resumes it: the same agent (ID, parent, model, directory) reopens its saved transcript, gets the message as a new prompt, and sends a new report. This also works for children from earlier Pi sessions. Only the agent that started the child can resume it, and the resumed child takes one of the 8 slots. Connected windows can't be resumed, and a child whose transcript is missing must be spawned fresh.
76
- - Delegation goes up to three levels below the main agent (child, grandchild, great-grandchild).
77
- - At most 8 agents can be active per profile, including parents waiting on descendants. Going over a limit returns an error; there is no queue.
98
+ - `tell` to a finished child resumes it: the same agent (ID, parent, model, directory) reopens its saved transcript, gets the message as a new prompt, and sends a new report. This also works for children from earlier Pi sessions. Only the agent that started the child can resume it, and the resumed child takes one active slot. Connected windows can't be resumed, and a child whose transcript is missing must be spawned fresh.
99
+ - By default only the main agent starts subagents. Set **Subagent levels** in `/optchat settings` to let them delegate further (3 means child, grandchild, great-grandchild).
100
+ - **Max active agents** (8 by default) caps how many agents can be active per profile, including parents waiting on descendants. Going over a limit returns an error; there is no queue.
78
101
  - Stopping an agent stops its whole subtree. A failed parent stops its descendants.
79
102
  - Agents run inside the Pi process. Closing Pi stops them; there is no detached mode.
80
103
 
@@ -119,7 +142,7 @@ Guidance shows as queued until delivered, or undelivered if the child stops firs
119
142
  Pick the destination profile, then run `/optchat import`.
120
143
 
121
144
  1. **Source**: Claude Code (`~/.claude/projects`), Claude Code memories, Codex (`~/.codex/sessions`, `~/.codex/archived_sessions`), or a ChatGPT export (ZIP, folder, or `conversations.json`; ZIP needs `unzip`). Scanning is local and makes no model calls.
122
- 2. **Select**: for Claude Code, its memories, and Codex, pick projects (busiest first), optionally filter by start date, then take all conversations or pick some. **Space** toggles, **Enter** continues, type to filter, **Ctrl+A**/**Ctrl+D** select/clear matches, **Esc** cancels. Nothing is classified as work or personal for you.
145
+ 2. **Select**: for Claude Code, its memories, and Codex, pick projects (busiest first), optionally filter by start date, then take all conversations or pick some. **Tab** toggles (and **Space** when the filter is empty), **Enter** continues, type to filter, **Ctrl+A**/**Ctrl+D** select/clear matches, **Esc** cancels. Nothing is classified as work or personal for you.
123
146
  3. **Mode** (only if the profile already has history):
124
147
  - **Append**: keep existing summaries and add the import. Faster and cheaper.
125
148
  - **Rebuild by conversation start date**: regenerate the whole tree, ordered by conversation start.
@@ -189,12 +212,12 @@ To delete a profile, delete its folder. Your original Pi sessions are kept in Pi
189
212
 
190
213
  ## How it differs from the recipe
191
214
 
192
- The recipe's four prompts are kept verbatim in `src/prompts.ts`, along with its numbers: 512-byte summary nodes (summaries up to 640 bytes are accepted without a retry), a 128,000-byte memory view, binary merges, 8 compression workers, fixed retry delays, 5 shortening attempts, and a 30,000-character tool output cap. Anthropic requests get stable cache breakpoints on the view, and when that view is not cached yet, one compactor call goes first and the others wait until it starts answering, so they read the cache instead of each writing it. See `docs/victor-recipe.md` for notes.
215
+ The recipe's four prompts are kept verbatim in `src/prompts.ts`, along with its numbers: 512-byte summary nodes (by default summaries up to 640 bytes are accepted without a retry, as long as they are smaller than what they replace; see Summary size tolerance), a 128,000-byte memory view, binary merges, 8 compression workers, fixed retry delays, 5 shortening attempts, and a 30,000-character tool output cap. Anthropic requests get stable cache breakpoints on the view, and when that view is not cached yet, one compactor call goes first and the others wait until it starts answering, so they read the cache instead of each writing it. See `docs/victor-recipe.md` for notes.
193
216
 
194
- Each run's context is the memory view, the previous exchange, and your new message. Deliberate additions:
217
+ Each run's context is the memory view, the previous exchange, and your new message. Deliberate additions (the ones that change the recipe's behaviour are settings, see [Settings](#settings)):
195
218
 
196
- 1. **Previous exchange kept verbatim.** Your last request (with any steering) and the final answer are included in full, so "why is that?" refers to what you actually read. Tool calls and reasoning are not carried over. It comes on top of the 128,000-byte view and is not truncated. A new Pi session starts with the memory view only.
197
- 2. **Subagents** are built in with Pi's SDK rather than a separate package. Children report individually instead of per batch, and can delegate two extra levels.
219
+ 1. **Previous exchange kept verbatim.** Your last request (with any steering) and the final answer are included in full, so "why is that?" refers to what you actually read. Tool calls and reasoning are not carried over. It comes on top of the 128,000-byte view. If it is over 16,000 bytes (about 4,000 tokens, usually a big paste; Previous exchange limit) it is left out entirely, and the model relies on the view and zoom as in Victor's recipe. A new Pi session starts with the memory view only.
220
+ 2. **Subagents** are built in with Pi's SDK rather than a separate package. Children report individually instead of per batch, and with Subagent levels above 1 they can delegate further.
198
221
  3. **Profiles**, the **inspector**, the **usage ledger**, **import**, and **connected windows** are additions. Import adds historical-record guidance to the prompts.
199
222
  4. **Not done**: computer use and hosting on an always-on machine.
200
223
 
@@ -13,8 +13,9 @@ The source remains upstream rather than duplicating the full article here. Its f
13
13
  - `src/memory.ts`: append-only log, binary summary tree, compression scheduling, bounded view, zoom/date.
14
14
  - `src/compactor.ts`: contextual compression and size retries.
15
15
  - `src/cache.ts`: stable Anthropic cache boundaries.
16
- - `src/transcript.ts`: fresh context per parent run, current-run tool loop retained. The previous completed exchange is also retained in full text, an intentional addition to the summary-only recipe for conversational continuity.
16
+ - `src/transcript.ts`: fresh context per parent run, current-run tool loop retained. The previous completed exchange is also retained in full text (left out if over 16,000 bytes by default), an intentional addition to the summary-only recipe for conversational continuity.
17
17
  - `src/agents.ts`: asynchronous Pi SDK children and automatic completion reports.
18
+ - `src/settings.ts`: per-profile settings for the departures from the recipe. Defaults are the recipe's (one subagent level), except the previous exchange (on) and the summary size tolerance (640 bytes, against the recipe's strict 512).
18
19
  - `src/import/`: profile-scoped historical imports retain user messages and final assistant replies, following the lighter history described in recipe section 10. Source adapters, final-reply detection, replay filtering, and ChatGPT branch labels are integration choices. Live-chat tool logging remains unchanged.
19
20
 
20
21
  Profiles, native Pi UI, conversation import, and local Git checkpoints are integration choices described in the README.
package/package.json CHANGED
@@ -1,11 +1,12 @@
1
1
  {
2
2
  "name": "pi-optchat",
3
- "version": "0.6.7",
3
+ "version": "0.6.8",
4
4
  "type": "module",
5
5
  "pi": {
6
6
  "extensions": [
7
7
  "./src/index.ts"
8
- ]
8
+ ],
9
+ "image": "https://raw.githubusercontent.com/jonaslsaa/pi-optchat/main/docs/banner.jpg"
9
10
  },
10
11
  "scripts": {
11
12
  "check": "tsc --noEmit",
package/src/agents.ts CHANGED
@@ -11,12 +11,13 @@ import { memoryTools } from './tools.ts';
11
11
  import { type Memory } from './memory.ts';
12
12
  import type { ModelChoice } from './compactor.ts';
13
13
  import { cachePayload } from './cache.ts';
14
- import { RunHistory, sessionMessages, type RunInfo, type FinishReason } from './runs.ts';
14
+ import { RunHistory, transition, sessionMessages, type RunInfo, type RunState, type FinishReason } from './runs.ts';
15
15
  import { UsageLedger } from './usage.ts';
16
16
  import { textContent } from './transcript.ts';
17
17
  import { Type } from 'typebox';
18
18
  import { result } from './tools.ts';
19
19
  import type { HandoffEvidence } from './handoff.ts';
20
+ import { DEFAULT_SETTINGS, type Settings } from './settings.ts';
20
21
 
21
22
  export interface LiveRun {
22
23
  session: AgentSession; info: RunInfo; updated: number; streaming?: AgentMessage;
@@ -26,6 +27,8 @@ export interface LiveRun {
26
27
  interface Options { parentSession?: string; usage?: UsageLedger; createSession?: typeof createAgentSession;
27
28
  /** Names of the built-in extensions the main session loaded (see `loadedBuiltins`). */
28
29
  builtins?: () => Iterable<string>;
30
+ /** Read on every spawn, so a changed profile setting applies to the next one. */
31
+ settings?: () => Pick<Settings, 'subagentLevels' | 'maxAgents'>;
29
32
  summarizeHandoff?: (run: RunInfo, messages: AgentMessage[], descendants?: HandoffEvidence[]) => Promise<string> }
30
33
 
31
34
  // Subagents load the user's installed extensions, except any copy of OptChat itself: they get memory tools directly and must not open a profile.
@@ -65,6 +68,7 @@ export class Children {
65
68
  private launching = 0;
66
69
  private readonly resuming = new Set<string>();
67
70
  private settling = 0;
71
+ private readonly launches = new Set<Promise<unknown>>();
68
72
  private readonly completions = new Set<Promise<void>>();
69
73
  constructor(private readonly memory: Memory, private readonly registry: ModelRegistry,
70
74
  private readonly choice: () => ModelChoice, private readonly instructions: () => string,
@@ -80,6 +84,8 @@ export class Children {
80
84
  } catch (error) { warn(`Could not backfill child usage for ${run.id}: ${String(error)}`); }
81
85
  }
82
86
  }
87
+ private get settings() { return this.options.settings?.() ?? DEFAULT_SETTINGS; }
88
+ private full(extra: number) { return this.running.size + this.launching + extra > this.settings.maxAgents; }
83
89
  get ids() { return [...this.running.keys()]; }
84
90
  get active() { return this.completions.size > 0 || this.launching > 0 || this.settling > 0; }
85
91
  live(id: string) { return this.running.get(id); }
@@ -125,13 +131,34 @@ export class Children {
125
131
  const cancelled = () => this.closing || (parentId !== undefined && this.running.get(parentId)?.info.state !== 'running');
126
132
  if (parentId && (!parent || parent.info.state !== 'running')) throw new Error('The parent is no longer running.');
127
133
  const depth = parent ? parent.info.depth + 1 : 1;
128
- if (depth > 3) throw new Error('Delegation depth limit reached: great-grandchildren cannot spawn.');
134
+ const { subagentLevels, maxAgents } = this.settings;
135
+ if (depth > subagentLevels) throw new Error(`Delegation depth limit reached: this profile allows ${subagentLevels} level${subagentLevels === 1 ? '' : 's'} of subagents.`);
129
136
  if (this.closing) throw new Error('Profile is closing.');
130
- if (this.running.size + this.launching + tasks.length > 8) throw new Error('Profile limit: at most 8 active agents, including parents and descendants. Reduce the batch or continue without delegating.');
137
+ if (this.full(tasks.length)) throw new Error(`Profile limit: at most ${maxAgents} active agents, including parents and descendants. Reduce the batch or continue without delegating.`);
131
138
  const view = this.memory.render();
132
139
  const selected = this.choice();
133
140
  const model = this.registry.find(selected.provider, selected.model);
134
141
  if (!model) throw new Error(`Subagent model unavailable: ${selected.provider}/${selected.model}`);
142
+ const launched = await this.track(this.launchBatch({ tasks, cwd, depth, parentId, connected, selected, model, signal, cancelled }));
143
+ // Each child reports independently. A slow sibling must not hold back a finished result.
144
+ for (const live of launched) {
145
+ const work = this.execute(live, `${view}\n\nYour task:\n${live.info.task}`).catch(error => this.warn(`Subagent completion failed: ${String(error)}`))
146
+ .finally(() => { this.completions.delete(work); this.changed(); });
147
+ this.completions.add(work);
148
+ live.completion = work;
149
+ }
150
+ this.changed();
151
+ return launched.map(c => c.info.id);
152
+ }
153
+ /** close() waits for launches, so shutdown never unlocks the profile under a child that is still opening. */
154
+ private track<T>(launch: Promise<T>) {
155
+ this.launches.add(launch);
156
+ const done = () => { this.launches.delete(launch); };
157
+ launch.then(done, done);
158
+ return launch;
159
+ }
160
+ private async launchBatch({ tasks, cwd, depth, parentId, connected, selected, model, signal, cancelled }: { tasks: { task: string; cwd?: string }[]; cwd: string; depth: number; parentId?: string; connected: boolean;
161
+ selected: ModelChoice; model: Model<Api>; signal?: AbortSignal; cancelled: () => boolean }) {
135
162
  const launched: LiveRun[] = [];
136
163
  let reserved = tasks.length;
137
164
  this.launching += reserved;
@@ -155,28 +182,21 @@ export class Children {
155
182
  for (const child of launched) {
156
183
  await this.shutdown(child.session); // Extensions such as MCP close their connections and stop their server processes.
157
184
  this.dispose(child.session); this.running.delete(child.info.id);
158
- child.info.state = 'failed'; child.info.ended = Date.now(); child.info.report = `Launch failed: ${String(error)}`;
185
+ transition(child.info, 'failed'); child.info.ended = Date.now(); child.info.report = `Launch failed: ${String(error)}`;
159
186
  if (child.info.connected) child.info.handoff = { reason: signal?.aborted ? 'disconnected' : 'failed' };
160
187
  this.save(child.info);
161
188
  if (child.info.connected) await this.deliverHandoff(child.info).catch(error => this.warn(`Handoff saved for recovery: ${String(error)}`));
162
189
  }
163
190
  throw error;
164
191
  } finally { this.launching -= reserved; }
165
- // Each child reports independently. A slow sibling must not hold back a finished result.
166
- for (const live of launched) {
167
- const work = this.execute(live, `${view}\n\nYour task:\n${live.info.task}`).catch(error => this.warn(`Subagent completion failed: ${String(error)}`))
168
- .finally(() => { this.completions.delete(work); this.changed(); });
169
- this.completions.add(work);
170
- live.completion = work;
171
- }
172
- this.changed();
173
- return launched.map(c => c.info.id);
192
+ return launched;
174
193
  }
175
194
  /** Builds a child session with the same prompt, tools and extensions whether it is new or resumed. */
176
195
  private async open(o: { id: string; directory: string; depth: number; parentId?: string; connected: boolean; provider: string; model: Model<Api>;
177
196
  thinking?: ModelChoice['thinking']; sessionManager: SessionManager }) {
178
197
  const { id, directory, depth, parentId, connected } = o;
179
- const delegation = depth < 3 ? 'You may delegate parts of your assigned task with spawn when useful. Child reports arrive automatically after your current run ends; the harness keeps you alive to receive them. Never poll, sleep, or wait in a tool for children. Finish your current work and return; you will be prompted with their results. The profile allows 8 active agents total.' : 'You are at the maximum delegation depth. Complete your task with your own tools.';
198
+ const { subagentLevels, maxAgents } = this.settings, delegates = depth < subagentLevels;
199
+ const delegation = delegates ? `You may delegate parts of your assigned task with spawn when useful. Child reports arrive automatically after your current run ends; the harness keeps you alive to receive them. Never poll, sleep, or wait in a tool for children. Finish your current work and return; you will be prompted with their results. The profile allows ${maxAgents} active agents total.` : 'You are at the maximum delegation depth. Complete your task with your own tools.';
180
200
  const instructions = [this.instructions(), delegation, connected ? 'You are speaking directly with the user in a connected window. Continue this conversation across requests. Use tell_parent for questions or findings the main agent needs now. A handoff will be generated when the user completes or disconnects the window.'
181
201
  : 'Use tell_parent only when your parent needs something now (a blocking question, an important early finding, or when asked to). Your final answer is delivered automatically; do not repeat it with tell_parent.'].filter(Boolean).join('\n\n');
182
202
  // The user's settings list their installed packages; a copy in memory keeps the child from writing them back.
@@ -198,17 +218,17 @@ export class Children {
198
218
  await loader.reload();
199
219
  const { session } = await (this.options.createSession ?? createAgentSession)({ cwd: directory, resourceLoader: loader, settingsManager,
200
220
  model: o.model, thinkingLevel: o.thinking, sessionManager: o.sessionManager,
201
- customTools: [...memoryTools(() => this.memory), ...(depth < 3 ? this.delegationTools(id, directory) : []), this.parentTool(id, parentId, connected)],
202
- excludeTools: depth < 3 ? [] : ['spawn', 'tell'],
221
+ customTools: [...memoryTools(() => this.memory), ...(delegates ? this.delegationTools(id, directory, subagentLevels, maxAgents) : []), this.parentTool(id, parentId, connected)],
222
+ excludeTools: delegates ? [] : ['spawn', 'tell'],
203
223
  });
204
224
  // Callers track the session only after this returns: clean up here if its extensions fail to start.
205
225
  try { await session.bindExtensions({}); }
206
226
  catch (error) { await this.shutdown(session); this.dispose(session); throw error; }
207
227
  return session;
208
228
  }
209
- private delegationTools(parentId: string, cwd: string) {
210
- return [{ name: 'spawn', label: 'Delegate task', description: 'Delegate parts of your task. Results arrive automatically after this run; never poll or sleep waiting. Maximum depth 3 and 8 active agents per profile.',
211
- parameters: Type.Object({ tasks: Type.Array(Type.Object({ task: Type.String(), cwd: Type.Optional(Type.String({ description: CWD_DOC })) }), { minItems: 1, maxItems: 8 }) }),
229
+ private delegationTools(parentId: string, cwd: string, levels: number, maxAgents: number) {
230
+ return [{ name: 'spawn', label: 'Delegate task', description: `Delegate parts of your task. Results arrive automatically after this run; never poll or sleep waiting. Maximum depth ${levels} and ${maxAgents} active agents per profile.`,
231
+ parameters: Type.Object({ tasks: Type.Array(Type.Object({ task: Type.String(), cwd: Type.Optional(Type.String({ description: CWD_DOC })) }), { minItems: 1 }) }),
212
232
  execute: async (_id: string, args: { tasks: { task: string; cwd?: string }[] }, signal?: AbortSignal) => result(`Started: ${(await this.spawn(args.tasks, cwd, signal, parentId)).join(', ')}. Results will arrive automatically.`),
213
233
  }, { name: 'tell', label: 'Guide child', description: 'Send guidance to one of your direct children. A finished child is resumed with its earlier conversation, and its new report arrives automatically.',
214
234
  parameters: Type.Object({ id: Type.String(), message: Type.String() }),
@@ -248,34 +268,36 @@ export class Children {
248
268
  const { session, info } = live;
249
269
  try {
250
270
  if (info.connected) await this.report(`[${info.id}] User started a connected conversation in ${info.cwd}. That agent is handling this request with the user directly; don't do it yourself. Initial message: ${info.task}\n\nUse tell with this agent ID only if you know something it needs. It stays open between replies and sends a final handoff on completion or disconnect.`);
251
- if (this.closing || info.handoff) throw new Error('Conversation stopped before its first request.');
271
+ // A stop that lands before the first prompt has no turn to abort, so it is honoured here.
272
+ const state: RunState = info.state; // a local, so the loop below still sees later state changes
273
+ if (this.closing || info.handoff || state === 'stopping') throw new Error('Stopped before its first request.');
252
274
  await session.prompt(prompt);
253
275
  while (info.state !== 'stopping') {
254
276
  const last = session.messages.findLast(m => m.role === 'assistant');
255
277
  if (last?.role === 'assistant' && (last.stopReason === 'error' || last.stopReason === 'aborted')) break;
256
278
  if (live.pendingGuidance.length) {
257
- info.state = 'running'; this.save(info);
279
+ transition(info, 'running'); this.save(info);
258
280
  await session.prompt(live.pendingGuidance.shift()!);
259
281
  continue;
260
282
  }
261
283
  if (live.pendingReports.length) {
262
- info.state = 'running'; this.save(info);
284
+ transition(info, 'running'); this.save(info);
263
285
  await session.prompt(live.pendingReports.splice(0).join('\n\n'));
264
286
  continue;
265
287
  }
266
288
  const children = this.directChildren(info.id).flatMap(child => child.completion ? [child.completion] : []);
267
289
  if (!children.length && !info.connected) break;
268
290
  const wake = new Promise<void>(resolve => { live.wake = resolve; });
269
- info.state = 'waiting'; this.save(info);
291
+ transition(info, 'waiting'); this.save(info);
270
292
  await Promise.race([...children, wake]); live.wake = undefined;
271
293
  }
272
294
  const last = session.messages.findLast(m => m.role === 'assistant');
273
- info.state = info.state === 'stopping' || last?.role === 'assistant' && last.stopReason === 'aborted' ? 'stopped'
274
- : last?.role === 'assistant' && last.stopReason === 'error' ? 'failed' : 'completed';
295
+ transition(info, info.state === 'stopping' || last?.role === 'assistant' && last.stopReason === 'aborted' ? 'stopped'
296
+ : last?.role === 'assistant' && last.stopReason === 'error' ? 'failed' : 'completed');
275
297
  info.report = last?.role === 'assistant' && (last.stopReason === 'error' || last.stopReason === 'aborted')
276
298
  ? `Task ${last.stopReason}: ${last.errorMessage ?? 'No details'}` : session.getLastAssistantText() || 'Finished without a text report.';
277
299
  } catch (error) {
278
- info.state = info.state === 'stopping' ? 'stopped' : 'failed'; info.report = `${info.state}: ${String(error)}`;
300
+ transition(info, info.state === 'stopping' ? 'stopped' : 'failed'); info.report = `${info.state}: ${String(error)}`;
279
301
  } finally {
280
302
  const children = this.directChildren(info.id);
281
303
  await Promise.allSettled(children.map(child => this.stop(child.info.id)));
@@ -288,7 +310,7 @@ export class Children {
288
310
  }
289
311
  if (info.connected) {
290
312
  info.handoff ??= { reason: 'failed' };
291
- info.state = info.handoff.reason === 'complete' ? 'completed' : 'interrupted';
313
+ transition(info, info.handoff.reason === 'complete' ? 'completed' : 'interrupted');
292
314
  this.save(info);
293
315
  await this.deliverHandoff(info);
294
316
  return;
@@ -311,7 +333,7 @@ export class Children {
311
333
  /** `caller` is the agent sending a manager message: undefined for the main agent, else the parent subagent's ID. */
312
334
  async tell(id: string, message: string, source: 'manager' | 'user' = 'manager', caller?: string) {
313
335
  const live = this.running.get(id);
314
- if (!live && source === 'manager' && this.history.records.has(id)) return this.resume(id, message, caller);
336
+ if (!live && source === 'manager' && this.history.records.has(id)) return this.track(this.resume(id, message, caller));
315
337
  if (live && !['running', 'waiting'].includes(live.info.state)) throw new Error(`${id} is finishing. Its report will arrive on its own${live.info.connected || source === 'user' ? '' : '; tell it again after that to resume it'}.`);
316
338
  if (!live) throw new Error(`No running subagent ${id}.`);
317
339
  const text = live.info.connected && source === 'manager' ? `[Main agent guidance]\n${message.trim()}` : message.trim(); if (!message.trim()) throw new Error('Message is empty.');
@@ -323,6 +345,7 @@ export class Children {
323
345
  else await live.session.steer(text);
324
346
  }
325
347
  catch (error) { guidance.state = 'undelivered'; this.save(live.info); throw error; }
348
+ if (guidance.state === 'undelivered') throw new Error(`${id} finished before it read the message. Tell it again to resume it.`);
326
349
  return 'Message queued for the next tool boundary.';
327
350
  }
328
351
  /** Reopens a finished child from its saved transcript, same ID, parent and model, and gives it a new message. */
@@ -338,7 +361,7 @@ export class Children {
338
361
  : `Only ${id}'s parent ${run.parentId} can resume it, and that parent is no longer running. Resume ${run.parentId} instead, or spawn a new subagent.`);
339
362
  }
340
363
  if (this.closing) throw new Error('Profile is closing.');
341
- if (this.running.size + this.launching + 1 > 8) throw new Error(`Profile limit: at most 8 active agents, including parents and descendants. ${id} can be resumed when one finishes.`);
364
+ if (this.full(1)) throw new Error(`Profile limit: at most ${this.settings.maxAgents} active agents, including parents and descendants. ${id} can be resumed when one finishes.`);
342
365
  let manager: SessionManager | undefined;
343
366
  try { if (run.sessionFile && existsSync(run.sessionFile)) manager = SessionManager.open(run.sessionFile); } catch { manager = undefined; }
344
367
  if (!manager?.getEntries().some(e => e.type === 'message')) throw new Error(`The saved transcript of ${id} is missing or unreadable, so it cannot be resumed. Spawn a fresh subagent and give it the context it needs.`);
@@ -387,7 +410,7 @@ export class Children {
387
410
  const recovery = (async () => {
388
411
  for (const run of eligible) {
389
412
  run.handoff ??= { reason: 'owner-stopped' };
390
- run.state = run.handoff.reason === 'complete' ? 'completed' : 'interrupted'; this.save(run);
413
+ transition(run, run.handoff.reason === 'complete' ? 'completed' : 'interrupted'); this.save(run);
391
414
  await this.deliverHandoff(run);
392
415
  }
393
416
  })();
@@ -426,17 +449,18 @@ export class Children {
426
449
  const descendants: LiveRun[] = [];
427
450
  const collect = (run: LiveRun) => { descendants.push(run); for (const child of this.directChildren(run.info.id)) collect(child); };
428
451
  collect(live);
429
- for (const run of descendants) {
452
+ const stopping = descendants.filter(run => transition(run.info, 'stopping'));
453
+ for (const run of stopping) {
430
454
  if (run.info.connected) run.info.handoff ??= { reason: 'owner-stopped' };
431
- run.info.state = 'stopping';
432
455
  run.wake?.();
433
456
  try { this.save(run.info); } catch (error) { this.warn(`Could not save stop status: ${String(error)}`); }
434
457
  }
435
- await Promise.allSettled(descendants.map(run => run.session.abort()));
458
+ await Promise.allSettled(stopping.map(run => run.session.abort()));
436
459
  }
437
460
  async close() {
438
461
  this.closing = true;
439
462
  await Promise.allSettled([...this.running.keys()].map(id => this.stop(id)));
463
+ await Promise.allSettled(this.launches);
440
464
  await Promise.allSettled(this.completions);
441
465
  }
442
466
  }
package/src/cache.ts CHANGED
@@ -1,3 +1,5 @@
1
+ import { isView } from './memory.ts';
2
+
1
3
  export function record(value: unknown): value is Record<string, unknown> {
2
4
  return typeof value === 'object' && value !== null && !Array.isArray(value);
3
5
  }
@@ -19,38 +21,26 @@ export function splitView(text: string) {
19
21
  export function cachePayload(payload: unknown): unknown {
20
22
  if (!record(payload) || !Array.isArray(payload.messages)) return payload;
21
23
  const messages = payload.messages;
22
- let found = false;
24
+ const view = new Set<unknown>();
23
25
  for (const message of messages) {
24
26
  if (!record(message) || message.role !== 'user' || !Array.isArray(message.content)) continue;
25
- for (let i = 0; i < message.content.length; i++) {
26
- const block: unknown = message.content[i];
27
- if (!record(block) || block.type !== 'text' || typeof block.text !== 'string' || !block.text.startsWith('<chat>\n')) continue;
28
- found = true;
29
- const pieces = splitView(block.text);
30
- message.content.splice(i, 1, ...pieces.map((text, j) => ({ type: 'text', text,
31
- ...(j < pieces.length - 1 ? { cache_control: { type: 'ephemeral' } } : {}) })));
32
- break;
33
- }
34
- if (found) break;
27
+ const at = message.content.findIndex((block: unknown) => record(block) && block.type === 'text' && typeof block.text === 'string' && isView(block.text));
28
+ if (at < 0) continue;
29
+ const pieces = splitView(message.content[at].text);
30
+ const blocks = pieces.map((text, j) => ({ type: 'text', text, ...(j < pieces.length - 1 ? { cache_control: { type: 'ephemeral' } } : {}) }));
31
+ message.content.splice(at, 1, ...blocks);
32
+ for (const block of blocks) view.add(block);
33
+ break;
35
34
  }
36
- if (!found) return payload;
35
+ if (!view.size) return payload;
37
36
  // Pi's default system/recent-message marks would exceed Anthropic's four-mark limit.
38
- // Remove all adapter marks first, keeping only the newly split view's three marks.
39
37
  for (const section of [payload.system, payload.tools]) {
40
38
  if (Array.isArray(section)) for (const item of section) if (record(item)) delete item.cache_control;
41
39
  }
42
- let seenView = false;
43
40
  for (const message of messages) {
44
41
  if (!record(message)) continue;
45
42
  delete message.cache_control;
46
- if (!Array.isArray(message.content)) continue;
47
- let inView = false;
48
- for (const item of message.content) {
49
- if (!record(item)) continue;
50
- if (!seenView && item.type === 'text' && typeof item.text === 'string' && item.text.startsWith('<chat>\n')) { inView = true; seenView = true; }
51
- if (!inView) delete item.cache_control;
52
- if (inView && typeof item.text === 'string' && item.text.includes('</chat>')) { delete item.cache_control; inView = false; }
53
- }
43
+ if (Array.isArray(message.content)) for (const item of message.content) if (record(item) && !view.has(item)) delete item.cache_control;
54
44
  }
55
45
  payload.cache_control = { type: 'ephemeral' };
56
46
  return payload;
package/src/checkpoint.ts CHANGED
@@ -7,10 +7,8 @@ const exec = promisify(execFile);
7
7
 
8
8
  export async function checkpoint(directory: string) {
9
9
  const git = (args: string[]) => exec('git', ['-C', directory, ...args], { maxBuffer: 1_000_000 });
10
- if (!existsSync(join(directory, '.git'))) {
11
- await git(['init', '-q', '-b', 'main']);
12
- atomicWrite(join(directory, '.gitignore'), '/runs/\n/memory.html\n/usage.jsonl\n*.tmp\n');
13
- }
10
+ if (!existsSync(join(directory, '.git'))) await git(['init', '-q', '-b', 'main']);
11
+ if (!existsSync(join(directory, '.gitignore'))) atomicWrite(join(directory, '.gitignore'), '/runs/\n/memory.html\n/usage.jsonl\n*.tmp\n');
14
12
  await git(['add', '-A', '--', '.']);
15
13
  const status = await git(['diff', '--cached', '--name-only']);
16
14
  if (!status.stdout.trim()) return;
package/src/compactor.ts CHANGED
@@ -5,12 +5,11 @@ import { COMPACT } from './prompts.ts';
5
5
  import { bytes, NODE, type Compressor } from './memory.ts';
6
6
  import { cachePayload, splitView } from './cache.ts';
7
7
  import { IMPORT_GUIDANCE } from './import/guidance.ts';
8
+ import { DEFAULT_SETTINGS } from './settings.ts';
8
9
 
9
10
  export interface ModelChoice { provider: string; model: string; thinking: ThinkingLevel }
10
- const scaleBase = 'user: Keep work and personal memory separate; use a binary summary tree and inspect original messages before acting. talk: Implemented the append-only log with durable writes and a stable view. echo: Checked caching, chronological summaries, cancellation, and profile locks. user: Main agent uses Opus; compactor uses Sonnet at medium effort. work: Worker completed the parser; tests cover invalid records and repeated imports. talk: The browser opens original messages, preserving dates and sources.';
11
- export const SCALE = scaleBase.padEnd(NODE, '.').slice(0, NODE);
12
- /** The model is asked for 512 bytes; a small overshoot costs less view space than a retry costs money. */
13
- export const ACCEPTED = NODE * 1.25;
11
+ /** A realistic summary line of exactly NODE bytes, on a topic no real chat shares so its wording can't leak into summaries. */
12
+ export const SCALE = 'user: Plan the Lisbon trip for 14-18 May: four adults, one in a wheelchair, 2400 EUR budget, no flights before 09:00. talk: Suggested Baixa; skip tram 28 (not step-free). tool: searched TAP, easyJet fares; echo: TAP TP1205 at 08:40 (too early), easyJet U27652 at 11:15 is 162 EUR each. user: "Book easyJet; step-free rooms matter more than a view." work: [4c1e9a20] Casa do Rio has two step-free rooms at 138 EUR/night, free cancellation to 10 May, held to 2 May. talk: Asked about a Sintra day trip, unanswered.';
14
13
  const WARM_MS = 4 * 60_000; // Anthropic's short cache lives 5 minutes from its last use.
15
14
 
16
15
  /** Parallel calls can't read a cache entry that isn't written yet, so one call primes a cold prefix and the rest wait until it answers. */
@@ -38,8 +37,9 @@ function primeFirst() {
38
37
  };
39
38
  };
40
39
  }
40
+ /** The model is asked for 512 bytes; `accepted` is the longest line kept without a retry (the profile's summary size tolerance). */
41
41
  export function createCompressor(registry: ModelRegistry, choice: () => ModelChoice,
42
- onUsage: (message: AssistantMessage) => void = () => {}): Compressor {
42
+ onUsage: (message: AssistantMessage) => void = () => {}, accepted = () => DEFAULT_SETTINGS.summaryAcceptBytes): Compressor {
43
43
  const gate = primeFirst();
44
44
  return async (input, signal) => {
45
45
  const selected = choice();
@@ -67,7 +67,8 @@ export function createCompressor(registry: ModelRegistry, choice: () => ModelCho
67
67
  const line = reply.content.filter(c => c.type === 'text').map(c => c.text).join('').trim();
68
68
  if (!line) throw new Error('Compactor returned no text.');
69
69
  tries.push(line);
70
- if (bytes(line) <= ACCEPTED) break;
70
+ // A merge of two short lines can come back nearly as big as both, so a line must also shrink what it replaces.
71
+ if (bytes(line) <= accepted() && bytes(line) < bytes(input.source)) break;
71
72
  messages.push(reply);
72
73
  const cut = Buffer.from(line).subarray(0, NODE).toString('utf8').replace(/\uFFFD$/, '');
73
74
  messages.push({ role: 'user', content: `That line is ${bytes(line)} bytes; the limit is 512. It must end where it is cut here:\n${cut}| ← LIMIT`, timestamp: Date.now() });
@@ -44,8 +44,8 @@ export class MultiSelect implements Component, Focusable {
44
44
  if (matchesKey(data, 'escape') || matchesKey(data, 'ctrl+c')) return this.finish(undefined);
45
45
  if (matchesKey(data, 'return')) {
46
46
  if (this.selected.size) return this.finish([...this.selected].sort((a, b) => a - b));
47
- this.error = 'Select at least one item with Space.';
48
- } else if (matchesKey(data, 'space')) {
47
+ this.error = 'Select at least one item with Tab.';
48
+ } else if (matchesKey(data, 'tab') || (matchesKey(data, 'space') && !this.filter.getValue())) {
49
49
  const index = this.visible[this.cursor];
50
50
  if (index !== undefined) { if (this.selected.has(index)) this.selected.delete(index); else this.selected.add(index); }
51
51
  } else if (matchesKey(data, 'ctrl+a')) this.visible.forEach(i => this.selected.add(i));
@@ -82,7 +82,7 @@ export class MultiSelect implements Component, Focusable {
82
82
  if (!this.visible.length) lines.push('No matches. Ctrl+U clears the filter.');
83
83
  lines.push(color('dim', `${this.visible.length ? this.cursor + 1 : 0}/${this.visible.length} matching · ${this.labels.length} total`));
84
84
  lines.push(color(this.error ? 'error' : 'dim', this.error || '↑↓ move · PgUp/PgDn page · Home/End jump'));
85
- lines.push(color('dim', 'Space toggle · Enter continue · Esc cancel'));
85
+ lines.push(color('dim', 'Tab toggle · Space toggle (empty filter) · Enter continue · Esc cancel'));
86
86
  lines.push(color('dim', 'Ctrl+A select matches · Ctrl+D clear matches'));
87
87
  return lines.map(line => truncateToWidth(line, width));
88
88
  }
@@ -22,9 +22,12 @@ const codexSubagent = (metadata: Record<string, unknown>) => metadata.source ===
22
22
  || record(metadata.source) && 'subagent' in metadata.source;
23
23
  const missingSource = (error: unknown) => record(error) && (error.code === 'ENOENT' || error.code === 'ENOTDIR');
24
24
  const missingWarning = (file: string) => `${file}: source file is no longer available; conversation skipped. Rescan to retry if it returns.`;
25
+ const zoneless = /^(\d{4}-\d{2}-\d{2})[T ](\d{2}:\d{2}(?::\d{2}(?:\.\d+)?)?)$/;
26
+ // A time without a zone is UTC, not the machine's zone. A number above 1e11 is milliseconds (1e11 seconds is the year 5138).
25
27
  export function timestamp(value: unknown, fallback: string): string {
26
- const time = typeof value === 'number' ? value * 1000 : typeof value === 'string' ? Date.parse(value) : NaN;
27
- return Number.isFinite(time) ? new Date(time).toISOString() : fallback;
28
+ const time = typeof value === 'number' ? (value > 1e11 ? value : value * 1000) : typeof value === 'string' ? Date.parse(value.trim().replace(zoneless, '$1T$2Z')) : NaN;
29
+ const date = new Date(time);
30
+ return Number.isNaN(date.getTime()) ? fallback : date.toISOString();
28
31
  }
29
32
  function text(value: unknown): string {
30
33
  if (typeof value === 'string') return value;
@@ -188,10 +191,19 @@ async function claudeProject(folder: string, name: string, signal?: AbortSignal)
188
191
  function frontmatter(content: string) {
189
192
  const match = /^---\r?\n([\s\S]*?)\r?\n---[ \t]*(?:\r?\n|$)/.exec(content);
190
193
  const fields = new Map<string, string>();
191
- for (const line of match ? match[1].split(/\r?\n/) : []) {
194
+ const lines = match ? match[1].split(/\r?\n/) : [];
195
+ for (let i = 0; i < lines.length; i++) {
192
196
  // Newer files nest type/modified under `metadata:`; the first occurrence of each key wins.
193
- const m = /^\s*([A-Za-z]+):\s*(.+?)\s*$/.exec(line);
194
- if (m && !fields.has(m[1])) fields.set(m[1], unquote(m[2]));
197
+ const m = /^(\s*)([A-Za-z]+):\s*(.+?)\s*$/.exec(lines[i]);
198
+ if (!m) continue;
199
+ let value: string;
200
+ if (/^[>|][+-]?\d?$/.test(m[3])) { // A folded or literal block is the indented lines after the key.
201
+ let end = i + 1;
202
+ while (end < lines.length && (!lines[end].trim() || lines[end].search(/\S/) > m[1].length)) end++;
203
+ value = lines.slice(i + 1, end).map(line => line.trim()).join(m[3].startsWith('>') ? ' ' : '\n').trim();
204
+ i = end - 1;
205
+ } else value = unquote(m[3]);
206
+ if (value && !fields.has(m[2])) fields.set(m[2], value);
195
207
  }
196
208
  return { fields, body: match ? content.slice(match[0].length) : content };
197
209
  }
@@ -233,16 +245,19 @@ export async function readConversation(c: Conversation, signal?: AbortSignal): P
233
245
  const nodes = Object.entries(mapping).filter((pair): pair is [string, Record<string, unknown>] => record(pair[1]));
234
246
  nodes.sort((a, b) => timestamp(record(a[1].message) ? a[1].message.create_time : undefined, c.date)
235
247
  .localeCompare(timestamp(record(b[1].message) ? b[1].message.create_time : undefined, c.date)));
236
- const ordered: typeof nodes = [], visited = new Set<string>(), visiting = new Set<string>();
237
- const visit = (key: string, node: Record<string, unknown>) => {
238
- if (visited.has(key)) return;
239
- if (visiting.has(key)) throw new Error(`${c.title}: cycle in ChatGPT conversation mapping.`);
240
- visiting.add(key);
241
- const parent = typeof node.parent === 'string' ? mapping[node.parent] : undefined;
242
- if (typeof node.parent === 'string' && record(parent)) visit(node.parent, parent);
243
- visiting.delete(key); visited.add(key); ordered.push([key, node]);
244
- };
245
- for (const [key, node] of nodes) visit(key, node);
248
+ const ordered: typeof nodes = [], visited = new Set<string>();
249
+ for (const first of nodes) {
250
+ const chain: typeof nodes = [], inChain = new Set<string>();
251
+ let link: (typeof nodes)[number] | undefined = first;
252
+ while (link && !visited.has(link[0])) {
253
+ const [key, node]: (typeof nodes)[number] = link;
254
+ if (inChain.has(key)) throw new Error(`${c.title}: cycle in ChatGPT conversation mapping.`);
255
+ inChain.add(key); chain.push(link);
256
+ const parent = typeof node.parent === 'string' ? mapping[node.parent] : undefined;
257
+ link = typeof node.parent === 'string' && record(parent) ? [node.parent, parent] : undefined;
258
+ }
259
+ for (const done of chain.reverse()) { visited.add(done[0]); ordered.push(done); }
260
+ }
246
261
  const selected = new Set<string>();
247
262
  let cursor = string(c.exported?.current_node);
248
263
  const hasSelectedBranch = !!cursor;