pi-optchat 0.8.7 → 0.8.9

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.
@@ -12,7 +12,7 @@ The source remains upstream rather than duplicating the full article here. Its p
12
12
 
13
13
  - `src/memory.ts`: append-only log, binary summary tree, compression scheduling (§4 of the 2026-10-08 revision: a message's summary starts once fewer than 8 lines before it are unbuilt, a merge once both halves are built, kept in a queue so the tree is never scanned for work), the compactions' own view (§4: the view merged further to 16,000-32,000 bytes, ending at the node; unlike the recipe, a line not built yet shows as a placeholder instead of ending it, so a summary still sees the built lines after it), the view (§3.2: the most due pair, measured from its last message, merges in one batch from 128,000 down to 64,000 bytes, and the view is saved to `view.json` rather than refolded at start), zoom/date, and the opt-in text search over original messages (`src/tools.ts` has the tool; not in the recipe, off by default).
14
14
  - `src/compactor.ts`: contextual compression and size retries, with the 2026-10-08 revision's task (a 512-dash ruler for the size) and its "Too long" retry, verbatim. The compactions' view shows each line under its `id+n|` head, as the task's line ids need, so a reply that copies a head has it removed.
15
- - `src/cache.ts`: Anthropic cache marks: the view in blocks of 4 lines, one mark on the last whole block and one at the request's end, as in recipe §3.3, plus two marks 20 and 40 blocks before the last. Anthropic looks back only 20 blocks from a mark, so with the recipe's one view mark a turn that added more than 80 lines, such as 40 tool calls and their results, made the next turn rewrite the whole view. OpenAI requests get no marks: GPT-5.6 and GPT-6 Luna reject the recipe's `prompt_cache_breakpoint` with a 400 (found by @aaaxn), so they rely on implicit prefix caching. OptChat doesn't set `reasoning.context` either: GPT-5.6 already defaults to the recipe's `"all_turns"`, and OpenAI documents it only for GPT-5.6 and GPT-6.1 Sol.
15
+ - `src/cache.ts`: Anthropic cache marks: the view in blocks of 4 lines, one mark on the last whole block and one at the request's end, as in recipe §3.3 (a compaction leaves its end unmarked, as Unii does: its task is never sent again), plus two marks 20 and 40 blocks before the last. Anthropic looks back only 20 blocks from a mark, so with the recipe's one view mark a turn that added more than 80 lines, such as 40 tool calls and their results, made the next turn rewrite the whole view. OpenAI requests get no marks: GPT-5.6 and GPT-6 Luna reject the recipe's `prompt_cache_breakpoint` with a 400 (found by @aaaxn), so they rely on implicit prefix caching. OptChat doesn't set `reasoning.context` either: GPT-5.6 already defaults to the recipe's `"all_turns"`, and OpenAI documents it only for GPT-5.6 and GPT-6.1 Sol.
16
16
  - `src/transcript.ts`: fresh context per parent run, current-run tool loop retained. The previous completed exchange is also retained in full text (left out if over 16,000 bytes by default), an intentional addition to the summary-only recipe for conversational continuity.
17
17
  - `src/agents.ts`: asynchronous Pi SDK children and automatic completion reports.
18
18
  - `src/settings.ts`: per-profile settings for the departures from the recipe. Defaults are the recipe's (no memory search), except the previous exchange (on), the summary size tolerance (640 bytes, against the recipe's strict 512) and the turn refresh threshold (30%, where the recipe has none). Grouped subagent reports are off by default, as in the revised recipe, which reports each subagent on its own.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-optchat",
3
- "version": "0.8.7",
3
+ "version": "0.8.9",
4
4
  "type": "module",
