pi-optchat 0.6.6 → 0.6.7

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
@@ -105,7 +105,9 @@ Set a different shortcut with `OPTCHAT_INSPECT_KEY=ctrl+shift+a pi`. If another
105
105
 
106
106
  Guidance shows as queued until delivered, or undelivered if the child stops first. Guidance you send is also saved in main memory. Reasoning is not shown. Transcripts stay browsable after restart, and browsing them makes no model calls.
107
107
 
108
- **Usage** shows this session, last hour, today, last 7 days, or all time (**Left/Right**). It breaks down main agent, subagents, compactor, and imports by model: uncached input, output, cache reads/writes, and estimated cost.
108
+ **Usage** shows this session, last hour, today, last 7 days, or all time (**Left/Right**): one row per role and model (main agent, subagents, compactor, imports) with estimated cost, share of the total, output tokens, and how much input came from the cache. Costs are API prices, not your subscription bill.
109
+
110
+ ![Usage page](docs/screenshots/usage.png)
109
111
 
110
112
  - Costs are API-rate estimates, not your subscription bill. Unknown rates show zero.
111
113
  - Record counts are not request counts; retries and tool overhead can add records.
@@ -187,7 +189,7 @@ To delete a profile, delete its folder. Your original Pi sessions are kept in Pi
187
189
 
188
190
  ## How it differs from the recipe
189
191
 
190
- The recipe's four prompts are kept verbatim in `src/prompts.ts`, along with its numbers: 512-byte summary nodes, a 128,000-byte memory view, binary merges, 8 compression workers, fixed retry delays, 5 shortening attempts, and a 30,000-character tool output cap. Anthropic requests get stable cache breakpoints on the view. See `docs/victor-recipe.md` for notes.
192
+ The recipe's four prompts are kept verbatim in `src/prompts.ts`, along with its numbers: 512-byte summary nodes (summaries up to 640 bytes are accepted without a retry), a 128,000-byte memory view, binary merges, 8 compression workers, fixed retry delays, 5 shortening attempts, and a 30,000-character tool output cap. Anthropic requests get stable cache breakpoints on the view, and when that view is not cached yet, one compactor call goes first and the others wait until it starts answering, so they read the cache instead of each writing it. See `docs/victor-recipe.md` for notes.
191
193
 
192
194
  Each run's context is the memory view, the previous exchange, and your new message. Deliberate additions:
193
195
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-optchat",
3
- "version": "0.6.6",
3
+ "version": "0.6.7",
4
4
  "type": "module",
5
5
  "pi": {
6
6
  "extensions": [
package/src/agent-view.ts CHANGED
@@ -3,7 +3,7 @@ import {
3
3
  createBashToolDefinition, createEditToolDefinition, createFindToolDefinition, createGrepToolDefinition, createLsToolDefinition, createReadToolDefinition, createWriteToolDefinition,
4
4
  type ExtensionContext,
5
5
  } from '@earendil-works/pi-coding-agent';
6
- import { Editor, Spacer, matchesKey, truncateToWidth, visibleWidth, type Component, type Focusable, type TUI, type TuiMouseEvent, type TuiMouseEventResult } from '@earendil-works/pi-tui';
6
+ import { Editor, Spacer, compositeTuiLine, matchesKey, truncateToWidth, visibleWidth, type Component, type Focusable, type TUI, type TuiMouseEvent, type TuiMouseEventResult } from '@earendil-works/pi-tui';
7
7
  import type { AgentMessage } from '@earendil-works/pi-agent-core';
8
8
  import type { Children } from './agents.ts';
9
9
  import type { RunInfo } from './runs.ts';
@@ -297,10 +297,27 @@ export class AgentView implements Component, Focusable {
297
297
  }
298
298
  }
299
299
 
300
+ /**
301
+ * Pi never composites overlays onto rows holding a terminal image (earendil-works/pi#6995), so images in the
302
+ * main chat would show through the full-screen view. While it is open, image rows under overlays are blanked.
303
+ * If Pi renames the hook, this quietly does nothing.
304
+ */
305
+ export function hideImagesUnderOverlays(tui: TUI) {
306
+ Object.assign(tui, { compositeLineAt: (...[base, ...rest]: Parameters<typeof compositeTuiLine>) =>
307
+ compositeTuiLine(/\x1b_G|\x1b\]1337;File=/.test(base) ? '' : base, ...rest) });
308
+ return () => { Reflect.deleteProperty(tui, 'compositeLineAt'); };
309
+ }
310
+
300
311
  /** Swaps the whole screen to the subagent's conversation; Pi restores the main chat on close. */
