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.
Files changed (47) hide show
  1. package/README.md +4 -2
  2. package/docs/core.md +38 -0
  3. package/docs/durable.md +35 -0
  4. package/docs/recipe-differences.md +1 -1
  5. package/docs/storage.md +2 -2
  6. package/docs/subagents.md +5 -1
  7. package/docs/victor-recipe.md +4 -4
  8. package/package.json +38 -12
  9. package/src/agent-view.ts +5 -6
  10. package/src/agents.ts +19 -12
  11. package/src/browser.ts +11 -7
  12. package/src/compactor.ts +10 -97
  13. package/src/connected-window.ts +4 -4
  14. package/src/core/compactor.ts +119 -0
  15. package/src/core/images.ts +24 -0
  16. package/src/core/import.ts +84 -0
  17. package/src/core/index.ts +13 -0
  18. package/src/core/memory.ts +460 -0
  19. package/src/core/messages.ts +108 -0
  20. package/src/core/prompts.ts +153 -0
  21. package/src/core/store.ts +48 -0
  22. package/src/core/tools.ts +64 -0
  23. package/src/durable/import.ts +114 -0
  24. package/src/durable/index.ts +5 -0
  25. package/src/durable/optchat.ts +274 -0
  26. package/src/durable/store.ts +162 -0
  27. package/src/handoff.ts +4 -3
  28. package/src/images.ts +2 -12
  29. package/src/import/job.ts +19 -51
  30. package/src/import/sources.ts +6 -4
  31. package/src/import/ui.ts +3 -3
  32. package/src/inbox.ts +1 -1
  33. package/src/index.ts +26 -38
  34. package/src/inspector.ts +3 -1
  35. package/src/memory.ts +18 -416
  36. package/src/profiles.ts +3 -2
  37. package/src/prompts.ts +1 -149
  38. package/src/report-message.ts +5 -3
  39. package/src/runs.ts +6 -1
  40. package/src/settings.ts +3 -2
  41. package/src/store.ts +16 -42
  42. package/src/title.ts +3 -2
  43. package/src/tools.ts +5 -62
  44. package/src/transcript.ts +18 -101
  45. package/src/usage.ts +1 -1
  46. package/src/window-bridge.ts +9 -6
  47. /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(memory.root, entries);
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' ? [...memory.root, ...added] : added;
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, MASTER, modelsHint, SEARCH_DOC, TURN_REFRESHED, VIEW_DOC } from './prompts.ts';
14
- import { cachePayload, record } from './cache.ts';
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, boundedMessage, atWork, buildContext, logMessage, previousExchange, refreshedRun, refreshBudget, refreshFraction, refreshPoint, reportReceipt, REPORT_RECEIPT, REPORT_TYPE, RUN_BOUNDARY, textContent, typedText } from './transcript.ts';
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, result, searchTool } from './tools.ts';
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. Before this profile has run once there is no
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
- if (prompt) pi.sendMessage<ReportDetails>({ customType: REPORT_TYPE, content: text, display: true, details: { count } }, { triggerTurn: true, deliverAs: 'steer' });
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'), undefined, config.jobs);
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} · ${oneLine(agent.task).slice(0, 80)}`, agent.id]));
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
- const startRun = (ctx: ExtensionContext) => {
408
- flush(); run = []; logged = 0; keep = 0; view = undefined; runStarted = true;
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
- startRun(ctx);
421
- const a = required();
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
- // A report is shown as our custom message, or sent as plain text before the first run has a prompt to reuse.
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: bounded.role === 'custom' ? { ...bounded, content: DUPLICATE_REPORT, display: false } : { ...message, content: DUPLICATE_REPORT } };
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 (a report run started while Pi was idle) keeps the last one we built.
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, promptFor(prompt, a.config), previous, atWork(a.children.working)) };
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) => { saveConfig(a.dir, config); syncSearch(config); } });
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 ? '↳ ' : ''}${oneLine(run.task)}`, status, time: elapsed((run.ended ?? Date.now()) - run.started) };
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));