5
5
  "pi": {
6
6
  "extensions": [
package/src/agents.ts CHANGED
@@ -23,7 +23,7 @@ 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>;
26
+ pendingReports: string[]; pendingGuidance: string[]; 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
28
  interruption?: RunInfo['guidance'];
29
29
  /** Messages being steered back into Pi's queue after it was cleared; other queue changes wait for it. */
@@ -98,6 +98,20 @@ export function subagentCompaction(contextWindow: number, fraction: number) {
98
98
  if (!(fraction > 0)) return { enabled: true };
99
99
  return { enabled: true, reserveTokens: Math.max(16_384, Math.round(contextWindow * (1 - fraction) - VIEW / 4)) };
100
100
  }
101
+ /** Waits for `promise`, or until `signal` aborts. */
102
+ async function abortable(promise: Promise<unknown>, signal?: AbortSignal) {
103
+ let stop = () => {};
104
+ const aborted = new Promise<void>(resolve => { stop = resolve; });
105
+ signal?.addEventListener('abort', stop, { once: true });
106
+ if (signal?.aborted) stop();
107
+ try { await Promise.race([promise, aborted]); } finally { signal?.removeEventListener('abort', stop); }
108
+ }
109
+ /** Resolves once the session's model starts answering, or its request ends without an answer. */
110
+ const answering = (session: AgentSession) => new Promise<void>(resolve => {
111
+ const off = session.subscribe(event => {
112
+ if (event.type === 'message_update' || event.type === 'agent_end' || event.type === 'message_end' && event.message.role === 'assistant') { off(); resolve(); }
113
+ });
114
+ });
101
115
  export class Children {
102
116
  private readonly running = new Map<string, LiveRun>();
103
117
  readonly history: RunHistory;
@@ -224,10 +238,14 @@ export class Children {
224
238
  const launched = await this.track(this.launchBatch({ tasks: runs, cwd, depth, parentId, connected, signal, cancelled }));
225
239
  // Grouped, the last child of this spawn to finish delivers every report; otherwise each reports on its own.
226
240
  const batch = groupReports && !connected ? { ids: launched.map(c => c.info.id), reports: new Map<string, string>() } : undefined;
241
+ // Siblings on one model and directory send the same system prompt, tools and view. Anthropic can't read a cache entry
242
+ // still being written, so their first requests wait until the first sibling starts answering and read its entry instead.
243
+ const [first] = launched, answered = launched.length > 1 && first.session.model?.api === 'anthropic-messages' ? answering(first.session) : undefined;
227
244
  for (const live of launched) {
228
245
  live.batch = batch;
229
246
  const work = this.execute(live, `${view}\n\nYour task:\n${live.info.task}`).catch(error => this.warn(`Subagent completion failed: ${String(error)}`))
230
247
  .finally(() => { this.completions.delete(work); this.changed(); });
248
+ if (live === first && answered) for (const sibling of launched.slice(1)) if ((['model', 'thinking', 'cwd'] as const).every(k => sibling.info[k] === first.info[k])) sibling.primed = Promise.race([answered, work]);
231
249
  this.completions.add(work);
232
250
  live.completion = work;
233
251
  }
@@ -295,7 +313,12 @@ export class Children {
295
313
  event.systemPromptOptions.customPrompt = allowSearch(`${SUBAGENT}\n\n${VIEW_DOC}${memorySearch ? SEARCH_DOC : ''}`, memorySearch);
296
314
  event.systemPromptOptions.sections.instructions = instructions;
297
315
  });
298
- pi.on('before_provider_request', (event, ctx) => ctx.model?.api === 'anthropic-messages' ? cachePayload(event.payload) : event.payload);
316
+ pi.on('before_provider_request', async (event, ctx) => {
317
+ // Set by launch for a sibling's first request; a stop or an interrupt aborts the request, which ends the wait.
318
+ const live = this.running.get(id), primed = live?.primed;
319
+ if (live && primed) { live.primed = undefined; await abortable(primed, ctx.signal); }
320
+ return ctx.model?.api === 'anthropic-messages' ? cachePayload(event.payload) : event.payload;
321
+ });
299
322
  }],
300
323
  });
301
324
  await loader.reload();