301
- export function showAgentView(ctx: ExtensionContext, options: { id: string; children: Children; signal?: AbortSignal }) {
302
- return ctx.ui.custom<void>((tui, theme, keybindings, done) => new AgentView({ ...options, tui,
303
- rows: () => tui.terminal.rows, redraw: () => tui.requestRender(), done: () => done(undefined),
304
- color: (tone, text) => theme.fg(tone, text), isExpandKey: data => keybindings.matches(data, 'app.tools.expand'),
305
- }), { overlay: true, overlayOptions: { width: '100%', maxHeight: '100%', row: 0, col: 0 } });
312
+ export async function showAgentView(ctx: ExtensionContext, options: { id: string; children: Children; signal?: AbortSignal }) {
313
+ let restore = () => {};
314
+ try {
315
+ return await ctx.ui.custom<void>((tui, theme, keybindings, done) => {
316
+ restore = hideImagesUnderOverlays(tui);
317
+ return new AgentView({ ...options, tui,
318
+ rows: () => tui.terminal.rows, redraw: () => tui.requestRender(), done: () => done(undefined),
319
+ color: (tone, text) => theme.fg(tone, text), isExpandKey: data => keybindings.matches(data, 'app.tools.expand'),
320
+ });
321
+ }, { overlay: true, overlayOptions: { width: '100%', maxHeight: '100%', row: 0, col: 0 } });
322
+ } finally { restore(); }
306
323
  }
package/src/compactor.ts CHANGED
@@ -3,32 +3,71 @@ import type { ThinkingLevel } from '@earendil-works/pi-agent-core';
3
3
  import type { ModelRegistry } from '@earendil-works/pi-coding-agent';
4
4
  import { COMPACT } from './prompts.ts';
5
5
  import { bytes, NODE, type Compressor } from './memory.ts';
6
- import { cachePayload } from './cache.ts';
6
+ import { cachePayload, splitView } from './cache.ts';
7
7
  import { IMPORT_GUIDANCE } from './import/guidance.ts';
8
8
 
9
9
  export interface ModelChoice { provider: string; model: string; thinking: ThinkingLevel }
10
10
  const scaleBase = 'user: Keep work and personal memory separate; use a binary summary tree and inspect original messages before acting. talk: Implemented the append-only log with durable writes and a stable view. echo: Checked caching, chronological summaries, cancellation, and profile locks. user: Main agent uses Opus; compactor uses Sonnet at medium effort. work: Worker completed the parser; tests cover invalid records and repeated imports. talk: The browser opens original messages, preserving dates and sources.';
11
11
  export const SCALE = scaleBase.padEnd(NODE, '.').slice(0, NODE);
12
+ /** The model is asked for 512 bytes; a small overshoot costs less view space than a retry costs money. */
13
+ export const ACCEPTED = NODE * 1.25;
14
+ const WARM_MS = 4 * 60_000; // Anthropic's short cache lives 5 minutes from its last use.
15
+
16
+ /** Parallel calls can't read a cache entry that isn't written yet, so one call primes a cold prefix and the rest wait until it answers. */
17
+ function primeFirst() {
18
+ const warm = new Map<string, number | Promise<void>>();
19
+ return async (prefix: string, signal: AbortSignal) => {
20
+ for (let state = warm.get(prefix); state !== undefined; state = warm.get(prefix)) {
21
+ if (typeof state === 'number') { if (Date.now() - state < WARM_MS) break; warm.delete(prefix); continue; }
22
+ // A cancelled waiter leaves at once instead of waiting for someone else's primer.
23
+ signal.throwIfAborted();
24
+ let wake = () => {};
25
+ const aborted = new Promise<void>(resolve => { wake = resolve; });
26
+ signal.addEventListener('abort', wake, { once: true });
27
+ await Promise.race([state, aborted]);
28
+ signal.removeEventListener('abort', wake);
29
+ signal.throwIfAborted();
30
+ }
31
+ let release = () => {};
32
+ const pending = warm.has(prefix) ? undefined : new Promise<void>(resolve => { release = resolve; });
33
+ if (pending) warm.set(prefix, pending);
34
+ return (ok: boolean) => {
35
+ if (ok) { for (const [k, at] of warm) if (typeof at === 'number' && Date.now() - at >= WARM_MS) warm.delete(k); warm.set(prefix, Date.now()); }
36
+ else if (warm.get(prefix) === pending) warm.delete(prefix);
37
+ release();
38
+ };
39
+ };
40
+ }
12
41
  export function createCompressor(registry: ModelRegistry, choice: () => ModelChoice,
13
42
  onUsage: (message: AssistantMessage) => void = () => {}): Compressor {
43
+ const gate = primeFirst();
14
44
  return async (input, signal) => {
15
45
  const selected = choice();
16
46
  const model = registry.find(selected.provider, selected.model);
17
47
  if (!model) throw new Error(`Compactor model unavailable: ${selected.provider}/${selected.model}. Use /optchat model.`);
18
48
  const step = `${input.historical ? IMPORT_GUIDANCE + '\n\n' : ''}For scale, this line is exactly 512 bytes:\n${SCALE}\n\n${input.merge ? 'Merge these two lines into one' : 'Compress this message into one line'}, in at most 512 bytes:\n${input.source}`;
19
49
  const messages: Message[] = [{ role: 'user', content: [{ type: 'text', text: input.context }, { type: 'text', text: step }], timestamp: Date.now() }];
50
+ const view = splitView(input.context);
51
+ const prefix = model.api === 'anthropic-messages' && view.length > 1 ? `${model.provider}/${model.id}/${selected.thinking}\n${view.slice(0, -1).join('')}` : undefined;
20
52
  const tries: string[] = [];
21
53
  for (let attempt = 0; attempt < 5; attempt++) {
22
- const reply = await registry.streamSimple(model, { systemPrompt: COMPACT, messages }, {
23
- reasoning: selected.thinking === 'off' ? undefined : selected.thinking, signal, cacheRetention: 'short',
24
- onPayload: payload => model.api === 'anthropic-messages' ? cachePayload(payload) : payload,
25
- }).result();
54
+ const warmed = prefix ? await gate(prefix, signal) : () => {};
55
+ let reply: AssistantMessage;
56
+ try {
57
+ const stream = registry.streamSimple(model, { systemPrompt: COMPACT, messages }, {
58
+ reasoning: selected.thinking === 'off' ? undefined : selected.thinking, signal, cacheRetention: 'short',
59
+ onPayload: payload => model.api === 'anthropic-messages' ? cachePayload(payload) : payload,
60
+ });
61
+ // The cache entry is usable once the model starts answering.
62
+ for await (const event of stream) if (event.type !== 'start') { warmed(event.type !== 'error'); break; }
63
+ reply = await stream.result();
64
+ } finally { warmed(false); }
26
65
  onUsage(reply);
27
66
  if (reply.stopReason === 'error' || reply.stopReason === 'aborted') throw new Error(reply.errorMessage ?? `Compactor ${reply.stopReason}`);
28
67
  const line = reply.content.filter(c => c.type === 'text').map(c => c.text).join('').trim();
29
68
  if (!line) throw new Error('Compactor returned no text.');
30
69
  tries.push(line);
31
- if (bytes(line) <= NODE) break;
70
+ if (bytes(line) <= ACCEPTED) break;
32
71
  messages.push(reply);
33
72
  const cut = Buffer.from(line).subarray(0, NODE).toString('utf8').replace(/\uFFFD$/, '');
34
73
  messages.push({ role: 'user', content: `That line is ${bytes(line)} bytes; the limit is 512. It must end where it is cut here:\n${cut}| ← LIMIT`, timestamp: Date.now() });
package/src/inspector.ts CHANGED
@@ -2,11 +2,23 @@ import { matchesKey, sliceByColumn, truncateToWidth, visibleWidth, wrapTextWithA
2
2
  import type { ExtensionContext } from '@earendil-works/pi-coding-agent';
3
3
  import type { Children } from './agents.ts';
4
4
  import { isActiveRun } from './runs.ts';
5
- import { ranges, summarizeUsage, type UsageLedger, type UsageRange } from './usage.ts';
5
+ import type { Usage } from '@earendil-works/pi-ai';
6
+ import { ranges, summarizeUsage, type UsageLedger, type UsageRange, type UsageRole } from './usage.ts';
6
7
 
7
8
  export const clean = (text: string) => text.replace(/[\x00-\x08\x0b-\x1f\x7f-\x9f]/g, '').replace(/\t/g, ' ');
8
9
  export const oneLine = (text: string) => clean(text).replace(/\n/g, ' ');
9
10
  export const count = (n: number) => n.toLocaleString('en-US');
11
+ const units = ['', 'k', 'M', 'B'];
12
+ /** 950, 12.3k, 164k, 2.4M: enough precision to compare rows at a glance. A value that rounds to 1000 moves up a unit. */
13
+ export const short = (n: number) => {
14
+ let unit = 0, value = n;
15
+ const round = (v: number) => v >= 100 || !unit ? Math.round(v) : Number(v.toFixed(1));
16
+ while (unit < units.length - 1 && round(value) >= 1000) { value /= 1000; unit++; }
17
+ return `${round(value)}${units[unit]}`;
18
+ };
19
+ const dollars = (n: number) => `$${n.toFixed(2)}`;
20
+ const cached = (u: Usage) => { const input = u.input + u.cacheRead + u.cacheWrite; return `${input ? Math.round(100 * u.cacheRead / input) : 0}%`; };
21
+ const roleNames: Record<UsageRole, string> = { main: 'main', subagent: 'subagents', compactor: 'compactor', import: 'import' };
10
22
  export const elapsed = (ms: number) => ms < 60_000 ? `${Math.max(0, Math.floor(ms / 1000))}s` : `${Math.floor(ms / 60_000)}m ${Math.floor(ms / 1000) % 60}s`;
11
23
  /** Shortens plain text with an ellipsis, preferring a word boundary. */
12
24
  export function fit(text: string, width: number) {
@@ -23,7 +35,7 @@ const spread = (left: string, right: string, width: number) => {
23
35
  export type InspectorPage = 'agents' | 'usage';
24
36
  /** Closing the panel either picks the subagent model or opens one agent's conversation. */
25
37
  export type InspectorAction = 'model' | { open: string };
26
- type Tone = 'accent' | 'muted' | 'dim' | 'error' | 'border';
38
+ type Tone = 'accent' | 'muted' | 'dim' | 'error' | 'warning' | 'border';
27
39
  interface Options {
28
40
  profile: string; session: string; children: Children; usage: UsageLedger; page: InspectorPage;
29
41
  rows: () => number; redraw: () => void; done: (action?: InspectorAction) => void;
@@ -96,24 +108,34 @@ export class Inspector implements Component, Focusable {
96
108
  this.scroll = Math.max(0, Math.min(this.scroll, this.lineCount - this.height));
97
109
  }
98
110
  }
99
- private usageLines() {
100
- const { usage, session, context } = this.options;
111
+ /** Period tabs, one headline, then an aligned table with a row per role and model. */
112
+ private usageLines(width: number) {
113
+ const { usage, session, context, color } = this.options;
101
114
  const entries = usage.select(this.range, session);
102
115
  const { total, groups } = summarizeUsage(entries);
103
- const input = total.input + total.cacheRead + total.cacheWrite;
104
- const lines = [`← → period: ${this.range}`, '', `Total processed: ${count(total.totalTokens)} tokens · ${entries.length} usage records`,
105
- `Input: ${count(input)} · Output: ${count(total.output)}`,
106
- `Cache read: ${count(total.cacheRead)} · Cache write: ${count(total.cacheWrite)} · Hit rate: ${input ? (100 * total.cacheRead / input).toFixed(1) : '0'}%`,
107
- `Estimated API-equivalent cost: $${total.cost.total.toFixed(4)}`, '',
108
- 'ROLE / MODEL — input · output · cache read/write · estimated cost'];
109
- for (const g of groups) lines.push(`${g.role} / ${g.model}\n ${count(g.usage.input)} uncached input · ${count(g.usage.output)} out · ${count(g.usage.cacheRead)}/${count(g.usage.cacheWrite)} cache · $${g.usage.cost.total.toFixed(4)} · ${g.requests} records`);
116
+ // No-break spaces keep a label like "Last 7 days" on one line when the tabs wrap.
117
+ const tabs = ranges.map(r => r.replaceAll(' ', '\u00a0')).map((r, i) => ranges[i] === this.range ? color('accent', `[${r}]`) : color('dim', r)).join(' ');
118
+ const lines = [...wrapTextWithAnsi(tabs, width), ''];
119
+ if (!entries.length) lines.push(color('muted', 'No usage in this period.'));
120
+ else {
121
+ lines.push(`${color('accent', dollars(total.cost.total))} estimated · ${short(total.totalTokens)} tokens · ${cached(total)} cached`, '');
122
+ const order = ['main', 'subagent', 'compactor', 'import'] as const;
123
+ const sorted = groups.sort((a, b) => order.indexOf(a.role) - order.indexOf(b.role) || b.usage.cost.total - a.usage.cost.total);
124
+ const rows = sorted.map((g, i) => [i && sorted[i - 1].role === g.role ? '' : roleNames[g.role], oneLine(g.model), dollars(g.usage.cost.total),
125
+ `${total.cost.total ? Math.round(100 * g.usage.cost.total / total.cost.total) : 0}%`, short(g.usage.output), cached(g.usage)]);
126
+ const header = ['', 'model', 'cost', 'share', 'out', 'cached'];
127
+ const widths = header.map((h, c) => Math.max(h.length, ...rows.map(r => r[c].length)));
128
+ // The model column goes first when the screen is too narrow for every column.
129
+ const columns = widths.reduce((sum, w) => sum + w + 2, 0) - 2 <= width ? [0, 1, 2, 3, 4, 5] : [0, 2, 3, 4, 5];
130
+ const line = (cells: string[]) => columns.map(c => c < 2 ? cells[c].padEnd(widths[c]) : cells[c].padStart(widths[c])).join(' ').trimEnd();
131
+ lines.push(color('dim', line(header)), ...rows.map(r => {
132
+ const text = line(r); return color('muted', text.slice(0, widths[0])) + text.slice(widths[0]);
133
+ }));
134
+ }
110
135
  const current = context();
111
- lines.push('', `Current parent context (Pi estimate): ${current == null ? 'unavailable' : `${count(current)} tokens`} — separate from cumulative usage.`,
112
- 'Costs use model API rates when available; not your subscription bill or remaining allowance.',
113
- 'Usage updates when responses finish. Tool overhead may be a usage record without a request count.',
114
- 'Main tracking starts with this version; the resumed parent session and saved children are backfilled.',
115
- 'Older compactor/child records without a parent session appear only in date-based totals.', ...usage.warnings);
116
- return lines.join('\n');
136
+ if (current != null) lines.push('', `${color('muted', 'Context now')} ${short(current)} tokens`);
137
+ lines.push('', color('dim', 'Estimated at API prices, not your subscription bill.'), ...usage.warnings.map(w => color('warning', w)));
138
+ return lines.flatMap(l => l ? wrapTextWithAnsi(l, width) : ['']);
117
139
  }
118
140
  render(width: number): string[] {
119
141
  const { color, children, profile } = this.options;
@@ -121,11 +143,11 @@ export class Inspector implements Component, Focusable {
121
143
  let title = `OptChat · ${profile} · ${this.page === 'usage' ? 'Usage' : 'Agents'}`;
122
144
  let info: string, body: string[], hint: string;
123
145
  if (this.page === 'usage') {
124
- const lines = wrapTextWithAnsi(clean(this.usageLines()), inner); this.lineCount = lines.length;
146
+ const lines = this.usageLines(inner); this.lineCount = lines.length;
125
147
  this.scroll = Math.max(0, Math.min(this.scroll, lines.length - this.height));
126
148
  body = lines.slice(this.scroll, this.scroll + this.height);
127
149
  info = `${this.scroll + 1}–${Math.min(this.scroll + this.height, lines.length)} / ${lines.length}`;
128
- hint = '←→ period · ↑↓/PgUp/PgDn scroll · Tab agents · Esc close';
150
+ hint = '←→ period · ↑↓ scroll · Tab agents · Esc close';
129
151
  } else {
130
152
  const list = children.history.list();
131
153
  if (!this.selected) this.selected = list[0]?.id;
package/src/usage.ts CHANGED
@@ -91,13 +91,13 @@ export class UsageLedger {
91
91
  }
92
92
 
93
93
  export function summarizeUsage(entries: UsageEntry[]) {
94
- const total = emptyUsage(), groups = new Map<string, { role: UsageRole; model: string; requests: number; usage: Usage }>();
94
+ const total = emptyUsage(), groups = new Map<string, { role: UsageRole; model: string; usage: Usage }>();
95
95
  for (const entry of entries) {
96
- const model = entry.provider ? `${entry.provider}/${entry.model}` : entry.model;
97
- const key = `${entry.role}:${model}`;
96
+ // By model name alone: older compactor records lack a provider and must still land in the same row.
97
+ const key = `${entry.role}:${entry.model}`;
98
98
  let group = groups.get(key);
99
- if (!group) { group = { role: entry.role, model, requests: 0, usage: emptyUsage() }; groups.set(key, group); }
100
- group.requests++; addUsage(group.usage, entry.usage); addUsage(total, entry.usage);
99
+ if (!group) { group = { role: entry.role, model: entry.model, usage: emptyUsage() }; groups.set(key, group); }
100
+ addUsage(group.usage, entry.usage); addUsage(total, entry.usage);
101
101
  }
102
102
  return { total, groups: [...groups.values()] };
103
103
  }