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 +1 -1
- package/src/agents.ts +36 -20
- package/src/runs.ts +6 -3
- package/src/settings-page.ts +2 -2
- package/src/settings.ts +13 -13
- package/src/window-bridge.ts +4 -0
package/package.json
CHANGED
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:
|
|
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?:
|
|
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
|
|
214
|
-
|
|
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
|
|
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(
|
|
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(
|
|
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
|
-
|
|
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(
|
|
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:
|
|
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)
|
|
547
|
-
|
|
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
|
-
|
|
566
|
-
|
|
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.
|
|
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
|
|
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
|
|
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:
|
|
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[] {
|
package/src/settings-page.ts
CHANGED
|
@@ -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
|
|
11
|
-
subagent: { label: 'Subagent model', description: 'Runs every subagent. Shortcut: /optchat agents model.', applies: 'Applies to subagents
|
|
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
|
|
7
|
-
applies: 'Applies to
|
|
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
|
|
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: '
|
|
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
|
|
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
|
|
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: '
|
|
22
|
-
applies: 'Applies from the next turn
|
|
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: `
|
|
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
|
|
28
|
-
applies: 'Applies to the next import, and to chat
|
|
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: '
|
|
31
|
-
applies: 'Applies from the next call
|
|
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;
|
package/src/window-bridge.ts
CHANGED
|
@@ -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;
|