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 +31 -8
- package/docs/victor-recipe.md +2 -1
- package/package.json +3 -2
- package/src/agents.ts +57 -33
- package/src/cache.ts +12 -22
- package/src/checkpoint.ts +2 -4
- package/src/compactor.ts +7 -6
- package/src/import/multi-select.ts +3 -3
- package/src/import/sources.ts +30 -15
- package/src/index.ts +30 -18
- package/src/memory.ts +38 -23
- package/src/profiles.ts +33 -14
- package/src/runs.ts +14 -2
- package/src/settings-page.ts +160 -0
- package/src/settings.ts +42 -0
- package/src/transcript.ts +15 -4
- package/src/usage.ts +6 -6
- package/src/window-bridge.ts +4 -4
package/README.md
CHANGED
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+

|
|
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
|
+

|
|
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
|
|
76
|
-
-
|
|
77
|
-
-
|
|
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**
|
|
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
|
|
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
|
|
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
|
|
package/docs/victor-recipe.md
CHANGED
|
@@ -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.
|
|
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
|
-
|
|
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.
|
|
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
|
|
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
|
-
|
|
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
|
|
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), ...(
|
|
202
|
-
excludeTools:
|
|
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:
|
|
211
|
-
parameters: Type.Object({ tasks: Type.Array(Type.Object({ task: Type.String(), cwd: Type.Optional(Type.String({ description: CWD_DOC })) }), { minItems: 1
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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.
|
|
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
|
|
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
|
-
|
|
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(
|
|
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
|
-
|
|
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
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
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 (!
|
|
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 (
|
|
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
|
-
|
|
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
|
-
|
|
11
|
-
export const SCALE =
|
|
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
|
-
|
|
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
|
|
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
|
}
|
package/src/import/sources.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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 =
|
|
194
|
-
if (
|
|
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>()
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
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;
|