pi-condense 2.11.5 → 2.12.0
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/CHANGELOG.md +12 -0
- package/README.md +2 -0
- package/package.json +4 -4
- package/src/error-purge.transport.test.ts +4 -3
- package/src/reload-rearm.integration.test.ts +2 -8
- package/src/summarizer-wiring.test.ts +138 -16
- package/src/summarizer.test.ts +29 -21
- package/src/summarizer.ts +41 -35
package/CHANGELOG.md
CHANGED
|
@@ -7,6 +7,18 @@ Published to npm as [`pi-condense`](https://www.npmjs.com/package/pi-condense) (
|
|
|
7
7
|
Pushing a `vX.Y.Z` tag triggers `.github/workflows/release.yml`, which runs the tests and
|
|
8
8
|
publishes via OIDC trusted publishing. See `.agents/skills/release/SKILL.md`.
|
|
9
9
|
|
|
10
|
+
## [2.12.0] - 2026-10-17
|
|
11
|
+
|
|
12
|
+
### Fixed
|
|
13
|
+
|
|
14
|
+
- Virtual (router-backed) models work as the summarizer - via `summarizerModel: "default"`, an explicit virtual `provider/id`, or as the outage-fallback session model. Summarizer calls now go through `ctx.modelRegistry.streamSimple()` instead of pi-ai's provider table, which had no `pi-virtual` entry and threw `No API provider registered for api: pi-virtual`. (#20)
|
|
15
|
+
- A summarizer stream that ignores the abort signal (a virtual router whose `route()` never resolves) no longer wedges the flush: the idle and ceiling timers now end the wait themselves. (#20)
|
|
16
|
+
|
|
17
|
+
### Changed
|
|
18
|
+
|
|
19
|
+
- `summarizerThinking` applies on every provider; it was sent as an OpenAI-specific `reasoningEffort` option that Anthropic-style adapters ignored. `default` and `off` still send no reasoning option, which on Anthropic means `thinkingEnabled: false` as before. (#20)
|
|
20
|
+
- Minimum supported host is pi 1.3.0; dev dependencies on `@earendil-works/pi-ai`, `pi-coding-agent`, and `pi-tui` move from `^0.83.0` to `^1.3.0`. (#20)
|
|
21
|
+
|
|
10
22
|
## [2.11.5] - 2026-10-08
|
|
11
23
|
|
|
12
24
|
### Changed
|
package/README.md
CHANGED
|
@@ -126,6 +126,8 @@ These are most informative for long single-chain sessions where Phase 3 (chain c
|
|
|
126
126
|
|
|
127
127
|
Published to npm as [`pi-condense`](https://www.npmjs.com/package/pi-condense).
|
|
128
128
|
|
|
129
|
+
Requires pi >= 1.3.0: the summarizer calls `ctx.modelRegistry.streamSimple()`, which is what lets a virtual (router-backed) model serve as the summarizer. Older hosts are unsupported; before pi 0.86.0 the method does not exist and every summarizer call fails with a `TypeError` notice.
|
|
130
|
+
|
|
129
131
|
**User scope** (all repos under your pi profile):
|
|
130
132
|
|
|
131
133
|
```bash
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pi-condense",
|
|
3
|
-
"version": "2.
|
|
3
|
+
"version": "2.12.0",
|
|
4
4
|
"description": "Pi coding-agent extension that summarizes completed tool-call batches, replaces raw outputs with short stubs, compresses closed tool-call chains, and recovers any original on demand via context_tree_query.",
|
|
5
5
|
"author": "Jacek Juraszek",
|
|
6
6
|
"license": "MIT",
|
|
@@ -57,9 +57,9 @@
|
|
|
57
57
|
"@sinclair/typebox": "*"
|
|
58
58
|
},
|
|
59
59
|
"devDependencies": {
|
|
60
|
-
"@earendil-works/pi-ai": "^
|
|
61
|
-
"@earendil-works/pi-coding-agent": "^
|
|
62
|
-
"@earendil-works/pi-tui": "^
|
|
60
|
+
"@earendil-works/pi-ai": "^1.3.0",
|
|
61
|
+
"@earendil-works/pi-coding-agent": "^1.3.0",
|
|
62
|
+
"@earendil-works/pi-tui": "^1.3.0",
|
|
63
63
|
"@sinclair/typebox": "^0.34.52"
|
|
64
64
|
}
|
|
65
65
|
}
|
|
@@ -35,6 +35,7 @@ const meta = { api: "openai-completions", provider: "openai", model: "grammar-te
|
|
|
35
35
|
|
|
36
36
|
function history(codeArgs: Record<string, unknown>) {
|
|
37
37
|
return [
|
|
38
|
+
{ role: "system", content: "", toolsAdded: [codemode], timestamp: 0 },
|
|
38
39
|
{ role: "user", content: "run it", timestamp: 1 },
|
|
39
40
|
{ role: "assistant", content: [{ type: "toolCall", id: "tc1", name: "codemode", arguments: codeArgs }], ...meta, stopReason: "toolUse", timestamp: 2 },
|
|
40
41
|
{ role: "toolResult", toolCallId: "tc1", toolName: "codemode", content: [{ type: "text", text: "Error: boom" }], isError: true, timestamp: 3 },
|
|
@@ -60,7 +61,7 @@ describe("error purge x pi-ai grammar tools (#19)", () => {
|
|
|
60
61
|
|
|
61
62
|
it("purged codemode args pass getGrammarToolInput (reporter repro)", () => {
|
|
62
63
|
const purged = purgeErroredArgs(history({ code: "x".repeat(1000) }), config);
|
|
63
|
-
const args = purged[
|
|
64
|
+
const args = purged[2].content[0].arguments;
|
|
64
65
|
expect(typeof args.code).toBe("string");
|
|
65
66
|
expect(Object.keys(args)).toEqual(["code"]);
|
|
66
67
|
expect(JSON.stringify(args).length).toBeLessThan(500);
|
|
@@ -69,7 +70,7 @@ describe("error purge x pi-ai grammar tools (#19)", () => {
|
|
|
69
70
|
|
|
70
71
|
it("request building succeeds on purged history; failure is the connection, not the grammar", async () => {
|
|
71
72
|
const purged = purgeErroredArgs(history({ code: "x".repeat(1000) }), config);
|
|
72
|
-
expect(purged[
|
|
73
|
+
expect(purged[2].content[0].arguments.code).toMatch(/^<purged-errored-args size=/);
|
|
73
74
|
const { result, calls } = await request(purged);
|
|
74
75
|
expect(result.stopReason).toBe("error");
|
|
75
76
|
// The OpenAI SDK wraps fetch rejections in APIConnectionError and drops the cause message.
|
|
@@ -80,7 +81,7 @@ describe("error purge x pi-ai grammar tools (#19)", () => {
|
|
|
80
81
|
|
|
81
82
|
it("negative control: the old { _purged } shape throws the grammar error before any request", async () => {
|
|
82
83
|
const legacy = history({ code: "x".repeat(1000) });
|
|
83
|
-
legacy[
|
|
84
|
+
legacy[2].content[0].arguments = { _purged: '<purged-errored-args size="1012"/>' };
|
|
84
85
|
const { result, calls } = await request(legacy);
|
|
85
86
|
expect(result.stopReason).toBe("error");
|
|
86
87
|
expect(result.errorMessage).toMatch(/Grammar tool call "codemode" requires argument "code" to be a string/);
|
|
@@ -1,8 +1,7 @@
|
|
|
1
|
-
import { describe, it, expect
|
|
1
|
+
import { describe, it, expect } from "bun:test";
|
|
2
2
|
import { mkdtempSync, readFileSync, writeFileSync } from "node:fs";
|
|
3
3
|
import { tmpdir } from "node:os";
|
|
4
4
|
import { join } from "node:path";
|
|
5
|
-
import * as actualCompat from "@earendil-works/pi-ai/compat";
|
|
6
5
|
import { supersededStub } from "./supersede.js";
|
|
7
6
|
|
|
8
7
|
// This must run before any module that transitively reads PI_CODING_AGENT_DIR
|
|
@@ -67,11 +66,6 @@ let streamImpl: (model: any, input?: any, opts?: any) => any = () => {
|
|
|
67
66
|
return okStream();
|
|
68
67
|
};
|
|
69
68
|
|
|
70
|
-
mock.module("@earendil-works/pi-ai/compat", () => ({
|
|
71
|
-
...actualCompat,
|
|
72
|
-
stream: (...args: any[]) => streamImpl(...args),
|
|
73
|
-
}));
|
|
74
|
-
|
|
75
69
|
type AppendedEntry = { type: string; data: unknown };
|
|
76
70
|
|
|
77
71
|
// The captured-but-unflushed batch: a user message, an assistant message with
|
|
@@ -305,7 +299,7 @@ function bootExtension(
|
|
|
305
299
|
modelRegistry: {
|
|
306
300
|
find: () => undefined,
|
|
307
301
|
getApiKeyAndHeaders: async () => ({ ok: true, apiKey: "test", headers: {} }),
|
|
308
|
-
|
|
302
|
+
streamSimple: (model: any, input: any, opts: any) => streamImpl(model, input, opts),
|
|
309
303
|
},
|
|
310
304
|
ui: {
|
|
311
305
|
setStatus() {},
|
|
@@ -1,22 +1,14 @@
|
|
|
1
|
-
import { describe, it, expect
|
|
2
|
-
import * as actualCompat from "@earendil-works/pi-ai/compat";
|
|
3
|
-
|
|
4
|
-
// Stub pi-ai's `stream` so runSummarization can be exercised without a network
|
|
5
|
-
// call. `streamImpl` is swapped per test to simulate primary/fallback outcomes.
|
|
1
|
+
import { describe, it, expect } from "bun:test";
|
|
6
2
|
let streamImpl: (model: any, input?: any, opts?: any) => any = () => {
|
|
7
3
|
throw new Error("streamImpl not set");
|
|
8
4
|
};
|
|
9
|
-
|
|
10
|
-
...actualCompat,
|
|
11
|
-
stream: (...args: any[]) => streamImpl(...args),
|
|
12
|
-
}));
|
|
13
|
-
|
|
14
|
-
const { summarizeBatch } = await import("./summarizer.js");
|
|
5
|
+
const { summarizeBatch, summarizeRange } = await import("./summarizer.js");
|
|
15
6
|
const { FallbackController } = await import("./summarizer-fallback.js");
|
|
16
7
|
const { DEFAULT_CONFIG } = await import("./types.js");
|
|
17
8
|
|
|
18
|
-
const PRIMARY = { id: "primary-model", provider: "provider-a", name: "Primary" };
|
|
19
|
-
const SESSION = { id: "session-model", provider: "provider-b", name: "Session" };
|
|
9
|
+
const PRIMARY = { id: "primary-model", provider: "provider-a", name: "Primary", api: "openai-responses" };
|
|
10
|
+
const SESSION = { id: "session-model", provider: "provider-b", name: "Session", api: "openai-responses" };
|
|
11
|
+
const VIRTUAL = { id: "auto", provider: "router", name: "Auto", api: "pi-virtual" };
|
|
20
12
|
|
|
21
13
|
const USAGE = {
|
|
22
14
|
input: 1,
|
|
@@ -67,6 +59,19 @@ function hangingStream(opts: any) {
|
|
|
67
59
|
};
|
|
68
60
|
}
|
|
69
61
|
|
|
62
|
+
// Models a router that never settles and ignores cancellation.
|
|
63
|
+
function deafStream() {
|
|
64
|
+
const never = new Promise<never>(() => {});
|
|
65
|
+
return {
|
|
66
|
+
async *[Symbol.asyncIterator]() {
|
|
67
|
+
await never;
|
|
68
|
+
},
|
|
69
|
+
async result() {
|
|
70
|
+
return never;
|
|
71
|
+
},
|
|
72
|
+
};
|
|
73
|
+
}
|
|
74
|
+
|
|
70
75
|
// Emits `events` thinking_delta events spaced `gapMs` apart, then completes
|
|
71
76
|
// successfully — UNLESS `opts.signal` aborts mid-drip, in which case the
|
|
72
77
|
// current sleep rejects, exactly like a real provider stream cancelling on
|
|
@@ -107,13 +112,19 @@ interface Note {
|
|
|
107
112
|
level: string;
|
|
108
113
|
}
|
|
109
114
|
|
|
110
|
-
function makeCtx(notes: Note[], sessionModel: any = SESSION, primaryModel: any = PRIMARY) {
|
|
115
|
+
function makeCtx(notes: Note[], sessionModel: any = SESSION, primaryModel: any = PRIMARY, authOk = true) {
|
|
116
|
+
const authCalls: any[] = [];
|
|
111
117
|
return {
|
|
112
118
|
model: sessionModel,
|
|
119
|
+
authCalls,
|
|
113
120
|
modelRegistry: {
|
|
114
121
|
find: () => primaryModel,
|
|
115
|
-
getApiKeyAndHeaders: async () =>
|
|
116
|
-
|
|
122
|
+
getApiKeyAndHeaders: async (model: any) => {
|
|
123
|
+
authCalls.push(model);
|
|
124
|
+
return authOk ? { ok: true, apiKey: "k", headers: {} } : { ok: false, error: "no key for provider-a" };
|
|
125
|
+
},
|
|
126
|
+
getProviderAuth: async () => ({ auth: { baseUrl: "https://seat.example" } }),
|
|
127
|
+
streamSimple: (model: any, input: any, opts: any) => streamImpl(model, input, opts),
|
|
117
128
|
},
|
|
118
129
|
ui: { notify: (msg: string, level: string) => notes.push({ msg, level }) },
|
|
119
130
|
} as any;
|
|
@@ -132,6 +143,90 @@ function makeBatch() {
|
|
|
132
143
|
|
|
133
144
|
const distinctConfig = { ...DEFAULT_CONFIG, summarizerModel: "provider-a/primary-model" };
|
|
134
145
|
|
|
146
|
+
describe("virtual models through the registry", () => {
|
|
147
|
+
for (const operation of ["batch", "range"] as const) {
|
|
148
|
+
const summarize = (config: any, ctx: any, options: any = {}) => operation === "batch"
|
|
149
|
+
? summarizeBatch(makeBatch(), config, ctx, options)
|
|
150
|
+
: summarizeRange("- step one", config, ctx, options);
|
|
151
|
+
|
|
152
|
+
it(`${operation}: virtual session model rescues a transient primary`, async () => {
|
|
153
|
+
const seen: any[] = [];
|
|
154
|
+
streamImpl = (model) => {
|
|
155
|
+
seen.push(model);
|
|
156
|
+
return model === PRIMARY ? errStream("down") : okStream("- routed summary");
|
|
157
|
+
};
|
|
158
|
+
const notes: Note[] = [];
|
|
159
|
+
const controller = new FallbackController();
|
|
160
|
+
const result = await summarize(distinctConfig, makeCtx(notes, VIRTUAL), { controller });
|
|
161
|
+
expect(result?.summaryText).toBe("- routed summary");
|
|
162
|
+
expect(controller.inFallback).toBe(true);
|
|
163
|
+
expect(seen[1]).toBe(VIRTUAL);
|
|
164
|
+
expect(notes.filter(n => n.level === "warning")).toHaveLength(1);
|
|
165
|
+
expect(notes.filter(n => n.level === "error")).toHaveLength(0);
|
|
166
|
+
});
|
|
167
|
+
|
|
168
|
+
for (const selection of ["default", "router/auto"]) {
|
|
169
|
+
it(`${operation}: virtual ${selection} skips auth and sends only summarizer context`, async () => {
|
|
170
|
+
const calls: any[] = [];
|
|
171
|
+
streamImpl = (model, input, opts) => {
|
|
172
|
+
calls.push({ model, input, opts });
|
|
173
|
+
return okStream("- ok");
|
|
174
|
+
};
|
|
175
|
+
const notes: Note[] = [];
|
|
176
|
+
const ctx = makeCtx(notes, VIRTUAL, VIRTUAL);
|
|
177
|
+
const result = await summarize({ ...DEFAULT_CONFIG, summarizerModel: selection }, ctx);
|
|
178
|
+
expect(result?.summaryText).toBe("- ok");
|
|
179
|
+
expect(calls).toHaveLength(1);
|
|
180
|
+
expect(calls[0].model).toBe(VIRTUAL);
|
|
181
|
+
expect(ctx.authCalls).toHaveLength(0);
|
|
182
|
+
expect(Object.keys(calls[0].input)).toEqual(["messages"]);
|
|
183
|
+
expect(calls[0].input.messages).toHaveLength(1);
|
|
184
|
+
expect(calls[0].input.messages[0].role).toBe("user");
|
|
185
|
+
expect(calls[0].opts.apiKey).toBeUndefined();
|
|
186
|
+
expect(calls[0].opts.headers).toBeUndefined();
|
|
187
|
+
expect(notes).toHaveLength(0);
|
|
188
|
+
});
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
it("passes the physical model unchanged and pre-checks once", async () => {
|
|
193
|
+
const seen: any[] = [];
|
|
194
|
+
streamImpl = (model) => {
|
|
195
|
+
seen.push(model);
|
|
196
|
+
return okStream("- ok");
|
|
197
|
+
};
|
|
198
|
+
const ctx = makeCtx([]);
|
|
199
|
+
await summarizeBatch(makeBatch(), DEFAULT_CONFIG, ctx);
|
|
200
|
+
expect(seen[0]).toBe(SESSION);
|
|
201
|
+
expect(seen[0].baseUrl).toBeUndefined();
|
|
202
|
+
expect(ctx.authCalls).toEqual([SESSION]);
|
|
203
|
+
});
|
|
204
|
+
|
|
205
|
+
it("missing physical credentials never stream or trip the controller", async () => {
|
|
206
|
+
let called = 0;
|
|
207
|
+
streamImpl = () => { called++; return okStream("- never"); };
|
|
208
|
+
const notes: Note[] = [];
|
|
209
|
+
const controller = new FallbackController();
|
|
210
|
+
const result = await summarizeBatch(makeBatch(), distinctConfig, makeCtx(notes, SESSION, PRIMARY, false), { controller });
|
|
211
|
+
expect(result).toBeNull();
|
|
212
|
+
expect(called).toBe(0);
|
|
213
|
+
expect(controller.inFallback).toBe(false);
|
|
214
|
+
expect(notes).toHaveLength(1);
|
|
215
|
+
expect(notes[0].level).toBe("error");
|
|
216
|
+
expect(notes[0].msg).toContain("no key for provider-a");
|
|
217
|
+
});
|
|
218
|
+
|
|
219
|
+
it("virtual stream failure returns null with the existing error notice", async () => {
|
|
220
|
+
streamImpl = () => errStream("router: no credentials for routed model");
|
|
221
|
+
const notes: Note[] = [];
|
|
222
|
+
const result = await summarizeBatch(makeBatch(), DEFAULT_CONFIG, makeCtx(notes, VIRTUAL));
|
|
223
|
+
expect(result).toBeNull();
|
|
224
|
+
expect(notes).toHaveLength(1);
|
|
225
|
+
expect(notes[0].level).toBe("error");
|
|
226
|
+
expect(notes[0].msg).toContain("router: no credentials for routed model");
|
|
227
|
+
});
|
|
228
|
+
});
|
|
229
|
+
|
|
135
230
|
describe("runSummarization wiring — same-model no-op (legacy path)", () => {
|
|
136
231
|
it("summarizerModel=default: transient failure notifies error, returns null, controller untouched", async () => {
|
|
137
232
|
streamImpl = () => errStream("provider overloaded");
|
|
@@ -223,6 +318,33 @@ describe("runSummarization wiring — abort", () => {
|
|
|
223
318
|
});
|
|
224
319
|
|
|
225
320
|
describe("runSummarization wiring — timeouts", () => {
|
|
321
|
+
for (const [kind, idleMs, maxMs, message] of [
|
|
322
|
+
["idle", 20, 0, /stalled/],
|
|
323
|
+
["ceiling", 0, 20, /ceiling/],
|
|
324
|
+
] as const) {
|
|
325
|
+
it(`stream that ignores the signal still times out (${kind})`, async () => {
|
|
326
|
+
streamImpl = () => deafStream();
|
|
327
|
+
const notes: Note[] = [];
|
|
328
|
+
const ctx = makeCtx(notes, VIRTUAL);
|
|
329
|
+
const cfg = { ...DEFAULT_CONFIG, summarizerModel: "default", summarizerIdleTimeoutMs: idleMs, summarizerMaxTimeoutMs: maxMs };
|
|
330
|
+
expect(await summarizeBatch(makeBatch(), cfg, ctx, {})).toBeNull();
|
|
331
|
+
expect(notes).toHaveLength(1);
|
|
332
|
+
expect(notes[0].level).toBe("warning");
|
|
333
|
+
expect(notes[0].msg).toMatch(message);
|
|
334
|
+
});
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
it("caller abort during a stream that ignores the signal throws, no warning", async () => {
|
|
338
|
+
streamImpl = () => deafStream();
|
|
339
|
+
const notes: Note[] = [];
|
|
340
|
+
const ctx = makeCtx(notes, VIRTUAL);
|
|
341
|
+
const ac = new AbortController();
|
|
342
|
+
setTimeout(() => ac.abort(), 20);
|
|
343
|
+
const cfg = { ...DEFAULT_CONFIG, summarizerModel: "default", summarizerIdleTimeoutMs: 0, summarizerMaxTimeoutMs: 0 };
|
|
344
|
+
await expect(summarizeBatch(makeBatch(), cfg, ctx, { signal: ac.signal })).rejects.toThrow();
|
|
345
|
+
expect(notes).toHaveLength(0);
|
|
346
|
+
});
|
|
347
|
+
|
|
226
348
|
it("idle timeout (default model): transient warning, returns null", async () => {
|
|
227
349
|
streamImpl = (_m, _i, opts) => hangingStream(opts);
|
|
228
350
|
const notes: Note[] = [];
|
package/src/summarizer.test.ts
CHANGED
|
@@ -1,25 +1,21 @@
|
|
|
1
|
-
import { describe, it, expect
|
|
2
|
-
import * as actualCompat from "@earendil-works/pi-ai/compat";
|
|
1
|
+
import { describe, it, expect } from "bun:test";
|
|
3
2
|
|
|
4
3
|
let seenInput: any;
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
};
|
|
19
|
-
},
|
|
20
|
-
}));
|
|
4
|
+
function captureStream(_model: any, input: any) {
|
|
5
|
+
seenInput = input;
|
|
6
|
+
return {
|
|
7
|
+
async *[Symbol.asyncIterator]() {},
|
|
8
|
+
async result() {
|
|
9
|
+
return {
|
|
10
|
+
stopReason: "stop",
|
|
11
|
+
content: [{ type: "text", text: "- summary" }],
|
|
12
|
+
usage: { input: 1, output: 1, cacheRead: 0, cacheWrite: 0, totalTokens: 2, cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 } },
|
|
13
|
+
};
|
|
14
|
+
},
|
|
15
|
+
};
|
|
16
|
+
}
|
|
21
17
|
|
|
22
|
-
const { isUsableSummary, summarizeBatch } = await import("./summarizer.js");
|
|
18
|
+
const { isUsableSummary, summarizeBatch, summarizerThinkingOptions } = await import("./summarizer.js");
|
|
23
19
|
const { DEFAULT_CONFIG } = await import("./types.js");
|
|
24
20
|
|
|
25
21
|
describe("isUsableSummary", () => {
|
|
@@ -37,6 +33,18 @@ describe("isUsableSummary", () => {
|
|
|
37
33
|
});
|
|
38
34
|
});
|
|
39
35
|
|
|
36
|
+
describe("summarizerThinkingOptions", () => {
|
|
37
|
+
it("omits reasoning for default and off", () => {
|
|
38
|
+
expect(summarizerThinkingOptions({ ...DEFAULT_CONFIG, summarizerThinking: "default" })).toEqual({});
|
|
39
|
+
expect(summarizerThinkingOptions({ ...DEFAULT_CONFIG, summarizerThinking: "off" })).toEqual({});
|
|
40
|
+
});
|
|
41
|
+
it("forwards every other level as reasoning", () => {
|
|
42
|
+
for (const level of ["minimal", "low", "medium", "high", "xhigh"] as const) {
|
|
43
|
+
expect(summarizerThinkingOptions({ ...DEFAULT_CONFIG, summarizerThinking: level })).toEqual({ reasoning: level });
|
|
44
|
+
}
|
|
45
|
+
});
|
|
46
|
+
});
|
|
47
|
+
|
|
40
48
|
describe("summarizer prompt", () => {
|
|
41
49
|
it("tells the model that an image marker is an image it cannot see", async () => {
|
|
42
50
|
const model = { id: "m", provider: "p", name: "M" };
|
|
@@ -45,7 +53,7 @@ describe("summarizer prompt", () => {
|
|
|
45
53
|
modelRegistry: {
|
|
46
54
|
find: () => model,
|
|
47
55
|
getApiKeyAndHeaders: async () => ({ ok: true, apiKey: "k", headers: {} }),
|
|
48
|
-
|
|
56
|
+
streamSimple: captureStream,
|
|
49
57
|
},
|
|
50
58
|
ui: { notify() {} },
|
|
51
59
|
} as any;
|
|
@@ -66,7 +74,7 @@ describe("summarizer prompt", () => {
|
|
|
66
74
|
modelRegistry: {
|
|
67
75
|
find: () => model,
|
|
68
76
|
getApiKeyAndHeaders: async () => ({ ok: true, apiKey: "k", headers: {} }),
|
|
69
|
-
|
|
77
|
+
streamSimple: captureStream,
|
|
70
78
|
},
|
|
71
79
|
ui: { notify() {} },
|
|
72
80
|
} as any;
|
package/src/summarizer.ts
CHANGED
|
@@ -1,5 +1,4 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import type { AssistantMessage } from "@earendil-works/pi-ai";
|
|
1
|
+
import type { AssistantMessage, ThinkingLevel } from "@earendil-works/pi-ai";
|
|
3
2
|
import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
4
3
|
import type {
|
|
5
4
|
CapturedBatch,
|
|
@@ -32,17 +31,14 @@ const RANGE_SYSTEM_PROMPT = `You are fusing several per-step summaries of one CL
|
|
|
32
31
|
- Keep any reference tokens like \`t12\` or \`b3\` intact.
|
|
33
32
|
- Be concise: a short narrative or a few grouped bullets, not one bullet per step.`;
|
|
34
33
|
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
if (level === "default") {
|
|
38
|
-
return {};
|
|
39
|
-
}
|
|
34
|
+
// Pi does not export isVirtualModel.
|
|
35
|
+
const VIRTUAL_MODEL_API = "pi-virtual";
|
|
40
36
|
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
return {
|
|
37
|
+
/** "off" is not a ThinkingLevel; omitting the option is how pi-ai and Pi's own compaction express it. */
|
|
38
|
+
export function summarizerThinkingOptions(config: ContextPruneConfig): { reasoning?: ThinkingLevel } {
|
|
39
|
+
const level: SummarizerThinking = config.summarizerThinking;
|
|
40
|
+
if (level === "default" || level === "off") return {};
|
|
41
|
+
return { reasoning: level };
|
|
46
42
|
}
|
|
47
43
|
|
|
48
44
|
/**
|
|
@@ -110,11 +106,23 @@ function combineSignals(...signals: (AbortSignal | undefined)[]): AbortSignal |
|
|
|
110
106
|
return AbortSignal.any(present); // Node 20+; host runtime is node 24.5.0
|
|
111
107
|
}
|
|
112
108
|
|
|
109
|
+
// Virtual routing can ignore the signal, so cancellation must end our wait
|
|
110
|
+
// without depending on the underlying stream settling.
|
|
111
|
+
function untilAbort<T>(promise: Promise<T>, signal: AbortSignal | undefined): Promise<T> {
|
|
112
|
+
if (!signal) return promise;
|
|
113
|
+
if (signal.aborted) return Promise.reject(new Error("summarize: aborted"));
|
|
114
|
+
return new Promise<T>((resolve, reject) => {
|
|
115
|
+
const onAbort = () => reject(new Error("summarize: aborted"));
|
|
116
|
+
signal.addEventListener("abort", onAbort, { once: true });
|
|
117
|
+
promise.then(resolve, reject).finally(() => signal.removeEventListener("abort", onAbort));
|
|
118
|
+
});
|
|
119
|
+
}
|
|
120
|
+
|
|
113
121
|
/**
|
|
114
122
|
* One summarization attempt against a specific model. Returns a classified
|
|
115
123
|
* outcome instead of throwing (except aborts, which propagate so flushPending
|
|
116
|
-
* can restore state).
|
|
117
|
-
* the fallback path. `unusable` = empty or length-truncated. Everything else
|
|
124
|
+
* can restore state). Physical-model auth failure is detected pre-stream and never
|
|
125
|
+
* reaches the fallback path; virtual models skip the pre-check. `unusable` = empty or length-truncated. Everything else
|
|
118
126
|
* that reaches the catch is `transient` (the outage bucket) — pi-ai surfaces
|
|
119
127
|
* no structured status code on the throw, so classification is coarse by design.
|
|
120
128
|
*/
|
|
@@ -149,25 +157,21 @@ async function runOnce(
|
|
|
149
157
|
: `summarizer ${modelLabel(model)} stalled (no output for ${Math.round(idleMs / 1000)}s)`;
|
|
150
158
|
|
|
151
159
|
try {
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
160
|
+
// Preserve auth classification for physical models without pre-checking
|
|
161
|
+
// a virtual model's provider instead of its routed physical target.
|
|
162
|
+
if (model.api !== VIRTUAL_MODEL_API) {
|
|
163
|
+
const auth = await ctx.modelRegistry.getApiKeyAndHeaders(model);
|
|
164
|
+
if (!auth.ok) {
|
|
165
|
+
const authMessage = "error" in auth ? auth.error : "authentication failed";
|
|
166
|
+
return { kind: "auth", message: authMessage };
|
|
167
|
+
}
|
|
156
168
|
}
|
|
157
169
|
|
|
158
|
-
// Mirror the main loop (model-runtime stream): auth resolution can carry a
|
|
159
|
-
// seat-specific baseUrl (e.g. GitHub Copilot business/enterprise endpoints).
|
|
160
|
-
// The shipped model data pins the individual host, which 421s other seats,
|
|
161
|
-
// so the resolved auth baseUrl must win over the static model baseUrl.
|
|
162
|
-
const providerAuth = await ctx.modelRegistry.getProviderAuth(model.provider);
|
|
163
|
-
const effectiveModel = providerAuth?.auth.baseUrl
|
|
164
|
-
? { ...model, baseUrl: providerAuth.auth.baseUrl }
|
|
165
|
-
: model;
|
|
166
|
-
|
|
167
170
|
// Pass the combined signal so the underlying fetch is cancelled immediately
|
|
168
171
|
// either when the user presses Esc, or when an idle/ceiling timeout fires.
|
|
169
|
-
const
|
|
170
|
-
|
|
172
|
+
const signal = combineSignals(options.signal, timeoutController.signal);
|
|
173
|
+
const responseStream = ctx.modelRegistry.streamSimple(
|
|
174
|
+
model,
|
|
171
175
|
{
|
|
172
176
|
messages: [
|
|
173
177
|
{
|
|
@@ -178,9 +182,7 @@ async function runOnce(
|
|
|
178
182
|
],
|
|
179
183
|
},
|
|
180
184
|
{
|
|
181
|
-
|
|
182
|
-
headers: auth.headers,
|
|
183
|
-
signal: combineSignals(options.signal, timeoutController.signal),
|
|
185
|
+
signal,
|
|
184
186
|
...summarizerThinkingOptions(config),
|
|
185
187
|
}
|
|
186
188
|
);
|
|
@@ -206,8 +208,12 @@ async function runOnce(
|
|
|
206
208
|
}
|
|
207
209
|
};
|
|
208
210
|
|
|
209
|
-
|
|
210
|
-
|
|
211
|
+
const events = responseStream[Symbol.asyncIterator]();
|
|
212
|
+
while (true) {
|
|
213
|
+
const next = await untilAbort(events.next(), signal);
|
|
214
|
+
if (next.done) break;
|
|
215
|
+
const event = next.value;
|
|
216
|
+
// Reset idle on ANY event (text_* and thinking_*), not just text - a
|
|
211
217
|
// reasoning-heavy model stays alive via thinking_delta and is never
|
|
212
218
|
// false-aborted for being quiet on text while it reasons.
|
|
213
219
|
bumpIdle();
|
|
@@ -222,7 +228,7 @@ async function runOnce(
|
|
|
222
228
|
// flushPending can detect it and restore batches.
|
|
223
229
|
if (options.signal?.aborted) throw new Error("summarize: aborted during stream");
|
|
224
230
|
|
|
225
|
-
const response = await responseStream.result();
|
|
231
|
+
const response = await untilAbort(responseStream.result(), signal);
|
|
226
232
|
reportTextProgress(response);
|
|
227
233
|
// stopReason "aborted" means the provider cut the stream short (e.g. signal
|
|
228
234
|
// fired just before the final chunk). Treat identically to the signal check
|