pi-optchat 0.6.2 → 0.6.3
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 +1 -0
- package/package.json +1 -1
- package/src/index.ts +21 -7
- package/src/report-message.ts +32 -0
- package/src/transcript.ts +8 -1
package/README.md
CHANGED
|
@@ -69,6 +69,7 @@ Ask in plain words, for example: "Spawn an agent to investigate this repository
|
|
|
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
71
|
- Each child reports back on its own when it finishes. The parent stays alive to receive reports; it never polls.
|
|
72
|
+
- 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.)
|
|
72
73
|
- 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.
|
|
73
74
|
- Delegation goes up to three levels below the main agent (child, grandchild, great-grandchild).
|
|
74
75
|
- 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.
|
package/package.json
CHANGED
package/src/index.ts
CHANGED
|
@@ -11,7 +11,8 @@ 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 { boundedMessage, buildContext, logMessage, previousExchange, RUN_BOUNDARY, textContent } from './transcript.ts';
|
|
14
|
+
import { asUser, boundedMessage, buildContext, logMessage, previousExchange, REPORT_TYPE, RUN_BOUNDARY, textContent } from './transcript.ts';
|
|
15
|
+
import { registerReportRenderer } from './report-message.ts';
|
|
15
16
|
import { memoryTools, result } from './tools.ts';
|
|
16
17
|
import { Children } from './agents.ts';
|
|
17
18
|
import { exportBrowser } from './browser.ts';
|
|
@@ -76,10 +77,16 @@ export default function optchat(pi: ExtensionAPI) {
|
|
|
76
77
|
}
|
|
77
78
|
}
|
|
78
79
|
};
|
|
80
|
+
// Shown as a dark background box, not as the user's own message. Before this profile has run once there is no
|
|
81
|
+
// built system prompt to reuse, so that rare case still goes through Pi's normal prompt path as a user message.
|
|
82
|
+
const sendReport = (text: string) => {
|
|
83
|
+
if (prompt) pi.sendMessage({ customType: REPORT_TYPE, content: text, display: true }, { triggerTurn: true, deliverAs: 'steer' });
|
|
84
|
+
else pi.sendUserMessage(text, { deliverAs: 'steer', expandPromptTemplates: false });
|
|
85
|
+
};
|
|
79
86
|
const deliverReport = async (text: string, once = false) => {
|
|
80
87
|
if (once && (active?.memory.root.some(e => e.receipt === reportReceipt(text)) || reports.includes(text))) return;
|
|
81
88
|
reports.push(text); saveReports();
|
|
82
|
-
if (!stopping)
|
|
89
|
+
if (!stopping) sendReport(text);
|
|
83
90
|
};
|
|
84
91
|
const stop = async () => {
|
|
85
92
|
remote?.close(); remote = undefined;
|
|
@@ -95,7 +102,7 @@ export default function optchat(pi: ExtensionAPI) {
|
|
|
95
102
|
await checkpoint(old.dir);
|
|
96
103
|
} finally {
|
|
97
104
|
await old.memory.close(); await old.unlock(); active = undefined;
|
|
98
|
-
run = []; previous = []; logged = 0; view = undefined; runStarted = false; receipts.clear();
|
|
105
|
+
run = []; previous = []; logged = 0; view = undefined; runStarted = false; receipts.clear(); prompt = '';
|
|
99
106
|
}
|
|
100
107
|
};
|
|
101
108
|
const chooseProfile = async (ctx: ExtensionContext): Promise<string | undefined> => {
|
|
@@ -143,7 +150,7 @@ export default function optchat(pi: ExtensionAPI) {
|
|
|
143
150
|
ctx.ui.notify(`OptChat · ${name} · ${memory.root.length} messages\nCompactor: ${config.compactor.provider}/${config.compactor.model} (${config.compactor.thinking})`, 'info');
|
|
144
151
|
const queuedReports = [...reports];
|
|
145
152
|
if (!pendingImport(dir)) recovery = children.recoverHandoffs().catch(error => ctx.ui.notify(`Handoff recovery: ${errorText(error)}`, 'error'));
|
|
146
|
-
setImmediate(() => { if (active?.memory === memory && !pendingImport(dir)) for (const text of queuedReports)
|
|
153
|
+
setImmediate(() => { if (active?.memory === memory && !pendingImport(dir)) for (const text of queuedReports) sendReport(text); });
|
|
147
154
|
} catch (error) {
|
|
148
155
|
await closeWindows?.(); closeWindows = undefined;
|
|
149
156
|
if (active && active.memory === openingMemory) {
|
|
@@ -195,10 +202,15 @@ export default function optchat(pi: ExtensionAPI) {
|
|
|
195
202
|
}
|
|
196
203
|
return { action: 'continue' };
|
|
197
204
|
});
|
|
198
|
-
|
|
205
|
+
const startRun = (ctx: ExtensionContext) => {
|
|
199
206
|
flush(); run = []; logged = 0; view = undefined; runStarted = true;
|
|
200
207
|
previous = previousExchange(ctx.sessionManager.getBranch());
|
|
201
208
|
pi.appendEntry(RUN_BOUNDARY, { state: 'start' });
|
|
209
|
+
};
|
|
210
|
+
// A report sent while Pi is idle starts its run without before_agent_start; it reuses the last built prompt.
|
|
211
|
+
pi.on('agent_start', (_event, ctx) => { if (active && !runStarted) startRun(ctx); });
|
|
212
|
+
pi.on('before_agent_start', (event, ctx) => {
|
|
213
|
+
startRun(ctx);
|
|
202
214
|
const a = required();
|
|
203
215
|
// Pi's own prompt sections (AGENTS.md files, skills, cwd) stay; the profile's instructions go last.
|
|
204
216
|
event.systemPromptOptions.customPrompt = `${MASTER}\n\n${VIEW_DOC}\n\nFor conversational continuity, the memory view may be followed by the immediately preceding completed exchange (its user requests and final answer, in full text), then the new input. Use that exact wording to understand follow-ups; older exchanges and previous tool output remain accessible through memory and zoom.`;
|
|
@@ -207,7 +219,8 @@ export default function optchat(pi: ExtensionAPI) {
|
|
|
207
219
|
});
|
|
208
220
|
pi.on('message_end', (event, ctx) => {
|
|
209
221
|
if (!active || !runStarted) return;
|
|
210
|
-
const
|
|
222
|
+
const bounded = boundedMessage(event.message);
|
|
223
|
+
const message = asUser(bounded);
|
|
211
224
|
if (message.role === 'user') {
|
|
212
225
|
try {
|
|
213
226
|
const text = textContent(message.content);
|
|
@@ -223,7 +236,7 @@ export default function optchat(pi: ExtensionAPI) {
|
|
|
223
236
|
if (view !== undefined) {
|
|
224
237
|
try { flush(); } catch (error) { ctx.abort(); fault = errorText(error); ctx.ui.notify(fault, 'error'); }
|
|
225
238
|
}
|
|
226
|
-
if (
|
|
239
|
+
if (bounded !== event.message) return { message: bounded };
|
|
227
240
|
});
|
|
228
241
|
pi.on('context_with_system', async (event, ctx) => {
|
|
229
242
|
try {
|
|
@@ -268,6 +281,7 @@ export default function optchat(pi: ExtensionAPI) {
|
|
|
268
281
|
}
|
|
269
282
|
});
|
|
270
283
|
registerConnectedRenderer(pi);
|
|
284
|
+
registerReportRenderer(pi);
|
|
271
285
|
for (const tool of memoryTools(() => required().memory)) pi.registerTool(tool);
|
|
272
286
|
pi.registerTool({ name: 'spawn', label: 'Spawn background agents',
|
|
273
287
|
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.',
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import { getMarkdownTheme, type ExtensionAPI, type Theme } from '@earendil-works/pi-coding-agent';
|
|
2
|
+
import { Box, Markdown, Text } from '@earendil-works/pi-tui';
|
|
3
|
+
import { REPORT_TYPE, textContent } from './transcript.ts';
|
|
4
|
+
|
|
5
|
+
const HEAD = /^\[([0-9a-f]{8})\] (?:(Message from subagent \(still running\)|Connected agent message): )?/;
|
|
6
|
+
|
|
7
|
+
/** Split "[id] Message from subagent (still running): body" into a short label and the body. */
|
|
8
|
+
export function reportParts(text: string) {
|
|
9
|
+
const match = HEAD.exec(text);
|
|
10
|
+
if (!match) return { label: 'subagent', body: text };
|
|
11
|
+
const kind = match[2] === undefined ? 'report' : match[2].startsWith('Message') ? 'still running' : 'connected window';
|
|
12
|
+
return { label: `subagent ${match[1]} · ${kind}`, body: text.slice(match[0].length) };
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/** The theme's neutral grey, darkened on dark themes so the box recedes behind the conversation. */
|
|
16
|
+
function darkBackground(theme: Theme) {
|
|
17
|
+
const rgb = /^\x1b\[48;2;(\d+);(\d+);(\d+)m$/.exec(theme.getBgAnsi('toolPendingBg'))?.slice(1).map(Number);
|
|
18
|
+
if (!rgb || rgb[0] + rgb[1] + rgb[2] > 3 * 128) return (text: string) => theme.bg('toolPendingBg', text);
|
|
19
|
+
const open = `\x1b[48;2;${rgb.map(c => Math.round(c * 0.7)).join(';')}m`;
|
|
20
|
+
return (text: string) => `${open}${text}\x1b[49m`;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/** Background traffic: a dark neutral box with dim text, so it never looks like something the user typed. */
|
|
24
|
+
export function registerReportRenderer(pi: ExtensionAPI) {
|
|
25
|
+
pi.registerMessageRenderer(REPORT_TYPE, (message, { outputPad }, theme) => {
|
|
26
|
+
const { label, body } = reportParts(textContent(message.content));
|
|
27
|
+
const box = new Box(outputPad, 1, darkBackground(theme));
|
|
28
|
+
box.addChild(new Text(theme.fg('dim', `↳ ${label}`), 0, 0));
|
|
29
|
+
box.addChild(new Markdown(body.trim(), 0, 0, getMarkdownTheme(), { color: text => theme.fg('muted', text) }));
|
|
30
|
+
return box;
|
|
31
|
+
});
|
|
32
|
+
}
|
package/src/transcript.ts
CHANGED
|
@@ -5,6 +5,8 @@ import { cap, type Memory } from './memory.ts';
|
|
|
5
5
|
import { record } from './cache.ts';
|
|
6
6
|
|
|
7
7
|
export const RUN_BOUNDARY = 'optchat.run';
|
|
8
|
+
/** Subagent traffic to the main agent: a custom message on screen, a plain user message to the model and memory. */
|
|
9
|
+
export const REPORT_TYPE = 'optchat-report';
|
|
8
10
|
|
|
9
11
|
export function textContent(content: unknown): string {
|
|
10
12
|
if (typeof content === 'string') return content;
|
|
@@ -16,6 +18,11 @@ export function textContent(content: unknown): string {
|
|
|
16
18
|
return '';
|
|
17
19
|
}).filter(Boolean).join('\n');
|
|
18
20
|
}
|
|
21
|
+
/** Reports reach the model, memory and the previous-exchange replay exactly as the user messages they used to be. */
|
|
22
|
+
export function asUser(message: AgentMessage): AgentMessage {
|
|
23
|
+
if (message.role !== 'custom' || message.customType !== REPORT_TYPE) return message;
|
|
24
|
+
return { role: 'user', content: textContent(message.content), timestamp: message.timestamp };
|
|
25
|
+
}
|
|
19
26
|
export function logMessage(memory: Memory, message: AgentMessage, receipt?: string) {
|
|
20
27
|
const date = new Date(message.timestamp).toISOString();
|
|
21
28
|
if (message.role === 'user') memory.append('user', textContent(message.content), date, receipt);
|
|
@@ -54,7 +61,7 @@ function completedExchange(history: readonly AgentMessage[]) {
|
|
|
54
61
|
export function previousExchange(branch: readonly SessionEntry[]) {
|
|
55
62
|
let end = -1;
|
|
56
63
|
let legacyEnd = branch.length;
|
|
57
|
-
const messages = (entries: readonly SessionEntry[]) => entries.flatMap(entry => entry.type === 'message' ? [entry.message] : []);
|
|
64
|
+
const messages = (entries: readonly SessionEntry[]) => entries.flatMap(entry => entry.type === 'message' ? [asUser(entry.message)] : entry.type === 'custom_message' && entry.customType === REPORT_TYPE ? [asUser({ role: 'custom', customType: entry.customType, content: entry.content, display: entry.display, timestamp: Date.parse(entry.timestamp) })] : []);
|
|
58
65
|
for (let i = branch.length - 1; i >= 0; i--) {
|
|
59
66
|
const entry = branch[i];
|
|
60
67
|
if (entry.type !== 'custom' || entry.customType !== RUN_BOUNDARY || !record(entry.data)) continue;
|