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 +1 -1
- package/src/compactor.ts +18 -6
- package/src/memory.ts +16 -11
package/package.json
CHANGED
package/src/compactor.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
|
-
import {
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
72
|
+
return await stream.result();
|
|
72
73
|
} finally { warmed(false); }
|
|
73
|
-
|
|
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
|
-
|
|
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.
|
|
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.
|
|
74
|
-
for (let best: number; this.merging && this.
|
|
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.
|
|
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
|
|
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
|
|
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;
|