pi-optchat 0.9.0 → 0.9.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 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
- Subagent and compactor settings are saved per profile and do not follow the main model. If you use other providers, change them before chatting. Authentication uses Pi's existing provider login.
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` | Compactor and subagent models |
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 |
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-optchat",
3
- "version": "0.9.0",
3
+ "version": "0.9.1",
4
4
  "type": "module",
5
5
  "pi": {
6
6
  "extensions": [
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;
@@ -219,6 +252,7 @@ export default function optchat(pi: ExtensionAPI) {
219
252
  atomicWrite(pending, JSON.stringify(reports));
220
253
  rememberProfile(name);
221
254
  active = { name, dir, config, memory, inbox, children, usage, unlock }; fault = undefined; idle = () => ctx.isIdle();
255
+ await restoreMain(ctx, active);
222
256
  if (ctx.mode === 'tui') unmountNavigation = mountNavigation(ctx, children, memory, shortcut, page => { void inspect(ctx, page); });
223
257
  // A one-shot run (`pi -p`, JSON) exits after its own prompt, which would end every conversation connected to it.
224
258
  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
- export interface ProfileConfig extends Settings { compactor: ModelChoice; subagent: ModelChoice }
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
- try { return { compactor: value.compactor, subagent: value.subagent, ...readSettings(value) }; }
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'); }