pi-optchat 0.6.8 → 0.7.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/src/compactor.ts CHANGED
@@ -1,4 +1,4 @@
1
- import type { AssistantMessage, Message } from '@earendil-works/pi-ai';
1
+ import { clampThinkingLevel, type Api, type AssistantMessage, type Message, type Model } from '@earendil-works/pi-ai';
2
2
  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';
@@ -8,8 +8,14 @@ import { IMPORT_GUIDANCE } from './import/guidance.ts';
8
8
  import { DEFAULT_SETTINGS } from './settings.ts';
9
9
 
10
10
  export interface ModelChoice { provider: string; model: string; thinking: ThinkingLevel }
11
- /** A realistic summary line of exactly NODE bytes, on a topic no real chat shares so its wording can't leak into summaries. */
12
- export const SCALE = 'user: Plan the Lisbon trip for 14-18 May: four adults, one in a wheelchair, 2400 EUR budget, no flights before 09:00. talk: Suggested Baixa; skip tram 28 (not step-free). tool: searched TAP, easyJet fares; echo: TAP TP1205 at 08:40 (too early), easyJet U27652 at 11:15 is 162 EUR each. user: "Book easyJet; step-free rooms matter more than a view." work: [4c1e9a20] Casa do Rio has two step-free rooms at 138 EUR/night, free cancellation to 10 May, held to 2 May. talk: Asked about a Sintra day trip, unanswered.';
11
+ /** A level the model can't take would be sent as no level, which Sonnet 5.5 runs at high effort; Pi's own sessions clamp the same way. */
12
+ export const reasoningFor = (model: Model<Api>, level: ThinkingLevel) => {
13
+ const thinking = clampThinkingLevel(model, level);
14
+ return thinking === 'off' ? undefined : thinking;
15
+ };
16
+ /** The size example: a line of exactly NODE bytes about OptChat itself. Every claim in it is true and timeless (no ids, PRs or decisions),
17
+ * so a summary that copies it states nothing false; the request also fences it off from the input. */
18
+ export const SCALE = 'note: How OptChat memory works. Each message becomes a leaf line: a short message is its own line, a longer one is compressed to about 512 bytes. Adjacent lines merge in pairs into a binary tree: two lines into one line covering both, two of those into one covering four, and so on. The view shows recent messages one per line and older ones more per line, within a fixed byte budget. zoom(id, n) opens line id+n into the two lines it was made from, down to the original message; date(id) tells when it was sent.';
13
19
  const WARM_MS = 4 * 60_000; // Anthropic's short cache lives 5 minutes from its last use.
14
20
 
15
21
  /** 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. */
