pi-optchat 0.8.2 → 0.8.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-optchat",
3
- "version": "0.8.2",
3
+ "version": "0.8.3",
4
4
  "type": "module",
5
5
  "pi": {
6
6
  "extensions": [
package/src/agents.ts CHANGED
@@ -314,7 +314,10 @@ export class Children {
314
314
  // Nothing to hand over: the run waits for its next message instead of ending, and whoever waits on its report hears why.
315
315
  if (transition(info, 'paused')) {
316
316
  this.save(info);
317
- if (!info.connected) await this.toParent(info.parentId, `[${info.id}] Interrupted by the user; it waits for their next message, so no report until then.`)
317
+ // In a grouped spawn the note takes the run's place, so its siblings' reports are not held until the user comes back; its later report arrives on its own.
318
+ const note = `[${info.id}] Interrupted by the user; it waits for their next message, so no report until then.`, batch = live.batch;
319
+ live.batch = undefined;
320
+ if (!info.connected) await (batch ? this.deliver(info, batch, note) : this.toParent(info.parentId, note))
318
321
  .catch(error => this.warn(`Could not tell the parent about the interrupt: ${String(error)}`));
319
322
  }
320
323
  continue;
@@ -331,7 +334,8 @@ export class Children {
331
334
  continue;
332
335
  }
333
336
  const last = session.messages.findLast(m => m.role === 'assistant');
334
- if (!paused && last?.role === 'assistant' && (last.stopReason === 'error' || last.stopReason === 'aborted')) break;
337
+ // An error ends the turn, not the run: children still at work report to it and it answers them, as the main agent does.
338
+ if (!paused && last?.role === 'assistant' && (last.stopReason === 'error' || last.stopReason === 'aborted') && !live.pendingReports.length && !this.directChildren(info.id).length) break;
335
339
  if (live.pendingGuidance.length) {
336
340
  transition(info, 'running'); this.save(info);
337
341
  await session.prompt(live.pendingGuidance.shift()!);
@@ -384,8 +388,10 @@ export class Children {
384
388
  // A metadata failure must not suppress delivery of the actual result.
385
389
  try { this.save(info); } catch (error) { this.warn(`Could not save run metadata: ${String(error)}`); }
386
390
  // A deleted run's transcript is gone.
387
- let text = `[${info.id}] ${info.report}${this.deleting.has(info.id) ? '' : `\n\n${fullChat(info.id)}`}`;
388
- const batch = live.batch;
391
+ await this.deliver(info, live.batch, `[${info.id}] ${info.report}${this.deleting.has(info.id) ? '' : `\n\n${fullChat(info.id)}`}`);
392
+ }
393
+ /** Sends a run's report to its parent; in a grouped spawn, only once every child of the spawn has one. */
394
+ private async deliver(info: RunInfo, batch: LiveRun['batch'], text: string) {
389
395
  let count: number | undefined;
390
396
  if (batch) {
391
397
  batch.reports.set(info.id, text);
@@ -395,7 +401,7 @@ export class Children {
395
401
  }
396
402
  if (info.parentId) {
397
403
  const parent = this.running.get(info.parentId);
398
- if (parent && parent.info.state !== 'stopping') { parent.pendingReports.push(text); this.changed(); }
404
+ if (parent && parent.info.state !== 'stopping') { parent.pendingReports.push(text); parent.wake?.(); this.changed(); }
399
405
  return;
400
406
  }
401
407
  if (this.closing) this.memory.append('work', text);
@@ -548,7 +554,8 @@ export class Children {
548
554
  }
549
555
  if (orphans.length) lines.push(`Also cut off, but not resumed because their parent agent is not running: ${orphans.join(', ')}.`);
550
556
  if (!resumed.length && !lines.length) return;
551
- await this.report([`Pi restarted while subagents were working.${resumed.length ? ` Resumed ${resumed.join(', ')} from where ${resumed.length > 1 ? 'they' : 'it'} left off; reports arrive as usual.` : ''}`, ...lines].join('\n'));
557
+ await this.report([`Pi restarted while subagents were working.${resumed.length ? ` Resumed ${resumed.join(', ')} from where ${resumed.length > 1 ? 'they' : 'it'} left off; ${this.settings.groupReports
558
+ ? 'grouped reports do not survive a restart, so each one reports on its own.' : 'reports arrive as usual.'}` : ''}`, ...lines].join('\n'));
552
559
  }
553
560
  async finish(id: string, reason: FinishReason) {
554
561
  const live = this.running.get(id);
package/src/compactor.ts CHANGED
@@ -51,7 +51,8 @@ export function createCompressor(registry: ModelRegistry, choice: () => ModelCho
51
51
  if (!model) throw new Error(`Compactor model unavailable: ${selected.provider}/${selected.model}. Use /optchat model.`);
52
52
  const thinking = reasoningFor(model, selected.thinking);
53
53
  const step = `${input.historical ? IMPORT_GUIDANCE + '\n\n' : ''}${compaction(input)}`;
54
- const messages: Message[] = [{ role: 'user', content: [{ type: 'text', text: input.context }, { type: 'text', text: step }], timestamp: Date.now() }];
54
+ // OpenAI joins text blocks with nothing between them, so the task starts its own paragraph after the view.
55
+ const messages: Message[] = [{ role: 'user', content: [{ type: 'text', text: input.context }, { type: 'text', text: `\n\n${step}` }], timestamp: Date.now() }];
55
56
  const view = splitView(input.context);
56
57
  const prefix = model.api === 'anthropic-messages' && view.length > 1 ? `${model.provider}/${model.id}/${thinking ?? 'off'}\n${view.slice(0, -1).join('')}` : undefined;
57
58
  const tries: string[] = [];
@@ -71,6 +72,8 @@ export function createCompressor(registry: ModelRegistry, choice: () => ModelCho
71
72
  } finally { warmed(false); }
72
73
  onUsage(reply);
73
74
  if (reply.stopReason === 'error' || reply.stopReason === 'aborted') throw new Error(reply.errorMessage ?? `Compactor ${reply.stopReason}`);
75
+ // A reply cut off at the token limit, or one that calls a tool, is no summary: ask again.
76
+ if (reply.stopReason === 'length' || reply.content.some(c => c.type === 'toolCall')) continue;
74
77
  // The compactions' view shows each line under its id+n| head, which a line can copy.
75
78
  const line = reply.content.filter(c => c.type === 'text').map(c => c.text).join('').trim().replace(/^\d+\+\d+\|\s*/, '');
76
79
  if (!line) throw new Error('Compactor returned no text.');
@@ -81,6 +84,7 @@ export function createCompressor(registry: ModelRegistry, choice: () => ModelCho
81
84
  const cut = Buffer.from(line).subarray(0, NODE).toString('utf8').replace(/\uFFFD$/, '');
82
85
  messages.push({ role: 'user', content: tooLong(bytes(line), cut), timestamp: Date.now() });
83
86
  }
87
+ if (!tries.length) throw new Error('Compactor returned no whole line in 5 tries.');
84
88
  return tries.reduce((a, b) => bytes(a) <= bytes(b) ? a : b);
85
89
  };
86
90
  }
package/src/index.ts CHANGED
@@ -31,6 +31,7 @@ import { openConnectedWindow, registerConnectedRenderer } from './connected-wind
31
31
  import { createHandoffSummarizer } from './handoff.ts';
32
32
  import { mainTitle, TabTitle } from './title.ts';
33
33
  import { showModelPicker, showSettings } from './settings-page.ts';
34
+ import { showMenu } from './menu.ts';
34
35
 
35
36
  const binding = 'optchat.profile';
36
37
  const CONNECT_MODES = ['auto', 'join', 'off'] as const;
@@ -48,6 +49,15 @@ const promptFor = (prompt: string, { previousExchange, memorySearch }: ProfileCo
48
49
  allowSearch(toggle(toggle(prompt, CONTINUITY, previousExchange, VIEW_DOC), SEARCH_DOC, memorySearch, previousExchange ? VIEW_DOC + CONTINUITY : VIEW_DOC), memorySearch);
49
50
  interface Active { name: string; dir: string; config: ProfileConfig; memory: Memory; inbox: Inbox; children: Children; usage: UsageLedger; unlock: () => Promise<void> }
50
51
 
52
+ /** OptChat's own version, read from the package.json beside src/ (in the checkout and the npm package alike); none if unreadable. */
53
+ export const version = ((): string | undefined => {
54
+ try {
55
+ const data: unknown = JSON.parse(readFileSync(new URL('../package.json', import.meta.url), 'utf8'));
56
+ return data && typeof data === 'object' && 'version' in data && typeof data.version === 'string' ? data.version : undefined;
57
+ } catch { return undefined; }
58
+ })();
59
+ const brand = version ? `OptChat v${version}` : 'OptChat';
60
+
51
61
  export default function optchat(pi: ExtensionAPI) {
52
62
  let active: Active | undefined;
53
63
  let remote: Awaited<ReturnType<typeof openConnectedWindow>> | undefined;
@@ -209,7 +219,7 @@ export default function optchat(pi: ExtensionAPI) {
209
219
  closeWindows = await serveWindows(dir, children, () => !stopping && !importing && !pendingImport(dir), deliverReport);
210
220
  untitle = children.subscribe(() => showTitle(ctx)); showTitle(ctx);
211
221
  status(ctx);
212
- ctx.ui.notify(`OptChat · ${name} · ${memory.root.length} messages\nCompactor: ${config.compactor.provider}/${config.compactor.model} (${config.compactor.thinking})`, 'info');
222
+ ctx.ui.notify(`${brand} · ${name} · ${memory.root.length} messages\nCompactor: ${config.compactor.provider}/${config.compactor.model} (${config.compactor.thinking})`, 'info');
213
223
  const queuedReports = [...reports];
214
224
  if (!pendingImport(dir)) {
215
225
  const recover = (work: Promise<void>, what: string) => work.catch(error => ctx.ui.notify(`${what}: ${errorText(error)}`, 'error'));
@@ -474,8 +484,8 @@ export default function optchat(pi: ExtensionAPI) {
474
484
  let action = args.trim();
475
485
  if (!action) {
476
486
  const a = active;
477
- const info = a ? `${a.name} · ${a.memory.root.length} messages · ${a.memory.pending} pending\nCompactor: ${a.config.compactor.model} (${a.config.compactor.thinking})\nAgents: ${a.config.subagent.model} (${a.config.subagent.thinking})\n${a.memory.lastError ?? ''}` : 'No active profile';
478
- action = await ctx.ui.select(`OptChat\n${info}`, ['profile', 'settings', 'model', 'agents', 'usage', 'activity', 'instructions', 'browse', 'import']) ?? '';
487
+ action = await showMenu(ctx, brand, a && { profile: a.name, messages: a.memory.root.length, pending: a.memory.pending,
488
+ compactor: a.config.compactor, agents: a.config.subagent, error: a.memory.lastError }) ?? '';
479
489
  }
480
490
  if (action === 'import') {
481
491
  const a = required();
package/src/memory.ts CHANGED
@@ -334,7 +334,7 @@ export class Memory {
334
334
  if (n === 1) {
335
335
  const { kind, text } = this.root[id], page = Math.min(limit ?? PAGE, PAGE);
336
336
  if (!Number.isSafeInteger(page) || page < 1) throw new Error('limit must be a positive integer.');
337
- if (offset === undefined && text.length <= page) return `${id}+0|${kind}: ${text}`;
337
+ if (offset === undefined && text.length <= page) return `${id}+1|${kind}: ${text}`;
338
338
  if (offset !== undefined && (!Number.isSafeInteger(offset) || offset < 0 || offset >= Math.max(1, text.length)))
339
339
  throw new Error(`Message ${id} has ${text.length} characters; offset must be 0 to ${Math.max(0, text.length - 1)}.`);
340
340
  // Never split a surrogate pair: a page starts on its first half and ends after its second.
@@ -343,7 +343,7 @@ export class Memory {
343
343
  // A one-unit page on a pair takes the whole pair, so the next offset always moves forward.
344
344
  if (to < text.length && /[\ud800-\udbff]/.test(text[to - 1])) to += to - 1 === from ? 1 : -1;
345
345
  const hint = text.length <= PAGE ? `\n[note: this message is only ${text.length.toLocaleString('en-US')} characters and fits in one zoom; offset/limit are for messages over ${PAGE.toLocaleString('en-US')}]` : '';
346
- return `${id}+0|${kind}: ${text.slice(from, to)}\n[showing characters ${from}-${to} of ${text.length}${to < text.length ? `; next page: offset ${to}` : ''}]${hint}`;
346
+ return `${id}+1|${kind}: ${text.slice(from, to)}\n[showing characters ${from}-${to} of ${text.length}${to < text.length ? `; next page: offset ${to}` : ''}]${hint}`;
347
347
  }
348
348
  if (offset !== undefined) throw new Error('offset and limit page one message: use them with n = 1.');
349
349
  const l = Math.log2(n) - 1, i = 2 * id / n;
package/src/menu.ts ADDED
@@ -0,0 +1,61 @@
1
+ import { Box, SelectList, Spacer, Text } from '@earendil-works/pi-tui';
2
+ import type { ExtensionContext, Theme } from '@earendil-works/pi-coding-agent';
3
+ import type { ModelChoice } from './compactor.ts';
4
+ import { count, oneLine } from './inspector.ts';
5
+ import { frame, showModel } from './settings-page.ts';
6
+
7
+ /** What `/optchat` offers, in menu order; each is also `/optchat <action>`. */
8
+ export const ACTIONS = ['profile', 'settings', 'model', 'agents', 'usage', 'activity', 'instructions', 'browse', 'import'] as const;
9
+ export type MenuAction = typeof ACTIONS[number];
10
+ const DESCRIPTIONS: Record<MenuAction, string> = {
11
+ profile: 'Switch or create a profile',
12
+ settings: 'Models, subagent limits, memory options',
13
+ model: 'Compactor model and effort',
14
+ agents: 'Live agent tree and saved runs',
15
+ usage: 'Token usage and cost estimates',
16
+ activity: 'Memory gauge and running agents',
17
+ instructions: 'Edit this profile\'s AGENTS.md',
18
+ browse: 'Open a readable snapshot of memory',
19
+ import: 'Import history, or resume a paused import',
20
+ };
21
+
22
+ export interface MenuStatus { profile: string; messages: number; pending: number; compactor: ModelChoice; agents: ModelChoice; error?: string }
23
+ /** The status block as label and value; the plain menu outside the TUI shows the same rows. */
24
+ export const statusRows = (s: MenuStatus) => [
25
+ ['Memory', `${count(s.messages)} messages · ${count(s.pending)} pending`],
26
+ // config.json only checks these are strings, so one with a newline or escape code must not break the block.
27
+ ['Compactor', oneLine(showModel(s.compactor))],
28
+ ['Agents', oneLine(showModel(s.agents))],
29
+ ] as const;
30
+ const failing = (error: string) => `Summaries failing: ${oneLine(error)}`;
31
+
32
+ /** Framed like the settings page: title, the profile's status as aligned rows, then the actions with what each opens. */
33
+ export function optchatMenu(theme: Theme, title: string, status: MenuStatus | undefined, close: (action?: MenuAction) => void, redraw = () => {}) {
34
+ const list = new SelectList(ACTIONS.map(value => ({ value, label: value, description: DESCRIPTIONS[value] })), ACTIONS.length, {
35
+ selectedPrefix: t => theme.fg('accent', t), selectedText: t => theme.fg('accent', t),
36
+ description: t => theme.fg('muted', t), scrollInfo: t => theme.fg('muted', t), noMatch: t => theme.fg('muted', t) },
37
+ { minPrimaryColumnWidth: 1, maxPrimaryColumnWidth: Math.max(...ACTIONS.map(a => a.length)) + 4 });
38
+ list.onSelect = item => close(ACTIONS.find(a => a === item.value));
39
+ list.onCancel = () => close();
40
+ const rows = status ? statusRows(status) : [];
41
+ const labels = Math.max(0, ...rows.map(([label]) => label.length));
42
+ const facts = status ? rows.map(([label, value]) => new Text(`${theme.fg('muted', label.padEnd(labels))} ${value}`, 1, 0)) : [new Text(theme.fg('muted', 'No active profile'), 1, 0)];
43
+ if (status?.error) facts.push(new Text(theme.fg('warning', failing(status.error)), 1, 0));
44
+ const actions = new Box(1, 0); actions.addChild(list);
45
+ const page = frame(theme, title, status?.profile, [new Spacer(1), ...facts, new Spacer(1), actions, new Spacer(1),
46
+ new Text(theme.fg('dim', '↑↓ select · Enter open · Esc close'), 1, 0)]);
47
+ return Object.assign(page, { handleInput: (data: string) => {
48
+ // j and k move too, as in Pi's own select dialog that this menu replaces.
49
+ const step = data === 'j' ? 1 : data === 'k' ? -1 : 0, at = ACTIONS.findIndex(a => a === list.getSelectedItem()?.value);
50
+ if (step) list.setSelectedIndex(Math.max(0, Math.min(ACTIONS.length - 1, at + step)));
51
+ else list.handleInput(data);
52
+ redraw();
53
+ } });
54
+ }
55
+
56
+ /** The menu in interactive Pi; elsewhere (RPC hosts) Pi's plain select with the same facts and actions. */
57
+ export function showMenu(ctx: ExtensionContext, title: string, status: MenuStatus | undefined): Promise<MenuAction | undefined> {
58
+ if (ctx.mode === 'tui') return ctx.ui.custom<MenuAction | undefined>((tui, theme, _keys, done) => optchatMenu(theme, title, status, done, () => tui.requestRender()));
59
+ const facts = status ? [status.profile, ...statusRows(status).map(([label, value]) => `${label}: ${value}`), ...status.error ? [failing(status.error)] : []] : ['No active profile'];
60
+ return ctx.ui.select([title, ...facts].join('\n'), [...ACTIONS]).then(choice => ACTIONS.find(a => a === choice));
61
+ }
@@ -11,7 +11,7 @@ const ROLES: Record<Role, { label: string; description: string; applies: string
11
11
  subagent: { label: 'Subagent model', description: 'Runs every subagent. Shortcut: /optchat agents model.', applies: 'Applies to subagents started after this.' },
12
12
  };
13
13
  const modelName = (choice: ModelChoice) => `${choice.provider}/${choice.model}`;
14
- const showModel = (choice: ModelChoice) => `${modelName(choice)} · ${choice.thinking}`;
14
+ export const showModel = (choice: ModelChoice) => `${modelName(choice)} · ${choice.thinking}`;
15
15
  /** The value column: the model, then whether it is the default (shown short, without its provider). */
16
16
  const modelValue = (theme: Theme, role: Role, choice: ModelChoice) => `${showModel(choice)}${theme.fg('dim', showModel(choice) === showModel(defaults[role])
17
17
  ? ' default' : ` default ${defaults[role].model} · ${defaults[role].thinking}`)}`;
@@ -114,9 +114,9 @@ function update(o: Options, patch: Partial<ProfileConfig>) {
114
114
  return undefined;
115
115
  }
116
116
  /** Bordered, titled like Pi's own dialogs. */
117
- function frame(theme: Theme, title: string, profile: string, body: Component[]) {
117
+ export function frame(theme: Theme, title: string, profile: string | undefined, body: Component[]) {
118
118
  const page = new Container();
119
- for (const child of [new DynamicBorder(s => theme.fg('border', s)), new Text(`${theme.bold(theme.fg('accent', title))}${theme.fg('muted', ` · ${profile}`)}`, 1, 0),
119
+ for (const child of [new DynamicBorder(s => theme.fg('border', s)), new Text(`${theme.bold(theme.fg('accent', title))}${profile ? theme.fg('muted', ` · ${profile}`) : ''}`, 1, 0),
120
120
  ...body, new DynamicBorder(s => theme.fg('border', s))]) page.addChild(child);
121
121
  return page;
122
122
  }
package/src/store.ts CHANGED
@@ -1,4 +1,4 @@
1
- import { closeSync, existsSync, fstatSync, fsyncSync, mkdirSync, openSync, readFileSync, readSync, readdirSync, renameSync, statSync, writeFileSync, writeSync } from 'node:fs';
1
+ import { closeSync, existsSync, fstatSync, fsyncSync, mkdirSync, openSync, readFileSync, readSync, readdirSync, renameSync, statSync, truncateSync, writeFileSync, writeSync } from 'node:fs';
2
2
  import { dirname, join, resolve } from 'node:path';
3
3
  import type { Entry, Summary } from './memory.ts';
4
4
 
@@ -61,7 +61,8 @@ export function atomicWrite(file: string, text: string | Uint8Array) {
61
61
  try { fsyncSync(parent); } finally { closeSync(parent); }
62
62
  }
63
63
  const otherWriter = (file: string) => new Error(`Another process wrote ${file}. Close every other Pi on this profile and restart Pi; nothing was written.`);
64
- /** With `size`, refuses to append unless the file still has that size. A last line left without its newline is ended first. Returns the new size. */
64
+ /** With `size`, refuses to append unless the file still has that size. A last line left without its newline is ended first. A write
65
+ * cut short is truncated away before the error is thrown. Returns the new size. */
65
66
  export function appendJson(file: string, value: unknown, size?: number) {
66
67
  const fd = openSync(file, 'a+', 0o600);
67
68
  try {
@@ -69,7 +70,10 @@ export function appendJson(file: string, value: unknown, size?: number) {
69
70
  if (size !== undefined && length !== size) throw otherWriter(file);
70
71
  const torn = length > 0 && readSync(fd, last, 0, 1, length - 1) === 1 && last[0] !== 0x0a;
71
72
  const data = Buffer.from((torn ? '\n' : '') + JSON.stringify(value) + '\n');
72
- if (writeSync(fd, data) !== data.length) throw new Error(`Incomplete write: ${file}`);
73
+ // A cut-short write is undone, so the file keeps the size the caller checks against. Windows refuses to truncate through an
74
+ // append handle, so this goes by path; if the undo fails too, the write's own error is still the one thrown.
75
+ try { if (writeSync(fd, data) !== data.length) throw new Error(`Incomplete write: ${file}`); }
76
+ catch (error) { try { truncateSync(file, length); } catch {} throw error; }
73
77
  fsyncSync(fd);
74
78
  return fstatSync(fd).size;
75
79
  } finally { closeSync(fd); }
package/src/transcript.ts CHANGED
@@ -171,7 +171,9 @@ export function buildContext(canonical: AgentMessage[], run: AgentMessage[], vie
171
171
  const messages = [...previous, ...run].filter(m => m.role !== 'system').map(message => {
172
172
  if (message.role !== 'user' || injected) return message;
173
173
  injected = true;
174
- return { ...message, content: [{ type: 'text' as const, text: view }, ...(typeof message.content === 'string' ? [{ type: 'text' as const, text: message.content }] : message.content)] };
174
+ // OpenAI joins text blocks with nothing between them, so the user's words start their own paragraph after the view.
175
+ const [first, ...rest] = typeof message.content === 'string' ? [{ type: 'text' as const, text: message.content }] : message.content;
176
+ return { ...message, content: [{ type: 'text' as const, text: view }, ...first?.type === 'text' ? [{ ...first, text: `\n\n${first.text}` }] : first ? [first] : [], ...rest] };
175
177
  });
176
178
  return [head, ...messages, ...status && messages.at(-1)?.role !== 'toolResult' ? [{ role: 'user' as const, content: [{ type: 'text' as const, text: status }], timestamp: 0 }] : []];
177
179
  }