package/src/cache.ts CHANGED
@@ -33,8 +33,9 @@ function lastBlock(message: Record<string, unknown>) {
33
33
  /** Anthropic: marks on the view (see MARKS) plus a mark at the end of the request, so the next call finds the last one's entry
34
34
  * and pays only for the lines after it. The end mark sits on the last block itself rather than as top-level automatic caching:
35
35
  * a proxy or setting that gives block marks a 1h TTL makes Anthropic refuse a top-level mark, whose TTL defaults to 5m, on the
36
- * same block ("Top-level cache_control has ttl='5m' but the target block already has cache_control with ttl='1h'"). */
37
- export function cachePayload(payload: unknown): unknown {
36
+ * same block ("Top-level cache_control has ttl='5m' but the target block already has cache_control with ttl='1h'").
37
+ * `end: false` leaves the end unmarked: a compaction's task is never sent again, so marking it would only write it at 1.25× (#197). */
38
+ export function cachePayload(payload: unknown, { end: markEnd = true } = {}): unknown {
38
39
  if (!record(payload) || !Array.isArray(payload.messages)) return payload;
39
40
  const messages = payload.messages;
40
41
  const view = new Set<unknown>();
@@ -42,9 +43,12 @@ export function cachePayload(payload: unknown): unknown {
42
43
  if (!record(message) || message.role !== 'user' || !Array.isArray(message.content)) continue;
43
44
  const at = message.content.findIndex((block: unknown) => record(block) && block.type === 'text' && typeof block.text === 'string' && isView(block.text));
44
45
  if (at < 0) continue;
45
- const pieces = splitView(message.content[at].text), marked = new Set(MARKS.map(back => pieces.length - 2 - back));
46
+ // A subagent's task follows the view in the same text. Lines never hold a newline, so the first one before </chat> ends the view;
47
+ // the task goes in its own block, so the view's marks fall on lines its siblings share and they can read one another's entry.
48
+ const text: string = message.content[at].text, close = text.indexOf('\n</chat>'), end = close < 0 ? text.length : close + '\n</chat>'.length;
49
+ const pieces = splitView(text.slice(0, end)), marked = new Set(MARKS.map(back => pieces.length - 2 - back));
46
50
  const blocks = pieces.map((text, j) => ({ type: 'text', text, ...(marked.has(j) ? { cache_control: { type: 'ephemeral' } } : {}) }));
47
- message.content.splice(at, 1, ...blocks);
51
+ message.content.splice(at, 1, ...blocks, ...(end < text.length ? [{ type: 'text', text: text.slice(end) }] : []));
48
52
  for (const block of blocks) view.add(block);
49
53
  break;
50
54
  }
@@ -62,7 +66,7 @@ export function cachePayload(payload: unknown): unknown {
62
66
  // Pi may end the request with an empty system message carrying settings (effort); it holds nothing to cache.
63
67
  const [before, last] = messages.filter(message => !record(message) || message.role !== 'system').slice(-2);
64
68
  const turn = isAtWork(last) && record(before) && before.role === 'user' ? lastBlock(before) : undefined;
65
- const end = turn ?? endBlock(messages);
69
+ const end = markEnd ? turn ?? endBlock(messages) : undefined;
66
70
  if (end) end.cache_control = { type: 'ephemeral' };
67
71
  return payload;
68
72
  }
package/src/compactor.ts CHANGED
@@ -42,10 +42,21 @@ function primeFirst() {
42
42
  };
43
43
  };
44
44
  }
45
+ /** Pi's agent loop retries a dropped connection itself; streamSimple doesn't, so a transient error is asked again before it surfaces. */
46
+ export async function retryTransient(ask: () => Promise<AssistantMessage>, onReply: (reply: AssistantMessage) => void,
47
+ delays: readonly number[] = [1000, 2000], signal?: AbortSignal) {
48
+ for (let retry = 0; ; retry++) {
49
+ const reply = await ask();
50
+ onReply(reply);
51
+ if (retry >= delays.length || !isRetryableAssistantError(reply)) return reply;
52
+ await sleep(delays[retry], undefined, { signal });
53
+ }
54
+ }
55
+
45
56
  /** The model is asked for 512 bytes; `accepted` is the longest line kept without a retry (the profile's summary size tolerance). */
46
57
  export function createCompressor(registry: ModelRegistry, choice: () => ModelChoice,
47
58
  onUsage: (message: AssistantMessage) => void = () => {}, accepted = () => DEFAULT_SETTINGS.summaryAcceptBytes,
48
- retryDelays: readonly number[] = [1000, 2000]): Compressor {
59
+ retryDelays?: readonly number[]): Compressor {
49
60
  const gate = primeFirst();
50
61
  return async (input, signal) => {
51
62
  const selected = choice();
@@ -65,26 +76,19 @@ export function createCompressor(registry: ModelRegistry, choice: () => ModelCho
65
76
  // A shared session id is the OpenAI prompt-cache key; SSE because over a websocket Codex would chain unrelated parallel calls on one cached connection.
66
77
  sessionId: 'optchat-compactor', transport: 'sse',
67
78
  reasoning: thinking, signal, cacheRetention: 'short',
68
- onPayload: payload => model.api === 'anthropic-messages' ? cachePayload(payload) : payload,
79
+ onPayload: payload => model.api === 'anthropic-messages' ? cachePayload(payload, { end: false }) : payload,
69
80
  });
70
81
  // The cache entry is usable once the model starts answering.
71
82
  for await (const event of stream) if (event.type !== 'start') { warmed(event.type !== 'error'); break; }
72
83
  return await stream.result();
73
84
  } finally { warmed(false); }
74
85
  };