@@ -45,17 +51,21 @@ export function createCompressor(registry: ModelRegistry, choice: () => ModelCho
45
51
  const selected = choice();
46
52
  const model = registry.find(selected.provider, selected.model);
47
53
  if (!model) throw new Error(`Compactor model unavailable: ${selected.provider}/${selected.model}. Use /optchat model.`);
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}`;
54
+ const thinking = reasoningFor(model, selected.thinking);
55
+ // Shown bare between the view and the input, the example was sometimes summarized as if it were chat, so it is labelled and both are tagged.
56
+ const step = `${input.historical ? IMPORT_GUIDANCE + '\n\n' : ''}For scale only, here is an example line, not from this chat; it is exactly 512 bytes and is never part of your input or your line:\n<example>${SCALE}</example>\n\n${input.merge ? 'Merge these two lines into one' : 'Compress this message into one line'}, in at most 512 bytes:\n<input>\n${input.source}\n</input>`;
49
57
  const messages: Message[] = [{ role: 'user', content: [{ type: 'text', text: input.context }, { type: 'text', text: step }], timestamp: Date.now() }];
50
58
  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;
59
+ const prefix = model.api === 'anthropic-messages' && view.length > 1 ? `${model.provider}/${model.id}/${thinking ?? 'off'}\n${view.slice(0, -1).join('')}` : undefined;
52
60
  const tries: string[] = [];
53
61
  for (let attempt = 0; attempt < 5; attempt++) {
54
62
  const warmed = prefix ? await gate(prefix, signal) : () => {};
55
63
  let reply: AssistantMessage;
56
64
  try {
57
65
  const stream = registry.streamSimple(model, { systemPrompt: COMPACT, messages }, {
58
- reasoning: selected.thinking === 'off' ? undefined : selected.thinking, signal, cacheRetention: 'short',
66
+ // A shared session id is the OpenAI prompt-cache key; SSE because over a websocket Codex would chain unrelated parallel calls on one cached connection.
67
+ sessionId: 'optchat-compactor', transport: 'sse',
68
+ reasoning: thinking, signal, cacheRetention: 'short',
59
69
  onPayload: payload => model.api === 'anthropic-messages' ? cachePayload(payload) : payload,
60
70
  });
61
71
  // The cache entry is usable once the model starts answering.
package/src/handoff.ts CHANGED
@@ -1,7 +1,7 @@
1
1
  import type { ModelRegistry } from '@earendil-works/pi-coding-agent';
2
2
  import type { AgentMessage } from '@earendil-works/pi-agent-core';
3
3
  import type { AssistantMessage } from '@earendil-works/pi-ai';
4
- import type { ModelChoice } from './compactor.ts';
4
+ import { reasoningFor, type ModelChoice } from './compactor.ts';
5
5
  import type { RunInfo } from './runs.ts';
6
6
  import { textContent } from './transcript.ts';
7
7
 
@@ -53,7 +53,7 @@ export function createHandoffSummarizer(registry: ModelRegistry, choice: () => M
53
53
  const reply = await registry.streamSimple(model, {
54
54
  systemPrompt: SYSTEM,
55
55
  messages: [{ role: 'user', timestamp: Date.now(), content: prefix + transcript.subarray(offset, end).toString('utf8') }],
56
- }, { reasoning: selected.thinking === 'off' ? undefined : selected.thinking, maxTokens, signal: AbortSignal.timeout(300_000) }).result();
56
+ }, { reasoning: reasoningFor(model, selected.thinking), maxTokens, signal: AbortSignal.timeout(300_000) }).result();
57
57
  usage(reply);
58
58
  if (reply.stopReason === 'error' || reply.stopReason === 'aborted') throw new Error(reply.errorMessage ?? reply.stopReason);
59
59
  if (reply.stopReason === 'length') throw new Error('Handoff hit the model output limit before finishing');
package/src/import/job.ts CHANGED
@@ -1,10 +1,10 @@
1
1
  import { existsSync, readFileSync, rmSync, cpSync, mkdirSync } from 'node:fs';
2
2
  import { join, relative } from 'node:path';
3
3
  import { randomUUID } from 'node:crypto';
4
- import { Memory, bytes, type Entry, type Compressor } from '../memory.ts';
4
+ import { Memory, bytes, isEntry, type Entry, type Compressor } from '../memory.ts';
5
5
  import { atomicWrite } from '../profiles.ts';
6
6
  import { record } from '../cache.ts';
7
- import type { ImportedEntry } from './sources.ts';
7
+ import { copyKey, type ImportedEntry } from './sources.ts';
8
8
 
9
9
  export type ImportMode = 'append' | 'rebuild';
10
10
  export interface ImportJob {
@@ -12,6 +12,8 @@ export interface ImportJob {
12
12
  added: number; skipped: number; total: number; inputBytes: number;
13
13
  }
14
14
  const pendingFile = (dir: string) => join(dir, 'imports', 'pending.json');
15
+ // The whole planned log. `main/` holds only the part already given to Memory, so resume continues from its length.
16
+ const STAGED = 'staged.jsonl';
15
17
  function generationPath(dir: string, name: string) {
16
18
  if (name !== '.' && !/^memories\/[a-f0-9-]{36}$/.test(name)) throw new Error('Invalid memory generation path.');
17
19
  return join(dir, name);
@@ -38,11 +40,13 @@ export function pendingImport(dir: string): ImportJob | undefined {
38
40
  }
39
41
  export function deduplicate(existing: readonly Entry[], incoming: readonly ImportedEntry[]) {
40
42
  const receipts = new Set(existing.map(e => e.receipt).filter(Boolean));
43
+ const copies = new Set(existing.map(copyKey).filter(Boolean));
41
44
  const added: ImportedEntry[] = []; let skipped = 0;
42
45
  for (const entry of incoming) {
43
46
  if (!entry.receipt?.startsWith('import:')) throw new Error('Imported entry has no stable source identity.');
44
- if (receipts.has(entry.receipt)) { skipped++; continue; }
45
- receipts.add(entry.receipt); added.push(entry);
47
+ const copy = copyKey(entry);
48
+ if (receipts.has(entry.receipt) || copy && copies.has(copy)) { skipped++; continue; }
49
+ receipts.add(entry.receipt); if (copy) copies.add(copy); added.push(entry);
46
50
  }
47
51
  return { added, skipped };
48
52
  }
@@ -68,8 +72,10 @@ export function prepareImport(dir: string, old: Memory, incoming: readonly Impor
68
72
  // Append copies existing summaries without changing their indices. Rebuild regenerates every node.
69
73
  const all = mode === 'rebuild' ? chronological([...old.root, ...added]) : [...old.root, ...chronological(added)];
70
74
  const entries: Entry[] = all.map((e, i) => ({ ...e, i, size: bytes(`${e.kind}: ${e.text}`) }));
71
- mkdirSync(join(path, 'tree'), { recursive: true, mode: 0o700 });
72
- atomicWrite(join(path, 'main', '000-import.jsonl'), entries.map(e => JSON.stringify(e)).join('\n') + '\n');
75
+ const kept = mode === 'append' ? old.root.length : 0;
76
+ for (const sub of ['main', 'tree']) mkdirSync(join(path, sub), { recursive: true, mode: 0o700 });
77
+ if (kept) atomicWrite(join(path, 'main', '000-import.jsonl'), entries.slice(0, kept).map(e => JSON.stringify(e)).join('\n') + '\n');
78
+ atomicWrite(join(path, STAGED), entries.map(e => JSON.stringify(e)).join('\n') + '\n');
73
79
  if (mode === 'append') cpSync(join(old.directory, 'tree'), join(path, 'tree'), { recursive: true });
74
80
  const job: ImportJob = { id, mode, previous: relative(dir, old.directory) || '.', target,
75
81
  created: new Date().toISOString(), added: added.length, skipped, total: entries.length,
@@ -88,17 +94,26 @@ export async function runImport(dir: string, compress: Compressor, signal: Abort
88
94
  progress: (state: ImportProgress) => void = () => {}): Promise<ImportJob> {
89
95
  const job = pendingImport(dir); if (!job) throw new Error('No pending import.');
90
96
  const path = generationPath(dir, job.target);
91
- if (!existsSync(join(path, 'main', '000-import.jsonl'))) throw new Error('Import staging data is missing; original memory remains intact.');
97
+ const finish = () => { rmSync(join(path, STAGED), { force: true }); rmSync(pendingFile(dir)); };
98
+ // A crash after the pointer swap leaves only the cleanup to do.
99
+ if (memoryDirectory(dir) === path) { finish(); return job; }
100
+ if (!existsSync(join(path, STAGED))) throw new Error('Import staging data is missing; original memory remains intact.');
101
+ const plan: unknown[] = readFileSync(join(path, STAGED), 'utf8').split('\n').filter(Boolean).map(line => JSON.parse(line));
102
+ // Every line must parse, and the plan must be whole and in order, so a damaged plan can't drop messages silently.
103
+ if (!plan.every(isEntry) || plan.length !== job.total || plan.some((e, i) => e.i !== i)) throw new Error('Import staging data is invalid; original memory remains intact.');
92
104
  const memory = new Memory(path, compress, () => {});
93
- const report = () => progress({ messages: memory.root.length - memory.pending, total: memory.root.length, summaries: memory.tree.size, error: memory.lastError });
105
+ const report = () => progress({ messages: memory.root.length - memory.pending, total: plan.length, summaries: memory.tree.size, error: memory.lastError });
94
106
  const timer = setInterval(report, 500);
95
107
  try {
96
- report(); await memory.settle(signal, true); signal.throwIfAborted();
108
+ if (memory.root.length > plan.length || memory.root.some((e, i) => e.text !== plan[i].text)) throw new Error('Import staging data does not match its log; original memory remains intact.');
109
+ report();
110
+ // Recipe §10: imported messages are compressed like any other, so each arrives once the one before it is summarized, as in a live chat.
111
+ for (const e of plan.slice(memory.root.length)) { await memory.settle(signal); memory.append(e.kind, e.text, e.date, e.receipt, e.origin); }
112
+ await memory.settle(signal, true); signal.throwIfAborted();
97
113
  // Close all writers before the single atomic pointer swap. The previous generation stays intact.
98
114
  await memory.close();
99
115
  atomicWrite(join(dir, 'imports', `${job.id}.json`), JSON.stringify({ ...job, completed: new Date().toISOString() }, null, 2));
100
116
  atomicWrite(join(dir, 'active-memory.json'), JSON.stringify(job.target));
101
- rmSync(pendingFile(dir));
102
- report(); return job;
117
+ finish(); report(); return job;
103
118
  } finally { clearInterval(timer); await memory.close(); }
104
119
  }
@@ -29,6 +29,8 @@ export function timestamp(value: unknown, fallback: string): string {
29
29
  const date = new Date(time);
30
30
  return Number.isNaN(date.getTime()) ? fallback : date.toISOString();
31
31
  }
32
+ /** Header time: minutes are enough to place a message, and the rest of an ISO stamp costs summary bytes. */
33
+ const minute = (date: string) => `${date.slice(0, 16).replace('T', ' ')}Z`;
32
34
  function text(value: unknown): string {
33
35
  if (typeof value === 'string') return value;
34
36
  if (Array.isArray(value)) return value.map(text).filter(Boolean).join('\n');
@@ -41,12 +43,46 @@ function text(value: unknown): string {
41
43
  if (typeof value.content_type === 'string') return `[${value.content_type} attachment; binary content is not imported]`;
42
44
  return '';
43
45
  }
46
+ /**
47
+ * Claude Code logs slash commands, `!` shell commands, and their local output as user messages. Returns undefined
48
+ * for anything else, '' for output to drop, or what the user typed (`/name args`, `/name`, `!command`).
49
+ */
50
+ function claudeCommand(content: string): string | undefined {
51
+ const s = content.trimStart();
52
+ if (/^<(local-command|bash)-(stdout|stderr)>/.test(s)) return '';
53
+ const shell = /^<bash-input>([\s\S]*?)<\/bash-input>/.exec(s)?.[1].trim();
54
+ if (shell !== undefined) return shell && `!${shell}`;
55
+ if (!/^<command-(name|message|args)>/.test(s)) return undefined;
56
+ const name = /<command-name>([^<]*)<\/command-name>/.exec(s)?.[1].trim(), args = /<command-args>([\s\S]*?)<\/command-args>/.exec(s)?.[1].trim();
57
+ return name ? `${name} ${args ?? ''}`.trimEnd() : '';
58
+ }
59
+ /**
60
+ * Context Codex injects as user messages, as codex-rs recognizes it (core/src/context/contextual_user_message.rs).
61
+ * Codex matches marked fragments ignoring case, but the `<external_…>` context, the internal-context source and the warnings exactly.
62
+ */
63
+ const CODEX_MARKED = /^(?:# AGENTS\.md instructions[\s\S]*<\/INSTRUCTIONS>|<(environment_context|user_shell_command|turn_aborted|subagent_notification|skill|agent_message_board_notification|recommended_plugins|goal_context)>[\s\S]*<\/\1>|<hook_prompt hook_run_id="[^"]+">[\s\S]*<\/hook_prompt>)$/i;
64
+ const CODEX_EXACT = /^(?:<external_([^>]+)>[\s\S]*<\/external_\1>|<codex_internal_context source="[a-z][a-z0-9_]*">[\s\S]*<\/codex_internal_context>|Warning: apply_patch was requested via [\s\S]*Use the apply_patch tool instead of exec_command\.|Warning: (?:Your account was flagged for potentially high-risk cyber activity|The maximum number of unified exec processes you can keep open is)[\s\S]*)$/;
65
+ /** What the user typed in a Codex message: every part except the context Codex injected. */
66
+ const codexTyped = (content: unknown) => (Array.isArray(content) ? content : [content]).map(text)
67
+ .filter(piece => piece && !CODEX_MARKED.test(piece.trim()) && !CODEX_EXACT.test(piece.trim())).join('\n');
44
68
  const digest = (s: string) => createHash('sha256').update(s).digest('hex');
69
+ /**
70
+ * A resumed Claude Code session copies the earlier messages into its own file, each with its uuid and text, so a copy
71
+ * matches on those across files. The header line names the file (and its format has changed), and a command stored raw
72
+ * by an older import is read as typed, so entries already in a profile match too. Messages without a uuid never match.
73
+ */
74
+ export function copyKey(e: Pick<ImportedEntry, 'kind' | 'text' | 'origin'>): string | undefined {
75
+ if (e.origin?.source !== 'claude' || e.origin.message.startsWith('line:')) return undefined;
76
+ const body = e.text.slice(e.text.indexOf('\n') + 1);
77
+ return JSON.stringify([e.origin.message, e.kind, claudeCommand(body) || body]);
78
+ }
45
79
  function imported(c: Conversation, id: string, kind: Kind, content: string, date: string, identity = content): ImportedEntry | undefined {
46
80
  if (!content.trim()) return undefined;
47
81
  // Text provenance survives compression. Stable per-message receipts survive moved files and repeated exports.
48
82
  const origin: Origin = { source: c.source, conversation: c.id, message: id, title: c.title, project: c.project };
49
- return { kind, date, origin, text: `[Historical ${c.source} · ${date} · conversation ${c.id} · ${c.title}]\n${content}`,
83
+ // The agent reads only text, so the id's first 13 characters stay in it: enough to find a Claude or Codex transcript by
84
+ // glob. Codex ids are UUIDv7, whose first 8 characters are a coarse timestamp shared by many sessions.
85
+ return { kind, date, origin, text: `[Historical ${c.source} · ${minute(date)} · ${c.id.slice(0, 13)} · ${c.title}]\n${content}`,
50
86
  receipt: `import:${digest(JSON.stringify([c.source, c.id, id, kind, identity]))}` };
51
87
  }
52
88
  async function* jsonLines(file: string, warnings: string[], limit = Infinity, signal?: AbortSignal) {
@@ -102,8 +138,10 @@ export async function scanLocal(source: 'claude' | 'codex', roots?: string[], si
102
138
  if (v.type === 'custom-title' || v.type === 'ai-title') title = string(v.customTitle ?? v.aiTitle) ?? title;
103
139
  }
104
140
  const m = source === 'claude' ? v.message : v.type === 'response_item' ? v.payload : undefined;
105
- if (record(m) && m.role === 'user' && !title) {
106
- title = text(m.content).replace(/\s+/g, ' ').slice(0, 110);
141
+ const user = record(m) && m.role === 'user' && !title;
142
+ const first = !user ? '' : source === 'claude' ? claudeCommand(text(m.content)) ?? text(m.content) : codexTyped(m.content);
143
+ if (first.trim()) {
144
+ title = first.replace(/\s+/g, ' ').slice(0, 110);
107
145
  date = timestamp(v.timestamp, date);
108
146
  }
109
147
  }
@@ -229,7 +267,7 @@ async function readMemory(c: Conversation, signal?: AbortSignal): Promise<{ entr
229
267
  const date = timestamp(fields.get('modified'), modified.toISOString());
230
268
  return { warnings: [], entries: [{ kind: 'note', date,
231
269
  origin: { source: c.source, conversation: c.id, message: hash.slice(0, 16), title: name, project: c.project },
232
- text: `[Historical Claude Code memory · ${date} · project ${c.project}${type ? ` · type ${one(type)}` : ''} · ${name}]\n${description ? one(description) + '\n\n' : ''}${body.trim()}`,
270
+ text: `[Historical Claude Code memory · ${minute(date)} · project ${c.project}${type ? ` · type ${one(type)}` : ''} · ${name}]\n${description ? one(description) + '\n\n' : ''}${body.trim()}`,
233
271
  receipt: `import:${digest(JSON.stringify([c.source, c.id, hash]))}` }] };
234
272
  }
235
273
 
@@ -332,7 +370,8 @@ export async function readConversation(c: Conversation, signal?: AbortSignal): P
332
370
  warnings.push(`${c.file}:${line}: unsupported Claude content block ${String(b.type)} skipped`);
333
371
  return [];
334
372
  });
335
- if (m.role === 'user' && parts.length) { finish(); for (const part of parts) add(part.id, 'user', part.content, date); }
373
+ // Receipts keep the raw text, so a command already imported is still recognized.
374
+ if (m.role === 'user' && parts.length) { finish(); for (const part of parts) add(part.id, 'user', claudeCommand(part.content) ?? part.content, date, part.content); }
336
375
  else if (m.role === 'assistant') {
337
376
  const final = m.stop_reason === 'end_turn' || m.stop_reason === 'stop_sequence';
338
377
  if (toolActivity || v.isApiErrorMessage === true || m.stop_reason && !final) pending = [];
@@ -347,7 +386,7 @@ export async function readConversation(c: Conversation, signal?: AbortSignal): P
347
386
  }
348
387
  if (c.source === 'codex' && v.type === 'response_item' && record(v.payload)) {
349
388
  const m = v.payload, id = string(m.id) ?? string(m.call_id) ?? `line:${line}`;
350
- if (m.type === 'message' && m.role === 'user') { finish(); add(id, 'user', text(m.content), date); }
389
+ if (m.type === 'message' && m.role === 'user') { finish(); add(id, 'user', codexTyped(m.content), date, text(m.content)); }
351
390
  else if (m.type === 'message' && m.role === 'assistant') {
352
391
  pending = [];
353
392
  const channel = m.channel ?? m.phase;
package/src/import/ui.ts CHANGED
@@ -78,7 +78,7 @@ export async function chooseImport(ctx: ImportUI, profile: string, memory: Memor
78
78
  let nodes = 0;
79
79
  for (let n = memory.root.length + added.length; n > 0; n = Math.floor(n / 2)) nodes += n;
80
80
  if (mode === 'append') nodes -= memory.tree.size;
81
- const preview = `${profile} · ${mode}\n${source === 'claude-memory' ? 'Each memory file as one dated historical note; MEMORY.md indexes excluded.' : 'Historical user messages and final replies; tool activity excluded.'}\n${conversations.length} ${unit} selected · ${added.length} new ${source === 'claude-memory' ? 'notes' : 'messages'} · ${skipped} duplicates skipped\n${size(inputBytes)} text to index (~${Math.ceil(inputBytes / 4).toLocaleString()} source tokens; rough estimate)\nCompactor: ${model}\nUp to ${nodes} new summary nodes; small nodes need no model call. Context and retries add usage.\nChatting in this profile pauses until completion or discard. You can pause and resume compression. The previous memory is retained.`;
81
+ const preview = `${profile} · ${mode}\n${source === 'claude-memory' ? 'Each memory file as one dated historical note; MEMORY.md indexes excluded.' : 'Historical user messages and final replies; tool activity excluded.'}\n${conversations.length} ${unit} selected · ${added.length} new ${source === 'claude-memory' ? 'notes' : 'messages'} · ${skipped} duplicates skipped\n${size(inputBytes)} text to index (~${Math.ceil(inputBytes / 4).toLocaleString()} source tokens; rough estimate)\nCompactor: ${model}\nUp to ${nodes} new summary nodes; small nodes need no model call. A big import costs about 3x the source tokens in compactor input, plus cached context and retries.\nChatting in this profile pauses until completion or discard. You can pause and resume compression. The previous memory is retained.`;
82
82
  if (!await ctx.ui.confirm('Start import?', preview, { signal })) return;
83
83
  return { entries, mode };
84
84
  }
package/src/index.ts CHANGED
@@ -3,6 +3,7 @@ import { existsSync, readFileSync } from 'node:fs';
3
3
  import { join } from 'node:path';
4
4
  import { hostname } from 'node:os';
5
5
  import { createHash } from 'node:crypto';
6
+ import { getSupportedThinkingLevels } from '@earendil-works/pi-ai';
6
7
  import type { AgentMessage } from '@earendil-works/pi-agent-core';
7
8
  import { parseSkillBlock, type ExtensionAPI, type ExtensionContext, type ExtensionCommandContext } from '@earendil-works/pi-coding-agent';
8
9
  import { Type } from 'typebox';
@@ -12,8 +13,8 @@ import { atomicWrite, createProfile, instructions, lastProfile, listProfiles, lo
12
13
  import { MASTER, VIEW_DOC } from './prompts.ts';
13
14
  import { cachePayload, record } from './cache.ts';
14
15
  import { asUser, boundedMessage, buildContext, logMessage, previousExchange, REPORT_TYPE, RUN_BOUNDARY, textContent, typedText } from './transcript.ts';
15
- import { registerReportRenderer } from './report-message.ts';
16
- import { memoryTools, result } from './tools.ts';
16
+ import { registerReportRenderer, type ReportDetails } from './report-message.ts';
17
+ import { memoryTools, result, SEARCH_DOC, searchTool } from './tools.ts';
17
18
  import { Children, CWD_DOC, loadedBuiltins } from './agents.ts';
18
19
  import { exportBrowser } from './browser.ts';
19
20
  import { Inbox } from './inbox.ts';
@@ -33,8 +34,10 @@ import { showSettings } from './settings-page.ts';
33
34
 
34
35
  const binding = 'optchat.profile';
35
36
  const CONTINUITY = '\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; left out when very long), then the new input. Use that exact wording to understand follow-ups; older exchanges and previous tool output remain accessible through memory and zoom.';
36
- /** A report run while idle reuses the last built prompt, so a Previous exchange change since then is applied here. */
37
- const continuity = (prompt: string, on: boolean) => on === prompt.includes(CONTINUITY) ? prompt : on ? prompt.replace(VIEW_DOC, VIEW_DOC + CONTINUITY) : prompt.replace(CONTINUITY, '');
37
+ 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
+ /** A report run while idle reuses the last built prompt, so Previous exchange and Memory search changes since then are applied here. */
39
+ const promptFor = (prompt: string, { previousExchange, memorySearch }: ProfileConfig) =>
40
+ toggle(toggle(prompt, CONTINUITY, previousExchange, VIEW_DOC), SEARCH_DOC, memorySearch, previousExchange ? VIEW_DOC + CONTINUITY : VIEW_DOC);
38
41
  interface Active { name: string; dir: string; config: ProfileConfig; memory: Memory; inbox: Inbox; children: Children; usage: UsageLedger; unlock: () => Promise<void> }
39
42
 
40
43
  export default function optchat(pi: ExtensionAPI) {
@@ -49,7 +52,8 @@ export default function optchat(pi: ExtensionAPI) {
49
52
  let prompt = '';
50
53
  let runStarted = false;
51
54
  let fault: string | undefined;
52
- let reports: string[] = [];
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
+ let reports: { text: string; count?: number; batch?: string }[] = [];
53
57
  const receipts = new Map<AgentMessage, string>();
54
58
  let checkpoints = Promise.resolve();
55
59
  let importing = false;
@@ -66,13 +70,21 @@ export default function optchat(pi: ExtensionAPI) {
66
70
  const a = active;
67
71
  if (a) title.show(text => ctx.ui.setTitle(text), mainTitle(a.name, working, a.children.ids.length));
68
72
  };
73
+ /** Journals written before batches held plain strings. */
74
+ const isPendingReport = (s: unknown): s is string | { text: string; count?: number } =>
75
+ typeof s === 'string' || record(s) && typeof s.text === 'string' && (s.count === undefined || typeof s.count === 'number');
69
76
  const reportReceipt = (text: string) => 'report:' + createHash('sha256').update(text).digest('hex');
70
77
  const errorText = (error: unknown) => error instanceof Error ? error.message : String(error);
71
78
  pi.registerFlag('optchat-profile', { description: 'OptChat profile (required for noninteractive sessions without a saved binding)', type: 'string' });
72
79
  const required = () => { if (!active) throw new Error('Choose an OptChat profile first: /optchat profile'); return active; };
73
80
  const status = (ctx: ExtensionContext) => {
74
81
  const a = active;
75
- ctx.ui.setStatus('optchat', a ? `OptChat: ${a.name} · ${a.memory.root.length} messages · ${a.memory.pending} pending · ${a.children.ids.length} agents${importing ? ' · importing' : pendingImport(a.dir) ? ' · import paused: /optchat import' : ''}` : 'OptChat: choose profile');
82
+ if (!a) { ctx.ui.setStatus('optchat', 'OptChat: choose profile'); return; }
83
+ // A display only: a broken import journal is refused where it matters, never here.
84
+ let note = '';
85
+ try { note = importing ? ' · importing' : pendingImport(a.dir) ? ' · import paused: /optchat import' : ''; }
86
+ catch { note = ' · import journal invalid'; }
87
+ ctx.ui.setStatus('optchat', `OptChat: ${a.name} · ${a.memory.root.length} messages · ${a.memory.pending} pending · ${a.children.ids.length} agents${note}`);
76
88
  };
77
89
  const saveReports = () => { if (active) atomicWrite(join(active.dir, 'pending-reports.json'), JSON.stringify(reports)); };
78
90
  const flush = () => {
@@ -84,21 +96,25 @@ export default function optchat(pi: ExtensionAPI) {
84
96
  if (receipt && !receipt.startsWith('report:')) active.inbox.acknowledge(receipt);
85
97
  receipts.delete(message);
86
98
  if (message.role === 'user') {
87
- const index = reports.indexOf(textContent(message.content));
99
+ const text = textContent(message.content), index = reports.findIndex(r => !r.batch && r.text === text);
88
100
  if (index >= 0) { reports.splice(index, 1); saveReports(); }
89
101
  }
90
102
  }
91
103
  };
92
104
  // Shown as a dark background box, not as the user's own message. Before this profile has run once there is no
93
105
  // built system prompt to reuse, so that rare case still goes through Pi's normal prompt path as a user message.
94
- const sendReport = (text: string) => {
95
- if (prompt) pi.sendMessage({ customType: REPORT_TYPE, content: text, display: true }, { triggerTurn: true, deliverAs: 'steer' });
106
+ const sendReport = (text: string, count?: number) => {
107
+ if (prompt) pi.sendMessage<ReportDetails>({ customType: REPORT_TYPE, content: text, display: true, details: { count } }, { triggerTurn: true, deliverAs: 'steer' });
96
108
  else pi.sendUserMessage(text, { deliverAs: 'steer', expandPromptTemplates: false });
97
109
  };
98
- const deliverReport = async (text: string, once = false) => {
99
- if (once && (active?.memory.root.some(e => e.receipt === reportReceipt(text)) || reports.includes(text))) return;
100
- reports.push(text); saveReports();
101
- if (!stopping) sendReport(text);
110
+ const deliverReport = async (text: string, { once = false, count }: { once?: boolean; count?: number } = {}) => {
111
+ if (once && (active?.memory.root.some(e => e.receipt === reportReceipt(text)) || reports.some(r => r.text === text))) return;
112
+ reports.push({ text, count }); saveReports();
113
+ if (!stopping) sendReport(text, count);
114
+ };
115
+ const holdReports = (batch: string, texts: string[]) => {
116
+ reports = [...reports.filter(r => r.batch !== batch), ...texts.length ? [{ text: texts.join('\n\n'), count: texts.length, batch }] : []];
117
+ saveReports();
102
118
  };
103
119
  const stop = async () => {
104
120
  remote?.close(); remote = undefined;
@@ -141,7 +157,7 @@ export default function optchat(pi: ExtensionAPI) {
141
157
  usage.backfill(ctx.sessionManager.getEntries(), sessionId);
142
158
  const pending = join(dir, 'pending-reports.json');
143
159
  const saved: unknown = existsSync(pending) ? JSON.parse(readFileSync(pending, 'utf8')) : [];
144
- if (!Array.isArray(saved) || !saved.every(s => typeof s === 'string')) throw new Error('Invalid pending report journal.');
160
+ if (!Array.isArray(saved) || !saved.every(isPendingReport)) throw new Error('Invalid pending report journal.');
145
161
  const memory = new Memory(memoryDirectory(dir), createCompressor(ctx.modelRegistry, () => config.compactor, message => {
146
162
  usage.compression(message, 'compactor', sessionId);
147
163
  status(ctx);
@@ -151,21 +167,23 @@ export default function optchat(pi: ExtensionAPI) {
151
167
  const recovered = pendingImport(dir) ? 0 : inbox.recover(memory);
152
168
  if (recovered) ctx.ui.notify(`Recovered ${recovered} unanswered inputs into ${name}'s memory. Ask to continue them when ready.`, 'info');
