pi-optchat 0.6.5 → 0.6.7
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 +6 -2
- package/package.json +1 -1
- package/src/agent-view.ts +23 -6
- package/src/agents.ts +119 -36
- package/src/compactor.ts +45 -6
- package/src/index.ts +3 -3
- package/src/inspector.ts +41 -19
- package/src/usage.ts +5 -5
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.
|
|
@@ -103,7 +105,9 @@ Set a different shortcut with `OPTCHAT_INSPECT_KEY=ctrl+shift+a pi`. If another
|
|
|
103
105
|
|
|
104
106
|
Guidance shows as queued until delivered, or undelivered if the child stops first. Guidance you send is also saved in main memory. Reasoning is not shown. Transcripts stay browsable after restart, and browsing them makes no model calls.
|
|
105
107
|
|
|
106
|
-
**Usage** shows this session, last hour, today, last 7 days, or all time (**Left/Right**)
|
|
108
|
+
**Usage** shows this session, last hour, today, last 7 days, or all time (**Left/Right**): one row per role and model (main agent, subagents, compactor, imports) with estimated cost, share of the total, output tokens, and how much input came from the cache. Costs are API prices, not your subscription bill.
|
|
109
|
+
|
|
110
|
+

|
|
107
111
|
|
|
108
112
|
- Costs are API-rate estimates, not your subscription bill. Unknown rates show zero.
|
|
109
113
|
- Record counts are not request counts; retries and tool overhead can add records.
|
|
@@ -185,7 +189,7 @@ To delete a profile, delete its folder. Your original Pi sessions are kept in Pi
|
|
|
185
189
|
|
|
186
190
|
## How it differs from the recipe
|
|
187
191
|
|
|
188
|
-
The recipe's four prompts are kept verbatim in `src/prompts.ts`, along with its numbers: 512-byte summary nodes, 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. See `docs/victor-recipe.md` for notes.
|
|
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.
|
|
189
193
|
|
|
190
194
|
Each run's context is the memory view, the previous exchange, and your new message. Deliberate additions:
|
|
191
195
|
|
package/package.json
CHANGED
package/src/agent-view.ts
CHANGED
|
@@ -3,7 +3,7 @@ import {
|
|
|
3
3
|
createBashToolDefinition, createEditToolDefinition, createFindToolDefinition, createGrepToolDefinition, createLsToolDefinition, createReadToolDefinition, createWriteToolDefinition,
|
|
4
4
|
type ExtensionContext,
|
|
5
5
|
} from '@earendil-works/pi-coding-agent';
|
|
6
|
-
import { Editor, Spacer, matchesKey, truncateToWidth, visibleWidth, type Component, type Focusable, type TUI, type TuiMouseEvent, type TuiMouseEventResult } from '@earendil-works/pi-tui';
|
|
6
|
+
import { Editor, Spacer, compositeTuiLine, matchesKey, truncateToWidth, visibleWidth, type Component, type Focusable, type TUI, type TuiMouseEvent, type TuiMouseEventResult } from '@earendil-works/pi-tui';
|
|
7
7
|
import type { AgentMessage } from '@earendil-works/pi-agent-core';
|
|
8
8
|
import type { Children } from './agents.ts';
|
|
9
9
|
import type { RunInfo } from './runs.ts';
|
|
@@ -297,10 +297,27 @@ export class AgentView implements Component, Focusable {
|
|
|
297
297
|
}
|
|
298
298
|
}
|
|
299
299
|
|
|
300
|
+
/**
|
|
301
|
+
* Pi never composites overlays onto rows holding a terminal image (earendil-works/pi#6995), so images in the
|
|
302
|
+
* main chat would show through the full-screen view. While it is open, image rows under overlays are blanked.
|
|
303
|
+
* If Pi renames the hook, this quietly does nothing.
|
|
304
|
+
*/
|
|
305
|
+
export function hideImagesUnderOverlays(tui: TUI) {
|
|
306
|
+
Object.assign(tui, { compositeLineAt: (...[base, ...rest]: Parameters<typeof compositeTuiLine>) =>
|
|
307
|
+
compositeTuiLine(/\x1b_G|\x1b\]1337;File=/.test(base) ? '' : base, ...rest) });
|
|
308
|
+
return () => { Reflect.deleteProperty(tui, 'compositeLineAt'); };
|
|
309
|
+
}
|
|
310
|
+
|
|
300
311
|
/** Swaps the whole screen to the subagent's conversation; Pi restores the main chat on close. */
|
|
301
|
-
export function showAgentView(ctx: ExtensionContext, options: { id: string; children: Children; signal?: AbortSignal }) {
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
312
|
+
export async function showAgentView(ctx: ExtensionContext, options: { id: string; children: Children; signal?: AbortSignal }) {
|
|
313
|
+
let restore = () => {};
|
|
314
|
+
try {
|
|
315
|
+
return await ctx.ui.custom<void>((tui, theme, keybindings, done) => {
|
|
316
|
+
restore = hideImagesUnderOverlays(tui);
|
|
317
|
+
return new AgentView({ ...options, tui,
|
|
318
|
+
rows: () => tui.terminal.rows, redraw: () => tui.requestRender(), done: () => done(undefined),
|
|
319
|
+
color: (tone, text) => theme.fg(tone, text), isExpandKey: data => keybindings.matches(data, 'app.tools.expand'),
|
|
320
|
+
});
|
|
321
|
+
}, { overlay: true, overlayOptions: { width: '100%', maxHeight: '100%', row: 0, col: 0 } });
|
|
322
|
+
} finally { restore(); }
|
|
306
323
|
}
|
package/src/agents.ts
CHANGED
|
@@ -2,8 +2,10 @@ import { randomUUID } from 'node:crypto';
|
|
|
2
2
|
import { existsSync, readFileSync, statSync } from 'node:fs';
|
|
3
3
|
import { join, dirname, resolve } from 'node:path';
|
|
4
4
|
import { homedir } from 'node:os';
|
|
5
|
-
import { createAgentSession, DefaultResourceLoader, SessionManager, SettingsManager, getAgentDir, type AgentSession, type AgentSessionEvent, type ModelRegistry } from '@earendil-works/pi-coding-agent';
|
|
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';
|
|
6
7
|
import type { AgentMessage } from '@earendil-works/pi-agent-core';
|
|
8
|
+
import type { Api, Model } from '@earendil-works/pi-ai';
|
|
7
9
|
import { SUBAGENT, VIEW_DOC } from './prompts.ts';
|
|
8
10
|
import { memoryTools } from './tools.ts';
|
|
9
11
|
import { type Memory } from './memory.ts';
|
|
@@ -22,6 +24,8 @@ export interface LiveRun {
|
|
|
22
24
|
pendingReports: string[]; pendingGuidance: string[]; wake?: () => void; completion?: Promise<void>;
|
|
23
25
|
}
|
|
24
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>;
|
|
25
29
|
summarizeHandoff?: (run: RunInfo, messages: AgentMessage[], descendants?: HandoffEvidence[]) => Promise<string> }
|
|
26
30
|
|
|
27
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.
|
|
@@ -40,12 +44,26 @@ export const taskDirectory = (cwd: string, path = '.', windows = process.platfor
|
|
|
40
44
|
resolve(cwd, path.replace(windows ? /^~(?=$|[\\/])/ : /^~(?=$|\/)/, homedir()));
|
|
41
45
|
export const CWD_DOC = 'Project directory the subagent works in (~ allowed); its AGENTS.md files load from there. Defaults to your current directory.';
|
|
42
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
|
+
});
|
|
43
60
|
export class Children {
|
|
44
61
|
private readonly running = new Map<string, LiveRun>();
|
|
45
62
|
readonly history: RunHistory;
|
|
46
63
|
private readonly listeners = new Set<() => void>();
|
|
47
64
|
private closing = false;
|
|
48
65
|
private launching = 0;
|
|
66
|
+
private readonly resuming = new Set<string>();
|
|
49
67
|
private settling = 0;
|
|
50
68
|
private readonly completions = new Set<Promise<void>>();
|
|
51
69
|
constructor(private readonly memory: Memory, private readonly registry: ModelRegistry,
|
|
@@ -122,32 +140,8 @@ export class Children {
|
|
|
122
140
|
signal?.throwIfAborted();
|
|
123
141
|
if (cancelled()) throw new Error('Parent or profile is stopping.');
|
|
124
142
|
const id = randomUUID().slice(0, 8), directory = taskDirectory(cwd, task.cwd);
|
|
125
|
-
const
|
|
126
|
-
|
|
127
|
-
: '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');
|
|
128
|
-
// The user's settings list their installed packages; a copy in memory keeps the child from writing them back.
|
|
129
|
-
const settingsManager = SettingsManager.inMemory({ ...SettingsManager.create(directory, getAgentDir()).getSettings(), compaction: { enabled: false }, cacheWarming: 'off' });
|
|
130
|
-
const loader = new DefaultResourceLoader({ cwd: directory, agentDir: getAgentDir(), settingsManager,
|
|
131
|
-
noPromptTemplates: true,
|
|
132
|
-
extensionsOverride: base => ({ ...base, extensions: base.extensions.filter(e => !isOptchat(e.resolvedPath)) }),
|
|
133
|
-
extensionFactories: [pi => {
|
|
134
|
-
const provider = this.registry.getRegisteredProviderConfig(selected.provider);
|
|
135
|
-
if (provider) pi.registerProvider(selected.provider, provider);
|
|
136
|
-
// Same prompt as the main agent (AGENTS.md files, skills, cwd); only the OptChat preamble differs.
|
|
137
|
-
pi.on('before_agent_start', event => {
|
|
138
|
-
event.systemPromptOptions.customPrompt = `${SUBAGENT}\n\n${VIEW_DOC}`;
|
|
139
|
-
event.systemPromptOptions.sections.instructions = instructions;
|
|
140
|
-
});
|
|
141
|
-
pi.on('before_provider_request', (event, ctx) => ctx.model?.api === 'anthropic-messages' ? cachePayload(event.payload) : event.payload);
|
|
142
|
-
}],
|
|
143
|
-
});
|
|
144
|
-
await loader.reload();
|
|
145
|
-
const { session } = await (this.options.createSession ?? createAgentSession)({ cwd: directory, resourceLoader: loader, settingsManager,
|
|
146
|
-
model, thinkingLevel: selected.thinking, sessionManager: SessionManager.create(directory, join(this.profileDirectory, 'runs')),
|
|
147
|
-
customTools: [...memoryTools(() => this.memory), ...(depth < 3 ? this.delegationTools(id, directory) : []), this.parentTool(id, parentId, connected)],
|
|
148
|
-
excludeTools: depth < 3 ? [] : ['spawn', 'tell'],
|
|
149
|
-
});
|
|
150
|
-
await session.bindExtensions({});
|
|
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')) });
|
|
151
145
|
const info: RunInfo = { id, task: task.task, cwd: directory, model: `${selected.provider}/${selected.model}`, thinking: session.thinkingLevel,
|
|
152
146
|
parentSession: this.options.parentSession ?? '', parentId, depth, sessionFile: session.sessionFile, started: Date.now(), state: 'running', guidance: [], ...(connected ? { connected: true } : {}) };
|
|
153
147
|
const live: LiveRun = { session, info, updated: Date.now(), tools: new Map(), pendingReports: [], pendingGuidance: [] };
|
|
@@ -159,6 +153,7 @@ export class Children {
|
|
|
159
153
|
}
|
|
160
154
|
} catch (error) {
|
|
161
155
|
for (const child of launched) {
|
|
156
|
+
await this.shutdown(child.session); // Extensions such as MCP close their connections and stop their server processes.
|
|
162
157
|
this.dispose(child.session); this.running.delete(child.info.id);
|
|
163
158
|
child.info.state = 'failed'; child.info.ended = Date.now(); child.info.report = `Launch failed: ${String(error)}`;
|
|
164
159
|
if (child.info.connected) child.info.handoff = { reason: signal?.aborted ? 'disconnected' : 'failed' };
|
|
@@ -169,7 +164,7 @@ export class Children {
|
|
|
169
164
|
} finally { this.launching -= reserved; }
|
|
170
165
|
// Each child reports independently. A slow sibling must not hold back a finished result.
|
|
171
166
|
for (const live of launched) {
|
|
172
|
-
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)}`))
|
|
173
168
|
.finally(() => { this.completions.delete(work); this.changed(); });
|
|
174
169
|
this.completions.add(work);
|
|
175
170
|
live.completion = work;
|
|
@@ -177,15 +172,49 @@ export class Children {
|
|
|
177
172
|
this.changed();
|
|
178
173
|
return launched.map(c => c.info.id);
|
|
179
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
|
+
}
|
|
180
209
|
private delegationTools(parentId: string, cwd: string) {
|
|
181
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.',
|
|
182
211
|
parameters: Type.Object({ tasks: Type.Array(Type.Object({ task: Type.String(), cwd: Type.Optional(Type.String({ description: CWD_DOC })) }), { minItems: 1, maxItems: 8 }) }),
|
|
183
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.`),
|
|
184
|
-
}, { 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.',
|
|
185
214
|
parameters: Type.Object({ id: Type.String(), message: Type.String() }),
|
|
186
215
|
execute: async (_id: string, args: { id: string; message: string }) => {
|
|
187
216
|
if (this.history.records.get(args.id)?.parentId !== parentId) throw new Error('You can only guide your own children.');
|
|
188
|
-
return result(await this.tell(args.id, args.message));
|
|
217
|
+
return result(await this.tell(args.id, args.message, 'manager', parentId));
|
|
189
218
|
},
|
|
190
219
|
}];
|
|
191
220
|
}
|
|
@@ -207,16 +236,20 @@ export class Children {
|
|
|
207
236
|
},
|
|
208
237
|
};
|
|
209
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
|
+
}
|
|
210
243
|
private dispose(session: AgentSession) {
|
|
211
244
|
try { session.dispose(); } catch (error) { this.warn(`Subagent cleanup failed: ${String(error)}`); }
|
|
212
245
|
}
|
|
213
246
|
private directChildren(id: string) { return [...this.running.values()].filter(c => c.info.parentId === id); }
|
|
214
|
-
private async execute(live: LiveRun,
|
|
247
|
+
private async execute(live: LiveRun, prompt: string) {
|
|
215
248
|
const { session, info } = live;
|
|
216
249
|
try {
|
|
217
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.`);
|
|
218
251
|
if (this.closing || info.handoff) throw new Error('Conversation stopped before its first request.');
|
|
219
|
-
await session.prompt(
|
|
252
|
+
await session.prompt(prompt);
|
|
220
253
|
while (info.state !== 'stopping') {
|
|
221
254
|
const last = session.messages.findLast(m => m.role === 'assistant');
|
|
222
255
|
if (last?.role === 'assistant' && (last.stopReason === 'error' || last.stopReason === 'aborted')) break;
|
|
@@ -249,8 +282,7 @@ export class Children {
|
|
|
249
282
|
await Promise.allSettled(children.flatMap(child => child.completion ? [child.completion] : []));
|
|
250
283
|
info.ended = Date.now();
|
|
251
284
|
for (const g of info.guidance) if (g.state === 'queued') g.state = 'undelivered';
|
|
252
|
-
|
|
253
|
-
catch (error) { this.warn(`Subagent cleanup failed: ${String(error)}`); }
|
|
285
|
+
await this.shutdown(session);
|
|
254
286
|
// A failed dispose must neither keep the slot taken nor drop the report below.
|
|
255
287
|
this.dispose(session); this.running.delete(info.id);
|
|
256
288
|
}
|
|
@@ -276,9 +308,12 @@ export class Children {
|
|
|
276
308
|
this.warn(`Subagent report saved but could not wake the parent: ${String(error)}`);
|
|
277
309
|
}
|
|
278
310
|
}
|
|
279
|
-
|
|
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) {
|
|
280
313
|
const live = this.running.get(id);
|
|
281
|
-
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}.`);
|
|
282
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.');
|
|
283
318
|
if (source === 'user') this.memory.append('user', `Direct guidance to subagent [${id}]: ${text}`);
|
|
284
319
|
const guidance: RunInfo['guidance'][number] = { text, date: Date.now(), state: 'queued', from: source };
|
|
@@ -290,6 +325,54 @@ export class Children {
|
|
|
290
325
|
catch (error) { guidance.state = 'undelivered'; this.save(live.info); throw error; }
|
|
291
326
|
return 'Message queued for the next tool boundary.';
|
|
292
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
|
+
}
|
|
293
376
|
async finish(id: string, reason: FinishReason) {
|
|
294
377
|
const live = this.running.get(id);
|
|
295
378
|
if (live?.info.connected) {
|
package/src/compactor.ts
CHANGED
|
@@ -3,32 +3,71 @@ import type { ThinkingLevel } from '@earendil-works/pi-agent-core';
|
|
|
3
3
|
import type { ModelRegistry } from '@earendil-works/pi-coding-agent';
|
|
4
4
|
import { COMPACT } from './prompts.ts';
|
|
5
5
|
import { bytes, NODE, type Compressor } from './memory.ts';
|
|
6
|
-
import { cachePayload } from './cache.ts';
|
|
6
|
+
import { cachePayload, splitView } from './cache.ts';
|
|
7
7
|
import { IMPORT_GUIDANCE } from './import/guidance.ts';
|
|
8
8
|
|
|
9
9
|
export interface ModelChoice { provider: string; model: string; thinking: ThinkingLevel }
|
|
10
10
|
const scaleBase = 'user: Keep work and personal memory separate; use a binary summary tree and inspect original messages before acting. talk: Implemented the append-only log with durable writes and a stable view. echo: Checked caching, chronological summaries, cancellation, and profile locks. user: Main agent uses Opus; compactor uses Sonnet at medium effort. work: Worker completed the parser; tests cover invalid records and repeated imports. talk: The browser opens original messages, preserving dates and sources.';
|
|
11
11
|
export const SCALE = scaleBase.padEnd(NODE, '.').slice(0, NODE);
|
|
12
|
+
/** The model is asked for 512 bytes; a small overshoot costs less view space than a retry costs money. */
|
|
13
|
+
export const ACCEPTED = NODE * 1.25;
|
|
14
|
+
const WARM_MS = 4 * 60_000; // Anthropic's short cache lives 5 minutes from its last use.
|
|
15
|
+
|
|
16
|
+
/** 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. */
|
|
17
|
+
function primeFirst() {
|
|
18
|
+
const warm = new Map<string, number | Promise<void>>();
|
|
19
|
+
return async (prefix: string, signal: AbortSignal) => {
|
|
20
|
+
for (let state = warm.get(prefix); state !== undefined; state = warm.get(prefix)) {
|
|
21
|
+
if (typeof state === 'number') { if (Date.now() - state < WARM_MS) break; warm.delete(prefix); continue; }
|
|
22
|
+
// A cancelled waiter leaves at once instead of waiting for someone else's primer.
|
|
23
|
+
signal.throwIfAborted();
|
|
24
|
+
let wake = () => {};
|
|
25
|
+
const aborted = new Promise<void>(resolve => { wake = resolve; });
|
|
26
|
+
signal.addEventListener('abort', wake, { once: true });
|
|
27
|
+
await Promise.race([state, aborted]);
|
|
28
|
+
signal.removeEventListener('abort', wake);
|
|
29
|
+
signal.throwIfAborted();
|
|
30
|
+
}
|
|
31
|
+
let release = () => {};
|
|
32
|
+
const pending = warm.has(prefix) ? undefined : new Promise<void>(resolve => { release = resolve; });
|
|
33
|
+
if (pending) warm.set(prefix, pending);
|
|
34
|
+
return (ok: boolean) => {
|
|
35
|
+
if (ok) { for (const [k, at] of warm) if (typeof at === 'number' && Date.now() - at >= WARM_MS) warm.delete(k); warm.set(prefix, Date.now()); }
|
|
36
|
+
else if (warm.get(prefix) === pending) warm.delete(prefix);
|
|
37
|
+
release();
|
|
38
|
+
};
|
|
39
|
+
};
|
|
40
|
+
}
|
|
12
41
|
export function createCompressor(registry: ModelRegistry, choice: () => ModelChoice,
|
|
13
42
|
onUsage: (message: AssistantMessage) => void = () => {}): Compressor {
|
|
43
|
+
const gate = primeFirst();
|
|
14
44
|
return async (input, signal) => {
|
|
15
45
|
const selected = choice();
|
|
16
46
|
const model = registry.find(selected.provider, selected.model);
|
|
17
47
|
if (!model) throw new Error(`Compactor model unavailable: ${selected.provider}/${selected.model}. Use /optchat model.`);
|
|
18
48
|
const step = `${input.historical ? IMPORT_GUIDANCE + '\n\n' : ''}For scale, this line is exactly 512 bytes:\n${SCALE}\n\n${input.merge ? 'Merge these two lines into one' : 'Compress this message into one line'}, in at most 512 bytes:\n${input.source}`;
|
|
19
49
|
const messages: Message[] = [{ role: 'user', content: [{ type: 'text', text: input.context }, { type: 'text', text: step }], timestamp: Date.now() }];
|
|
50
|
+
const view = splitView(input.context);
|
|
51
|
+
const prefix = model.api === 'anthropic-messages' && view.length > 1 ? `${model.provider}/${model.id}/${selected.thinking}\n${view.slice(0, -1).join('')}` : undefined;
|
|
20
52
|
const tries: string[] = [];
|
|
21
53
|
for (let attempt = 0; attempt < 5; attempt++) {
|
|
22
|
-
const
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
54
|
+
const warmed = prefix ? await gate(prefix, signal) : () => {};
|
|
55
|
+
let reply: AssistantMessage;
|
|
56
|
+
try {
|
|
57
|
+
const stream = registry.streamSimple(model, { systemPrompt: COMPACT, messages }, {
|
|
58
|
+
reasoning: selected.thinking === 'off' ? undefined : selected.thinking, signal, cacheRetention: 'short',
|
|
59
|
+
onPayload: payload => model.api === 'anthropic-messages' ? cachePayload(payload) : payload,
|
|
60
|
+
});
|
|
61
|
+
// The cache entry is usable once the model starts answering.
|
|
62
|
+
for await (const event of stream) if (event.type !== 'start') { warmed(event.type !== 'error'); break; }
|
|
63
|
+
reply = await stream.result();
|
|
64
|
+
} finally { warmed(false); }
|
|
26
65
|
onUsage(reply);
|
|
27
66
|
if (reply.stopReason === 'error' || reply.stopReason === 'aborted') throw new Error(reply.errorMessage ?? `Compactor ${reply.stopReason}`);
|
|
28
67
|
const line = reply.content.filter(c => c.type === 'text').map(c => c.text).join('').trim();
|
|
29
68
|
if (!line) throw new Error('Compactor returned no text.');
|
|
30
69
|
tries.push(line);
|
|
31
|
-
if (bytes(line) <=
|
|
70
|
+
if (bytes(line) <= ACCEPTED) break;
|
|
32
71
|
messages.push(reply);
|
|
33
72
|
const cut = Buffer.from(line).subarray(0, NODE).toString('utf8').replace(/\uFFFD$/, '');
|
|
34
73
|
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() });
|
package/src/index.ts
CHANGED
|
@@ -14,7 +14,7 @@ import { cachePayload, record } from './cache.ts';
|
|
|
14
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, CWD_DOC } 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';
|
|
@@ -147,7 +147,7 @@ export default function optchat(pi: ExtensionAPI) {
|
|
|
147
147
|
const recovered = pendingImport(dir) ? 0 : inbox.recover(memory);
|
|
148
148
|
if (recovered) ctx.ui.notify(`Recovered ${recovered} unanswered inputs into ${name}'s memory. Ask to continue them when ready.`, 'info');
|
|
149
149
|
const children = new Children(memory, ctx.modelRegistry, () => config.subagent, () => `${instructions(dir)}\n\n${IMPORT_GUIDANCE}`,
|
|
150
|
-
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),
|
|
151
151
|
summarizeHandoff: createHandoffSummarizer(ctx.modelRegistry, () => config.compactor, message => usage.compression(message, 'compactor', sessionId)) });
|
|
152
152
|
const loggedReports = new Set(memory.root.map(e => e.receipt));
|
|
153
153
|
reports = saved.filter((s): s is string => typeof s === 'string' && !loggedReports.has(reportReceipt(s)));
|
|
@@ -321,7 +321,7 @@ export default function optchat(pi: ExtensionAPI) {
|
|
|
321
321
|
return result(`Started: ${ids.join(', ')}. Reports will arrive automatically.`);
|
|
322
322
|
},
|
|
323
323
|
});
|
|
324
|
-
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.',
|
|
325
325
|
parameters: Type.Object({ id: Type.String(), message: Type.String() }),
|
|
326
326
|
async execute(_id, args) { return result(await required().children.tell(args.id, args.message)); },
|
|
327
327
|
});
|
package/src/inspector.ts
CHANGED
|
@@ -2,11 +2,23 @@ import { matchesKey, sliceByColumn, truncateToWidth, visibleWidth, wrapTextWithA
|
|
|
2
2
|
import type { ExtensionContext } from '@earendil-works/pi-coding-agent';
|
|
3
3
|
import type { Children } from './agents.ts';
|
|
4
4
|
import { isActiveRun } from './runs.ts';
|
|
5
|
-
import
|
|
5
|
+
import type { Usage } from '@earendil-works/pi-ai';
|
|
6
|
+
import { ranges, summarizeUsage, type UsageLedger, type UsageRange, type UsageRole } from './usage.ts';
|
|
6
7
|
|
|
7
8
|
export const clean = (text: string) => text.replace(/[\x00-\x08\x0b-\x1f\x7f-\x9f]/g, '').replace(/\t/g, ' ');
|
|
8
9
|
export const oneLine = (text: string) => clean(text).replace(/\n/g, ' ');
|
|
9
10
|
export const count = (n: number) => n.toLocaleString('en-US');
|
|
11
|
+
const units = ['', 'k', 'M', 'B'];
|
|
12
|
+
/** 950, 12.3k, 164k, 2.4M: enough precision to compare rows at a glance. A value that rounds to 1000 moves up a unit. */
|
|
13
|
+
export const short = (n: number) => {
|
|
14
|
+
let unit = 0, value = n;
|
|
15
|
+
const round = (v: number) => v >= 100 || !unit ? Math.round(v) : Number(v.toFixed(1));
|
|
16
|
+
while (unit < units.length - 1 && round(value) >= 1000) { value /= 1000; unit++; }
|
|
17
|
+
return `${round(value)}${units[unit]}`;
|
|
18
|
+
};
|
|
19
|
+
const dollars = (n: number) => `$${n.toFixed(2)}`;
|
|
20
|
+
const cached = (u: Usage) => { const input = u.input + u.cacheRead + u.cacheWrite; return `${input ? Math.round(100 * u.cacheRead / input) : 0}%`; };
|
|
21
|
+
const roleNames: Record<UsageRole, string> = { main: 'main', subagent: 'subagents', compactor: 'compactor', import: 'import' };
|
|
10
22
|
export const elapsed = (ms: number) => ms < 60_000 ? `${Math.max(0, Math.floor(ms / 1000))}s` : `${Math.floor(ms / 60_000)}m ${Math.floor(ms / 1000) % 60}s`;
|
|
11
23
|
/** Shortens plain text with an ellipsis, preferring a word boundary. */
|
|
12
24
|
export function fit(text: string, width: number) {
|
|
@@ -23,7 +35,7 @@ const spread = (left: string, right: string, width: number) => {
|
|
|
23
35
|
export type InspectorPage = 'agents' | 'usage';
|
|
24
36
|
/** Closing the panel either picks the subagent model or opens one agent's conversation. */
|
|
25
37
|
export type InspectorAction = 'model' | { open: string };
|
|
26
|
-
type Tone = 'accent' | 'muted' | 'dim' | 'error' | 'border';
|
|
38
|
+
type Tone = 'accent' | 'muted' | 'dim' | 'error' | 'warning' | 'border';
|
|
27
39
|
interface Options {
|
|
28
40
|
profile: string; session: string; children: Children; usage: UsageLedger; page: InspectorPage;
|
|
29
41
|
rows: () => number; redraw: () => void; done: (action?: InspectorAction) => void;
|
|
@@ -96,24 +108,34 @@ export class Inspector implements Component, Focusable {
|
|
|
96
108
|
this.scroll = Math.max(0, Math.min(this.scroll, this.lineCount - this.height));
|
|
97
109
|
}
|
|
98
110
|
}
|
|
99
|
-
|
|
100
|
-
|
|
111
|
+
/** Period tabs, one headline, then an aligned table with a row per role and model. */
|
|
112
|
+
private usageLines(width: number) {
|
|
113
|
+
const { usage, session, context, color } = this.options;
|
|
101
114
|
const entries = usage.select(this.range, session);
|
|
102
115
|
const { total, groups } = summarizeUsage(entries);
|
|
103
|
-
|
|
104
|
-
const
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
'
|
|
109
|
-
|
|
116
|
+
// No-break spaces keep a label like "Last 7 days" on one line when the tabs wrap.
|
|
117
|
+
const tabs = ranges.map(r => r.replaceAll(' ', '\u00a0')).map((r, i) => ranges[i] === this.range ? color('accent', `[${r}]`) : color('dim', r)).join(' ');
|
|
118
|
+
const lines = [...wrapTextWithAnsi(tabs, width), ''];
|
|
119
|
+
if (!entries.length) lines.push(color('muted', 'No usage in this period.'));
|
|
120
|
+
else {
|
|
121
|
+
lines.push(`${color('accent', dollars(total.cost.total))} estimated · ${short(total.totalTokens)} tokens · ${cached(total)} cached`, '');
|
|
122
|
+
const order = ['main', 'subagent', 'compactor', 'import'] as const;
|
|
123
|
+
const sorted = groups.sort((a, b) => order.indexOf(a.role) - order.indexOf(b.role) || b.usage.cost.total - a.usage.cost.total);
|
|
124
|
+
const rows = sorted.map((g, i) => [i && sorted[i - 1].role === g.role ? '' : roleNames[g.role], oneLine(g.model), dollars(g.usage.cost.total),
|
|
125
|
+
`${total.cost.total ? Math.round(100 * g.usage.cost.total / total.cost.total) : 0}%`, short(g.usage.output), cached(g.usage)]);
|
|
126
|
+
const header = ['', 'model', 'cost', 'share', 'out', 'cached'];
|
|
127
|
+
const widths = header.map((h, c) => Math.max(h.length, ...rows.map(r => r[c].length)));
|
|
128
|
+
// The model column goes first when the screen is too narrow for every column.
|
|
129
|
+
const columns = widths.reduce((sum, w) => sum + w + 2, 0) - 2 <= width ? [0, 1, 2, 3, 4, 5] : [0, 2, 3, 4, 5];
|
|
130
|
+
const line = (cells: string[]) => columns.map(c => c < 2 ? cells[c].padEnd(widths[c]) : cells[c].padStart(widths[c])).join(' ').trimEnd();
|
|
131
|
+
lines.push(color('dim', line(header)), ...rows.map(r => {
|
|
132
|
+
const text = line(r); return color('muted', text.slice(0, widths[0])) + text.slice(widths[0]);
|
|
133
|
+
}));
|
|
134
|
+
}
|
|
110
135
|
const current = context();
|
|
111
|
-
lines.push('',
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
'Main tracking starts with this version; the resumed parent session and saved children are backfilled.',
|
|
115
|
-
'Older compactor/child records without a parent session appear only in date-based totals.', ...usage.warnings);
|
|
116
|
-
return lines.join('\n');
|
|
136
|
+
if (current != null) lines.push('', `${color('muted', 'Context now')} ${short(current)} tokens`);
|
|
137
|
+
lines.push('', color('dim', 'Estimated at API prices, not your subscription bill.'), ...usage.warnings.map(w => color('warning', w)));
|
|
138
|
+
return lines.flatMap(l => l ? wrapTextWithAnsi(l, width) : ['']);
|
|
117
139
|
}
|
|
118
140
|
render(width: number): string[] {
|
|
119
141
|
const { color, children, profile } = this.options;
|
|
@@ -121,11 +143,11 @@ export class Inspector implements Component, Focusable {
|
|
|
121
143
|
let title = `OptChat · ${profile} · ${this.page === 'usage' ? 'Usage' : 'Agents'}`;
|
|
122
144
|
let info: string, body: string[], hint: string;
|
|
123
145
|
if (this.page === 'usage') {
|
|
124
|
-
const lines =
|
|
146
|
+
const lines = this.usageLines(inner); this.lineCount = lines.length;
|
|
125
147
|
this.scroll = Math.max(0, Math.min(this.scroll, lines.length - this.height));
|
|
126
148
|
body = lines.slice(this.scroll, this.scroll + this.height);
|
|
127
149
|
info = `${this.scroll + 1}–${Math.min(this.scroll + this.height, lines.length)} / ${lines.length}`;
|
|
128
|
-
hint = '←→ period ·
|
|
150
|
+
hint = '←→ period · ↑↓ scroll · Tab agents · Esc close';
|
|
129
151
|
} else {
|
|
130
152
|
const list = children.history.list();
|
|
131
153
|
if (!this.selected) this.selected = list[0]?.id;
|
package/src/usage.ts
CHANGED
|
@@ -91,13 +91,13 @@ export class UsageLedger {
|
|
|
91
91
|
}
|
|
92
92
|
|
|
93
93
|
export function summarizeUsage(entries: UsageEntry[]) {
|
|
94
|
-
const total = emptyUsage(), groups = new Map<string, { role: UsageRole; model: string;
|
|
94
|
+
const total = emptyUsage(), groups = new Map<string, { role: UsageRole; model: string; usage: Usage }>();
|
|
95
95
|
for (const entry of entries) {
|
|
96
|
-
|
|
97
|
-
const key = `${entry.role}:${model}`;
|
|
96
|
+
// By model name alone: older compactor records lack a provider and must still land in the same row.
|
|
97
|
+
const key = `${entry.role}:${entry.model}`;
|
|
98
98
|
let group = groups.get(key);
|
|
99
|
-
if (!group) { group = { role: entry.role, model
|
|
100
|
-
|
|
99
|
+
if (!group) { group = { role: entry.role, model: entry.model, usage: emptyUsage() }; groups.set(key, group); }
|
|
100
|
+
addUsage(group.usage, entry.usage); addUsage(total, entry.usage);
|
|
101
101
|
}
|
|
102
102
|
return { total, groups: [...groups.values()] };
|
|
103
103
|
}
|