75
- // Pi's agent loop retries a dropped connection itself; streamSimple doesn't, so a transient error is asked again before it surfaces.
76
- const answer = async () => {
77
- for (let retry = 0; ; retry++) {
78
- const reply = await ask();
79
- onUsage(reply);
80
- if (retry >= retryDelays.length || !isRetryableAssistantError(reply)) return reply;
81
- await sleep(retryDelays[retry], undefined, { signal });
82
- }
83
- };
86
+ const answer = () => retryTransient(ask, onUsage, retryDelays, signal);
84
87
  for (let attempt = 0; attempt < 5; attempt++) {
85
88
  const reply = await answer();
86
89
  // A stop reason means the model answered: a refusal or a content filter, which asking again repeats.
87
- if (reply.stopReason === 'error' && reply.rawStopReason !== undefined && !isRetryableAssistantError(reply))
90
+ // A provider's own 'error' stop (Mistral) is a failure on its side, not an answer: a plain error, tried again later.
91
+ if (reply.stopReason === 'error' && reply.rawStopReason !== undefined && reply.rawStopReason !== 'error' && !isRetryableAssistantError(reply))
88
92
  throw new Refused(reply.errorMessage ?? `Compactor stopped: ${reply.rawStopReason}`);
89
93
  if (reply.stopReason === 'error' || reply.stopReason === 'aborted') throw new Error(reply.errorMessage ?? `Compactor ${reply.stopReason}`);
90
94
  // A reply cut off at the token limit, or one that calls a tool, is no summary: ask again.
package/src/handoff.ts CHANGED
@@ -1,7 +1,7 @@
1
1
  import type { ModelRegistry } from '@earendil-works/pi-coding-agent';
2
2
  import type { AgentMessage } from '@earendil-works/pi-agent-core';
3
3
  import type { AssistantMessage } from '@earendil-works/pi-ai';
4
- import { reasoningFor, type ModelChoice } from './compactor.ts';
4
+ import { reasoningFor, retryTransient, type ModelChoice } from './compactor.ts';
5
5
  import { HANDOFF } from './prompts.ts';
6
6
  import type { RunInfo } from './runs.ts';
7
7
  import { textContent } from './transcript.ts';
@@ -30,7 +30,7 @@ function formatEvidence({ run, messages, transcriptError }: HandoffEvidence) {
30
30
 
31
31
  /** Summarize in one call when possible; otherwise fold chunks with the prior handoff included in the budget. */
32
32
  export function createHandoffSummarizer(registry: ModelRegistry, choice: () => ModelChoice,
33
- usage: (message: AssistantMessage) => void) {
33
+ usage: (message: AssistantMessage) => void, retryDelays?: readonly number[]) {
34
34
  return async (run: RunInfo, messages: AgentMessage[], descendants: HandoffEvidence[] = []) => {
35
35
  const selected = choice(), model = registry.find(selected.provider, selected.model);
36
36
  if (!model) throw new Error('Profile compactor model unavailable');
@@ -50,11 +50,10 @@ export function createHandoffSummarizer(registry: ModelRegistry, choice: () => M
50
50
  let end = Math.min(transcript.length, offset + available);
51
51
  // Keep multibyte characters intact at chunk boundaries.
52
52
  while (end < transcript.length && (transcript[end] & 0xc0) === 0x80) end--;
53
- const reply = await registry.streamSimple(model, {
53
+ const reply = await retryTransient(() => registry.streamSimple(model, {
54
54
  systemPrompt: HANDOFF,
55
55
  messages: [{ role: 'user', timestamp: Date.now(), content: prefix + transcript.subarray(offset, end).toString('utf8') }],
56
- }, { reasoning: reasoningFor(model, selected.thinking), maxTokens, signal: AbortSignal.timeout(300_000) }).result();
57
- usage(reply);
56
+ }, { reasoning: reasoningFor(model, selected.thinking), maxTokens, signal: AbortSignal.timeout(300_000) }).result(), usage, retryDelays);
58
57
  if (reply.stopReason === 'error' || reply.stopReason === 'aborted') throw new Error(reply.errorMessage ?? reply.stopReason);
59
58
  if (reply.stopReason === 'length') throw new Error('Handoff hit the model output limit before finishing');
60
59
  summary = textContent(reply.content).trim();
package/src/memory.ts CHANGED
@@ -279,14 +279,13 @@ export class Memory {
279
279
  }).finally(() => { this.busy.delete(id); this.schedule(); this.events.emit('change'); });
280
280
  this.busy.set(id, promise);
281
281
  };
282
- // A message's node starts once fewer than `jobs` lines before it are unbuilt; a merge, once both halves are built.
283
- let ahead = 0;
284
- for (const i of this.unbuilt) {
285
- if (ahead++ === this.jobs) break;
286
- if (this.busy.size >= this.jobs) return;
287
- run({ l: 0, i });
288
- }
289
- for (const part of this.merges.values()) {
282
+ // A message's node starts once fewer than `jobs` lines before it are unbuilt; a merge, once both halves are built. They start
283
+ // in the order of the context they end at, a merge first at a tie, as Unii's do (#194): messages first left the merges waiting
284
+ // through a backlog, so the views ran over budget and changed at every node built, and compactions read about 60% from the cache.
285
+ const ready: Part[] = [];
286
+ for (const i of this.unbuilt) { if (ready.length === this.jobs) break; ready.push({ l: 0, i }); }
287
+ const at = (part: Part) => part.l ? end(part) : part.i;
288
+ for (const part of [...ready, ...this.merges.values()].sort((a, b) => at(a) - at(b) || b.l - a.l)) {
290
289
  if (this.busy.size >= this.jobs) return;
291
290
  run(part);
292
291
  }