153
169
  const children = new Children(memory, ctx.modelRegistry, () => config.subagent, () => `${instructions(dir)}\n\n${IMPORT_GUIDANCE}`,
154
- deliverReport, text => ctx.ui.notify(text, 'error'), dir, { parentSession: sessionId, usage, builtins: () => loadedBuiltins(pi), settings: () => config,
170
+ deliverReport, text => ctx.ui.notify(text, 'error'), dir, { parentSession: sessionId, usage, builtins: () => loadedBuiltins(pi), settings: () => config, hold: holdReports,
155
171
  summarizeHandoff: createHandoffSummarizer(ctx.modelRegistry, () => config.compactor, message => usage.compression(message, 'compactor', sessionId)) });
156
172
  const loggedReports = new Set(memory.root.map(e => e.receipt));
157
- reports = saved.filter((s): s is string => typeof s === 'string' && !loggedReports.has(reportReceipt(s)));
173
+ // Reports a crash held back with their unfinished siblings are delivered now, as they are.
174
+ reports = saved.map(s => typeof s === 'string' ? { text: s } : { text: s.text, count: s.count })
175
+ .filter(r => !loggedReports.has(reportReceipt(r.text)));
158
176
  atomicWrite(pending, JSON.stringify(reports));
159
177
  rememberProfile(name);
160
178
  active = { name, dir, config, memory, inbox, children, usage, unlock }; fault = undefined;
161
- if (ctx.mode === 'tui') unmountNavigation = mountNavigation(ctx, children, shortcut, page => { void inspect(ctx, page); });
179
+ if (ctx.mode === 'tui') unmountNavigation = mountNavigation(ctx, children, memory, shortcut, page => { void inspect(ctx, page); });
162
180
  closeWindows = await serveWindows(dir, children, () => !stopping && !importing && !pendingImport(dir), deliverReport);
163
181
  untitle = children.subscribe(() => showTitle(ctx)); showTitle(ctx);
164
182
  status(ctx);
165
183
  ctx.ui.notify(`OptChat · ${name} · ${memory.root.length} messages\nCompactor: ${config.compactor.provider}/${config.compactor.model} (${config.compactor.thinking})`, 'info');
166
184
  const queuedReports = [...reports];
167
185
  if (!pendingImport(dir)) recovery = children.recoverHandoffs().catch(error => ctx.ui.notify(`Handoff recovery: ${errorText(error)}`, 'error'));
168
- setImmediate(() => { if (active?.memory === memory && !pendingImport(dir)) for (const text of queuedReports) sendReport(text); });
186
+ setImmediate(() => { if (active?.memory === memory && !pendingImport(dir)) for (const r of queuedReports) sendReport(r.text, r.count); });
169
187
  } catch (error) {
170
188
  await closeWindows?.(); closeWindows = undefined;
171
189
  untitle?.(); untitle = undefined; title.clear();
@@ -244,7 +262,8 @@ export default function optchat(pi: ExtensionAPI) {
244
262
  startRun(ctx);
245
263
  const a = required();
246
264
  // Pi's own prompt sections (AGENTS.md files, skills, cwd) stay; the profile's instructions go last.
247
- event.systemPromptOptions.customPrompt = `${MASTER}\n\n${VIEW_DOC}${a.config.previousExchange ? CONTINUITY : ''}`;
265
+ syncSearch(a.config);
266
+ event.systemPromptOptions.customPrompt = `${MASTER}\n\n${VIEW_DOC}${a.config.previousExchange ? CONTINUITY : ''}${a.config.memorySearch ? SEARCH_DOC : ''}`;
248
267
  event.systemPromptOptions.sections.instructions = `${instructions(a.dir)}\n\n${IMPORT_GUIDANCE}`;
249
268
  prompt = event.systemPrompt;
250
269
  });
