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/README.md +30 -20
- package/docs/victor-recipe.md +4 -4
- package/package.json +2 -2
- package/src/agent-view.ts +18 -5
- package/src/agents.ts +138 -30
- package/src/compactor.ts +16 -6
- package/src/handoff.ts +2 -2
- package/src/import/job.ts +26 -11
- package/src/import/sources.ts +45 -6
- package/src/import/ui.ts +1 -1
- package/src/index.ts +62 -34
- package/src/inspector.ts +33 -15
- package/src/memory.ts +34 -11
- package/src/navigation.ts +9 -8
- package/src/prompts.ts +1 -1
- package/src/report-message.ts +8 -4
- package/src/runs.ts +8 -4
- package/src/settings-page.ts +11 -7
- package/src/settings.ts +6 -0
- package/src/tools.ts +28 -1
package/src/compactor.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type
|
|
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
|
|
12
|
-
export const
|
|
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
|
|
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}/${
|
|
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
|
-
|
|
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
|
|
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:
|
|
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
|
|
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
|
-
|
|
45
|
-
receipts.
|
|
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
|
-
|
|
72
|
-
|
|
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
|
-
|
|
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:
|
|
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
|
-
|
|
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
|
-
|
|
102
|
-
report(); return job;
|
|
117
|
+
finish(); report(); return job;
|
|
103
118
|
} finally { clearInterval(timer); await memory.close(); }
|
|
104
119
|
}
|
package/src/import/sources.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
106
|
-
|
|
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
|
-
|
|
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',
|
|
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.
|
|
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
|
-
|
|
37
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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.
|
|
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(
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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.
|
|
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,
|
|
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
|
|
326
|
-
return result(
|
|
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
|
|
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
|
|
341
|
-
const
|
|
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
|
|
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}
|
|
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
|
}
|