pi-optchat 0.8.4 → 0.8.5

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.4",
3
+ "version": "0.8.5",
4
4
  "type": "module",
5
5
  "pi": {
6
6
  "extensions": [
package/src/compactor.ts CHANGED
@@ -1,4 +1,5 @@
1
- import { clampThinkingLevel, type Api, type AssistantMessage, type Message, type Model } from '@earendil-works/pi-ai';
1
+ import { setTimeout as sleep } from 'node:timers/promises';
2
+ import { clampThinkingLevel, isRetryableAssistantError, type Api, type AssistantMessage, type Message, type Model } from '@earendil-works/pi-ai';
2
3
  import type { ThinkingLevel } from '@earendil-works/pi-agent-core';
3
4
  import type { ModelRegistry } from '@earendil-works/pi-coding-agent';
4
5
  import { COMPACT, compaction, IMPORT_GUIDANCE, tooLong } from './prompts.ts';
@@ -43,7 +44,8 @@ function primeFirst() {
43
44
  }
44
45
  /** The model is asked for 512 bytes; `accepted` is the longest line kept without a retry (the profile's summary size tolerance). */
45
46
  export function createCompressor(registry: ModelRegistry, choice: () => ModelChoice,
46
- onUsage: (message: AssistantMessage) => void = () => {}, accepted = () => DEFAULT_SETTINGS.summaryAcceptBytes): Compressor {
47
+ onUsage: (message: AssistantMessage) => void = () => {}, accepted = () => DEFAULT_SETTINGS.summaryAcceptBytes,
48
+ retryDelays: readonly number[] = [1000, 2000]): Compressor {
47
49
  const gate = primeFirst();
48
50
  return async (input, signal) => {
49
51
  const selected = choice();
@@ -56,9 +58,8 @@ export function createCompressor(registry: ModelRegistry, choice: () => ModelCho
56
58
  const view = splitView(input.context);
57
59
  const prefix = model.api === 'anthropic-messages' && view.length > 1 ? `${model.provider}/${model.id}/${thinking ?? 'off'}\n${view.slice(0, -1).join('')}` : undefined;
58
60
  const tries: string[] = [];
59
- for (let attempt = 0; attempt < 5; attempt++) {
61
+ const ask = async () => {
60
62
  const warmed = prefix ? await gate(prefix, signal) : () => {};
61
- let reply: AssistantMessage;
62
63
  try {
63
64
  const stream = registry.streamSimple(model, { systemPrompt: COMPACT, messages }, {
64
65
  // 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.
@@ -68,9 +69,20 @@ export function createCompressor(registry: ModelRegistry, choice: () => ModelCho
68
69
  });
69
70
  // The cache entry is usable once the model starts answering.
70
71
  for await (const event of stream) if (event.type !== 'start') { warmed(event.type !== 'error'); break; }
71
- reply = await stream.result();
72
+ return await stream.result();
72
73
  } finally { warmed(false); }
73
- onUsage(reply);
74
+ };
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
+ };
84
+ for (let attempt = 0; attempt < 5; attempt++) {
85
+ const reply = await answer();
74
86
  if (reply.stopReason === 'error' || reply.stopReason === 'aborted') throw new Error(reply.errorMessage ?? `Compactor ${reply.stopReason}`);
75
87
  // A reply cut off at the token limit, or one that calls a tool, is no summary: ask again.
76
88
  if (reply.stopReason === 'length' || reply.content.some(c => c.type === 'toolCall')) continue;
package/src/memory.ts CHANGED
@@ -26,6 +26,7 @@ export const flat = (s: string) => s.replace(/[\r\n]+/g, ' ');
26
26
  const lineBytes = (s: string) => bytes(flat(s));
27
27
  const notice = (omitted: number) => `\n[${omitted} characters omitted; head and tail retained]\n`;
28
28
  const VIEW_OPEN = '<chat>\n';
29
+ const VIEW_FRAME_BYTES = bytes(`${VIEW_OPEN}</chat>`);
29
30
  export const isView = (text: string) => text.startsWith(VIEW_OPEN);
30
31
  export function cap(text: string, limit = CAP) {
31
32
  if (text.length <= limit) return text;
@@ -51,10 +52,10 @@ export function mostDue(view: readonly Part[], total: number, built: (part: Part
51
52
  class Sawtooth {
52
53
  readonly parts: Part[] = [];
53
54
  private measured = 0;
54
- private merging = false;
55
+ merging = false;
55
56
  constructor(private readonly high: number, private readonly low: number, private readonly node: (part: Part) => Summary | undefined) {}
56
- private lineSize(part: Part) { return this.node(part)?.size ?? UNBUILT_BYTES; }
57
- get size() { return this.measured; }
57
+ private lineSize(part: Part) { return bytes(`${start(part)}+${2 ** part.l}|\n`) + (this.node(part)?.size ?? UNBUILT_BYTES); }
58
+ get size() { return VIEW_FRAME_BYTES + this.measured + (this.parts.length ? 0 : 1); } // render() keeps a blank line in an empty view.
58
59
  push(part: Part) { this.parts.push(part); this.measured += this.lineSize(part); }
59
60
  /** The line covering message `at`. The lines tile the log in order, so a binary search finds it. */
60
61
  covering(at: number): Part | undefined {
@@ -66,17 +67,17 @@ class Sawtooth {
66
67
  }
67
68
  }
68
69
  /** A message got its node. Its line is usually still shown, but after a damaged tree file a saved parent can already hide it. */
69
- built(i: number) { if (this.covering(i)?.l === 0) this.measured += this.lineSize({ l: 0, i }) - UNBUILT_BYTES; }
70
+ built(i: number) { if (this.covering(i)?.l === 0) this.measured += this.node({ l: 0, i })!.size - UNBUILT_BYTES; }
70
71
  /** Returns whether any lines merged. */
71
72
  fit(total: number, force = false) {
72
73
  const lines = this.parts.length;
73
- if (force || this.measured > this.high) this.merging = true;
74
- for (let best: number; this.merging && this.measured > this.low && (best = mostDue(this.parts, total, part => !!this.node(part))) >= 0;) {
74
+ if (force || this.size > this.high) this.merging = true;
75
+ for (let best: number; this.merging && this.size > this.low && (best = mostDue(this.parts, total, part => !!this.node(part))) >= 0;) {
75
76
  const a = this.parts[best], b = this.parts[best + 1], parent = { l: a.l + 1, i: a.i / 2 };
76
77
  this.measured += this.lineSize(parent) - this.lineSize(a) - this.lineSize(b);
77
78
  this.parts.splice(best, 2, parent);
78
79
  }
79
- if (this.measured <= this.low) this.merging = false;
80
+ if (this.size <= this.low) this.merging = false;
80
81
  return this.parts.length < lines;
81
82
  }
82
83
  }
@@ -187,6 +188,8 @@ export class Memory {
187
188
  const rebuild = () => { this.warn('Rebuilt the memory view: the saved one does not match the log.'); return 0; };
188
189
  let saved: unknown;
189
190
  try { saved = JSON.parse(view); } catch { return rebuild(); }
191
+ const merging = object(saved) && saved.merging === true;
192
+ if (object(saved) && typeof saved.merging === 'boolean') saved = saved.parts;
190
193
  if (!Array.isArray(saved)) return rebuild();
191
194
  const parts: Part[] = [];
192
195
  let covered = 0;
@@ -198,13 +201,14 @@ export class Memory {
198
201
  parts.push(part); covered = end(part);
199
202
  }
200
203
  for (const part of parts) this.chat.push(part);
204
+ this.chat.merging = merging;
201
205
  return covered;
202
206
  }
203
- /** Saved when it merges: a view that only grew is the saved one plus a line per later message, as `load` replays it.
207
+ /** Saved when it merges or changes batch state: a view that only grew is the saved one plus later messages, as `load` replays it.
204
208
  * The log stays authoritative, so a failed save only warns. */
205
209
  private save() {
206
210
  const failed = (error: unknown) => { this.warn(`Could not save the memory view: ${error instanceof Error ? error.message : String(error)}`); };
207
- try { void this.enqueue(() => this.store.saveView(JSON.stringify(this.view.map(p => [p.l, p.i]))), failed); }
211
+ try { void this.enqueue(() => this.store.saveView(JSON.stringify({ parts: this.view.map(p => [p.l, p.i]), merging: this.chat.merging })), failed); }
208
212
  catch (error) { failed(error); }
209
213
  }
210
214
  private push(i: number) {
@@ -227,14 +231,15 @@ export class Memory {
227
231
  onChange(listener: () => void) { this.events.on('change', listener); return () => { this.events.off('change', listener); }; }
228
232
  /** The view line covering message `at`. */
229
233
  covering(at: number) { return this.chat.covering(at); }
230
- /** Returns whether the chat's view merged. */
234
+ /** Returns whether the chat's view or batch state changed. */
231
235
  private fit(total = this.root.length) {
236
+ const merging = this.chat.merging;
232
237
  const merged = this.chat.fit(total);
233
238
  this.compaction.fit(total, merged);
234
239
  const owed = this.owed();
235
240
  this.peak = owed > 0 ? Math.max(this.peak, owed) : 0;
236
241
  this.events.emit('change');
237
- return merged;
242
+ return merged || merging !== this.chat.merging;
238
243
  }
239
244
  private schedule() {
240
245
  if (this.scheduled || this.stopped) return;