pi-optchat 0.10.1 → 0.11.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +4 -2
- package/docs/core.md +38 -0
- package/docs/durable.md +35 -0
- package/docs/recipe-differences.md +1 -1
- package/docs/storage.md +2 -2
- package/docs/subagents.md +5 -1
- package/docs/victor-recipe.md +4 -4
- package/package.json +38 -12
- package/src/agent-view.ts +5 -6
- package/src/agents.ts +19 -12
- package/src/browser.ts +11 -7
- package/src/compactor.ts +10 -97
- package/src/connected-window.ts +4 -4
- package/src/core/compactor.ts +119 -0
- package/src/core/images.ts +24 -0
- package/src/core/import.ts +84 -0
- package/src/core/index.ts +13 -0
- package/src/core/memory.ts +460 -0
- package/src/core/messages.ts +108 -0
- package/src/core/prompts.ts +153 -0
- package/src/core/store.ts +48 -0
- package/src/core/tools.ts +64 -0
- package/src/durable/import.ts +114 -0
- package/src/durable/index.ts +5 -0
- package/src/durable/optchat.ts +274 -0
- package/src/durable/store.ts +162 -0
- package/src/handoff.ts +4 -3
- package/src/images.ts +2 -12
- package/src/import/job.ts +19 -51
- package/src/import/sources.ts +6 -4
- package/src/import/ui.ts +3 -3
- package/src/inbox.ts +1 -1
- package/src/index.ts +26 -38
- package/src/inspector.ts +3 -1
- package/src/memory.ts +18 -416
- package/src/profiles.ts +3 -2
- package/src/prompts.ts +1 -149
- package/src/report-message.ts +5 -3
- package/src/runs.ts +6 -1
- package/src/settings.ts +3 -2
- package/src/store.ts +16 -42
- package/src/title.ts +3 -2
- package/src/tools.ts +5 -62
- package/src/transcript.ts +18 -101
- package/src/usage.ts +1 -1
- package/src/window-bridge.ts +9 -6
- /package/src/{cache.ts → core/cache.ts} +0 -0
package/src/import/ui.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import type { ExtensionUIContext } from '@earendil-works/pi-coding-agent';
|
|
2
2
|
import type { Memory } from '../memory.ts';
|
|
3
|
-
import { bytes } from '../memory.ts';
|
|
3
|
+
import { bytes } from '../core/memory.ts';
|
|
4
4
|
import { scanLocal, scanChatGPT, scanClaudeMemories, readConversation, type Conversation, type ImportedEntry, type Source } from './sources.ts';
|
|
5
5
|
import { deduplicate, type ImportMode, type ImportJob, type ImportProgress } from './job.ts';
|
|
6
6
|
import { selectMany } from './multi-select.ts';
|
|
@@ -64,7 +64,7 @@ export async function chooseImport(ctx: ImportUI, profile: string, memory: Memor
|
|
|
64
64
|
if (choice !== 'Continue with supported records') return;
|
|
65
65
|
}
|
|
66
66
|
if (!entries.length) { ctx.ui.notify('No readable messages remain to import. Rescan or choose different conversations.', 'info'); return; }
|
|
67
|
-
const { added, skipped } = deduplicate(
|
|
67
|
+
const existing = memory.entries(), { added, skipped } = deduplicate(existing, entries);
|
|
68
68
|
if (!added.length) { ctx.ui.notify(`Nothing new to import (${skipped} messages already present).`, 'info'); return; }
|
|
69
69
|
let mode: ImportMode = 'append';
|
|
70
70
|
if (memory.root.length) {
|
|
@@ -75,7 +75,7 @@ export async function chooseImport(ctx: ImportUI, profile: string, memory: Memor
|
|
|
75
75
|
if (!selected) return;
|
|
76
76
|
mode = selected.startsWith('Rebuild') ? 'rebuild' : 'append';
|
|
77
77
|
}
|
|
78
|
-
const affected = mode === 'rebuild' ? [...
|
|
78
|
+
const affected = mode === 'rebuild' ? [...existing, ...added] : added;
|
|
79
79
|
const inputBytes = affected.reduce((n, e) => n + bytes(e.text), 0);
|
|
80
80
|
let nodes = 0;
|
|
81
81
|
for (let n = memory.root.length + added.length; n > 0; n = Math.floor(n / 2)) nodes += n;
|
package/src/inbox.ts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { randomUUID } from 'node:crypto';
|
|
2
2
|
import { existsSync, readFileSync } from 'node:fs';
|
|
3
3
|
import { join } from 'node:path';
|
|
4
|
-
import { record } from './cache.ts';
|
|
4
|
+
import { record } from './core/cache.ts';
|
|
5
5
|
import { atomicWrite } from './store.ts';
|
|
6
6
|
import type { Memory } from './memory.ts';
|
|
7
7
|
|
package/src/index.ts
CHANGED
|
@@ -10,12 +10,15 @@ import { Memory } from './memory.ts';
|
|
|
10
10
|
import { atomicWrite, storeFor } from './store.ts';
|
|
11
11
|
import { createCompressor, type ModelChoice } from './compactor.ts';
|
|
12
12
|
import { createProfile, holdProfile, instructions, isWindows, lastProfile, listProfiles, loadConfig, profilePath, rememberProfile, saveConfig, ProfileBusyError, type ProfileConfig } from './profiles.ts';
|
|
13
|
-
import { allowSearch, CONTINUITY, IMPORT_GUIDANCE,
|
|
14
|
-
import {
|
|
13
|
+
import { allowSearch, CONTINUITY, IMPORT_GUIDANCE, SEARCH_DOC, TURN_REFRESHED, VIEW_DOC } from './core/prompts.ts';
|
|
14
|
+
import { MASTER, modelsHint } from './prompts.ts';
|
|
15
|
+
import { cachePayload, record } from './core/cache.ts';
|
|
15
16
|
import { saveImages } from './images.ts';
|
|
16
|
-
import { asUser,
|
|
17
|
+
import { asUser, atWork, buildContext, logMessage, previousExchange, refreshFraction, reportReceipt, REPORT_RECEIPT, REPORT_TYPE, RUN_BOUNDARY, textContent, typedText } from './transcript.ts';
|
|
18
|
+
import { boundedMessage, refreshedRun, refreshBudget, refreshPoint } from './core/messages.ts';
|
|
17
19
|
import { registerReportRenderer, type ReportDetails } from './report-message.ts';
|
|
18
|
-
import { memoryTools
|
|
20
|
+
import { memoryTools } from './tools.ts';
|
|
21
|
+
import { result, searchTool } from './core/tools.ts';
|
|
19
22
|
import { Children, loadedBuiltins, modelsTool, refreshContextFiles, TASKS } from './agents.ts';
|
|
20
23
|
import { isActiveRun, type RunInfo } from './runs.ts';
|
|
21
24
|
import { exportBrowser } from './browser.ts';
|
|
@@ -24,7 +27,7 @@ import { checkpoint } from './checkpoint.ts';
|
|
|
24
27
|
import { memoryDirectory, pendingImport, prepareImport, runImport, discardImport } from './import/job.ts';
|
|
25
28
|
import { chooseImport, showProgress } from './import/ui.ts';
|
|
26
29
|
import { UsageLedger } from './usage.ts';
|
|
27
|
-
import { oneLine, showInspector, type InspectorPage } from './inspector.ts';
|
|
30
|
+
import { agentLabel, oneLine, showInspector, type InspectorPage } from './inspector.ts';
|
|
28
31
|
import { showAgentView } from './agent-view.ts';
|
|
29
32
|
import { inspectorShortcut, mountNavigation } from './navigation.ts';
|
|
30
33
|
import { joinHeadless, listAttachableAgents, serveWindows } from './window-bridge.ts';
|
|
@@ -44,10 +47,6 @@ async function printReply(text: string) {
|
|
|
44
47
|
catch (error) { if (!record(error) || error.code !== 'EAGAIN') throw error; await new Promise(resolve => setTimeout(resolve, 10)); }
|
|
45
48
|
}
|
|
46
49
|
}
|
|
47
|
-
const toggle = (prompt: string, line: string, on: boolean, after: string) => on === prompt.includes(line) ? prompt : on ? prompt.replace(after, after + line) : prompt.replace(line, '');
|
|
48
|
-
/** A report run while idle reuses the last built prompt, so Previous exchange and Memory search changes since then are applied here. */
|
|
49
|
-
const promptFor = (prompt: string, { previousExchange, memorySearch }: ProfileConfig) =>
|
|
50
|
-
allowSearch(toggle(toggle(prompt, CONTINUITY, previousExchange, VIEW_DOC), SEARCH_DOC, memorySearch, previousExchange ? VIEW_DOC + CONTINUITY : VIEW_DOC), memorySearch);
|
|
51
50
|
interface Active { name: string; dir: string; config: ProfileConfig; memory: Memory; inbox: Inbox; children: Children; usage: UsageLedger; unlock: () => Promise<void> }
|
|
52
51
|
|
|
53
52
|
/** OptChat's own version, read from the package.json beside src/ (in the checkout and the npm package alike); none if unreadable. */
|
|
@@ -173,13 +172,11 @@ export default function optchat(pi: ExtensionAPI) {
|
|
|
173
172
|
}
|
|
174
173
|
}
|
|
175
174
|
};
|
|
176
|
-
// Shown as a dark background box, not as the user's own message.
|
|
177
|
-
// built system prompt to reuse, so that rare case still goes through Pi's normal prompt path as a user message.
|
|
175
|
+
// Shown as a dark background box, not as the user's own message.
|
|
178
176
|
const sendReport = (report: Report) => {
|
|
179
177
|
const { text, count } = report;
|
|
180
178
|
report.steered = !idle();
|
|
181
|
-
|
|
182
|
-
else pi.sendUserMessage(text, { deliverAs: 'steer', expandPromptTemplates: false });
|
|
179
|
+
pi.sendMessage<ReportDetails>({ customType: REPORT_TYPE, content: text, display: true, details: { count } }, { triggerTurn: true, deliverAs: 'steer' });
|
|
183
180
|
};
|
|
184
181
|
const deliverReport = async (text: string, { once = false, count }: { once?: boolean; count?: number } = {}) => {
|
|
185
182
|
if (once && (active?.memory.root.some(e => e.receipt === reportReceipt(text)) || reports.some(r => r.text === text))) return;
|
|
@@ -236,10 +233,10 @@ export default function optchat(pi: ExtensionAPI) {
|
|
|
236
233
|
const pending = join(dir, 'pending-reports.json');
|
|
237
234
|
const saved: unknown = existsSync(pending) ? JSON.parse(readFileSync(pending, 'utf8')) : [];
|
|
238
235
|
if (!Array.isArray(saved) || !saved.every(isPendingReport)) throw new Error('Invalid pending report journal.');
|
|
239
|
-
const memory = await Memory.open(memoryDirectory(dir), createCompressor(ctx.modelRegistry, () => config.compactor, message => {
|
|
236
|
+
const memory = await Memory.open(memoryDirectory(dir), createCompressor(ctx.modelRegistry, () => config.compactor, { onUsage: message => {
|
|
240
237
|
usage.compression(message, 'compactor', sessionId);
|
|
241
238
|
status(ctx);
|
|
242
|
-
}, () => config.summaryAcceptBytes), warning => ctx.ui.notify(warning, 'error'),
|
|
239
|
+
}, accepted: () => config.summaryAcceptBytes }), { warn: warning => ctx.ui.notify(warning, 'error'), jobs: config.jobs });
|
|
243
240
|
openingMemory = memory;
|
|
244
241
|
const inbox = new Inbox(dir);
|
|
245
242
|
const recovered = pendingImport(dir) ? 0 : inbox.recover(memory);
|
|
@@ -321,7 +318,7 @@ export default function optchat(pi: ExtensionAPI) {
|
|
|
321
318
|
const listed = settled ? undefined : await listAttachableAgents(profilePath(name)).catch(() => undefined);
|
|
322
319
|
const agents = listed ?? [];
|
|
323
320
|
const hint = listed?.length === 0 ? '\nNo background agents are running. Ask the main agent to start one.' : '';
|
|
324
|
-
const labels = new Map(agents.map(agent => [`Open subagent ${agent.id} · ${agent.state} · ${
|
|
321
|
+
const labels = new Map(agents.map(agent => [`Open subagent ${agent.id} · ${agent.state} · ${agentLabel(agent).slice(0, 80)}`, agent.id]));
|
|
325
322
|
const choice = await ctx.ui.select(`${name} is open in another window\n${error.owner}${hint}`,
|
|
326
323
|
[CONNECT, ...labels.keys(), ...(settled ? [] : [BACK])]);
|
|
327
324
|
const agentId = choice && labels.get(choice);
|
|
@@ -404,21 +401,14 @@ export default function optchat(pi: ExtensionAPI) {
|
|
|
404
401
|
}
|
|
405
402
|
return { action: 'continue' };
|
|
406
403
|
});
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
const config = active?.config;
|
|
410
|
-
previous = config?.previousExchange ? previousExchange(ctx.sessionManager.getBranch(), config.previousExchangeKB * 1000) : [];
|
|
411
|
-
pi.appendEntry(RUN_BOUNDARY, { state: 'start' });
|
|
412
|
-
};
|
|
413
|
-
// A report sent while Pi is idle starts its run without before_agent_start; it reuses the last built prompt.
|
|
414
|
-
pi.on('agent_start', (_event, ctx) => {
|
|
415
|
-
working = true; showTitle(ctx);
|
|
416
|
-
if (active && !runStarted) startRun(ctx);
|
|
417
|
-
});
|
|
404
|
+
pi.on('agent_start', (_event, ctx) => { working = true; showTitle(ctx); });
|
|
405
|
+
// Every run starts here, reports sent while Pi is idle too (Pi 1.2+).
|
|
418
406
|
pi.on('before_agent_start', (event, ctx) => {
|
|
419
407
|
if (!active) return; // Plain Pi run: leave Pi's own prompt untouched.
|
|
420
|
-
|
|
421
|
-
|
|
408
|
+
const a = active;
|
|
409
|
+
flush(); run = []; logged = 0; keep = 0; view = undefined; runStarted = true;
|
|
410
|
+
previous = a.config.previousExchange ? previousExchange(ctx.sessionManager.getBranch(), a.config.previousExchangeKB * 1000) : [];
|
|
411
|
+
pi.appendEntry(RUN_BOUNDARY, { state: 'start' });
|
|
422
412
|
// Pi's own prompt sections (AGENTS.md files, skills, cwd) stay; the profile's instructions go last.
|
|
423
413
|
syncSearch(a.config);
|
|
424
414
|
event.systemPromptOptions.customPrompt = allowSearch(`${MASTER}\n\n${modelsHint(a.config.subagent)}\n\n${VIEW_DOC}${a.config.previousExchange ? CONTINUITY : ''}${a.config.memorySearch ? SEARCH_DOC : ''}`, a.config.memorySearch);
|
|
@@ -440,13 +430,12 @@ export default function optchat(pi: ExtensionAPI) {
|
|
|
440
430
|
if (message.role === 'user') {
|
|
441
431
|
try {
|
|
442
432
|
const text = textContent(message.content);
|
|
443
|
-
|
|
444
|
-
if (bounded.role === 'custom' && bounded.customType === REPORT_TYPE || reports.some(r => !r.batch && r.text === text)) {
|
|
433
|
+
if (bounded.role === 'custom' && bounded.customType === REPORT_TYPE) {
|
|
445
434
|
// A report sent again after a lost steer can still arrive twice (Pi kept the old steer after an abort): drop the extra copy.
|
|
446
435
|
// Copies are counted, so a later report that happens to repeat an earlier text still arrives.
|
|
447
436
|
const receipt = reportReceipt(text), copies = run.slice(logged).filter(m => receipts.get(m) === receipt).length;
|
|
448
437
|
if (reports.filter(r => !r.batch && r.text === text).length <= copies && (copies > 0 || active.memory.root.some(e => e.receipt === receipt)))
|
|
449
|
-
return { message:
|
|
438
|
+
return { message: { ...bounded, content: DUPLICATE_REPORT, display: false } };
|
|
450
439
|
receipts.set(message, receipt);
|
|
451
440
|
} else {
|
|
452
441
|
// The inbox journaled the typed input: match without image references or Pi's image notes.
|
|
@@ -485,7 +474,7 @@ export default function optchat(pi: ExtensionAPI) {
|
|
|
485
474
|
flush();
|
|
486
475
|
}
|
|
487
476
|
// Extensions that run after ours (Pi's built-in MCP adds <mcp_servers>) still change the prompt; send what Pi has now.
|
|
488
|
-
// A prompt without our preamble
|
|
477
|
+
// A prompt without our preamble keeps the last one we built.
|
|
489
478
|
const current = ctx.getSystemPrompt();
|
|
490
479
|
if (current.startsWith(MASTER.slice(0, 40))) prompt = current;
|
|
491
480
|
// A turn past its budget moves its steps into the view, keeping the task and the last tool call whole (#146).
|
|
@@ -498,7 +487,7 @@ export default function optchat(pi: ExtensionAPI) {
|
|
|
498
487
|
ctx.ui.notify(`OptChat moved ${Math.round(size / 4000)}k tokens of this turn into memory (${((Date.now() - began) / 1000).toFixed(1)}s).`, 'info');
|
|
499
488
|
}
|
|
500
489
|
// Built on every call, unlike the view, so it is never out of date; it goes last, after everything cached.
|
|
501
|
-
return { messages: buildContext(event.messages, refreshedRun(run, keep, TURN_REFRESHED), view,
|
|
490
|
+
return { messages: buildContext(event.messages, refreshedRun(run, keep, TURN_REFRESHED), view, prompt, previous, atWork(a.children.working)) };
|
|
502
491
|
} catch (error) {
|
|
503
492
|
// Pi catches extension errors. Explicitly abort so it cannot fall back to old context.
|
|
504
493
|
ctx.abort();
|
|
@@ -538,7 +527,6 @@ export default function optchat(pi: ExtensionAPI) {
|
|
|
538
527
|
pi.registerTool(zoom); pi.registerTool(date);
|
|
539
528
|
pi.registerTool({ ...searchTool(() => required().memory), defaultActive: false });
|
|
540
529
|
// The tool and its prompt line change together, once per toggle, so the cached prefix is otherwise stable.
|
|
541
|
-
// Synced on save too: a report turn started while idle reuses the tool set without before_agent_start.
|
|
542
530
|
const syncSearch = ({ memorySearch }: ProfileConfig) => {
|
|
543
531
|
const tools = pi.getActiveTools();
|
|
544
532
|
if (tools.includes('search') !== memorySearch) pi.setActiveTools(memorySearch ? [...tools, 'search'] : tools.filter(name => name !== 'search'));
|
|
@@ -560,7 +548,7 @@ export default function optchat(pi: ExtensionAPI) {
|
|
|
560
548
|
/** The settings page's options: every model Pi is logged in to, with the thinking levels it takes, sorted so each provider's models sit together. */
|
|
561
549
|
const settingsOptions = (ctx: ExtensionContext, a: Active) => ({ profile: a.name, config: a.config,
|
|
562
550
|
models: ctx.modelRegistry.getAvailable().map(m => ({ name: `${m.provider}/${m.id}`, thinking: getSupportedThinkingLevels(m) })).sort((x, y) => x.name.localeCompare(y.name)),
|
|
563
|
-
save: (config: ProfileConfig) =>
|
|
551
|
+
save: (config: ProfileConfig) => saveConfig(a.dir, config) });
|
|
564
552
|
const pickModel = async (ctx: ExtensionContext, role: 'compactor' | 'subagent') => {
|
|
565
553
|
const a = required();
|
|
566
554
|
if (ctx.mode !== 'tui') throw new Error('Choosing a model requires interactive Pi. Edit config.json in the profile directory instead.');
|
|
@@ -621,9 +609,9 @@ export default function optchat(pi: ExtensionAPI) {
|
|
|
621
609
|
if (!job) return;
|
|
622
610
|
}
|
|
623
611
|
if (!closed) { await a.memory.close(); closed = true; }
|
|
624
|
-
const compress = createCompressor(ctx.modelRegistry, () => a.config.compactor, message => {
|
|
612
|
+
const compress = createCompressor(ctx.modelRegistry, () => a.config.compactor, { onUsage: message => {
|
|
625
613
|
a.usage.compression(message, 'import', ctx.sessionManager.getSessionId());
|
|
626
|
-
}, () => a.config.summaryAcceptBytes);
|
|
614
|
+
}, accepted: () => a.config.summaryAcceptBytes });
|
|
627
615
|
const completed = await showProgress(ctx, job, (signal, progress) => runImport(a.dir, compress, signal, a.config.jobs, progress), signal);
|
|
628
616
|
ctx.ui.notify(completed ? `Imported ${job.added} messages into ${a.name}. Previous memory retained at ${job.previous === '.' ? a.dir : join(a.dir, job.previous)}.`
|
|
629
617
|
: 'Import paused. Use /optchat import to resume. Other profiles remain available.', 'info');
|
package/src/inspector.ts
CHANGED
|
@@ -8,6 +8,8 @@ import { ranges, summarizeUsage, type UsageLedger, type UsageRange, type UsageRo
|
|
|
8
8
|
|
|
9
9
|
export const clean = (text: string) => text.replace(/[\x00-\x08\x0b-\x1f\x7f-\x9f]/g, '').replace(/\t/g, ' ');
|
|
10
10
|
export const oneLine = (text: string) => clean(text).replace(/\n/g, ' ');
|
|
11
|
+
/** How an agent is named to the user: its spawn title, else its task on one line. */
|
|
12
|
+
export const agentLabel = (run: { task: string; title?: string }) => run.title ?? oneLine(run.task);
|
|
11
13
|
export const count = (n: number) => n.toLocaleString('en-US');
|
|
12
14
|
const units = ['', 'k', 'M', 'B'];
|
|
13
15
|
/** 950, 12.3k, 164k, 2.4M: enough precision to compare rows at a glance. A value that rounds to 1000 moves up a unit. */
|
|
@@ -191,7 +193,7 @@ export class Inspector implements Component, Focusable {
|
|
|
191
193
|
const rows = list.slice(this.top, this.top + this.height).map(run => {
|
|
192
194
|
const live = children.live(run.id), tools = live ? [...live.tools.values()].map(t => t.name).join(', ') : '';
|
|
193
195
|
const status = live ? `${run.state === 'stopping' ? 'stopping' : run.state === 'waiting' ? 'waiting for children' : run.state === 'paused' ? 'interrupted · waiting for you' : tools || (live.streaming ? 'responding' : 'working')} · ${elapsed(Date.now() - live.updated)} ago` : isHandingOff(run) ? 'handing off' : run.state;
|
|
194
|
-
return { run, task: `${' '.repeat(run.depth - 1)}${run.parentId ? '↳ ' : ''}${
|
|
196
|
+
return { run, task: `${' '.repeat(run.depth - 1)}${run.parentId ? '↳ ' : ''}${agentLabel(run)}`, status, time: elapsed((run.ended ?? Date.now()) - run.started) };
|
|
195
197
|
});
|
|
196
198
|
// Columns: task (flexible) · status · duration (right-aligned); status yields first on narrow screens.
|
|
197
199
|
const timeWidth = Math.max(0, ...rows.map(r => r.time.length));
|