pi-optchat 0.6.4 → 0.6.6
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 +4 -2
- package/package.json +1 -1
- package/src/agents.ts +139 -44
- package/src/inbox.ts +8 -2
- package/src/index.ts +29 -17
- package/src/memory.ts +41 -17
- package/src/transcript.ts +8 -2
package/README.md
CHANGED
|
@@ -68,9 +68,11 @@ Compression and subagents make extra model requests with your provider credentia
|
|
|
68
68
|
Ask in plain words, for example: "Spawn an agent to investigate this repository and report back."
|
|
69
69
|
|
|
70
70
|
- Children get the profile's memory view (frozen at launch), its instructions, read-only `zoom`/`date`, and normal coding tools plus your installed extensions.
|
|
71
|
+
- Children also get the Pi built-in extensions the main session loaded: MCP, codemode and tool search. Your MCP servers (`~/.pi/agent/mcp.json`, the project's `.pi/mcp.json`) work in subagents, with the sign-ins you made in Pi. If MCP is off in the main session (`--no-mcp`, `-builtin:mcp` in settings, or an extension that replaces `/mcp`), it is off in subagents too. Each subagent opens its own server connections (stdio servers start once per subagent) and closes them when it ends.
|
|
71
72
|
- Each child reports back on its own when it finishes. The parent stays alive to receive reports; it never polls.
|
|
72
73
|
- 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.)
|
|
73
74
|
- 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.
|
|
74
76
|
- Delegation goes up to three levels below the main agent (child, grandchild, great-grandchild).
|
|
75
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.
|
|
76
78
|
- Stopping an agent stops its whole subtree. A failed parent stops its descendants.
|
|
@@ -137,7 +139,7 @@ See OpenAI's guides on [exporting ChatGPT data](https://help.openai.com/en/artic
|
|
|
137
139
|
|
|
138
140
|
## Connected windows
|
|
139
141
|
|
|
140
|
-
Each profile is locked to one Pi process. If you open the same profile in a second terminal, Pi offers to connect it to the original window as a subagent.
|
|
142
|
+
Each profile is locked to one Pi process. If you open the same profile in a second terminal, Pi offers to connect it to the original window as a subagent, or to go back to the profile picker.
|
|
141
143
|
|
|
142
144
|
- Your first message starts a subagent in the second window's working directory. Later messages continue the same conversation.
|
|
143
145
|
- The window looks like a normal Pi chat: replies, tool calls with their output and running time, the working spinner, and reports from the subagent's own agents in the dark box. Ctrl+O expands tool output.
|
|
@@ -181,7 +183,7 @@ To delete a profile, delete its folder. Your original Pi sessions are kept in Pi
|
|
|
181
183
|
- **Images** are available during the current run but stored in memory as text placeholders.
|
|
182
184
|
- **Pi's auto-compaction is off.** A single very long run can still hit the model's context limit; stop it and continue in a new turn.
|
|
183
185
|
- **Restarts**: unsent inputs are recovered into memory, and pending subagent reports are delivered. Interrupted subagents are not restarted.
|
|
184
|
-
- **
|
|
186
|
+
- **Prompt-template inputs** can be saved twice: expanded, and later in their original form as an unanswered input, because Pi expands them after the input journal records them. Skill commands (`/skill:name`) are matched back to their journaled input and don't have this problem. Plain text chat is unaffected.
|
|
185
187
|
|
|
186
188
|
## How it differs from the recipe
|
|
187
189
|
|
package/package.json
CHANGED
package/src/agents.ts
CHANGED
|
@@ -1,8 +1,11 @@
|
|
|
1
1
|
import { randomUUID } from 'node:crypto';
|
|
2
|
-
import { existsSync, readFileSync } from 'node:fs';
|
|
3
|
-
import { join, dirname } from 'node:path';
|
|
4
|
-
import {
|
|
2
|
+
import { existsSync, readFileSync, statSync } from 'node:fs';
|
|
3
|
+
import { join, dirname, resolve } from 'node:path';
|
|
4
|
+
import { homedir } from 'node:os';
|
|
5
|
+
import { createAgentSession, DefaultResourceLoader, SessionManager, SettingsManager, getAgentDir, type AgentSession, type AgentSessionEvent, type ExtensionAPI, type InlineExtension, type ModelRegistry } from '@earendil-works/pi-coding-agent';
|
|
6
|
+
import * as sdk from '@earendil-works/pi-coding-agent';
|
|
5
7
|
import type { AgentMessage } from '@earendil-works/pi-agent-core';
|
|
8
|
+
import type { Api, Model } from '@earendil-works/pi-ai';
|
|
6
9
|
import { SUBAGENT, VIEW_DOC } from './prompts.ts';
|
|
7
10
|
import { memoryTools } from './tools.ts';
|
|
8
11
|
import { type Memory } from './memory.ts';
|
|
@@ -21,6 +24,8 @@ export interface LiveRun {
|
|
|
21
24
|
pendingReports: string[]; pendingGuidance: string[]; wake?: () => void; completion?: Promise<void>;
|
|
22
25
|
}
|
|
23
26
|
interface Options { parentSession?: string; usage?: UsageLedger; createSession?: typeof createAgentSession;
|
|
27
|
+
/** Names of the built-in extensions the main session loaded (see `loadedBuiltins`). */
|
|
28
|
+
builtins?: () => Iterable<string>;
|
|
24
29
|
summarizeHandoff?: (run: RunInfo, messages: AgentMessage[], descendants?: HandoffEvidence[]) => Promise<string> }
|
|
25
30
|
|
|
26
31
|
// Subagents load the user's installed extensions, except any copy of OptChat itself: they get memory tools directly and must not open a profile.
|
|
@@ -28,17 +33,37 @@ const packageName = (path: string): string | undefined => {
|
|
|
28
33
|
for (let dir = dirname(path); dir !== dirname(dir); dir = dirname(dir)) {
|
|
29
34
|
const manifest = join(dir, 'package.json');
|
|
30
35
|
if (!existsSync(manifest)) continue;
|
|
31
|
-
|
|
32
|
-
|
|
36
|
+
try {
|
|
37
|
+
const data: unknown = JSON.parse(readFileSync(manifest, 'utf8'));
|
|
38
|
+
return data && typeof data === 'object' && 'name' in data && typeof data.name === 'string' ? data.name : undefined;
|
|
39
|
+
} catch { return undefined; } // A broken manifest is not OptChat's and must not block every spawn.
|
|
33
40
|
}
|
|
34
41
|
};
|
|
42
|
+
/** A task's cwd may start with `~` and may be relative to the spawning agent's directory. */
|
|
43
|
+
export const taskDirectory = (cwd: string, path = '.', windows = process.platform === 'win32') =>
|
|
44
|
+
resolve(cwd, path.replace(windows ? /^~(?=$|[\\/])/ : /^~(?=$|\/)/, homedir()));
|
|
45
|
+
export const CWD_DOC = 'Project directory the subagent works in (~ allowed); its AGENTS.md files load from there. Defaults to your current directory.';
|
|
35
46
|
const isOptchat = (path: string) => packageName(path) === 'pi-optchat';
|
|
47
|
+
|
|
48
|
+
// Pi's CLI adds its built-in extensions (MCP, codemode, tool search) to its own session; SDK sessions such as
|
|
49
|
+
// subagents must add them. Pi versions that do not export a factory simply do not get that extension.
|
|
50
|
+
const BUILTINS: Record<string, string> = { mcp: 'createMcpExtension', codemode: 'createCodemodeExtension', 'tool-search': 'createToolSearchExtension' };
|
|
51
|
+
const PREFIX = 'builtin:';
|
|
52
|
+
/** The built-in extensions a session loaded: `--no-mcp`, `-builtin:<name>` settings and replacing extensions leave them out. */
|
|
53
|
+
export const loadedBuiltins = (pi: Pick<ExtensionAPI, 'getCommands' | 'getAllTools'>) => new Set([...pi.getCommands(), ...pi.getAllTools()]
|
|
54
|
+
.map(item => item.sourceInfo?.path ?? '').filter(path => path.startsWith(PREFIX) && Object.hasOwn(BUILTINS, path.slice(PREFIX.length))).map(path => path.slice(PREFIX.length)));
|
|
55
|
+
/** Fresh built-in extensions for one session, as `builtin:<name>` resources, so the session's own settings still apply. */
|
|
56
|
+
export const builtinExtensions = (names: Iterable<string>): InlineExtension[] => [...new Set(names)].flatMap(name => {
|
|
57
|
+
const create = Object.hasOwn(BUILTINS, name) ? (sdk as unknown as Record<string, unknown>)[BUILTINS[name]] : undefined;
|
|
58
|
+
return typeof create === 'function' ? [{ name, factory: create(), replaceable: true, builtin: true }] : [];
|
|
59
|
+
});
|
|
36
60
|
export class Children {
|
|
37
61
|
private readonly running = new Map<string, LiveRun>();
|
|
38
62
|
readonly history: RunHistory;
|
|
39
63
|
private readonly listeners = new Set<() => void>();
|
|
40
64
|
private closing = false;
|
|
41
65
|
private launching = 0;
|
|
66
|
+
private readonly resuming = new Set<string>();
|
|
42
67
|
private settling = 0;
|
|
43
68
|
private readonly completions = new Set<Promise<void>>();
|
|
44
69
|
constructor(private readonly memory: Memory, private readonly registry: ModelRegistry,
|
|
@@ -92,6 +117,7 @@ export class Children {
|
|
|
92
117
|
} catch (error) { this.warn(`Could not record subagent activity: ${String(error)}`); }
|
|
93
118
|
}
|
|
94
119
|
async spawn(tasks: { task: string; cwd?: string }[], cwd: string, signal?: AbortSignal, parentId?: string, connected = false) {
|
|
120
|
+
for (const directory of tasks.map(t => taskDirectory(cwd, t.cwd))) if (!existsSync(directory) || !statSync(directory).isDirectory()) throw new Error(`No such directory: ${directory}`);
|
|
95
121
|
if (this.closing) throw new Error('Profile is closing.');
|
|
96
122
|
this.settling++;
|
|
97
123
|
try { await this.memory.settle(signal); } finally { this.settling--; }
|
|
@@ -113,33 +139,9 @@ export class Children {
|
|
|
113
139
|
for (const task of tasks) {
|
|
114
140
|
signal?.throwIfAborted();
|
|
115
141
|
if (cancelled()) throw new Error('Parent or profile is stopping.');
|
|
116
|
-
const id = randomUUID().slice(0, 8), directory = task.cwd
|
|
117
|
-
const
|
|
118
|
-
|
|
119
|
-
: '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');
|
|
120
|
-
// The user's settings list their installed packages; a copy in memory keeps the child from writing them back.
|
|
121
|
-
const settingsManager = SettingsManager.inMemory({ ...SettingsManager.create(directory, getAgentDir()).getSettings(), compaction: { enabled: false }, cacheWarming: 'off' });
|
|
122
|
-
const loader = new DefaultResourceLoader({ cwd: directory, agentDir: getAgentDir(), settingsManager,
|
|
123
|
-
noPromptTemplates: true,
|
|
124
|
-
extensionsOverride: base => ({ ...base, extensions: base.extensions.filter(e => !isOptchat(e.resolvedPath)) }),
|
|
125
|
-
extensionFactories: [pi => {
|
|
126
|
-
const provider = this.registry.getRegisteredProviderConfig(selected.provider);
|
|
127
|
-
if (provider) pi.registerProvider(selected.provider, provider);
|
|
128
|
-
// Same prompt as the main agent (AGENTS.md files, skills, cwd); only the OptChat preamble differs.
|
|
129
|
-
pi.on('before_agent_start', event => {
|
|
130
|
-
event.systemPromptOptions.customPrompt = `${SUBAGENT}\n\n${VIEW_DOC}`;
|
|
131
|
-
event.systemPromptOptions.sections.instructions = instructions;
|
|
132
|
-
});
|
|
133
|
-
pi.on('before_provider_request', (event, ctx) => ctx.model?.api === 'anthropic-messages' ? cachePayload(event.payload) : event.payload);
|
|
134
|
-
}],
|
|
135
|
-
});
|
|
136
|
-
await loader.reload();
|
|
137
|
-
const { session } = await (this.options.createSession ?? createAgentSession)({ cwd: directory, resourceLoader: loader, settingsManager,
|
|
138
|
-
model, thinkingLevel: selected.thinking, sessionManager: SessionManager.create(directory, join(this.profileDirectory, 'runs')),
|
|
139
|
-
customTools: [...memoryTools(() => this.memory), ...(depth < 3 ? this.delegationTools(id, directory) : []), this.parentTool(id, parentId, connected)],
|
|
140
|
-
excludeTools: depth < 3 ? [] : ['spawn', 'tell'],
|
|
141
|
-
});
|
|
142
|
-
await session.bindExtensions({});
|
|
142
|
+
const id = randomUUID().slice(0, 8), directory = taskDirectory(cwd, task.cwd);
|
|
143
|
+
const session = await this.open({ id, directory, depth, parentId, connected, provider: selected.provider, model, thinking: selected.thinking,
|
|
144
|
+
sessionManager: SessionManager.create(directory, join(this.profileDirectory, 'runs')) });
|
|
143
145
|
const info: RunInfo = { id, task: task.task, cwd: directory, model: `${selected.provider}/${selected.model}`, thinking: session.thinkingLevel,
|
|
144
146
|
parentSession: this.options.parentSession ?? '', parentId, depth, sessionFile: session.sessionFile, started: Date.now(), state: 'running', guidance: [], ...(connected ? { connected: true } : {}) };
|
|
145
147
|
const live: LiveRun = { session, info, updated: Date.now(), tools: new Map(), pendingReports: [], pendingGuidance: [] };
|
|
@@ -151,7 +153,8 @@ export class Children {
|
|
|
151
153
|
}
|
|
152
154
|
} catch (error) {
|
|
153
155
|
for (const child of launched) {
|
|
154
|
-
|
|
156
|
+
await this.shutdown(child.session); // Extensions such as MCP close their connections and stop their server processes.
|
|
157
|
+
this.dispose(child.session); this.running.delete(child.info.id);
|
|
155
158
|
child.info.state = 'failed'; child.info.ended = Date.now(); child.info.report = `Launch failed: ${String(error)}`;
|
|
156
159
|
if (child.info.connected) child.info.handoff = { reason: signal?.aborted ? 'disconnected' : 'failed' };
|
|
157
160
|
this.save(child.info);
|
|
@@ -161,7 +164,7 @@ export class Children {
|
|
|
161
164
|
} finally { this.launching -= reserved; }
|
|
162
165
|
// Each child reports independently. A slow sibling must not hold back a finished result.
|
|
163
166
|
for (const live of launched) {
|
|
164
|
-
const work = this.execute(live, view).catch(error => this.warn(`Subagent completion failed: ${String(error)}`))
|
|
167
|
+
const work = this.execute(live, `${view}\n\nYour task:\n${live.info.task}`).catch(error => this.warn(`Subagent completion failed: ${String(error)}`))
|
|
165
168
|
.finally(() => { this.completions.delete(work); this.changed(); });
|
|
166
169
|
this.completions.add(work);
|
|
167
170
|
live.completion = work;
|
|
@@ -169,15 +172,49 @@ export class Children {
|
|
|
169
172
|
this.changed();
|
|
170
173
|
return launched.map(c => c.info.id);
|
|
171
174
|
}
|
|
175
|
+
/** Builds a child session with the same prompt, tools and extensions whether it is new or resumed. */
|
|
176
|
+
private async open(o: { id: string; directory: string; depth: number; parentId?: string; connected: boolean; provider: string; model: Model<Api>;
|
|
177
|
+
thinking?: ModelChoice['thinking']; sessionManager: SessionManager }) {
|
|
178
|
+
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.';
|
|
180
|
+
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
|
+
: '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
|
+
// The user's settings list their installed packages; a copy in memory keeps the child from writing them back.
|
|
183
|
+
const settingsManager = SettingsManager.inMemory({ ...SettingsManager.create(directory, getAgentDir()).getSettings(), compaction: { enabled: false }, cacheWarming: 'off' });
|
|
184
|
+
const loader = new DefaultResourceLoader({ cwd: directory, agentDir: getAgentDir(), settingsManager,
|
|
185
|
+
noPromptTemplates: true,
|
|
186
|
+
extensionsOverride: base => ({ ...base, extensions: base.extensions.filter(e => !isOptchat(e.resolvedPath)) }),
|
|
187
|
+
extensionFactories: [...builtinExtensions(this.options.builtins?.() ?? []), pi => {
|
|
188
|
+
const provider = this.registry.getRegisteredProviderConfig(o.provider);
|
|
189
|
+
if (provider) pi.registerProvider(o.provider, provider);
|
|
190
|
+
// Same prompt as the main agent (AGENTS.md files, skills, cwd); only the OptChat preamble differs.
|
|
191
|
+
pi.on('before_agent_start', event => {
|
|
192
|
+
event.systemPromptOptions.customPrompt = `${SUBAGENT}\n\n${VIEW_DOC}`;
|
|
193
|
+
event.systemPromptOptions.sections.instructions = instructions;
|
|
194
|
+
});
|
|
195
|
+
pi.on('before_provider_request', (event, ctx) => ctx.model?.api === 'anthropic-messages' ? cachePayload(event.payload) : event.payload);
|
|
196
|
+
}],
|
|
197
|
+
});
|
|
198
|
+
await loader.reload();
|
|
199
|
+
const { session } = await (this.options.createSession ?? createAgentSession)({ cwd: directory, resourceLoader: loader, settingsManager,
|
|
200
|
+
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'],
|
|
203
|
+
});
|
|
204
|
+
// Callers track the session only after this returns: clean up here if its extensions fail to start.
|
|
205
|
+
try { await session.bindExtensions({}); }
|
|
206
|
+
catch (error) { await this.shutdown(session); this.dispose(session); throw error; }
|
|
207
|
+
return session;
|
|
208
|
+
}
|
|
172
209
|
private delegationTools(parentId: string, cwd: string) {
|
|
173
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.',
|
|
174
|
-
parameters: Type.Object({ tasks: Type.Array(Type.Object({ task: Type.String(), cwd: Type.Optional(Type.String()) }), { minItems: 1, maxItems: 8 }) }),
|
|
211
|
+
parameters: Type.Object({ tasks: Type.Array(Type.Object({ task: Type.String(), cwd: Type.Optional(Type.String({ description: CWD_DOC })) }), { minItems: 1, maxItems: 8 }) }),
|
|
175
212
|
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.`),
|
|
176
|
-
}, { name: 'tell', label: 'Guide child', description: 'Send guidance to one of your direct children.',
|
|
213
|
+
}, { 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.',
|
|
177
214
|
parameters: Type.Object({ id: Type.String(), message: Type.String() }),
|
|
178
215
|
execute: async (_id: string, args: { id: string; message: string }) => {
|
|
179
216
|
if (this.history.records.get(args.id)?.parentId !== parentId) throw new Error('You can only guide your own children.');
|
|
180
|
-
return result(await this.tell(args.id, args.message));
|
|
217
|
+
return result(await this.tell(args.id, args.message, 'manager', parentId));
|
|
181
218
|
},
|
|
182
219
|
}];
|
|
183
220
|
}
|
|
@@ -199,13 +236,20 @@ export class Children {
|
|
|
199
236
|
},
|
|
200
237
|
};
|
|
201
238
|
}
|
|
239
|
+
private async shutdown(session: AgentSession) {
|
|
240
|
+
try { await session.extensionRunner.emit({ type: 'session_shutdown', reason: 'quit' }); }
|
|
241
|
+
catch (error) { this.warn(`Subagent cleanup failed: ${String(error)}`); }
|
|
242
|
+
}
|
|
243
|
+
private dispose(session: AgentSession) {
|
|
244
|
+
try { session.dispose(); } catch (error) { this.warn(`Subagent cleanup failed: ${String(error)}`); }
|
|
245
|
+
}
|
|
202
246
|
private directChildren(id: string) { return [...this.running.values()].filter(c => c.info.parentId === id); }
|
|
203
|
-
private async execute(live: LiveRun,
|
|
247
|
+
private async execute(live: LiveRun, prompt: string) {
|
|
204
248
|
const { session, info } = live;
|
|
205
249
|
try {
|
|
206
250
|
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.`);
|
|
207
251
|
if (this.closing || info.handoff) throw new Error('Conversation stopped before its first request.');
|
|
208
|
-
await session.prompt(
|
|
252
|
+
await session.prompt(prompt);
|
|
209
253
|
while (info.state !== 'stopping') {
|
|
210
254
|
const last = session.messages.findLast(m => m.role === 'assistant');
|
|
211
255
|
if (last?.role === 'assistant' && (last.stopReason === 'error' || last.stopReason === 'aborted')) break;
|
|
@@ -238,9 +282,9 @@ export class Children {
|
|
|
238
282
|
await Promise.allSettled(children.flatMap(child => child.completion ? [child.completion] : []));
|
|
239
283
|
info.ended = Date.now();
|
|
240
284
|
for (const g of info.guidance) if (g.state === 'queued') g.state = 'undelivered';
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
285
|
+
await this.shutdown(session);
|
|
286
|
+
// A failed dispose must neither keep the slot taken nor drop the report below.
|
|
287
|
+
this.dispose(session); this.running.delete(info.id);
|
|
244
288
|
}
|
|
245
289
|
if (info.connected) {
|
|
246
290
|
info.handoff ??= { reason: 'failed' };
|
|
@@ -264,9 +308,12 @@ export class Children {
|
|
|
264
308
|
this.warn(`Subagent report saved but could not wake the parent: ${String(error)}`);
|
|
265
309
|
}
|
|
266
310
|
}
|
|
267
|
-
|
|
311
|
+
/** `caller` is the agent sending a manager message: undefined for the main agent, else the parent subagent's ID. */
|
|
312
|
+
async tell(id: string, message: string, source: 'manager' | 'user' = 'manager', caller?: string) {
|
|
268
313
|
const live = this.running.get(id);
|
|
269
|
-
if (!live
|
|
314
|
+
if (!live && source === 'manager' && this.history.records.has(id)) return this.resume(id, message, caller);
|
|
315
|
+
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
|
+
if (!live) throw new Error(`No running subagent ${id}.`);
|
|
270
317
|
const text = live.info.connected && source === 'manager' ? `[Main agent guidance]\n${message.trim()}` : message.trim(); if (!message.trim()) throw new Error('Message is empty.');
|
|
271
318
|
if (source === 'user') this.memory.append('user', `Direct guidance to subagent [${id}]: ${text}`);
|
|
272
319
|
const guidance: RunInfo['guidance'][number] = { text, date: Date.now(), state: 'queued', from: source };
|
|
@@ -278,6 +325,54 @@ export class Children {
|
|
|
278
325
|
catch (error) { guidance.state = 'undelivered'; this.save(live.info); throw error; }
|
|
279
326
|
return 'Message queued for the next tool boundary.';
|
|
280
327
|
}
|
|
328
|
+
/** Reopens a finished child from its saved transcript, same ID, parent and model, and gives it a new message. */
|
|
329
|
+
private async resume(id: string, message: string, caller: string | undefined) {
|
|
330
|
+
const run = this.history.records.get(id)!, text = message.trim();
|
|
331
|
+
if (!text) throw new Error('Message is empty.');
|
|
332
|
+
if (run.connected) throw new Error(`${id} was a connected conversation with the user. It has ended and its handoff was delivered, so it cannot be resumed. Spawn a new subagent instead.`);
|
|
333
|
+
if (!['completed', 'failed', 'stopped', 'interrupted'].includes(run.state) || this.resuming.has(id)) throw new Error(`${id} is still finishing. Its report will arrive on its own.`);
|
|
334
|
+
if (run.parentId !== caller) {
|
|
335
|
+
if (!run.parentId) throw new Error(`Only the main agent can resume ${id}.`);
|
|
336
|
+
const parent = this.running.get(run.parentId);
|
|
337
|
+
throw new Error(parent && ['running', 'waiting'].includes(parent.info.state) ? `Only ${id}'s parent ${run.parentId} can resume it. Ask ${run.parentId} with tell.`
|
|
338
|
+
: `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
|
+
}
|
|
340
|
+
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.`);
|
|
342
|
+
let manager: SessionManager | undefined;
|
|
343
|
+
try { if (run.sessionFile && existsSync(run.sessionFile)) manager = SessionManager.open(run.sessionFile); } catch { manager = undefined; }
|
|
344
|
+
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.`);
|
|
345
|
+
if (!existsSync(run.cwd) || !statSync(run.cwd).isDirectory()) throw new Error(`${id}'s directory ${run.cwd} no longer exists, so it cannot be resumed. Spawn a fresh subagent instead.`);
|
|
346
|
+
const slash = run.model.indexOf('/'), provider = run.model.slice(0, slash);
|
|
347
|
+
const model = slash > 0 ? this.registry.find(provider, run.model.slice(slash + 1)) : undefined;
|
|
348
|
+
if (!model) throw new Error(`${id}'s model ${run.model} is not available, so it cannot be resumed. Spawn a fresh subagent instead.`);
|
|
349
|
+
this.launching++; this.resuming.add(id);
|
|
350
|
+
let reserved = true;
|
|
351
|
+
try {
|
|
352
|
+
// No thinking level: the session restores the one it ran with.
|
|
353
|
+
const session = await this.open({ id, directory: run.cwd, depth: run.depth, parentId: run.parentId, connected: false, provider, model, sessionManager: manager });
|
|
354
|
+
// stop() cannot see this child until it is registered, so a parent stopped meanwhile must cancel it here.
|
|
355
|
+
const parent = run.parentId ? this.running.get(run.parentId) : undefined;
|
|
356
|
+
if (this.closing || run.parentId && (!parent || !['running', 'waiting'].includes(parent.info.state))) {
|
|
357
|
+
await this.shutdown(session); this.dispose(session); throw new Error('Parent or profile is stopping.');
|
|
358
|
+
}
|
|
359
|
+
// The finished record stays untouched (and resumable) unless the new one is saved.
|
|
360
|
+
const { ended: _ended, ...rest } = run;
|
|
361
|
+
const info: RunInfo = { ...rest, state: 'running', started: Date.now(), parentSession: this.options.parentSession ?? run.parentSession,
|
|
362
|
+
guidance: [...run.guidance, { text, date: Date.now(), state: 'queued', from: 'manager' }] };
|
|
363
|
+
try { this.save(info); } catch (error) { this.history.records.set(id, run); await this.shutdown(session); this.dispose(session); throw error; }
|
|
364
|
+
const live: LiveRun = { session, info, updated: Date.now(), tools: new Map(), pendingReports: [], pendingGuidance: [] };
|
|
365
|
+
this.running.set(id, live);
|
|
366
|
+
this.launching--; reserved = false;
|
|
367
|
+
session.subscribe(event => this.observe(live, event));
|
|
368
|
+
const work = this.execute(live, text).catch(error => this.warn(`Subagent completion failed: ${String(error)}`))
|
|
369
|
+
.finally(() => { this.completions.delete(work); this.changed(); });
|
|
370
|
+
this.completions.add(work);
|
|
371
|
+
live.completion = work;
|
|
372
|
+
} finally { if (reserved) this.launching--; this.resuming.delete(id); }
|
|
373
|
+
this.changed();
|
|
374
|
+
return `${id} had finished, so I resumed it with its earlier conversation. Its new report will come back on its own.`;
|
|
375
|
+
}
|
|
281
376
|
async finish(id: string, reason: FinishReason) {
|
|
282
377
|
const live = this.running.get(id);
|
|
283
378
|
if (live?.info.connected) {
|
package/src/inbox.ts
CHANGED
|
@@ -23,8 +23,14 @@ export class Inbox {
|
|
|
23
23
|
const item = { id: randomUUID(), text, date: new Date().toISOString() };
|
|
24
24
|
this.items.push(item); this.save(); return item.id;
|
|
25
25
|
}
|
|
26
|
-
claim(text: string) {
|
|
27
|
-
|
|
26
|
+
claim(text: string) { return this.claimWhere(i => i.text === text); }
|
|
27
|
+
/** Pi expands `/skill:name args` after the input is journaled; match the expansion back to that input. */
|
|
28
|
+
claimSkill(name: string, args = '') {
|
|
29
|
+
const command = `/skill:${name}`;
|
|
30
|
+
return this.claimWhere(i => i.text.startsWith(command) && /^(\s|$)/.test(i.text.slice(command.length)) && i.text.slice(command.length).trim() === args);
|
|
31
|
+
}
|
|
32
|
+
private claimWhere(match: (item: Arrival) => boolean) {
|
|
33
|
+
const item = this.items.find(i => !this.claimed.has(i.id) && match(i));
|
|
28
34
|
if (!item) return undefined;
|
|
29
35
|
this.claimed.add(item.id); return item.id;
|
|
30
36
|
}
|
package/src/index.ts
CHANGED
|
@@ -4,17 +4,17 @@ import { join } from 'node:path';
|
|
|
4
4
|
import { hostname } from 'node:os';
|
|
5
5
|
import { createHash } from 'node:crypto';
|
|
6
6
|
import type { AgentMessage, ThinkingLevel } from '@earendil-works/pi-agent-core';
|
|
7
|
-
import type
|
|
7
|
+
import { parseSkillBlock, type ExtensionAPI, type ExtensionContext, type ExtensionCommandContext } from '@earendil-works/pi-coding-agent';
|
|
8
8
|
import { Type } from 'typebox';
|
|
9
9
|
import { Memory } from './memory.ts';
|
|
10
10
|
import { createCompressor } from './compactor.ts';
|
|
11
11
|
import { atomicWrite, createProfile, instructions, lastProfile, listProfiles, loadConfig, lockProfile, profilePath, rememberProfile, saveConfig, ProfileBusyError, type ProfileConfig } from './profiles.ts';
|
|
12
12
|
import { MASTER, VIEW_DOC } from './prompts.ts';
|
|
13
13
|
import { cachePayload, record } from './cache.ts';
|
|
14
|
-
import { asUser, boundedMessage, buildContext, logMessage, previousExchange, REPORT_TYPE, RUN_BOUNDARY, textContent } from './transcript.ts';
|
|
14
|
+
import { asUser, boundedMessage, buildContext, logMessage, previousExchange, REPORT_TYPE, RUN_BOUNDARY, textContent, typedText } from './transcript.ts';
|
|
15
15
|
import { registerReportRenderer } from './report-message.ts';
|
|
16
16
|
import { memoryTools, result } from './tools.ts';
|
|
17
|
-
import { Children } from './agents.ts';
|
|
17
|
+
import { Children, CWD_DOC, loadedBuiltins } from './agents.ts';
|
|
18
18
|
import { exportBrowser } from './browser.ts';
|
|
19
19
|
import { Inbox } from './inbox.ts';
|
|
20
20
|
import { checkpoint } from './checkpoint.ts';
|
|
@@ -114,6 +114,7 @@ export default function optchat(pi: ExtensionAPI) {
|
|
|
114
114
|
untitle?.(); untitle = undefined; title.clear(); working = false;
|
|
115
115
|
}
|
|
116
116
|
};
|
|
117
|
+
const CONNECT = 'Start a connected subagent conversation here', BACK = 'Back';
|
|
117
118
|
const chooseProfile = async (ctx: ExtensionContext): Promise<string | undefined> => {
|
|
118
119
|
if (!ctx.hasUI) return undefined;
|
|
119
120
|
const names = listProfiles(), last = lastProfile();
|
|
@@ -146,7 +147,7 @@ export default function optchat(pi: ExtensionAPI) {
|
|
|
146
147
|
const recovered = pendingImport(dir) ? 0 : inbox.recover(memory);
|
|
147
148
|
if (recovered) ctx.ui.notify(`Recovered ${recovered} unanswered inputs into ${name}'s memory. Ask to continue them when ready.`, 'info');
|
|
148
149
|
const children = new Children(memory, ctx.modelRegistry, () => config.subagent, () => `${instructions(dir)}\n\n${IMPORT_GUIDANCE}`,
|
|
149
|
-
deliverReport, text => ctx.ui.notify(text, 'error'), dir, { parentSession: sessionId, usage,
|
|
150
|
+
deliverReport, text => ctx.ui.notify(text, 'error'), dir, { parentSession: sessionId, usage, builtins: () => loadedBuiltins(pi),
|
|
150
151
|
summarizeHandoff: createHandoffSummarizer(ctx.modelRegistry, () => config.compactor, message => usage.compression(message, 'compactor', sessionId)) });
|
|
151
152
|
const loggedReports = new Set(memory.root.map(e => e.receipt));
|
|
152
153
|
reports = saved.filter((s): s is string => typeof s === 'string' && !loggedReports.has(reportReceipt(s)));
|
|
@@ -175,20 +176,28 @@ export default function optchat(pi: ExtensionAPI) {
|
|
|
175
176
|
|
|
176
177
|
pi.on('session_start', async (_event, ctx) => {
|
|
177
178
|
stopping = false;
|
|
178
|
-
const
|
|
179
|
+
const entries = ctx.sessionManager.getEntries();
|
|
180
|
+
const saved = entries.findLast(e => e.type === 'custom' && e.customType === binding);
|
|
179
181
|
const boundName = saved?.type === 'custom' && record(saved.data) && typeof saved.data.name === 'string' ? saved.data.name : undefined;
|
|
182
|
+
// A session with conversation in it belongs to its profile. One that is only bound (a fresh `/optchat profile` session) may still pick another.
|
|
183
|
+
const settled = boundName !== undefined && entries.some(e => e.type === 'message' || e.type === 'custom_message');
|
|
180
184
|
const flag = pi.getFlag('optchat-profile');
|
|
181
185
|
try {
|
|
182
186
|
if (boundName && typeof flag === 'string' && flag !== boundName) throw new Error(`Session belongs to ${boundName}; cannot resume it as ${flag}.`);
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
187
|
+
let name = boundName ?? (typeof flag === 'string' ? flag : await chooseProfile(ctx));
|
|
188
|
+
for (;;) {
|
|
189
|
+
if (!name) { status(ctx); return; }
|
|
190
|
+
try { await openProfile(name, ctx); break; }
|
|
191
|
+
catch (error) {
|
|
192
|
+
if (!(error instanceof ProfileBusyError) || ctx.mode !== 'tui') throw error;
|
|
193
|
+
// A resumed conversation already belongs to this profile, so another profile needs a new session (/optchat profile).
|
|
194
|
+
const choice = await ctx.ui.select(`${name} is open in another window\n${error.owner}`, settled ? [CONNECT] : [CONNECT, BACK]);
|
|
195
|
+
if (choice === CONNECT) { remote = await openConnectedWindow(pi, ctx, name, text => title.show(t => ctx.ui.setTitle(t), text)); fault = undefined; break; }
|
|
196
|
+
if (choice !== BACK) throw error;
|
|
197
|
+
name = await chooseProfile(ctx);
|
|
198
|
+
}
|
|
190
199
|
}
|
|
191
|
-
if (
|
|
200
|
+
if (name !== boundName) pi.appendEntry(binding, { name });
|
|
192
201
|
} catch (error) {
|
|
193
202
|
if (active) await stop().catch(() => {});
|
|
194
203
|
fault = errorText(error); ctx.ui.notify(fault, 'error');
|
|
@@ -243,7 +252,10 @@ export default function optchat(pi: ExtensionAPI) {
|
|
|
243
252
|
const text = textContent(message.content);
|
|
244
253
|
if (reports.includes(text)) receipts.set(message, reportReceipt(text));
|
|
245
254
|
else {
|
|
246
|
-
|
|
255
|
+
// The inbox journaled the typed input: match without image placeholders or Pi's image notes.
|
|
256
|
+
const typed = typedText(message.content), skill = parseSkillBlock(typed.bare);
|
|
257
|
+
let receipt = active.inbox.claim(typed.text) ?? active.inbox.claim(typed.bare)
|
|
258
|
+
?? (skill ? active.inbox.claimSkill(skill.name, skill.userMessage) : undefined);
|
|
247
259
|
if (!receipt) { active.inbox.record(text); receipt = active.inbox.claim(text); }
|
|
248
260
|
if (receipt) receipts.set(message, receipt);
|
|
249
261
|
}
|
|
@@ -302,14 +314,14 @@ export default function optchat(pi: ExtensionAPI) {
|
|
|
302
314
|
registerReportRenderer(pi);
|
|
303
315
|
for (const tool of memoryTools(() => required().memory)) pi.registerTool(tool);
|
|
304
316
|
pi.registerTool({ name: 'spawn', label: 'Spawn background agents',
|
|
305
|
-
description: 'Start background subagents, returning IDs immediately. Use only when the user asks. Each receives the current memory view and read-only zoom/date. Children may delegate two more levels; the whole profile allows 8 active agents. Completion reports arrive automatically; never poll or sleep waiting for them.',
|
|
306
|
-
parameters: Type.Object({ tasks: Type.Array(Type.Object({ task: Type.String(), cwd: Type.Optional(Type.String()) }), { minItems: 1, maxItems: 8 }) }),
|
|
317
|
+
description: 'Start background subagents, returning IDs immediately. Use only when the user asks. Give each task the cwd of the project it works on, so the subagent starts there with that project\'s AGENTS.md. Each receives the current memory view and read-only zoom/date. Children may delegate two more levels; the whole profile allows 8 active agents. Completion reports arrive automatically; never poll or sleep waiting for them.',
|
|
318
|
+
parameters: Type.Object({ tasks: Type.Array(Type.Object({ task: Type.String(), cwd: Type.Optional(Type.String({ description: CWD_DOC })) }), { minItems: 1, maxItems: 8 }) }),
|
|
307
319
|
async execute(_id, args, signal, _update, ctx) {
|
|
308
320
|
const ids = await required().children.spawn(args.tasks, ctx.cwd, signal); status(ctx);
|
|
309
321
|
return result(`Started: ${ids.join(', ')}. Reports will arrive automatically.`);
|
|
310
322
|
},
|
|
311
323
|
});
|
|
312
|
-
pi.registerTool({ name: 'tell', label: 'Tell background agent', description: 'Send a message to a running
|
|
324
|
+
pi.registerTool({ name: 'tell', label: 'Tell background agent', description: 'Send a message to a subagent. A running one gets it at its next tool boundary. A finished one you started is resumed with its earlier conversation, and its new report arrives automatically.',
|
|
313
325
|
parameters: Type.Object({ id: Type.String(), message: Type.String() }),
|
|
314
326
|
async execute(_id, args) { return result(await required().children.tell(args.id, args.message)); },
|
|
315
327
|
});
|
package/src/memory.ts
CHANGED
|
@@ -12,10 +12,12 @@ export interface Part { l: number; i: number }
|
|
|
12
12
|
export interface Summary extends Part { text: string; size: number }
|
|
13
13
|
export interface Compression { context: string; source: string; merge: boolean; historical?: boolean }
|
|
14
14
|
export type Compressor = (input: Compression, signal: AbortSignal) => Promise<string>;
|
|
15
|
-
const key = ({ l, i }: Part) =>
|
|
15
|
+
const key = ({ l, i }: Part) => l * 2 ** 40 + i;
|
|
16
|
+
const UNBUILT = '(not summarized yet: zoom it)';
|
|
16
17
|
export const start = ({ l, i }: Part) => i * 2 ** l;
|
|
17
18
|
export const end = (part: Part) => start(part) + 2 ** part.l;
|
|
18
19
|
export const bytes = (s: string) => Buffer.byteLength(s, 'utf8');
|
|
20
|
+
const UNBUILT_BYTES = bytes(UNBUILT);
|
|
19
21
|
export const flat = (s: string) => s.replace(/[\r\n]+/g, ' ');
|
|
20
22
|
export function cap(text: string, limit = CAP) {
|
|
21
23
|
if (text.length <= limit) return text;
|
|
@@ -68,13 +70,17 @@ function isSummary(value: unknown): value is Summary {
|
|
|
68
70
|
/** The log is authoritative. The tree and monotonically coarsening view follow recipe §§2–6. */
|
|
69
71
|
export class Memory {
|
|
70
72
|
readonly root: Entry[] = [];
|
|
71
|
-
readonly tree = new Map<
|
|
73
|
+
readonly tree = new Map<number, Summary>();
|
|
72
74
|
readonly view: Part[] = [];
|
|
73
75
|
private readonly events = new EventEmitter();
|
|
74
76
|
private readonly controller = new AbortController();
|
|
75
|
-
private readonly busy = new Map<
|
|
76
|
-
private readonly retryAt = new Map<
|
|
77
|
-
private readonly reported = new Set<
|
|
77
|
+
private readonly busy = new Map<number, Promise<void>>();
|
|
78
|
+
private readonly retryAt = new Map<number, number>();
|
|
79
|
+
private readonly reported = new Set<number>();
|
|
80
|
+
private viewBytes = 0;
|
|
81
|
+
private leaves = 0;
|
|
82
|
+
/** Per level, every node below this index is built. */
|
|
83
|
+
private readonly low: number[] = [];
|
|
78
84
|
private retryTimer?: ReturnType<typeof setTimeout>;
|
|
79
85
|
private scheduled = false;
|
|
80
86
|
private stopped = false;
|
|
@@ -91,40 +97,53 @@ export class Memory {
|
|
|
91
97
|
for (const value of records(join(directory, 'tree'), warn)) {
|
|
92
98
|
if (!isSummary(value) || value.l < 0 || value.i < 0 || end(value) > this.root.length)
|
|
93
99
|
throw new Error('Invalid OptChat summary record.');
|
|
100
|
+
if (value.l === 0 && !this.tree.has(key(value))) this.leaves++;
|
|
94
101
|
this.tree.set(key(value), { ...value, size: bytes(value.text) });
|
|
95
102
|
}
|
|
96
103
|
// Fold history in order; do not retile the entire log on each turn.
|
|
97
|
-
for (let i = 0; i < this.root.length; i++) { this.
|
|
104
|
+
for (let i = 0; i < this.root.length; i++) { this.push(i); this.fit(i + 1); }
|
|
98
105
|
this.schedule();
|
|
99
106
|
}
|
|
100
107
|
append(kind: Kind, text: string, date = new Date().toISOString(), receipt?: string) {
|
|
101
108
|
if (this.stopped) throw new Error('Memory is closed.');
|
|
102
109
|
const entry: Entry = { i: this.root.length, kind, text, date, size: bytes(`${kind}: ${text}`), ...(receipt ? { receipt } : {}) };
|
|
103
110
|
appendJson(join(this.directory, 'main', `${localDay()}.jsonl`), entry);
|
|
104
|
-
this.root.push(entry); this.
|
|
111
|
+
this.root.push(entry); this.push(entry.i); this.fit(); this.schedule();
|
|
105
112
|
return entry;
|
|
106
113
|
}
|
|
107
114
|
node(part: Part) { return this.tree.get(key(part)); }
|
|
108
|
-
private text(part: Part) { return this.node(part)?.text ??
|
|
115
|
+
private text(part: Part) { return this.node(part)?.text ?? UNBUILT; }
|
|
116
|
+
private partBytes(part: Part) { return this.node(part)?.size ?? UNBUILT_BYTES; }
|
|
117
|
+
private push(i: number) { const part = { l: 0, i }; this.view.push(part); this.viewBytes += this.partBytes(part); }
|
|
109
118
|
render() { return `<chat>\n${this.view.map(p => `${start(p)}+${2 ** p.l}|${flat(this.text(p))}`).join('\n')}\n</chat>`; }
|
|
110
119
|
get ready() { return this.view.every(p => this.node(p)); }
|
|
111
|
-
get pending() { return this.root.
|
|
120
|
+
get pending() { return this.root.length - this.leaves; }
|
|
112
121
|
get active() { return this.busy.size; }
|
|
113
|
-
get size() { return this.
|
|
122
|
+
get size() { return this.viewBytes; }
|
|
123
|
+
/** The view tiles the log in order, so a binary search finds the part covering a position. */
|
|
124
|
+
private visible(part: Part) {
|
|
125
|
+
const at = start(part);
|
|
126
|
+
for (let lo = 0, hi = this.view.length - 1; lo <= hi;) {
|
|
127
|
+
const mid = (lo + hi) >> 1, p = this.view[mid];
|
|
128
|
+
if (end(p) <= at) lo = mid + 1;
|
|
129
|
+
else if (start(p) > at) hi = mid - 1;
|
|
130
|
+
else return p.l === part.l && p.i === part.i;
|
|
131
|
+
}
|
|
132
|
+
return false;
|
|
133
|
+
}
|
|
114
134
|
private fit(total = this.root.length) {
|
|
115
|
-
|
|
116
|
-
while (size > this.budget) {
|
|
135
|
+
while (this.viewBytes > this.budget) {
|
|
117
136
|
let best = -1; let due = -Infinity;
|
|
118
137
|
for (let j = 0; j + 1 < this.view.length; j++) {
|
|
119
138
|
const a = this.view[j], b = this.view[j + 1];
|
|
120
|
-
if (a.l !== b.l || a.i % 2 || b.i !== a.i + 1
|
|
139
|
+
if (a.l !== b.l || a.i % 2 || b.i !== a.i + 1) continue;
|
|
121
140
|
const age = (total - start(a)) / 2 ** (a.l + 2);
|
|
122
|
-
if (age > due) { best = j; due = age; }
|
|
141
|
+
if (age > due && this.node({ l: a.l + 1, i: a.i / 2 })) { best = j; due = age; }
|
|
123
142
|
}
|
|
124
143
|
if (best < 0) break;
|
|
125
144
|
const a = this.view[best], b = this.view[best + 1];
|
|
126
145
|
const parent = { l: a.l + 1, i: a.i / 2 };
|
|
127
|
-
|
|
146
|
+
this.viewBytes += this.partBytes(parent) - this.partBytes(a) - this.partBytes(b);
|
|
128
147
|
this.view.splice(best, 2, parent);
|
|
129
148
|
}
|
|
130
149
|
this.events.emit('change');
|
|
@@ -140,11 +159,14 @@ export class Memory {
|
|
|
140
159
|
const first = this.view.find(p => !this.node(p));
|
|
141
160
|
const boundary = first ? start(first) : total;
|
|
142
161
|
for (let l = 0; 2 ** l <= total; l++) {
|
|
143
|
-
|
|
162
|
+
let low = this.low[l] ?? 0;
|
|
163
|
+
while (this.node({ l, i: low })) low++;
|
|
164
|
+
this.low[l] = low;
|
|
165
|
+
for (let i = low; (i + 1) * 2 ** l <= total; i++) {
|
|
144
166
|
if (this.busy.size >= this.jobs) return;
|
|
145
167
|
const part = { l, i }, id = key(part);
|
|
168
|
+
if ((l === 0 ? i : end(part)) > boundary) break;
|
|
146
169
|
if (this.node(part) || this.busy.has(id) || (this.retryAt.get(id) ?? 0) > Date.now()) continue;
|
|
147
|
-
if ((l === 0 ? i : end(part)) > boundary) continue;
|
|
148
170
|
if (l && (!this.node({ l: l - 1, i: 2 * i }) || !this.node({ l: l - 1, i: 2 * i + 1 }))) continue;
|
|
149
171
|
const promise = this.build(part).catch(error => {
|
|
150
172
|
if (this.stopped) return;
|
|
@@ -173,6 +195,8 @@ export class Memory {
|
|
|
173
195
|
const node = { ...part, text, size: bytes(text) };
|
|
174
196
|
appendJson(join(this.directory, 'tree', `${localDay()}.jsonl`), node);
|
|
175
197
|
this.tree.set(key(part), node); this.retryAt.delete(key(part));
|
|
198
|
+
// A leaf is usually still in the view when built, but after a damaged tree file a saved parent can already hide it.
|
|
199
|
+
if (part.l === 0) { this.leaves++; if (this.visible(part)) this.viewBytes += node.size - UNBUILT_BYTES; }
|
|
176
200
|
if (!this.retryAt.size) this.lastError = undefined;
|
|
177
201
|
this.fit();
|
|
178
202
|
}
|
package/src/transcript.ts
CHANGED
|
@@ -8,16 +8,22 @@ export const RUN_BOUNDARY = 'optchat.run';
|
|
|
8
8
|
/** Subagent traffic to the main agent: a custom message on screen, a plain user message to the model and memory. */
|
|
9
9
|
export const REPORT_TYPE = 'optchat-report';
|
|
10
10
|
|
|
11
|
-
export function textContent(content: unknown): string {
|
|
11
|
+
export function textContent(content: unknown, images = true): string {
|
|
12
12
|
if (typeof content === 'string') return content;
|
|
13
13
|
if (!Array.isArray(content)) return '';
|
|
14
14
|
return content.map((part: unknown) => {
|
|
15
15
|
if (typeof part !== 'object' || part === null) return '';
|
|
16
16
|
if ('type' in part && part.type === 'text' && 'text' in part && typeof part.text === 'string') return part.text;
|
|
17
|
-
if ('type' in part && part.type === 'image') return '[image attachment: available in Pi session; text memory does not preserve image bytes]';
|
|
17
|
+
if (images && 'type' in part && part.type === 'image') return '[image attachment: available in Pi session; text memory does not preserve image bytes]';
|
|
18
18
|
return '';
|
|
19
19
|
}).filter(Boolean).join('\n');
|
|
20
20
|
}
|
|
21
|
+
/** What the user typed, as Pi's input event (and so the inbox) saw it: no image placeholders, and without the
|
|
22
|
+
* `[Image …]` notes Pi appends after the text when it resizes, converts or omits an attached image. */
|
|
23
|
+
export function typedText(content: unknown) {
|
|
24
|
+
const text = textContent(content, false);
|
|
25
|
+
return { text, bare: text.replace(/\n\n\[Image[ :][^\n]*\](?:\n\[Image[ :][^\n]*\])*$/, '') };
|
|
26
|
+
}
|
|
21
27
|
/** Reports reach the model, memory and the previous-exchange replay exactly as the user messages they used to be. */
|
|
22
28
|
export function asUser(message: AgentMessage): AgentMessage {
|
|
23
29
|
if (message.role !== 'custom' || message.customType !== REPORT_TYPE) return message;
|