pi-optchat 0.10.0 → 0.10.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-optchat",
3
- "version": "0.10.0",
3
+ "version": "0.10.1",
4
4
  "type": "module",
5
5
  "pi": {
6
6
  "extensions": [
package/src/agents.ts CHANGED
@@ -11,7 +11,7 @@ import { memoryTools, searchTool } from './tools.ts';
11
11
  import { VIEW, type Memory } from './memory.ts';
12
12
  import type { ModelChoice } from './compactor.ts';
13
13
  import { cachePayload } from './cache.ts';
14
- import { CUT_OFF, RunHistory, isRunning, transition, sessionMessages, savedSession, undelivered, type RunInfo, type RunState, type FinishReason } from './runs.ts';
14
+ import { CUT_OFF, RunHistory, isRunning, transition, sessionMessages, savedSession, undelivered, sentText, type Guidance, type RunInfo, type RunState, type FinishReason } from './runs.ts';
15
15
  import { UsageLedger } from './usage.ts';
16
16
  import { fullChat, refreshFraction, textContent } from './transcript.ts';
17
17
  import { Type, type Static } from 'typebox';
@@ -23,9 +23,13 @@ import { THINKING } from './profiles.ts';
23
23
  export interface LiveRun {
24
24
  session: AgentSession; info: RunInfo; updated: number; streaming?: AgentMessage;
25
25
  tools: Map<string, { name: string; args: unknown; output?: unknown; started: number }>;
26
- pendingReports: string[]; pendingGuidance: string[]; wake?: () => void; completion?: Promise<void>; primed?: Promise<unknown>;
26
+ pendingReports: string[]; pendingGuidance: Guidance[]; wake?: () => void; completion?: Promise<void>; primed?: Promise<unknown>;
27
27
  /** Set by the user's interrupt: the queued guidance the run continues with once its current step is aborted; with none, it pauses. */
28
- interruption?: RunInfo['guidance'];
28
+ interruption?: Guidance[];
29
+ /** Guidance just handed to session.prompt(): the next user message is it, as Pi may rewrite the text (a /skill: line becomes the skill). */
30
+ prompting?: Guidance;
31
+ /** Every steer into Pi's queue, one at a time, so each knows which queued text is its own (see `steer`). */
32
+ steering: Promise<unknown>;
29
33
  /** Messages being steered back into Pi's queue after it was cleared; other queue changes wait for it. */
30
34
  requeueing?: Promise<void>;
31
35
  /** Shared by the children of one spawn whose reports are delivered together, in spawn order. */
@@ -210,8 +214,9 @@ export class Children {
210
214
  if (event.type === 'turn_end' || event.type === 'agent_settled') this.options.usage?.backfill(live.session.sessionManager.getEntries(), live.info.parentSession, 'subagent', live.info.id);
211
215
  if (event.type === 'message_start' && event.message.role === 'user') {
212
216
  const text = textContent(event.message.content);
213
- const guidance = live.info.guidance.find(g => g.state === 'queued' && g.text === text);
214
- if (guidance) { guidance.state = 'delivered'; this.save(live.info); }
217
+ const guidance = live.prompting ?? live.info.guidance.find(g => g.state === 'queued' && sentText(g) === text);
218
+ live.prompting = undefined;
219
+ if (guidance) { if (text !== guidance.text) guidance.sent = text; guidance.state = 'delivered'; this.save(live.info); }
215
220
  }
216
221
  if (event.type === 'tool_execution_start') live.tools.set(event.toolCallId, { name: event.toolName, args: event.args, started: Date.now() });
217
222
  if (event.type === 'tool_execution_update') {
@@ -288,7 +293,7 @@ export class Children {
288
293
  sessionManager: SessionManager.create(directory, join(this.profileDirectory, 'runs')) });
289
294
  const info: RunInfo = { id, task: task.task, cwd: directory, model: `${task.model.provider}/${task.model.id}`, thinking: session.thinkingLevel,
290
295
  parentSession: this.options.parentSession ?? '', parentId, depth, sessionFile: session.sessionFile, started: Date.now(), state: 'running', guidance: [], ...(connected ? { connected: true } : {}) };
291
- const live: LiveRun = { session, info, updated: Date.now(), tools: new Map(), pendingReports: [], pendingGuidance: [] };
296
+ const live: LiveRun = { session, info, updated: Date.now(), tools: new Map(), pendingReports: [], pendingGuidance: [], steering: Promise.resolve() };
292
297
  launched.push(live); this.save(info); this.running.set(id, live);
293
298
  this.launching--; reserved--;
294
299
  session.subscribe(event => this.observe(live, event));
@@ -378,7 +383,7 @@ export class Children {
378
383
  if (!parentId) { await this.report(text); return true; }
379
384
  const parent = this.running.get(parentId);
380
385
  if (!parent || !['running', 'waiting', 'paused'].includes(parent.info.state)) return false;
381
- if (parent.info.state === 'running') await parent.session.steer(text);
386
+ if (parent.info.state === 'running') await this.steer(parent, text);
382
387
  else { parent.pendingReports.push(text); parent.wake?.(); } // A paused parent keeps it until the user resumes it.
383
388
  this.changed();
384
389
  return true;
@@ -407,7 +412,7 @@ export class Children {
407
412
  // Read Pi's queue only once a rebuild has landed, and take the interrupt's messages off it so they arrive once.
408
413
  // Until this is done, tell() holds new messages in pendingGuidance, where the turn below or a pause picks them up.
409
414
  await live.requeueing;
410
- const texts = new Set(guidance.map(g => g.text));
415
+ const texts = new Set(guidance.map(sentText));
411
416
  await this.requeue(live, session.clearQueue().steering.filter(text => !texts.has(text)));
412
417
  live.interruption = undefined;
413
418
  if (!guidance.length) {
@@ -425,7 +430,7 @@ export class Children {
425
430
  if (!transition(info, 'running')) continue; // A stop during the cleanup wins.
426
431
  for (const g of guidance) g.state = 'delivered';
427
432
  this.save(info);
428
- await session.prompt(interruptedWith(guidance.map(g => g.text)));
433
+ await session.prompt(interruptedWith(guidance.map(sentText)));
429
434
  continue;
430
435
  }
431
436
  const paused = info.state === 'paused';
@@ -438,7 +443,9 @@ export class Children {
438
443
  if (!paused && last?.role === 'assistant' && (last.stopReason === 'error' || last.stopReason === 'aborted') && !live.pendingReports.length && !this.directChildren(info.id).length) break;
439
444
  if (live.pendingGuidance.length) {
440
445
  transition(info, 'running'); this.save(info);
441
- await session.prompt(live.pendingGuidance.shift()!);
446
+ live.prompting = live.pendingGuidance.shift()!;
447
+ // An extension command sends no message at all, so this can't outlive the prompt and claim a later one.
448
+ try { await session.prompt(live.prompting.text); } finally { live.prompting = undefined; }
442
449
  continue;
443
450
  }
444
451
  if (live.pendingReports.length) {
@@ -474,7 +481,7 @@ export class Children {
474
481
  // Pi's queue must be empty before the handoff turn, or it would take a cancelled message as new input. Guidance in it is
475
482
  // already marked undelivered; the rest (a child's message) joins the reports the window never read.
476
483
  await live.requeueing;
477
- const guidance = new Set(info.guidance.map(g => g.text));
484
+ const guidance = new Set(info.guidance.map(sentText));
478
485
  const unread = [...live.pendingReports.splice(0), ...session.clearQueue().steering.filter(text => !guidance.has(text))];
479
486
  if (unread.length) info.handoff.unread = unread;
480
487
  transition(info, info.handoff.reason === 'complete' ? 'completed' : 'interrupted');
@@ -539,12 +546,14 @@ export class Children {
539
546
  if (!live) throw new Error(`No running subagent ${id}.`);
540
547
  const text = live.info.connected && source === 'manager' ? `[Main agent guidance]\n${message.trim()}` : message.trim(); if (!message.trim()) throw new Error('Message is empty.');
541
548
  if (source === 'user') this.memory.append('user', `Direct guidance to subagent [${id}]: ${text}`);
542
- const guidance: RunInfo['guidance'][number] = { text, date: Date.now(), state: 'queued', from: source };
549
+ const guidance: Guidance = { text, date: Date.now(), state: 'queued', from: source };
543
550
  live.info.guidance.push(guidance); this.save(live.info);
544
551
  try {
545
552
  await live.requeueing; // Keeps the queue in the order messages were sent.
546
- if (live.info.state === 'running' && !live.interruption) await live.session.steer(text);
547
- else { live.pendingGuidance.push(text); live.wake?.(); } // Waiting, paused or being interrupted: this message starts its next turn.
553
+ if (live.info.state === 'running' && !live.interruption) {
554
+ const sent = await this.steer(live, text);
555
+ if (sent !== undefined && sent !== text) guidance.sent = sent;
556
+ } else { live.pendingGuidance.push(guidance); live.wake?.(); } // Waiting, paused or being interrupted: this message starts its next turn.
548
557
  }
549
558
  catch (error) { guidance.state = 'undelivered'; this.save(live.info); throw error; }
550
559
  if (guidance.state === 'undelivered') throw new Error(`${id} finished before it read the message. Tell it again to resume it.`);
@@ -562,8 +571,8 @@ export class Children {
562
571
  // Nothing yields before the abort starts, so the run cannot finish in between and drop the messages.
563
572
  // They leave pendingGuidance and Pi's steering queue here, and execute() sends them in one prompt. Pi's queue must be empty
564
573
  // before the abort: after an aborted tool call Pi hands it to a request the abort cancels.
565
- const texts = new Set(guidance.map(g => g.text));
566
- live.pendingGuidance = live.pendingGuidance.filter(text => !texts.has(text));
574
+ live.pendingGuidance = live.pendingGuidance.filter(g => !guidance.includes(g));
575
+ const texts = new Set(guidance.map(sentText));
567
576
  // The rest of the queue (a child's tell_parent message) follows the interrupt's prompt, as reports to a paused run do.
568
577
  live.pendingReports.push(...live.session.clearQueue().steering.filter(text => !texts.has(text)));
569
578
  live.interruption = guidance;
@@ -578,11 +587,11 @@ export class Children {
578
587
  if (!live || text === undefined || live.requeueing || live.interruption) return undefined;
579
588
  // The queue holds only texts, so of equal ones the last is taken back, record and queue entry alike.
580
589
  const guidance = live.info.guidance.findLast(g => g.state === 'queued' && g.text === text)!;
581
- const pending = live.pendingGuidance.lastIndexOf(guidance.text);
590
+ const pending = live.pendingGuidance.indexOf(guidance);
582
591
  if (pending >= 0) live.pendingGuidance.splice(pending, 1);
583
592
  else {
584
593
  const steering = live.session.getSteeringMessages();
585
- const at = steering.lastIndexOf(guidance.text);
594
+ const at = steering.lastIndexOf(sentText(guidance));
586
595
  if (at < 0) return undefined; // Already on its way to the agent.
587
596
  // Pi cannot drop a single queued message: clear the queue and steer the rest back in order.
588
597
  void this.requeue(live, live.session.clearQueue().steering.filter((_, i) => i !== at));
@@ -590,9 +599,16 @@ export class Children {
590
599
  live.info.guidance.splice(live.info.guidance.indexOf(guidance), 1); this.save(live.info);
591
600
  return guidance.text;
592
601
  }
602
+ /** Pi's steer(), returning what Pi queued, which may differ from `text` (a /skill: line becomes the skill): that is what the agent receives. */
603
+ private steer(live: LiveRun, text: string) {
604
+ // One at a time, so the newest entry in Pi's queue is this one's.
605
+ const sent = live.steering.then(async () => await live.session.steer(text) === 'queued' ? live.session.getSteeringMessages().at(-1) : undefined);
606
+ live.steering = sent.catch(() => undefined);
607
+ return sent;
608
+ }
593
609
  private requeue(live: LiveRun, texts: string[]) {
594
610
  const work: Promise<void> = (async () => {
595
- try { for (const text of texts) await live.session.steer(text); }
611
+ try { for (const text of texts) await this.steer(live, text); }
596
612
  catch (error) { this.warn(`Could not requeue a message for ${live.info.id}: ${String(error)}`); }
597
613
  })().finally(() => { if (live.requeueing === work) live.requeueing = undefined; });
598
614
  live.requeueing = work;
@@ -634,7 +650,7 @@ export class Children {
634
650
  const info: RunInfo = { ...rest, state: 'running', started: Date.now(), parentSession: this.options.parentSession ?? run.parentSession,
635
651
  guidance: [...run.guidance, { text: prompt, date: Date.now(), state: 'queued', from: 'manager' }] };
636
652
  try { this.save(info); } catch (error) { this.history.records.set(id, run); await this.shutdown(session); this.dispose(session); throw error; }
637
- const live: LiveRun = { session, info, updated: Date.now(), tools: new Map(), pendingReports: [], pendingGuidance: [] };
653
+ const live: LiveRun = { session, info, updated: Date.now(), tools: new Map(), pendingReports: [], pendingGuidance: [], steering: Promise.resolve() };
638
654
  this.running.set(id, live);
639
655
  this.launching--; reserved = false;
640
656
  session.subscribe(event => this.observe(live, event));
package/src/runs.ts CHANGED
@@ -20,9 +20,12 @@ export interface RunInfo {
20
20
  handoff?: { reason: FinishReason; text?: string; delivered?: boolean; unread?: string[] };
21
21
  /** Working when Pi closed or crashed: the next start resumes it (see `Children.resumeCutOff`). */
22
22
  cutOff?: boolean;
23
- /** `from` is missing on runs saved before senders were recorded. */
24
- guidance: { text: string; date: number; state: 'queued' | 'delivered' | 'undelivered'; from?: 'user' | 'manager' }[];
23
+ /** `from` is missing on runs saved before senders were recorded. `sent`: what Pi queued, when it rewrote `text` (a `/skill:` line becomes the skill itself). */
24
+ guidance: Guidance[];
25
25
  }
26
+ export type Guidance = { text: string; date: number; state: 'queued' | 'delivered' | 'undelivered'; from?: 'user' | 'manager'; sent?: string };
27
+ /** The text the agent receives, which is how Pi's queue and the session know the message. */
28
+ export const sentText = (g: Guidance) => g.sent ?? g.text;
26
29
  const moves: Record<RunState, readonly RunState[]> = {
27
30
  running: ['waiting', 'paused', 'stopping', 'completed', 'failed', 'stopped', 'interrupted'],
28
31
  waiting: ['running', 'paused', 'stopping', 'completed', 'failed', 'stopped', 'interrupted'],
@@ -63,7 +66,7 @@ function isRun(value: unknown): value is RunInfo {
63
66
  && (value.handoff.unread === undefined || Array.isArray(value.handoff.unread) && value.handoff.unread.every(text => typeof text === 'string')))
64
67
  && typeof value.depth === 'number' && Number.isInteger(value.depth) && value.depth >= 1
65
68
  && (value.parentId === undefined || typeof value.parentId === 'string')
66
- && Array.isArray(value.guidance) && value.guidance.every(g => record(g) && typeof g.text === 'string' && typeof g.date === 'number' && ['queued', 'delivered', 'undelivered'].includes(String(g.state)) && (g.from === undefined || g.from === 'user' || g.from === 'manager'));
69
+ && Array.isArray(value.guidance) && value.guidance.every(g => record(g) && typeof g.text === 'string' && typeof g.date === 'number' && ['queued', 'delivered', 'undelivered'].includes(String(g.state)) && (g.from === undefined || g.from === 'user' || g.from === 'manager') && (g.sent === undefined || typeof g.sent === 'string'));
67
70
  }
68
71
  export const CUT_OFF = 'Pi closed before this agent finished. Its partial transcript is retained.';
69
72
  export function sessionMessages(file: string): AgentMessage[] {
@@ -7,8 +7,8 @@ import type { ThinkingLevel } from '@earendil-works/pi-agent-core';
7
7
 
8
8
  type Role = 'compactor' | 'subagent';
9
9
  const ROLES: Record<Role, { label: string; description: string; applies: string }> = {
10
- compactor: { label: 'Compactor model', description: 'Writes memory summaries, imports and connected-window handoffs. Shortcut: /optchat model.', applies: 'Applies to the next summary.' },
11
- subagent: { label: 'Subagent model', description: 'Runs every subagent. Shortcut: /optchat agents model.', applies: 'Applies to subagents started after this.' },
10
+ compactor: { label: 'Compactor model', description: 'Writes memory summaries, imports and fallback handoffs. Shortcut: /optchat model.', applies: 'Applies to the next summary.' },
11
+ subagent: { label: 'Subagent model', description: 'Runs every subagent. Shortcut: /optchat agents model.', applies: 'Applies to new subagents.' },
12
12
  };
13
13
  const modelName = (choice: ModelChoice) => `${choice.provider}/${choice.model}`;
14
14
  export const showModel = (choice: ModelChoice) => `${modelName(choice)} · ${choice.thinking}`;
package/src/settings.ts CHANGED
@@ -3,32 +3,32 @@ import { NODE } from './memory.ts';
3
3
  /** Per-profile choices where OptChat departs from Victor's recipe. Each default is the recipe's behaviour unless noted. */
4
4
  export const SETTINGS = {
5
5
  subagentLevels: { kind: 'number', label: 'Subagent levels', default: 1, min: 1, unit: '',
6
- description: 'How many levels of subagents. 1: only the main agent starts subagents. 2: those subagents may start their own, and so on.',
7
- applies: 'Applies to subagents started or resumed after this.' },
6
+ description: 'How deep subagents can nest. 1: only the main agent starts them.',
7
+ applies: 'Applies to new or resumed subagents.' },
8
8
  maxAgents: { kind: 'number', label: 'Max active agents', default: 8, min: 1, unit: '',
9
- description: 'Subagents running at once in this profile, all levels together. It also caps how deep a chain can go.',
9
+ description: 'Subagents running at once, all levels together. Also caps chain depth.',
10
10
  applies: 'Applies to the next spawn.' },
11
11
  groupReports: { kind: 'toggle', label: 'Group subagent reports', default: false,
12
- description: 'Off: each subagent reports as soon as it finishes, as in Victor\'s recipe. On: the subagents started by one spawn report together, in one message once the last of them finishes.',
12
+ description: 'On: subagents from one spawn report together, once the last finishes. Off: each reports when done (Victor\'s recipe).',
13
13
  applies: 'Applies to the next spawn.' },
14
14
  previousExchange: { kind: 'toggle', label: 'Previous exchange', default: true,
15
- description: 'Send your last request and its answer in full with the next turn, so a follow-up reads the exact wording. Off: memory view only, as in Victor\'s recipe. On by default: a study found it costs little and saves zooms.',
15
+ description: 'Send your last request and its answer in full with the next turn, so follow-ups see the exact wording. Off: memory view only (Victor\'s recipe).',
16
16
  applies: 'Applies from the next turn.' },
17
17
  previousExchangeKB: { kind: 'number', label: 'Previous exchange limit', default: 16, min: 1, unit: 'KB',
18
- description: 'A larger last exchange, usually a big paste, is left out and the model zooms into memory instead.',
18
+ description: 'A larger exchange, usually a big paste, is left out; the model zooms instead.',
19
19
  applies: 'Applies from the next turn.' },
20
20
  memorySearch: { kind: 'toggle', label: 'Memory search', default: false,
21
- description: 'Give agents a search tool that finds original messages by plain text, newest first, for exact names, numbers, paths or errors the view doesn\'t show. Off: zoom and date only, as in Victor\'s recipe. Turning it on or off makes the next turn re-cache its prompt once.',
22
- applies: 'Applies from the next turn, and to subagents started or resumed after this.' },
21
+ description: 'Lets agents search original messages by plain text. Off: zoom and date only (Victor\'s recipe). Toggling re-caches the prompt once.',
22
+ applies: 'Applies from the next turn and to new or resumed subagents.' },
23
23
  summaryAcceptBytes: { kind: 'number', label: 'Summary size tolerance', default: NODE * 1.25, min: NODE, unit: 'bytes',
24
- description: `The compactor is always asked for ${NODE}-byte lines; a longer line up to this size is kept instead of retried. ${NODE}: strict, as in Victor's recipe. The default saves retries for a little more view space.`,
24
+ description: `Summary lines up to this size are kept instead of retried (the compactor aims for ${NODE}). ${NODE}: strict (Victor's recipe).`,
25
25
  applies: 'Applies to the next summary.' },
26
26
  jobs: { kind: 'number', label: 'Summaries at once', default: 8, min: 1, max: 64, unit: '',
27
- description: 'Summaries built at once, by chat compaction and by an import. With N at once, a summary can see up to N−1 lines not summarized yet before it, so higher is faster but a little less accurate (at 8 that costs about 0.8 points per summary, #108). Higher also hits your provider\'s rate limits harder; the compactor retries what is refused. If your provider caps parallel requests, set it below that cap so the main turn has room.',
28
- applies: 'Applies to the next import, and to chat once the profile opens again (e.g. after /reload).' },
27
+ description: 'Summaries built in parallel. Higher is faster, slightly less accurate and harder on rate limits; keep it below your provider\'s parallel cap.',
28
+ applies: 'Applies to the next import, and to chat after /reload.' },
29
29
  refreshAt: { kind: 'percent', label: 'Turn refresh threshold', default: 0.3, min: 0, max: 0.9,
30
- description: 'Once the current turn\'s messages pass this share of the model\'s context window, they move into memory and the turn goes on with a fresh window: the rebuilt view, the task word for word, and its last tool call and result. 0%: off, as in Victor\'s recipe, and a long turn can run out of window. OPTCHAT_REFRESH_AT (a fraction, e.g. 0.3; 0 is off) overrides it. Subagents use Pi\'s own compaction at about the same point.',
31
- applies: 'Applies from the next call, and to subagents started or resumed after this.' },
30
+ description: 'When a turn fills this share of the context window, it moves into memory and continues in a fresh window. 0%: off (Victor\'s recipe). OPTCHAT_REFRESH_AT overrides it.',
31
+ applies: 'Applies from the next call and to new or resumed subagents.' },
32
32
  } as const;
33
33
 
34
34
  export type SettingKey = keyof typeof SETTINGS;
@@ -191,6 +191,10 @@ export async function serveWindows(directory: string, children: Children, availa
191
191
  if (task) { text = info.task; firstUser = false; }
192
192
  if (message.role === 'user') {
193
193
  // The task, and an interrupt, which continues with your queued messages, are yours too.
194
+ // Your messages show as you typed them, also inside an interrupt's prompt, not as the skill Pi expanded them into.
195
+ // Longest first: a skill sent without arguments is the start of the same skill sent with some.
196
+ const typed = info.guidance.flatMap(g => g.from === 'user' && g.sent ? [{ sent: g.sent, text: g.text }] : []).sort((a, b) => b.sent.length - a.sent.length);
197
+ for (const g of typed) text = text.replaceAll(g.sent, () => g.text);
194
198
  const yours = task || text.startsWith(INTERRUPTED) || info.guidance.some(g => g.from === 'user' && g.text === text.trim());
195
199
  if (text) send({ kind: 'event', name: 'message', from: yours ? 'user' : undefined, text: shorten(text, 200_000) });
196
200
  continue;