pi-optchat 0.9.0 → 0.9.2
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 +3 -3
- package/docs/connected-windows.md +1 -1
- package/package.json +1 -1
- package/src/agents.ts +11 -3
- package/src/handoff.ts +2 -2
- package/src/index.ts +38 -2
- package/src/profiles.ts +5 -3
package/README.md
CHANGED
|
@@ -75,11 +75,11 @@ Details, errors and join rules: [docs/connected-windows.md](docs/connected-windo
|
|
|
75
75
|
|
|
76
76
|
| Role | Default | Change with |
|
|
77
77
|
| --- | --- | --- |
|
|
78
|
-
| Main agent | Whatever is selected in Pi | `/model` |
|
|
78
|
+
| Main agent | Whatever is selected in Pi, then the profile's last pick | `/model` |
|
|
79
79
|
| Subagents | Anthropic Opus 5.5, high | `/optchat agents model` |
|
|
80
80
|
| Compactor (summaries, imports, handoffs) | Anthropic Sonnet 5.5, medium | `/optchat model` |
|
|
81
81
|
|
|
82
|
-
|
|
82
|
+
All three are saved per profile. The main agent's model and thinking level are saved whenever you change them in Pi and picked again when the profile opens, unless the saved model is no longer available. This includes resumed sessions: the profile's pick replaces the one the session last used. On Pi's command line, `--model` (or `--provider`) keeps that model, `--thinking` that level, and `--models` keeps a saved model only if it is in that list. Subagent and compactor settings do not follow the main model. If you use other providers, change them before chatting. Authentication uses Pi's existing provider login.
|
|
83
83
|
|
|
84
84
|
Compression and subagents make extra model requests with your provider credentials.
|
|
85
85
|
|
|
@@ -202,7 +202,7 @@ Profile data lives in `~/.optchat/profiles/<name>/` (override the root with `OPT
|
|
|
202
202
|
| `active-memory.json`, `memories/<id>/` | After an import: pointer to the active `main/` and `tree/`. Older generations are kept. |
|
|
203
203
|
| `imports/pending.json` | Resumable import state |
|
|
204
204
|
| `AGENTS.md` | Profile instructions |
|
|
205
|
-
| `config.json` |
|
|
205
|
+
| `config.json` | Main, compactor and subagent models, and settings |
|
|
206
206
|
| `pending-inputs.json`, `pending-reports.json` | Recovery journals |
|
|
207
207
|
| `runs/` | Subagent sessions and run metadata |
|
|
208
208
|
| `usage.jsonl` | Usage ledger |
|
|
@@ -18,7 +18,7 @@ If you open the same profile in a second terminal, Pi offers to connect it to th
|
|
|
18
18
|
|
|
19
19
|
- Run `/complete` when done. The window closes, remaining work stops, and the compactor writes a handoff for the main agent: decisions, changes, evidence, failures, unfinished work, and links to the transcripts.
|
|
20
20
|
- Closing or force-quitting the window also produces a handoff, marked **interrupted**.
|
|
21
|
-
- If the original window is closed cleanly, handoffs are delivered on next start. If it is killed, reopening the profile recovers unfinished handoffs (work is not restarted).
|
|
21
|
+
- If the original window is closed cleanly or reloaded, it doesn't wait for the compactor: handoffs, including one still being written after `/complete`, are written and delivered on next start. If it is killed, reopening the profile recovers unfinished handoffs (work is not restarted).
|
|
22
22
|
- Closing the original window, or running `/reload` in it, ends every connected conversation; Pi gives no chance to warn before a reload. The connected window then shows **disconnected**. `/new` there starts over: after the profile picker if Pi was started without `--optchat-profile`, a profile still open elsewhere offers **Start a connected subagent conversation here** again, and a free one opens in this window.
|
|
23
23
|
|
|
24
24
|
### Handoff limits
|
package/package.json
CHANGED
package/src/agents.ts
CHANGED
|
@@ -49,7 +49,7 @@ interface Options { parentSession?: string; usage?: UsageLedger; createSession?:
|
|
|
49
49
|
hold?: (batch: string, texts: string[]) => void;
|
|
50
50
|
/** Read on every spawn, so a changed profile setting applies to the next one. Missing settings take their defaults. */
|
|
51
51
|
settings?: () => Partial<Settings>;
|
|
52
|
-
summarizeHandoff?: (run: RunInfo, messages: AgentMessage[], descendants?: HandoffEvidence[]) => Promise<string> }
|
|
52
|
+
summarizeHandoff?: (run: RunInfo, messages: AgentMessage[], descendants?: HandoffEvidence[], signal?: AbortSignal) => Promise<string> }
|
|
53
53
|
|
|
54
54
|
// Subagents load the user's installed extensions, except any copy of OptChat itself: they get memory tools directly and must not open a profile.
|
|
55
55
|
const packageName = (path: string): string | undefined => {
|
|
@@ -133,6 +133,8 @@ export class Children {
|
|
|
133
133
|
private settling = 0;
|
|
134
134
|
private readonly launches = new Set<Promise<unknown>>();
|
|
135
135
|
private readonly completions = new Set<Promise<void>>();
|
|
136
|
+
/** Aborted when Pi starts closing: see deferHandoffs(). */
|
|
137
|
+
private readonly deferring = new AbortController();
|
|
136
138
|
constructor(private readonly memory: Memory, private readonly registry: ModelRegistry,
|
|
137
139
|
private readonly choice: () => ModelChoice, private readonly instructions: () => string,
|
|
138
140
|
private readonly report: (text: string, options?: { once?: boolean; count?: number }) => Promise<void>, private readonly warn: (text: string) => void,
|
|
@@ -681,6 +683,8 @@ export class Children {
|
|
|
681
683
|
const handoff = run.handoff;
|
|
682
684
|
if (!handoff || handoff.delivered) return;
|
|
683
685
|
if (!handoff.text) {
|
|
686
|
+
const deferred = this.deferring.signal;
|
|
687
|
+
if (deferred.aborted) return;
|
|
684
688
|
const evidence = [run, ...this.history.descendants(run.id)].map((record): HandoffEvidence => {
|
|
685
689
|
try { return { run: record, messages: this.messages(record.id) }; }
|
|
686
690
|
catch (error) { return { run: record, messages: [], transcriptError: String(error) }; }
|
|
@@ -688,8 +692,9 @@ export class Children {
|
|
|
688
692
|
let summary: string;
|
|
689
693
|
try {
|
|
690
694
|
if (!this.options.summarizeHandoff) throw new Error('No handoff summarizer configured');
|
|
691
|
-
summary = await this.options.summarizeHandoff(run, evidence[0].messages, evidence.slice(1));
|
|
695
|
+
summary = await this.options.summarizeHandoff(run, evidence[0].messages, evidence.slice(1), deferred);
|
|
692
696
|
} catch (error) {
|
|
697
|
+
if (deferred.aborted) return;
|
|
693
698
|
summary = `Automatic summary unavailable: ${String(error)}\nInitial request: ${run.task}\nLast recorded result: ${run.report ?? 'No final answer recorded.'}\nUndelivered guidance: ${run.guidance.filter(g => g.state === 'undelivered').map(g => g.text).join('\n')}\nRead the saved transcript or run metadata for the full work and user corrections.`;
|
|
694
699
|
}
|
|
695
700
|
const source = evidence.map(({ run: record, transcriptError }) => {
|
|
@@ -747,8 +752,11 @@ export class Children {
|
|
|
747
752
|
if (!reports) this.memory.append('work', `[${id}] deleted by the user`);
|
|
748
753
|
} finally { for (const run of marked) this.deleting.delete(run); this.changed(); }
|
|
749
754
|
}
|
|
755
|
+
/** Pi is closing: a handoff not written yet is left undelivered for the next start's recoverHandoffs(), and one being
|
|
756
|
+
* written is aborted, so closing or /reload never waits on a model call. */
|
|
757
|
+
deferHandoffs() { this.deferring.abort(); }
|
|
750
758
|
async close() {
|
|
751
|
-
this.closing = true;
|
|
759
|
+
this.closing = true; this.deferHandoffs();
|
|
752
760
|
// Only work in progress is picked up at the next start: not paused runs waiting for the user, runs being stopped, or connected windows (they hand off).
|
|
753
761
|
for (const { info } of this.running.values()) if (!info.connected && (info.state === 'running' || info.state === 'waiting')) info.cutOff = true;
|
|
754
762
|
await Promise.allSettled([...this.running.keys()].map(id => this.stop(id, 'pi')));
|
package/src/handoff.ts
CHANGED
|
@@ -31,7 +31,7 @@ function formatEvidence({ run, messages, transcriptError }: HandoffEvidence) {
|
|
|
31
31
|
/** Summarize in one call when possible; otherwise fold chunks with the prior handoff included in the budget. */
|
|
32
32
|
export function createHandoffSummarizer(registry: ModelRegistry, choice: () => ModelChoice,
|
|
33
33
|
usage: (message: AssistantMessage) => void, retryDelays?: readonly number[]) {
|
|
34
|
-
return async (run: RunInfo, messages: AgentMessage[], descendants: HandoffEvidence[] = []) => {
|
|
34
|
+
return async (run: RunInfo, messages: AgentMessage[], descendants: HandoffEvidence[] = [], signal?: AbortSignal) => {
|
|
35
35
|
const selected = choice(), model = registry.find(selected.provider, selected.model);
|
|
36
36
|
if (!model) throw new Error('Profile compactor model unavailable');
|
|
37
37
|
const transcript = Buffer.from([{ run, messages }, ...descendants].map(formatEvidence).join('\n\n'));
|
|
@@ -53,7 +53,7 @@ export function createHandoffSummarizer(registry: ModelRegistry, choice: () => M
|
|
|
53
53
|
const reply = await retryTransient(() => registry.streamSimple(model, {
|
|
54
54
|
systemPrompt: HANDOFF,
|
|
55
55
|
messages: [{ role: 'user', timestamp: Date.now(), content: prefix + transcript.subarray(offset, end).toString('utf8') }],
|
|
56
|
-
}, { reasoning: reasoningFor(model, selected.thinking), maxTokens, signal: AbortSignal.timeout(300_000) }).result(), usage, retryDelays);
|
|
56
|
+
}, { reasoning: reasoningFor(model, selected.thinking), maxTokens, signal: AbortSignal.any([AbortSignal.timeout(300_000), ...signal ? [signal] : []]) }).result(), usage, retryDelays, signal);
|
|
57
57
|
if (reply.stopReason === 'error' || reply.stopReason === 'aborted') throw new Error(reply.errorMessage ?? reply.stopReason);
|
|
58
58
|
if (reply.stopReason === 'length') throw new Error('Handoff hit the model output limit before finishing');
|
|
59
59
|
summary = textContent(reply.content).trim();
|
package/src/index.ts
CHANGED
|
@@ -4,11 +4,11 @@ import { join } from 'node:path';
|
|
|
4
4
|
import { hostname } from 'node:os';
|
|
5
5
|
import { getSupportedThinkingLevels } from '@earendil-works/pi-ai';
|
|
6
6
|
import type { AgentMessage } from '@earendil-works/pi-agent-core';
|
|
7
|
-
import { parseSkillBlock, type ExtensionAPI, type ExtensionContext, type ExtensionCommandContext } from '@earendil-works/pi-coding-agent';
|
|
7
|
+
import { parseArgs, parseSkillBlock, type ExtensionAPI, type ExtensionContext, type ExtensionCommandContext } from '@earendil-works/pi-coding-agent';
|
|
8
8
|
import { Type } from 'typebox';
|
|
9
9
|
import { Memory } from './memory.ts';
|
|
10
10
|
import { atomicWrite, storeFor } from './store.ts';
|
|
11
|
-
import { createCompressor } from './compactor.ts';
|
|
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
13
|
import { allowSearch, CONTINUITY, IMPORT_GUIDANCE, MASTER, modelsHint, SEARCH_DOC, TURN_REFRESHED, VIEW_DOC } from './prompts.ts';
|
|
14
14
|
import { cachePayload, record } from './cache.ts';
|
|
@@ -114,6 +114,39 @@ export default function optchat(pi: ExtensionAPI) {
|
|
|
114
114
|
try { return await joinHeadless(profilePath(name)); }
|
|
115
115
|
catch (error) { process.exitCode = 1; throw error; }
|
|
116
116
|
};
|
|
117
|
+
// The main model follows the profile: each pick in Pi is saved to its config.json, and opening the profile picks it again.
|
|
118
|
+
// `--model`/`--provider`/`--thinking` on Pi's command line win over the saved choice for as long as this Pi runs; `--models` only keeps a saved model inside its scope.
|
|
119
|
+
const launch = parseArgs(process.argv.slice(2)), pinnedModel = launch.model !== undefined || launch.provider !== undefined;
|
|
120
|
+
// `restored`: where the last restore left Pi. Pi fires thinking_level_select without awaiting it, so a handler may run after the restore; that state isn't a pick.
|
|
121
|
+
let restoring = false, restored: ModelChoice | undefined;
|
|
122
|
+
const sameChoice = (a: ModelChoice | undefined, b: ModelChoice) => a?.provider === b.provider && a.model === b.model && a.thinking === b.thinking;
|
|
123
|
+
const mainChoice = ({ model }: ExtensionContext): ModelChoice | undefined => model && { provider: model.provider, model: model.id, thinking: pi.getThinkingLevel() };
|
|
124
|
+
const rememberMain = (ctx: ExtensionContext) => {
|
|
125
|
+
// Read Pi's state, not the event: a model switch reports its thinking level before the model, and a late handler sees where both ended up.
|
|
126
|
+
const a = active, main = mainChoice(ctx);
|
|
127
|
+
if (restoring || !a || !main || sameChoice(restored, main)) return;
|
|
128
|
+
restored = undefined;
|
|
129
|
+
if (sameChoice(a.config.main, main)) return;
|
|
130
|
+
try { saveConfig(a.dir, { ...a.config, main }); a.config.main = main; }
|
|
131
|
+
catch (error) { ctx.ui.notify(`Could not save the main model to ${a.name}: ${errorText(error)}`, 'error'); }
|
|
132
|
+
};
|
|
133
|
+
pi.on('model_select', (_event, ctx) => rememberMain(ctx));
|
|
134
|
+
pi.on('thinking_level_select', (_event, ctx) => rememberMain(ctx));
|
|
135
|
+
const restoreMain = async (ctx: ExtensionContext, { name, config: { main } }: Active) => {
|
|
136
|
+
restored = undefined;
|
|
137
|
+
if (!main || pinnedModel || launch.models && !ctx.scopedModels.some(({ model }) => model.provider === main.provider && model.id === main.model)) return;
|
|
138
|
+
const model = ctx.modelRegistry.find(main.provider, main.model), thinking = launch.thinking ?? main.thinking;
|
|
139
|
+
restoring = true;
|
|
140
|
+
try {
|
|
141
|
+
const current = ctx.model?.provider === main.provider && ctx.model.id === main.model;
|
|
142
|
+
if (!model || !current && !await pi.setModel(model).catch(() => false)) {
|
|
143
|
+
ctx.ui.notify(`${name}'s main model ${main.provider}/${main.model} is unavailable; keeping ${ctx.model ? `${ctx.model.provider}/${ctx.model.id}` : 'Pi\'s default'}.`, 'warning');
|
|
144
|
+
return;
|
|
145
|
+
}
|
|
146
|
+
pi.setThinkingLevel(thinking);
|
|
147
|
+
restored = mainChoice(ctx);
|
|
148
|
+
} finally { restoring = false; }
|
|
149
|
+
};
|
|
117
150
|
const required = () => { if (!active) throw new Error('Choose an OptChat profile first: /optchat profile'); return active; };
|
|
118
151
|
const status = (ctx: ExtensionContext) => {
|
|
119
152
|
const a = active;
|
|
@@ -165,6 +198,8 @@ export default function optchat(pi: ExtensionAPI) {
|
|
|
165
198
|
if (!active) return;
|
|
166
199
|
const old = active;
|
|
167
200
|
try {
|
|
201
|
+
// Ending connected windows and recovery must not wait on handoff summaries: the next start writes and delivers them.
|
|
202
|
+
old.children.deferHandoffs();
|
|
168
203
|
await closeWindows?.(); closeWindows = undefined;
|
|
169
204
|
await recovery;
|
|
170
205
|
await old.children.close(); flush(); if (!pendingImport(old.dir)) old.inbox.recover(old.memory);
|
|
@@ -219,6 +254,7 @@ export default function optchat(pi: ExtensionAPI) {
|
|
|
219
254
|
atomicWrite(pending, JSON.stringify(reports));
|
|
220
255
|
rememberProfile(name);
|
|
221
256
|
active = { name, dir, config, memory, inbox, children, usage, unlock }; fault = undefined; idle = () => ctx.isIdle();
|
|
257
|
+
await restoreMain(ctx, active);
|
|
222
258
|
if (ctx.mode === 'tui') unmountNavigation = mountNavigation(ctx, children, memory, shortcut, page => { void inspect(ctx, page); });
|
|
223
259
|
// A one-shot run (`pi -p`, JSON) exits after its own prompt, which would end every conversation connected to it.
|
|
224
260
|
if (ctx.mode !== 'print' && ctx.mode !== 'json') closeWindows = await serveWindows(dir, children, () => !stopping && !importing && !pendingImport(dir), deliverReport);
|
package/src/profiles.ts
CHANGED
|
@@ -12,7 +12,8 @@ export const isWindows = process.platform === 'win32';
|
|
|
12
12
|
const PIPE_PREFIX = '\\\\.\\pipe\\';
|
|
13
13
|
|
|
14
14
|
export const dataHome = () => resolve(process.env.OPTCHAT_HOME ?? join(homedir(), '.optchat'));
|
|
15
|
-
|
|
15
|
+
/** `main` is the last model and thinking level picked in Pi while this profile was open; absent until one is picked. */
|
|
16
|
+
export interface ProfileConfig extends Settings { compactor: ModelChoice; subagent: ModelChoice; main?: ModelChoice }
|
|
16
17
|
export const defaults: ProfileConfig = {
|
|
17
18
|
compactor: { provider: 'anthropic', model: 'claude-sonnet-5-5', thinking: 'medium' },
|
|
18
19
|
subagent: { provider: 'anthropic', model: 'claude-opus-5-5', thinking: 'high' },
|
|
@@ -50,8 +51,9 @@ function modelChoice(value: unknown): value is ModelChoice {
|
|
|
50
51
|
}
|
|
51
52
|
export function loadConfig(dir: string): ProfileConfig {
|
|
52
53
|
const value: unknown = JSON.parse(readFileSync(join(dir, 'config.json'), 'utf8'));
|
|
53
|
-
if (!record(value) || !modelChoice(value.compactor) || !modelChoice(value.subagent)) throw new Error(`Invalid profile config: ${dir}/config.json`);
|
|
54
|
-
|
|
54
|
+
if (!record(value) || !modelChoice(value.compactor) || !modelChoice(value.subagent) || value.main !== undefined && !modelChoice(value.main)) throw new Error(`Invalid profile config: ${dir}/config.json`);
|
|
55
|
+
const { compactor, subagent, main } = value;
|
|
56
|
+
try { return { compactor, subagent, ...main && { main }, ...readSettings(value) }; }
|
|
55
57
|
catch (error) { throw new Error(`Invalid profile config: ${dir}/config.json: ${error instanceof Error ? error.message : String(error)}`); }
|
|
56
58
|
}
|
|
57
59
|
export function instructions(dir: string) { return readFileSync(join(dir, 'AGENTS.md'), 'utf8'); }
|