@@ -255,7 +274,7 @@ export default function optchat(pi: ExtensionAPI) {
255
274
  if (message.role === 'user') {
256
275
  try {
257
276
  const text = textContent(message.content);
258
- if (reports.includes(text)) receipts.set(message, reportReceipt(text));
277
+ if (reports.some(r => !r.batch && r.text === text)) receipts.set(message, reportReceipt(text));
259
278
  else {
260
279
  // The inbox journaled the typed input: match without image placeholders or Pi's image notes.
261
280
  const typed = typedText(message.content), skill = parseSkillBlock(typed.bare);
@@ -283,7 +302,7 @@ export default function optchat(pi: ExtensionAPI) {
283
302
  view = a.memory.render(); // Capture old history before logging the new input.
284
303
  flush();
285
304
  }
286
- return { messages: buildContext(event.messages, run, view, continuity(prompt, a.config.previousExchange), previous) };
305
+ return { messages: buildContext(event.messages, run, view, promptFor(prompt, a.config), previous) };
287
306
  } catch (error) {
288
307
  // Pi catches extension errors. Explicitly abort so it cannot fall back to old context.
289
308
  ctx.abort();
@@ -318,12 +337,19 @@ export default function optchat(pi: ExtensionAPI) {
318
337
  registerConnectedRenderer(pi);
319
338
  registerReportRenderer(pi);
320
339
  for (const tool of memoryTools(() => required().memory)) pi.registerTool(tool);
340
+ pi.registerTool({ ...searchTool(() => required().memory), defaultActive: false });
341
+ // The tool and its prompt line change together, once per toggle, so the cached prefix is otherwise stable.
342
+ // Synced on save too: a report turn started while idle reuses the tool set without before_agent_start.
343
+ const syncSearch = ({ memorySearch }: ProfileConfig) => {
344
+ const tools = pi.getActiveTools();
345
+ if (tools.includes('search') !== memorySearch) pi.setActiveTools(memorySearch ? [...tools, 'search'] : tools.filter(name => name !== 'search'));
346
+ };
321
347
  pi.registerTool({ name: 'spawn', label: 'Spawn background agents',
322
348
  description: 'Start background subagents, returning IDs immediately. Use only when the user asks. Give each task the cwd of the project it works on, so the subagent starts there with that project\'s AGENTS.md. Each receives the current memory view and read-only zoom/date. Whether children may delegate further, and how many agents may run at once, is set per profile. Completion reports arrive automatically; never poll or sleep waiting for them.',
323
349
  parameters: Type.Object({ tasks: Type.Array(Type.Object({ task: Type.String(), cwd: Type.Optional(Type.String({ description: CWD_DOC })) }), { minItems: 1 }) }),
324
350
  async execute(_id, args, signal, _update, ctx) {
325
- const ids = await required().children.spawn(args.tasks, ctx.cwd, signal); status(ctx);
326
- return result(`Started: ${ids.join(', ')}. Reports will arrive automatically.`);
351
+ const text = await required().children.start(args.tasks, ctx.cwd, signal); status(ctx);
352
+ return result(text);
327
353
  },
328
354
  });
329
355
  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.',
@@ -333,12 +359,14 @@ export default function optchat(pi: ExtensionAPI) {
333
359
 
334
360
  const pickModel = async (ctx: ExtensionContext, role: 'compactor' | 'subagent') => {
335
361
  const a = required(), current = a.config[role];
336
- const choices = ctx.modelRegistry.getAvailable().map(m => `${m.provider}/${m.id}`);
362
+ const available = ctx.modelRegistry.getAvailable(), choices = available.map(m => `${m.provider}/${m.id}`);
337
363
  choices.sort((a, b) => Number(b === `${current.provider}/${current.model}`) - Number(a === `${current.provider}/${current.model}`) || a.localeCompare(b));
338
364
  const selected = await ctx.ui.select(`${a.name}: ${role} model`, choices);
339
365
  if (!selected) return;
340
- const picked = await ctx.ui.select('Thinking level', [...THINKING]);
341
- const thinking = THINKING.find(level => level === picked);
366
+ const model = available.find(m => `${m.provider}/${m.id}` === selected);
367
+ const levels = model ? getSupportedThinkingLevels(model) : THINKING;
368
+ const picked = await ctx.ui.select('Thinking level', [...levels]);
369
+ const thinking = levels.find(level => level === picked);
342
370
  if (!thinking) return;
343
371
  const separator = selected.indexOf('/');
344
372
  a.config[role] = { provider: selected.slice(0, separator), model: selected.slice(separator + 1), thinking };
@@ -353,7 +381,7 @@ export default function optchat(pi: ExtensionAPI) {
353
381
  if (importing) throw new Error('Close the import dialog before opening the inspector.');
354
382
  inspectorController = controller;
355
383
  const signal = inspectorController.signal;
356
- const action = await showInspector(ctx, { profile: a.name, session: ctx.sessionManager.getSessionId(), children: a.children, usage: a.usage, page, signal,
384
+ const action = await showInspector(ctx, { profile: a.name, session: ctx.sessionManager.getSessionId(), children: a.children, usage: a.usage, memory: a.memory, page, signal,
357
385
  refreshUsage: () => { collectUsage(ctx); try { a.children.collectUsage(); } catch (error) { ctx.ui.notify(`Could not save child usage: ${errorText(error)}`, 'error'); } },
358
386
  });
359
387
  if (signal.aborted) return;
@@ -362,7 +390,7 @@ export default function optchat(pi: ExtensionAPI) {
362
390
  } catch (error) { ctx.ui.notify(errorText(error), 'error'); }
363
391
  finally { if (inspectorController === controller) inspectorController = undefined; }
364
392
  };
365
- pi.registerShortcut(shortcut, { description: 'Inspect OptChat agents and usage', handler: ctx => inspect(ctx, 'agents') });
393
+ pi.registerShortcut(shortcut, { description: 'Inspect OptChat agents, usage and background activity', handler: ctx => inspect(ctx, 'agents') });
366
394
  const command = async (args: string, ctx: ExtensionCommandContext): Promise<void> => {
367
395
  if (remote) { ctx.ui.notify('Manage this profile in its original window. Here use /tell-main or /complete.', 'info'); return; }
368
396
  if (importing) throw new Error('Close the import dialog before changing profile settings.');
@@ -370,7 +398,7 @@ export default function optchat(pi: ExtensionAPI) {
370
398
  if (!action) {
371
399
  const a = active;
372
400
  const info = a ? `${a.name} · ${a.memory.root.length} messages · ${a.memory.pending} pending\nCompactor: ${a.config.compactor.model} (${a.config.compactor.thinking})\nAgents: ${a.config.subagent.model} (${a.config.subagent.thinking})\n${a.memory.lastError ?? ''}` : 'No active profile';
373
- action = await ctx.ui.select(`OptChat\n${info}`, ['profile', 'settings', 'model', 'agents', 'usage', 'instructions', 'browse', 'import']) ?? '';
401
+ action = await ctx.ui.select(`OptChat\n${info}`, ['profile', 'settings', 'model', 'agents', 'usage', 'activity', 'instructions', 'browse', 'import']) ?? '';
374
402
  }
375
403
  if (action === 'import') {
376
404
  const a = required();
@@ -423,12 +451,12 @@ export default function optchat(pi: ExtensionAPI) {
423
451
  if (action === 'settings') {
424
452
  const a = required();
425
453
  if (ctx.mode !== 'tui') throw new Error('/optchat settings requires interactive Pi. Edit config.json in the profile directory instead.');
426
- return showSettings(ctx, { profile: a.name, config: a.config, models: ctx.modelRegistry.getAvailable().map(m => `${m.provider}/${m.id}`).sort(),
427
- save: config => saveConfig(a.dir, config) });
454
+ return showSettings(ctx, { profile: a.name, config: a.config, models: ctx.modelRegistry.getAvailable().map(m => ({ name: `${m.provider}/${m.id}`, thinking: getSupportedThinkingLevels(m) })).sort((a, b) => a.name.localeCompare(b.name)),
455
+ save: config => { saveConfig(a.dir, config); syncSearch(config); } });
428
456
  }
429
457
  if (action === 'model') return pickModel(ctx, 'compactor');
430
458
  if (action === 'agents model') return pickModel(ctx, 'subagent');
431
- if (action === 'agents' || action === 'usage') return inspect(ctx, action);
459
+ if (action === 'agents' || action === 'usage' || action === 'activity') return inspect(ctx, action);
432
460
  if (action === 'instructions') {
433
461
  const a = required();
434
462
  const edited = await ctx.ui.editor(`${a.name} · AGENTS.md`, instructions(a.dir));
@@ -440,7 +468,7 @@ export default function optchat(pi: ExtensionAPI) {
440
468
  if (ctx.hasUI) execFile(process.platform === 'darwin' ? 'open' : 'xdg-open', [file], error => { if (error) ctx.ui.notify(`Open ${file}`, 'info'); });
441
469
  ctx.ui.notify(file, 'info'); return;
442
470
  }
443
- if (action) throw new Error('Use /optchat [profile|settings|model|agents|usage|instructions|browse|import].');
471
+ if (action) throw new Error('Use /optchat [profile|settings|model|agents|usage|activity|instructions|browse|import].');
444
472
  };
445
473
  pi.registerCommand('complete', { description: 'End this connected conversation and hand off to the main agent', handler: async (_args, ctx) => {
446
474
  if (!remote) { ctx.ui.notify('/complete is for connected subagent windows.', 'info'); return; }
@@ -451,7 +479,7 @@ export default function optchat(pi: ExtensionAPI) {
451
479
  try { await remote.tell(args); } catch (error) { ctx.ui.notify(errorText(error), 'error'); }
452
480
  } });
453
481
  pi.registerCommand('optchat', { description: 'OptChat profiles, settings, models, agents, instructions, memory browser, and imports',
454
- getArgumentCompletions: prefix => ['profile', 'settings', 'model', 'agents', 'agents model', 'usage', 'instructions', 'browse', 'import'].filter(s => s.startsWith(prefix)).map(value => ({ value, label: value })),
482
+ getArgumentCompletions: prefix => ['profile', 'settings', 'model', 'agents', 'agents model', 'usage', 'activity', 'instructions', 'browse', 'import'].filter(s => s.startsWith(prefix)).map(value => ({ value, label: value })),
455
483
  handler: async (args, ctx) => { try { await command(args, ctx); } catch (error) { ctx.ui.notify(errorText(error), 'error'); } },
456
484
  });
457
485
  }