pi-optchat 0.7.2 → 0.8.1
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 +36 -22
- package/THIRD_PARTY_NOTICES.md +2 -2
- package/docs/victor-recipe.md +5 -5
- package/package.json +2 -1
- package/src/agents.ts +78 -14
- package/src/browser.ts +5 -3
- package/src/cache.ts +14 -10
- package/src/checkpoint.ts +1 -1
- package/src/compactor.ts +9 -11
- package/src/handoff.ts +3 -3
- package/src/images.ts +44 -0
- package/src/import/job.ts +15 -9
- package/src/import/sources.ts +108 -14
- package/src/import/ui.ts +6 -4
- package/src/inbox.ts +15 -4
- package/src/index.ts +122 -41
- package/src/inspector.ts +19 -2
- package/src/memory.ts +196 -79
- package/src/profiles.ts +51 -32
- package/src/prompts.ts +191 -5
- package/src/runs.ts +16 -3
- package/src/settings-page.ts +2 -2
- package/src/settings.ts +12 -5
- package/src/tools.ts +10 -6
- package/src/transcript.ts +26 -9
- package/src/window-bridge.ts +76 -17
- package/src/import/guidance.ts +0 -1
package/src/index.ts
CHANGED
|
@@ -1,48 +1,57 @@
|
|
|
1
1
|
import { execFile } from 'node:child_process';
|
|
2
|
-
import { existsSync, readFileSync } from 'node:fs';
|
|
2
|
+
import { existsSync, readFileSync, writeSync } from 'node:fs';
|
|
3
3
|
import { join } from 'node:path';
|
|
4
4
|
import { hostname } from 'node:os';
|
|
5
|
-
import { createHash } from 'node:crypto';
|
|
6
5
|
import { getSupportedThinkingLevels } from '@earendil-works/pi-ai';
|
|
7
6
|
import type { AgentMessage } from '@earendil-works/pi-agent-core';
|
|
8
7
|
import { parseSkillBlock, type ExtensionAPI, type ExtensionContext, type ExtensionCommandContext } from '@earendil-works/pi-coding-agent';
|
|
9
8
|
import { Type } from 'typebox';
|
|
10
|
-
import { Memory } from './memory.ts';
|
|
9
|
+
import { atomicWrite, Memory } from './memory.ts';
|
|
11
10
|
import { createCompressor } from './compactor.ts';
|
|
12
|
-
import {
|
|
13
|
-
import { MASTER, VIEW_DOC } from './prompts.ts';
|
|
11
|
+
import { createProfile, instructions, isWindows, lastProfile, listProfiles, loadConfig, lockProfile, profilePath, rememberProfile, saveConfig, ProfileBusyError, type ProfileConfig } from './profiles.ts';
|
|
12
|
+
import { allowSearch, CONTINUITY, IMPORT_GUIDANCE, MASTER, SEARCH_DOC, VIEW_DOC } from './prompts.ts';
|
|
14
13
|
import { cachePayload, record } from './cache.ts';
|
|
15
|
-
import {
|
|
14
|
+
import { saveImages } from './images.ts';
|
|
15
|
+
import { asUser, boundedMessage, buildContext, logMessage, previousExchange, reportReceipt, REPORT_RECEIPT, REPORT_TYPE, RUN_BOUNDARY, textContent, typedText } from './transcript.ts';
|
|
16
16
|
import { registerReportRenderer, type ReportDetails } from './report-message.ts';
|
|
17
|
-
import { memoryTools, result,
|
|
17
|
+
import { memoryTools, result, searchTool } from './tools.ts';
|
|
18
18
|
import { Children, CWD_DOC, loadedBuiltins } from './agents.ts';
|
|
19
19
|
import { exportBrowser } from './browser.ts';
|
|
20
20
|
import { Inbox } from './inbox.ts';
|
|
21
21
|
import { checkpoint } from './checkpoint.ts';
|
|
22
22
|
import { memoryDirectory, pendingImport, prepareImport, runImport, discardImport } from './import/job.ts';
|
|
23
23
|
import { chooseImport, showProgress } from './import/ui.ts';
|
|
24
|
-
import { IMPORT_GUIDANCE } from './import/guidance.ts';
|
|
25
24
|
import { UsageLedger } from './usage.ts';
|
|
26
|
-
import { showInspector, type InspectorPage } from './inspector.ts';
|
|
25
|
+
import { oneLine, showInspector, type InspectorPage } from './inspector.ts';
|
|
27
26
|
import { showAgentView } from './agent-view.ts';
|
|
28
27
|
import { inspectorShortcut, mountNavigation } from './navigation.ts';
|
|
29
|
-
import { serveWindows } from './window-bridge.ts';
|
|
28
|
+
import { joinHeadless, serveWindows } from './window-bridge.ts';
|
|
30
29
|
import { openConnectedWindow, registerConnectedRenderer } from './connected-window.ts';
|
|
31
30
|
import { createHandoffSummarizer } from './handoff.ts';
|
|
32
31
|
import { mainTitle, TabTitle } from './title.ts';
|
|
33
32
|
import { showModelPicker, showSettings } from './settings-page.ts';
|
|
34
33
|
|
|
35
34
|
const binding = 'optchat.profile';
|
|
36
|
-
const
|
|
35
|
+
const CONNECT_MODES = ['auto', 'join', 'off'] as const;
|
|
36
|
+
/** Print mode routes `process.stdout` to stderr so only the answer reaches stdout; the joined reply goes to fd 1 itself. */
|
|
37
|
+
async function printReply(text: string) {
|
|
38
|
+
const bytes = Buffer.from(`${text}\n`);
|
|
39
|
+
for (let at = 0; at < bytes.length;) {
|
|
40
|
+
try { at += writeSync(1, bytes, at); }
|
|
41
|
+
catch (error) { if (!record(error) || error.code !== 'EAGAIN') throw error; await new Promise(resolve => setTimeout(resolve, 10)); }
|
|
42
|
+
}
|
|
43
|
+
}
|
|
37
44
|
const toggle = (prompt: string, line: string, on: boolean, after: string) => on === prompt.includes(line) ? prompt : on ? prompt.replace(after, after + line) : prompt.replace(line, '');
|
|
38
45
|
/** A report run while idle reuses the last built prompt, so Previous exchange and Memory search changes since then are applied here. */
|
|
39
46
|
const promptFor = (prompt: string, { previousExchange, memorySearch }: ProfileConfig) =>
|
|
40
|
-
toggle(toggle(prompt, CONTINUITY, previousExchange, VIEW_DOC), SEARCH_DOC, memorySearch, previousExchange ? VIEW_DOC + CONTINUITY : VIEW_DOC);
|
|
47
|
+
allowSearch(toggle(toggle(prompt, CONTINUITY, previousExchange, VIEW_DOC), SEARCH_DOC, memorySearch, previousExchange ? VIEW_DOC + CONTINUITY : VIEW_DOC), memorySearch);
|
|
41
48
|
interface Active { name: string; dir: string; config: ProfileConfig; memory: Memory; inbox: Inbox; children: Children; usage: UsageLedger; unlock: () => Promise<void> }
|
|
42
49
|
|
|
43
50
|
export default function optchat(pi: ExtensionAPI) {
|
|
44
51
|
let active: Active | undefined;
|
|
45
52
|
let remote: Awaited<ReturnType<typeof openConnectedWindow>> | undefined;
|
|
53
|
+
/** `pi -p` on a profile another Pi owns: the prompt goes to a subagent in that Pi (--optchat-connect). */
|
|
54
|
+
let joined: Awaited<ReturnType<typeof joinHeadless>> | undefined;
|
|
46
55
|
let closeWindows: (() => Promise<void>) | undefined;
|
|
47
56
|
let recovery: Promise<void> | undefined;
|
|
48
57
|
let run: AgentMessage[] = [];
|
|
@@ -52,8 +61,11 @@ export default function optchat(pi: ExtensionAPI) {
|
|
|
52
61
|
let prompt = '';
|
|
53
62
|
let runStarted = false;
|
|
54
63
|
let fault: string | undefined;
|
|
55
|
-
/** Reports not yet in memory. `count`: how many subagent reports the text joins. `batch`: held while the rest of that spawn runs, not sent yet.
|
|
56
|
-
|
|
64
|
+
/** Reports not yet in memory. `count`: how many subagent reports the text joins. `batch`: held while the rest of that spawn runs, not sent yet.
|
|
65
|
+
* `steered`: last sent into a running turn, where Esc or a dequeue can clear it from Pi's queue unseen. */
|
|
66
|
+
let reports: { text: string; count?: number; batch?: string; steered?: boolean }[] = [];
|
|
67
|
+
type Report = (typeof reports)[number];
|
|
68
|
+
let idle = () => true;
|
|
57
69
|
const receipts = new Map<AgentMessage, string>();
|
|
58
70
|
let checkpoints = Promise.resolve();
|
|
59
71
|
let importing = false;
|
|
@@ -73,9 +85,20 @@ export default function optchat(pi: ExtensionAPI) {
|
|
|
73
85
|
/** Journals written before batches held plain strings. */
|
|
74
86
|
const isPendingReport = (s: unknown): s is string | { text: string; count?: number } =>
|
|
75
87
|
typeof s === 'string' || record(s) && typeof s.text === 'string' && (s.count === undefined || typeof s.count === 'number');
|
|
76
|
-
const reportReceipt = (text: string) => 'report:' + createHash('sha256').update(text).digest('hex');
|
|
77
88
|
const errorText = (error: unknown) => error instanceof Error ? error.message : String(error);
|
|
78
89
|
pi.registerFlag('optchat-profile', { description: 'OptChat profile (required for noninteractive sessions without a saved binding)', type: 'string' });
|
|
90
|
+
pi.registerFlag('optchat-connect', { description: 'pi -p on a profile open in another Pi: auto (join it if busy, default), join (only join), off (error if busy)', type: 'string', default: 'auto' });
|
|
91
|
+
const connectMode = () => {
|
|
92
|
+
const mode = pi.getFlag('optchat-connect') ?? 'auto';
|
|
93
|
+
const known = CONNECT_MODES.find(m => m === mode);
|
|
94
|
+
if (!known) { process.exitCode = 1; throw new Error(`--optchat-connect must be ${CONNECT_MODES.join(', ')}; got ${String(mode)}`); }
|
|
95
|
+
return known;
|
|
96
|
+
};
|
|
97
|
+
/** Pi exits 0 after an extension error; a pipeline must see a failed join. */
|
|
98
|
+
const joinProfile = async (name: string) => {
|
|
99
|
+
try { return await joinHeadless(profilePath(name)); }
|
|
100
|
+
catch (error) { process.exitCode = 1; throw error; }
|
|
101
|
+
};
|
|
79
102
|
const required = () => { if (!active) throw new Error('Choose an OptChat profile first: /optchat profile'); return active; };
|
|
80
103
|
const status = (ctx: ExtensionContext) => {
|
|
81
104
|
const a = active;
|
|
@@ -86,14 +109,15 @@ export default function optchat(pi: ExtensionAPI) {
|
|
|
86
109
|
catch { note = ' · import journal invalid'; }
|
|
87
110
|
ctx.ui.setStatus('optchat', `OptChat: ${a.name} · ${a.memory.root.length} messages · ${a.memory.pending} pending · ${a.children.ids.length} agents${note}`);
|
|
88
111
|
};
|
|
89
|
-
|
|
112
|
+
// `steered` describes this process's Pi queue, so it is not journaled.
|
|
113
|
+
const saveReports = () => { if (active) atomicWrite(join(active.dir, 'pending-reports.json'), JSON.stringify(reports, (key, value: unknown) => key === 'steered' ? undefined : value)); };
|
|
90
114
|
const flush = () => {
|
|
91
115
|
if (!active) return;
|
|
92
116
|
while (logged < run.length) {
|
|
93
117
|
const message = run[logged];
|
|
94
118
|
const receipt = receipts.get(message);
|
|
95
119
|
logMessage(active.memory, message, receipt); logged++;
|
|
96
|
-
if (receipt && !receipt.startsWith(
|
|
120
|
+
if (receipt && !receipt.startsWith(REPORT_RECEIPT)) active.inbox.acknowledge(receipt);
|
|
97
121
|
receipts.delete(message);
|
|
98
122
|
if (message.role === 'user') {
|
|
99
123
|
const text = textContent(message.content), index = reports.findIndex(r => !r.batch && r.text === text);
|
|
@@ -103,21 +127,24 @@ export default function optchat(pi: ExtensionAPI) {
|
|
|
103
127
|
};
|
|
104
128
|
// Shown as a dark background box, not as the user's own message. Before this profile has run once there is no
|
|
105
129
|
// built system prompt to reuse, so that rare case still goes through Pi's normal prompt path as a user message.
|
|
106
|
-
const sendReport = (
|
|
130
|
+
const sendReport = (report: Report) => {
|
|
131
|
+
const { text, count } = report;
|
|
132
|
+
report.steered = !idle();
|
|
107
133
|
if (prompt) pi.sendMessage<ReportDetails>({ customType: REPORT_TYPE, content: text, display: true, details: { count } }, { triggerTurn: true, deliverAs: 'steer' });
|
|
108
134
|
else pi.sendUserMessage(text, { deliverAs: 'steer', expandPromptTemplates: false });
|
|
109
135
|
};
|
|
110
136
|
const deliverReport = async (text: string, { once = false, count }: { once?: boolean; count?: number } = {}) => {
|
|
111
137
|
if (once && (active?.memory.root.some(e => e.receipt === reportReceipt(text)) || reports.some(r => r.text === text))) return;
|
|
112
|
-
|
|
113
|
-
|
|
138
|
+
const report = { text, count };
|
|
139
|
+
reports.push(report); saveReports();
|
|
140
|
+
if (!stopping) sendReport(report);
|
|
114
141
|
};
|
|
115
142
|
const holdReports = (batch: string, texts: string[]) => {
|
|
116
143
|
reports = [...reports.filter(r => r.batch !== batch), ...texts.length ? [{ text: texts.join('\n\n'), count: texts.length, batch }] : []];
|
|
117
144
|
saveReports();
|
|
118
145
|
};
|
|
119
146
|
const stop = async () => {
|
|
120
|
-
remote?.close(); remote = undefined;
|
|
147
|
+
remote?.close(); remote = undefined; joined?.close(); joined = undefined;
|
|
121
148
|
inspectorController?.abort(); unmountNavigation?.(); unmountNavigation = undefined;
|
|
122
149
|
stopping = true; importController?.abort(); await importTask?.catch(() => {});
|
|
123
150
|
if (!active) return;
|
|
@@ -130,13 +157,14 @@ export default function optchat(pi: ExtensionAPI) {
|
|
|
130
157
|
await checkpoint(old.dir);
|
|
131
158
|
} finally {
|
|
132
159
|
await old.memory.close(); await old.unlock(); active = undefined;
|
|
133
|
-
run = []; previous = []; logged = 0; view = undefined; runStarted = false; receipts.clear(); prompt = '';
|
|
160
|
+
run = []; previous = []; logged = 0; view = undefined; runStarted = false; receipts.clear(); prompt = ''; idle = () => true;
|
|
134
161
|
untitle?.(); untitle = undefined; title.clear(); working = false;
|
|
135
162
|
}
|
|
136
163
|
};
|
|
137
164
|
const CONNECT = 'Start a connected subagent conversation here', BACK = 'Back';
|
|
138
165
|
const chooseProfile = async (ctx: ExtensionContext): Promise<string | undefined> => {
|
|
139
|
-
|
|
166
|
+
// Outside the TUI (print, json, RPC hosts) never pick or create a profile: a dialog would hang the host, and a guess could write into the wrong memory.
|
|
167
|
+
if (ctx.mode !== 'tui') return undefined;
|
|
140
168
|
const names = listProfiles(), last = lastProfile();
|
|
141
169
|
if (last) names.sort((a, b) => Number(b === last) - Number(a === last));
|
|
142
170
|
const selected = await ctx.ui.select('OptChat profile', [...names, '+ Create profile']);
|
|
@@ -175,15 +203,19 @@ export default function optchat(pi: ExtensionAPI) {
|
|
|
175
203
|
.filter(r => !loggedReports.has(reportReceipt(r.text)));
|
|
176
204
|
atomicWrite(pending, JSON.stringify(reports));
|
|
177
205
|
rememberProfile(name);
|
|
178
|
-
active = { name, dir, config, memory, inbox, children, usage, unlock }; fault = undefined;
|
|
206
|
+
active = { name, dir, config, memory, inbox, children, usage, unlock }; fault = undefined; idle = () => ctx.isIdle();
|
|
179
207
|
if (ctx.mode === 'tui') unmountNavigation = mountNavigation(ctx, children, memory, shortcut, page => { void inspect(ctx, page); });
|
|
180
208
|
closeWindows = await serveWindows(dir, children, () => !stopping && !importing && !pendingImport(dir), deliverReport);
|
|
181
209
|
untitle = children.subscribe(() => showTitle(ctx)); showTitle(ctx);
|
|
182
210
|
status(ctx);
|
|
183
211
|
ctx.ui.notify(`OptChat · ${name} · ${memory.root.length} messages\nCompactor: ${config.compactor.provider}/${config.compactor.model} (${config.compactor.thinking})`, 'info');
|
|
184
212
|
const queuedReports = [...reports];
|
|
185
|
-
if (!pendingImport(dir))
|
|
186
|
-
|
|
213
|
+
if (!pendingImport(dir)) {
|
|
214
|
+
const recover = (work: Promise<void>, what: string) => work.catch(error => ctx.ui.notify(`${what}: ${errorText(error)}`, 'error'));
|
|
215
|
+
// Only an interactive Pi picks cut-off subagents up: a one-shot headless run would stop them again as it exits.
|
|
216
|
+
recovery = Promise.all([recover(children.recoverHandoffs(), 'Handoff recovery'), ...ctx.mode === 'tui' ? [recover(children.resumeCutOff(), 'Resuming subagents')] : []]).then(() => {});
|
|
217
|
+
}
|
|
218
|
+
setImmediate(() => { if (active?.memory === memory && !pendingImport(dir)) for (const r of queuedReports) sendReport(r); });
|
|
187
219
|
} catch (error) {
|
|
188
220
|
await closeWindows?.(); closeWindows = undefined;
|
|
189
221
|
untitle?.(); untitle = undefined; title.clear();
|
|
@@ -207,10 +239,17 @@ export default function optchat(pi: ExtensionAPI) {
|
|
|
207
239
|
try {
|
|
208
240
|
if (boundName && typeof flag === 'string' && flag !== boundName) throw new Error(`Session belongs to ${boundName}; cannot resume it as ${flag}.`);
|
|
209
241
|
let name = boundName ?? (typeof flag === 'string' ? flag : await chooseProfile(ctx));
|
|
242
|
+
// Only plain `pi -p` joins: JSON and RPC output carry Pi's own events, which a joined reply would bypass.
|
|
243
|
+
const connect = ctx.mode === 'print' ? connectMode() : 'off';
|
|
244
|
+
if (connect === 'join' && !name) { process.exitCode = 1; throw new Error('--optchat-connect join needs a profile: pass --optchat-profile'); }
|
|
210
245
|
for (;;) {
|
|
211
246
|
if (!name) { status(ctx); return; }
|
|
212
|
-
try {
|
|
213
|
-
|
|
247
|
+
try {
|
|
248
|
+
if (connect === 'join') joined = await joinProfile(name);
|
|
249
|
+
else await openProfile(name, ctx);
|
|
250
|
+
break;
|
|
251
|
+
} catch (error) {
|
|
252
|
+
if (error instanceof ProfileBusyError && connect === 'auto') { joined = await joinProfile(name); break; }
|
|
214
253
|
if (!(error instanceof ProfileBusyError) || ctx.mode !== 'tui') throw error;
|
|
215
254
|
// A resumed conversation already belongs to this profile, so another profile needs a new session (/optchat profile).
|
|
216
255
|
const choice = await ctx.ui.select(`${name} is open in another window\n${error.owner}`, settled ? [CONNECT] : [CONNECT, BACK]);
|
|
@@ -222,7 +261,10 @@ export default function optchat(pi: ExtensionAPI) {
|
|
|
222
261
|
if (name !== boundName) pi.appendEntry(binding, { name });
|
|
223
262
|
} catch (error) {
|
|
224
263
|
if (active) await stop().catch(() => {});
|
|
225
|
-
fault = errorText(error);
|
|
264
|
+
fault = errorText(error);
|
|
265
|
+
// Pi reports a throwing handler on stderr (print) or as an extension_error event (RPC); notify there is a no-op or easy to miss.
|
|
266
|
+
if (ctx.mode !== 'tui') throw error;
|
|
267
|
+
ctx.ui.notify(fault, 'error');
|
|
226
268
|
}
|
|
227
269
|
// Pi sets its own title once every session_start handler has finished, so put ours back afterwards.
|
|
228
270
|
for (const ms of [0, 250, 1000]) setTimeout(() => title.reapply(), ms).unref();
|
|
@@ -232,6 +274,13 @@ export default function optchat(pi: ExtensionAPI) {
|
|
|
232
274
|
pi.on('session_before_switch', () => remote || importing || active?.children.active ? { cancel: true } : undefined);
|
|
233
275
|
pi.on('session_before_fork', () => remote || importing || active?.children.active ? { cancel: true } : undefined);
|
|
234
276
|
pi.on('input', async (event, ctx) => {
|
|
277
|
+
if (joined) {
|
|
278
|
+
try {
|
|
279
|
+
if (event.images?.length) throw new Error('Joining a running profile accepts text only; provide a file path for the agent to read.');
|
|
280
|
+
await printReply(await joined.ask(event.text, ctx.cwd));
|
|
281
|
+
} catch (error) { process.stderr.write(`OptChat: ${errorText(error)}\n`); process.exitCode = 1; }
|
|
282
|
+
return { action: 'handled' };
|
|
283
|
+
}
|
|
235
284
|
if (remote) {
|
|
236
285
|
try {
|
|
237
286
|
if (event.images?.length) throw new Error('Connected windows currently accept text only; provide a file path for the agent to read.');
|
|
@@ -239,10 +288,14 @@ export default function optchat(pi: ExtensionAPI) {
|
|
|
239
288
|
} catch (error) { ctx.ui.notify(errorText(error), 'error'); ctx.ui.setEditorText(event.text); }
|
|
240
289
|
return { action: 'handled' };
|
|
241
290
|
}
|
|
242
|
-
if (!active) {
|
|
291
|
+
if (!active) {
|
|
292
|
+
// Without a profile or flag, a headless run is plain Pi. A requested profile that failed to open refuses instead of running without memory.
|
|
293
|
+
if (ctx.mode !== 'tui' && !fault) return { action: 'continue' };
|
|
294
|
+
ctx.ui.notify(fault ?? 'Select a profile with /optchat profile before chatting.', 'error'); return { action: 'handled' };
|
|
295
|
+
}
|
|
243
296
|
if (importing || pendingImport(active.dir)) { ctx.ui.notify('This profile has an import in progress. Use /optchat import to resume or discard it, or switch profiles.', 'info'); return { action: 'handled' }; }
|
|
244
297
|
if (event.source !== 'extension') {
|
|
245
|
-
try { active.inbox.record(event.text); }
|
|
298
|
+
try { active.inbox.record(event.text, event.streamingBehavior !== undefined); }
|
|
246
299
|
catch (error) { ctx.ui.notify(`Could not save input: ${errorText(error)}`, 'error'); return { action: 'handled' }; }
|
|
247
300
|
}
|
|
248
301
|
return { action: 'continue' };
|
|
@@ -259,24 +312,39 @@ export default function optchat(pi: ExtensionAPI) {
|
|
|
259
312
|
if (active && !runStarted) startRun(ctx);
|
|
260
313
|
});
|
|
261
314
|
pi.on('before_agent_start', (event, ctx) => {
|
|
315
|
+
if (!active) return; // Plain Pi run: leave Pi's own prompt untouched.
|
|
262
316
|
startRun(ctx);
|
|
263
317
|
const a = required();
|
|
264
318
|
// Pi's own prompt sections (AGENTS.md files, skills, cwd) stay; the profile's instructions go last.
|
|
265
319
|
syncSearch(a.config);
|
|
266
|
-
event.systemPromptOptions.customPrompt = `${MASTER}\n\n${VIEW_DOC}${a.config.previousExchange ? CONTINUITY : ''}${a.config.memorySearch ? SEARCH_DOC : ''}
|
|
320
|
+
event.systemPromptOptions.customPrompt = allowSearch(`${MASTER}\n\n${VIEW_DOC}${a.config.previousExchange ? CONTINUITY : ''}${a.config.memorySearch ? SEARCH_DOC : ''}`, a.config.memorySearch);
|
|
267
321
|
event.systemPromptOptions.sections.instructions = `${instructions(a.dir)}\n\n${IMPORT_GUIDANCE}`;
|
|
268
322
|
prompt = event.systemPrompt;
|
|
269
323
|
});
|
|
270
|
-
|
|
324
|
+
const DUPLICATE_REPORT = '(duplicate report, already delivered)';
|
|
325
|
+
pi.on('message_end', async (event, ctx) => {
|
|
271
326
|
if (!active || !runStarted) return;
|
|
272
327
|
const bounded = boundedMessage(event.message);
|
|
273
328
|
const message = asUser(bounded);
|
|
329
|
+
// Before the message is logged, so the image its text names is already there for zoom; zoom's own images are kept already.
|
|
330
|
+
if (message.role === 'user' || message.role === 'toolResult' && message.toolName !== 'zoom') {
|
|
331
|
+
try { await saveImages(active.memory.directory, message.content); }
|
|
332
|
+
catch (error) { ctx.ui.notify(`OptChat could not keep an image: ${errorText(error)}`, 'warning'); }
|
|
333
|
+
}
|
|
334
|
+
if (!active) return;
|
|
274
335
|
if (message.role === 'user') {
|
|
275
336
|
try {
|
|
276
337
|
const text = textContent(message.content);
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
//
|
|
338
|
+
// A report is shown as our custom message, or sent as plain text before the first run has a prompt to reuse.
|
|
339
|
+
if (bounded.role === 'custom' && bounded.customType === REPORT_TYPE || reports.some(r => !r.batch && r.text === text)) {
|
|
340
|
+
// A report sent again after a lost steer can still arrive twice (Pi kept the old steer after an abort): drop the extra copy.
|
|
341
|
+
// Copies are counted, so a later report that happens to repeat an earlier text still arrives.
|
|
342
|
+
const receipt = reportReceipt(text), copies = run.slice(logged).filter(m => receipts.get(m) === receipt).length;
|
|
343
|
+
if (reports.filter(r => !r.batch && r.text === text).length <= copies && (copies > 0 || active.memory.root.some(e => e.receipt === receipt)))
|
|
344
|
+
return { message: bounded.role === 'custom' ? { ...bounded, content: DUPLICATE_REPORT, display: false } : { ...message, content: DUPLICATE_REPORT } };
|
|
345
|
+
receipts.set(message, receipt);
|
|
346
|
+
} else {
|
|
347
|
+
// The inbox journaled the typed input: match without image references or Pi's image notes.
|
|
280
348
|
const typed = typedText(message.content), skill = parseSkillBlock(typed.bare);
|
|
281
349
|
let receipt = active.inbox.claim(typed.text) ?? active.inbox.claim(typed.bare)
|
|
282
350
|
?? (skill ? active.inbox.claimSkill(skill.name, skill.userMessage) : undefined);
|
|
@@ -293,12 +361,21 @@ export default function optchat(pi: ExtensionAPI) {
|
|
|
293
361
|
});
|
|
294
362
|
pi.on('context_with_system', async (event, ctx) => {
|
|
295
363
|
try {
|
|
364
|
+
if (!active) return; // Plain Pi run: pass context through unmodified.
|
|
296
365
|
const a = required();
|
|
297
366
|
if (importing || pendingImport(a.dir)) throw new Error('Profile is unavailable while importing.');
|
|
298
367
|
if (fault) throw new Error(fault);
|
|
299
368
|
if (view === undefined) {
|
|
300
|
-
|
|
301
|
-
|
|
369
|
+
// A misconfigured summarizer retries forever, so say why instead of spinning silently.
|
|
370
|
+
let shown: string | undefined;
|
|
371
|
+
const show = () => {
|
|
372
|
+
const error = a.memory.lastError;
|
|
373
|
+
const text = `Waiting for OptChat summaries…${error ? ` failing: ${oneLine(error)}${error.includes('/optchat') ? '' : ' (see /optchat model)'}` : ''}`;
|
|
374
|
+
if (text !== shown) ctx.ui.setWorkingMessage(shown = text);
|
|
375
|
+
};
|
|
376
|
+
show();
|
|
377
|
+
const unsubscribe = a.memory.onChange(show);
|
|
378
|
+
try { await a.memory.settle(ctx.signal); } finally { unsubscribe(); ctx.ui.setWorkingMessage(); }
|
|
302
379
|
view = a.memory.render(); // Capture old history before logging the new input.
|
|
303
380
|
flush();
|
|
304
381
|
}
|
|
@@ -324,7 +401,9 @@ export default function optchat(pi: ExtensionAPI) {
|
|
|
324
401
|
pi.on('turn_end', (_event, ctx) => collectUsage(ctx));
|
|
325
402
|
pi.on('agent_settled', async (_event, ctx) => {
|
|
326
403
|
collectUsage(ctx);
|
|
327
|
-
try { flush(); } catch (error) { fault = errorText(error); ctx.ui.notify(fault, 'error'); }
|
|
404
|
+
try { flush(); active?.inbox.dropReturned(); } catch (error) { fault = errorText(error); ctx.ui.notify(fault, 'error'); }
|
|
405
|
+
// A report steered into this run that never reached it (Esc or a dequeue cleared Pi's queue) is sent again, now as its own turn.
|
|
406
|
+
if (active && !fault && !stopping) for (const r of reports) if (r.steered && !r.batch) sendReport(r);
|
|
328
407
|
if (runStarted) pi.appendEntry(RUN_BOUNDARY, { state: 'end' });
|
|
329
408
|
runStarted = false; status(ctx);
|
|
330
409
|
working = false; showTitle(ctx);
|
|
@@ -424,7 +503,7 @@ export default function optchat(pi: ExtensionAPI) {
|
|
|
424
503
|
const compress = createCompressor(ctx.modelRegistry, () => a.config.compactor, message => {
|
|
425
504
|
a.usage.compression(message, 'import', ctx.sessionManager.getSessionId());
|
|
426
505
|
}, () => a.config.summaryAcceptBytes);
|
|
427
|
-
const completed = await showProgress(ctx, job, (signal, progress) => runImport(a.dir, compress, signal, progress), signal);
|
|
506
|
+
const completed = await showProgress(ctx, job, (signal, progress) => runImport(a.dir, compress, signal, a.config.importJobs, progress), signal);
|
|
428
507
|
ctx.ui.notify(completed ? `Imported ${job.added} messages into ${a.name}. Previous memory retained at ${job.previous === '.' ? a.dir : join(a.dir, job.previous)}.`
|
|
429
508
|
: 'Import paused. Use /optchat import to resume. Other profiles remain available.', 'info');
|
|
430
509
|
})();
|
|
@@ -438,6 +517,7 @@ export default function optchat(pi: ExtensionAPI) {
|
|
|
438
517
|
}
|
|
439
518
|
if (action === 'profile') {
|
|
440
519
|
if (!ctx.isIdle() || active?.children.active) throw new Error('Finish or stop active work before switching profiles.');
|
|
520
|
+
if (ctx.mode !== 'tui') throw new Error('/optchat profile requires interactive Pi. Start headless runs with --optchat-profile <name>.');
|
|
441
521
|
const selected = await chooseProfile(ctx);
|
|
442
522
|
if (!selected || selected === active?.name) return;
|
|
443
523
|
await ctx.newSession({ setup: async manager => { manager.appendCustomEntry(binding, { name: selected }); } });
|
|
@@ -459,7 +539,8 @@ export default function optchat(pi: ExtensionAPI) {
|
|
|
459
539
|
}
|
|
460
540
|
if (action === 'browse') {
|
|
461
541
|
const a = required(), file = exportBrowser(a.memory, a.name, a.dir);
|
|
462
|
-
|
|
542
|
+
// explorer.exe exits 1 even when it opened the file, so only its launch failing counts there.
|
|
543
|
+
if (ctx.hasUI) execFile(isWindows ? 'explorer.exe' : process.platform === 'darwin' ? 'open' : 'xdg-open', [file], error => { if (error && (!isWindows || error.code === 'ENOENT')) ctx.ui.notify(`Open ${file}`, 'info'); });
|
|
463
544
|
ctx.ui.notify(file, 'info'); return;
|
|
464
545
|
}
|
|
465
546
|
if (action) throw new Error('Use /optchat [profile|settings|model|agents|usage|activity|instructions|browse|import].');
|
package/src/inspector.ts
CHANGED
|
@@ -60,6 +60,8 @@ export class Inspector implements Component, Focusable {
|
|
|
60
60
|
private lineCount = 0;
|
|
61
61
|
private hintLines = 1;
|
|
62
62
|
private range: UsageRange = 'This session';
|
|
63
|
+
private confirmDelete?: string;
|
|
64
|
+
private notice = '';
|
|
63
65
|
private ended = false;
|
|
64
66
|
private readonly unsubscribe: (() => void)[];
|
|
65
67
|
private readonly timer: ReturnType<typeof setInterval>;
|
|
@@ -87,6 +89,7 @@ export class Inspector implements Component, Focusable {
|
|
|
87
89
|
handleInput(data: string) {
|
|
88
90
|
if (this.ended) return;
|
|
89
91
|
if (matchesKey(data, 'escape') || matchesKey(data, 'ctrl+c')) return this.finish();
|
|
92
|
+
const deleting = this.confirmDelete; this.confirmDelete = undefined; this.notice = '';
|
|
90
93
|
if (matchesKey(data, 'tab')) {
|
|
91
94
|
this.page = nextPage(this.page); this.scroll = 0;
|
|
92
95
|
} else if (this.page === 'activity') this.scrollInput(data);
|
|
@@ -103,10 +106,22 @@ export class Inspector implements Component, Focusable {
|
|
|
103
106
|
else if (matchesKey(data, 'home')) this.select(-Infinity);
|
|
104
107
|
else if (matchesKey(data, 'end')) this.select(Infinity);
|
|
105
108
|
else if (data === 'm') return this.finish('model');
|
|
109
|
+
else if ((data === 'd' || matchesKey(data, 'delete')) && this.selected) {
|
|
110
|
+
if (deleting === this.selected) this.remove(this.selected);
|
|
111
|
+
else this.confirmDelete = this.selected;
|
|
112
|
+
}
|
|
106
113
|
else if (matchesKey(data, 'return') && this.selected) return this.finish({ open: this.selected });
|
|
107
114
|
}
|
|
108
115
|
this.options.redraw();
|
|
109
116
|
}
|
|
117
|
+
/** Once the run is gone, the cursor moves to the next row that is not, else the one above. */
|
|
118
|
+
private remove(id: string) {
|
|
119
|
+
const { children } = this.options, list = children.history.list(), doomed = new Set([id, ...children.history.descendants(id).map(r => r.id)]);
|
|
120
|
+
const index = list.findIndex(r => r.id === id), next = list.slice(index).find(r => !doomed.has(r.id)) ?? list.slice(0, index).findLast(r => !doomed.has(r.id));
|
|
121
|
+
children.remove(id).then(() => { if (!children.history.records.has(this.selected ?? '')) this.selected = next?.id; },
|
|
122
|
+
(error: unknown) => { this.notice = `Not deleted: ${error instanceof Error ? error.message : String(error)}`; })
|
|
123
|
+
.finally(() => { if (!this.ended) this.options.redraw(); });
|
|
124
|
+
}
|
|
110
125
|
private scrollInput(data: string) {
|
|
111
126
|
const delta = matchesKey(data, 'up') ? -1 : matchesKey(data, 'down') ? 1 : matchesKey(data, 'pageUp') ? -this.height : matchesKey(data, 'pageDown') ? this.height : 0;
|
|
112
127
|
if (delta || matchesKey(data, 'home') || matchesKey(data, 'end')) {
|
|
@@ -191,10 +206,12 @@ export class Inspector implements Component, Focusable {
|
|
|
191
206
|
});
|
|
192
207
|
if (!body.length) body.push(color('muted', 'No agents yet. Ask the main agent to delegate a task.'));
|
|
193
208
|
info = `${list.filter(isActiveRun).length} active · ${list.length} saved${list.length ? ` · ${cursor + 1}/${list.length}` : ''}`;
|
|
194
|
-
hint =
|
|
209
|
+
hint = this.confirmDelete ? `Press d again to ${children.live(this.confirmDelete) ? 'stop and delete' : 'delete'} this agent and the agents it started`
|
|
210
|
+
: this.notice || '↑↓ select · Enter open · m model · d delete · Tab usage · Esc close';
|
|
195
211
|
}
|
|
196
212
|
title = fit(title, Math.max(1, inner - visibleWidth(info) - 2));
|
|
197
|
-
const
|
|
213
|
+
const tone = this.page !== 'agents' ? 'dim' : this.confirmDelete ? 'warning' : this.notice ? 'error' : 'dim';
|
|
214
|
+
const footer = wrapTextWithAnsi(hint, inner).map(line => color(tone, line));
|
|
198
215
|
this.hintLines = footer.length;
|
|
199
216
|
const rule = color('border', '─'.repeat(Math.max(1, width)));
|
|
200
217
|
// Same layout as Pi's own selectors: rules above and below, content indented by one column.
|