pi-optchat 0.11.2 → 0.11.3

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
@@ -79,7 +79,7 @@ Details, errors and join rules: [docs/connected-windows.md](docs/connected-windo
79
79
  | Subagents | Anthropic Opus 5.5, high | `/optchat agents model` |
80
80
  | Compactor (summaries, imports, fallback handoffs) | Anthropic Sonnet 5.5, medium | `/optchat model` |
81
81
 
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.
82
+ All three are saved per profile. The main agent's model and thinking level are saved when you first open the profile and whenever you change them in Pi, then 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
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-optchat",
3
- "version": "0.11.2",
3
+ "version": "0.11.3",
4
4
  "type": "module",
5
5
  "exports": {
6
6
  ".": "./src/index.ts",
package/src/index.ts CHANGED
@@ -47,7 +47,7 @@ async function printReply(text: string) {
47
47
  catch (error) { if (!record(error) || error.code !== 'EAGAIN') throw error; await new Promise(resolve => setTimeout(resolve, 10)); }
48
48
  }
49
49
  }
50
- interface Active { name: string; dir: string; config: ProfileConfig; memory: Memory; inbox: Inbox; children: Children; usage: UsageLedger; unlock: () => Promise<void> }
50
+ interface Active { name: string; session: string; dir: string; config: ProfileConfig; memory: Memory; inbox: Inbox; children: Children; usage: UsageLedger; unlock: () => Promise<void> }
51
51
 
52
52
  /** OptChat's own version, read from the package.json beside src/ (in the checkout and the npm package alike); none if unreadable. */
53
53
  export const version = ((): string | undefined => {
@@ -73,6 +73,8 @@ export default function optchat(pi: ExtensionAPI) {
73
73
  let view: string | undefined;
74
74
  let prompt = '';
75
75
  let runStarted = false;
76
+ /** The run was started by a custom message (`sendMessage` with `triggerTurn` while idle): its first message is that input, shown or hidden. */
77
+ let wake = false;
76
78
  let fault: string | undefined;
77
79
  /** 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.
78
80
  * `steered`: last sent into a running turn, where Esc or a dequeue can clear it from Pi's queue unseen. */
@@ -133,7 +135,12 @@ export default function optchat(pi: ExtensionAPI) {
133
135
  pi.on('thinking_level_select', (_event, ctx) => rememberMain(ctx));
134
136
  const restoreMain = async (ctx: ExtensionContext, { name, config: { main } }: Active) => {
135
137
  restored = undefined;
136
- if (!main || pinnedModel || launch.models && !ctx.scopedModels.some(({ model }) => model.provider === main.provider && model.id === main.model)) return;
138
+ if (!main) {
139
+ // Nothing saved yet: record where Pi is, so later opens keep this model instead of following Pi's default.
140
+ if (!pinnedModel) rememberMain(ctx);
141
+ return;
142
+ }
143
+ if (pinnedModel || launch.models && !ctx.scopedModels.some(({ model }) => model.provider === main.provider && model.id === main.model)) return;
137
144
  const model = ctx.modelRegistry.find(main.provider, main.model), thinking = launch.thinking ?? main.thinking;
138
145
  restoring = true;
139
146
  try {
@@ -223,6 +230,10 @@ export default function optchat(pi: ExtensionAPI) {
223
230
  const openProfile = async (name: string, ctx: ExtensionContext) => {
224
231
  const dir = profilePath(name);
225
232
  if (!existsSync(dir)) throw new Error(`Profile ${name} does not exist. Use /optchat profile to create it.`);
233
+ // Pi's RPC mode binds extensions twice after new_session/switch_session/fork/clone (setRebindSession, then rebindSession()),
234
+ // so session_start can arrive again for the session this profile already serves. Taking the lock again would meet our own socket.
235
+ // Only the same session is kept: another session without a session_shutdown still fails loudly below.
236
+ if (active?.name === name && active.session === ctx.sessionManager.getSessionId()) { await restoreMain(ctx, active); status(ctx); return; }
226
237
  const unlock = await holdProfile(storeFor(dir), dir, name, `${name} · PID ${process.pid} · ${hostname()} · ${ctx.cwd}`, warning => ctx.ui.notify(warning, 'warning'));
227
238
  let openingMemory: Memory | undefined;
228
239
  try {
@@ -250,7 +261,7 @@ export default function optchat(pi: ExtensionAPI) {
250
261
  .filter(r => !loggedReports.has(reportReceipt(r.text)));
251
262
  atomicWrite(pending, JSON.stringify(reports));
252
263
  rememberProfile(name);
253
- active = { name, dir, config, memory, inbox, children, usage, unlock }; fault = undefined; idle = () => ctx.isIdle();
264
+ active = { name, session: sessionId, dir, config, memory, inbox, children, usage, unlock }; fault = undefined; idle = () => ctx.isIdle();
254
265
  await restoreMain(ctx, active);
255
266
  if (ctx.mode === 'tui') unmountNavigation = mountNavigation(ctx, children, memory, shortcut, page => { void inspect(ctx, page); });
256
267
  // A one-shot run (`pi -p`, JSON) exits after its own prompt, which would end every conversation connected to it.
@@ -406,7 +417,7 @@ export default function optchat(pi: ExtensionAPI) {
406
417
  pi.on('before_agent_start', (event, ctx) => {
407
418
  if (!active) return; // Plain Pi run: leave Pi's own prompt untouched.
408
419
  const a = active;
409
- flush(); run = []; logged = 0; keep = 0; view = undefined; runStarted = true;
420
+ flush(); run = []; logged = 0; keep = 0; view = undefined; runStarted = true; wake = event.messageType === 'custom';
410
421
  previous = a.config.previousExchange ? previousExchange(ctx.sessionManager.getBranch(), a.config.previousExchangeKB * 1000) : [];
411
422
  pi.appendEntry(RUN_BOUNDARY, { state: 'start' });
412
423
  // Pi's own prompt sections (AGENTS.md files, skills, cwd) stay; the profile's instructions go last.
@@ -420,7 +431,8 @@ export default function optchat(pi: ExtensionAPI) {
420
431
  pi.on('message_end', async (event, ctx) => {
421
432
  if (!active || !runStarted) return;
422
433
  const bounded = boundedMessage(event.message);
423
- const message = asUser(bounded);
434
+ // Pi puts the message that starts a run first, before any context it or other extensions inject.
435
+ const message = asUser(bounded, wake); if (bounded.role !== 'system') wake = false;
424
436
  // Before the message is logged, so the image its text names is already there for zoom; zoom's own images are kept already.
425
437
  if (message.role === 'user' || message.role === 'toolResult' && message.toolName !== 'zoom') {
426
438
  try { await saveImages(active.memory.store, message.content); }
package/src/profiles.ts CHANGED
@@ -13,7 +13,7 @@ export const isWindows = process.platform === 'win32';
13
13
  const PIPE_PREFIX = '\\\\.\\pipe\\';
14
14
 
15
15
  export const dataHome = () => resolve(process.env.OPTCHAT_HOME ?? join(homedir(), '.optchat'));
16
- /** `main` is the last model and thinking level picked in Pi while this profile was open; absent until one is picked. */
16
+ /** `main` is the last model and thinking level picked in Pi while this profile was open, recorded on the first open too. */
17
17
  export interface ProfileConfig extends Settings { compactor: ModelChoice; subagent: ModelChoice; main?: ModelChoice }
18
18
  export const defaults: ProfileConfig = {
19
19
  compactor: { provider: 'anthropic', model: 'claude-sonnet-5-5', thinking: 'medium' },
package/src/transcript.ts CHANGED
@@ -24,13 +24,13 @@ export function typedText(content: unknown) {
24
24
  const text = textContent(content, false);
25
25
  return { text, bare: text.replace(/\n\n\[Image[ :][^\n]*\](?:\n\[Image[ :][^\n]*\])*$/, '') };
26
26
  }
27
- /** Pi's convertToLlm sends every custom message to the model as a user message. A shown one is part of the chat, so it
28
- * becomes a user message here too; another extension's starts with "[customType] ", as the recipe marks background work, so
29
- * the compactor never takes it for the user's words. A hidden one (display false), such as context an extension injects
30
- * each turn, stays custom: the model still sees it, and memory and the replay leave it out. Reports reach the model and the
31
- * previous-exchange replay as user messages; memory logs them as `work`. */
32
- export function asUser(message: AgentMessage): AgentMessage {
33
- if (message.role !== 'custom' || !message.display) return message;
27
+ /** Pi's convertToLlm sends every custom message to the model as a user message. One that is shown, or that starts the run
28
+ * (`input`, shown or hidden), is part of the chat, so it becomes a user message here too; another extension's starts with
29
+ * "[customType] ", as the recipe marks background work, so the compactor never takes it for the user's words. Any other hidden
30
+ * one (display false), such as context an extension injects each turn, stays custom: the model still sees it, and memory and
31
+ * the replay leave it out. Reports reach the model and the previous-exchange replay as user messages; memory logs them as `work`. */
32
+ export function asUser(message: AgentMessage, input = false): AgentMessage {
33
+ if (message.role !== 'custom' || !message.display && !input) return message;
34
34
  const { content, customType, timestamp } = message;
35
35
  if (customType === REPORT_TYPE) return { role: 'user', content, timestamp };
36
36
  const tag = `[${customType}] `;
@@ -57,7 +57,14 @@ export function previousExchange(branch: readonly SessionEntry[], limit = PREVIO
57
57
  function latestExchange(branch: readonly SessionEntry[]) {
58
58
  let end = -1;
59
59
  let legacyEnd = branch.length;
60
- const messages = (entries: readonly SessionEntry[]) => entries.flatMap(entry => entry.type === 'message' ? [asUser(entry.message)] : entry.type === 'custom_message' ? [asUser({ role: 'custom', customType: entry.customType, content: entry.content, display: entry.display, timestamp: Date.parse(entry.timestamp) })] : []);
60
+ // A run's first message is its input, a hidden custom one too: Pi puts it before any context it or other extensions inject.
61
+ const messages = (entries: readonly SessionEntry[], run = false) => entries.flatMap(entry => {
62
+ const message: AgentMessage | undefined = entry.type === 'message' ? entry.message : entry.type === 'custom_message'
63
+ ? { role: 'custom', customType: entry.customType, content: entry.content, display: entry.display, timestamp: Date.parse(entry.timestamp) } : undefined;
64
+ if (!message) return [];
65
+ const input = run; if (message.role !== 'system') run = false;
66
+ return [asUser(message, input)];
67
+ });
61
68
  for (let i = branch.length - 1; i >= 0; i--) {
62
69
  const entry = branch[i];
63
70
  if (entry.type !== 'custom' || entry.customType !== RUN_BOUNDARY || !record(entry.data)) continue;
@@ -65,7 +72,7 @@ function latestExchange(branch: readonly SessionEntry[]) {
65
72
  if (entry.data.state === 'end') end = i;
66
73
  else if (entry.data.state === 'start') {
67
74
  if (end >= 0) {
68
- const exchange = completedExchange(messages(branch.slice(i + 1, end)), imageRef);
75
+ const exchange = completedExchange(messages(branch.slice(i + 1, end), true), imageRef);
69
76
  if (exchange.length) return exchange;
70
77
  }
71
78
  end = -1;