pi-condense 2.11.4 → 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 CHANGED
@@ -7,6 +7,24 @@ 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
+
22
+ ## [2.11.5] - 2026-10-08
23
+
24
+ ### Changed
25
+
26
+ - `minBatchChars` defaults to 2000 (was 1000): batches under 2,000 raw chars compress to only about 44% of their size when summarized, so they now stay verbatim and skip the summarizer call. Set `contextPrune.minBatchChars` explicitly to keep the old threshold.
27
+
10
28
  ## [2.11.4] - 2026-10-08
11
29
 
12
30
  ### Fixed
package/PRUNING.md CHANGED
@@ -600,7 +600,7 @@ captured batches (from turn_end or session scan)
600
600
  │ identical (toolName, normalize(resultText)) → alias of original;
601
601
  │ no LLM call; persist as context-prune-dedup-alias
602
602
  │
603
- ├─ 5. Trivial-batch skip (config: minBatchChars, default 1000)
603
+ ├─ 5. Trivial-batch skip (config: minBatchChars, default 2000)
604
604
  │ batches whose remaining raw chars < threshold → skip; no LLM call;
605
605
  │ leave originals in context; advance frontier
606
606
  │
@@ -677,11 +677,11 @@ Why a window and not the whole result: summarizer input is paid on every flush,
677
677
 
678
678
  ### Trivial-batch skip (minBatchChars)
679
679
 
680
- `minBatchChars: number` (default `1000`) is a pre-flush guard against "summary would be roughly the same size as the input" cases. If the total raw `resultText` across a batch is below the threshold, the batch is skipped: no summarizer LLM call, no `context-prune-index` entry, no `context-prune-summary` injection. The frontier still advances, so the same tool calls are not reconsidered next flush.
680
+ `minBatchChars: number` (default `2000`) is a pre-flush guard against "summary would be roughly the same size as the input" cases. If the total raw `resultText` across a batch is below the threshold, the batch is skipped: no summarizer LLM call, no `context-prune-index` entry, no `context-prune-summary` injection. The frontier still advances, so the same tool calls are not reconsidered next flush.
681
681
 
682
682
  Why it exists: a short LLM summary like "Tool X did Y" is itself ~50–150 chars per call. For a 200-byte file read or an `ls` of a short directory, the summary is the same size or larger than the input — the post-call `skipped-oversized` mechanism would catch it anyway, but only after the LLM round-trip and the cost. `minBatchChars` short-circuits the obvious cases at zero LLM cost.
683
683
 
684
- Set `minBatchChars: 0` to disable. The default `1000` skips obvious trivial batches (`git status`, small file reads, short directory listings) without affecting realistic tool outputs. Edit with `/pruner min-batch-chars <n>` or via the settings overlay.
684
+ Set `minBatchChars: 0` to disable. The default `2000` skips obvious trivial batches (`git status`, small file reads, short directory listings) without affecting realistic tool outputs; batches under 2,000 chars compress to only about 44% of their size when summarized (the per-call bullet scaffolding dominates), against about 7% for a typical session, so summarizing them costs an LLM call for little context saved. Edit with `/pruner min-batch-chars <n>` or via the settings overlay.
685
685
 
686
686
  ### Content-hash dedup
687
687
 
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.11.4",
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": "^0.83.0",
61
- "@earendil-works/pi-coding-agent": "^0.83.0",
62
- "@earendil-works/pi-tui": "^0.83.0",
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
  }
package/src/commands.ts CHANGED
@@ -591,7 +591,7 @@ export function registerCommands(
591
591
  values: MIN_BATCH_CHARS_PRESETS.map((p) => p.value),
592
592
  currentValue: MIN_BATCH_CHARS_PRESETS.some((p) => p.value === String(config.minBatchChars))
593
593
  ? String(config.minBatchChars)
594
- : MIN_BATCH_CHARS_PRESETS[2].value, // fall back to "1000" if a custom value isn't in the preset cycle
594
+ : MIN_BATCH_CHARS_PRESETS[3].value, // fall back to "2000" if a custom value isn't in the preset cycle
595
595
  description: minBatchCharsDescription(config),
596
596
  },
597
597
  {
@@ -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[1].content[0].arguments;
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[1].content[0].arguments.code).toMatch(/^<purged-errored-args size=/);
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[1].content[0].arguments = { _purged: '<purged-errored-args size="1012"/>' };
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, mock } from "bun:test";
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
- getProviderAuth: async () => undefined,
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, mock } from "bun:test";
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
- mock.module("@earendil-works/pi-ai/compat", () => ({
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 () => ({ ok: true, apiKey: "k", headers: {} }),
116
- getProviderAuth: async () => undefined,
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[] = [];
@@ -1,25 +1,21 @@
1
- import { describe, it, expect, mock } from "bun:test";
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
- mock.module("@earendil-works/pi-ai/compat", () => ({
6
- ...actualCompat,
7
- stream: (_model: any, input: any) => {
8
- seenInput = input;
9
- return {
10
- async *[Symbol.asyncIterator]() {},
11
- async result() {
12
- return {
13
- stopReason: "stop",
14
- content: [{ type: "text", text: "- summary" }],
15
- usage: { input: 1, output: 1, cacheRead: 0, cacheWrite: 0, totalTokens: 2, cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 } },
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
- getProviderAuth: async () => undefined,
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
- getProviderAuth: async () => undefined,
77
+ streamSimple: captureStream,
70
78
  },
71
79
  ui: { notify() {} },
72
80
  } as any;
package/src/summarizer.ts CHANGED
@@ -1,5 +1,4 @@
1
- import { stream } from "@earendil-works/pi-ai/compat";
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
- export function summarizerThinkingOptions(config: ContextPruneConfig): Record<string, unknown> {
36
- const level: SummarizerThinking = config.summarizerThinking;
37
- if (level === "default") {
38
- return {};
39
- }
34
+ // Pi does not export isVirtualModel.
35
+ const VIRTUAL_MODEL_API = "pi-virtual";
40
36
 
41
- // stream()/complete() accept provider-level options. For reasoning-capable providers,
42
- // pi-ai adapters translate reasoningEffort into the provider-specific field.
43
- // "off" intentionally sends no effort; adapters that support explicit disable
44
- // handle that the same way as an absent effort, while preserving compatibility.
45
- return { reasoningEffort: level === "off" ? undefined : level };
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). Auth failure is detected pre-stream and never reaches
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
- const auth = await ctx.modelRegistry.getApiKeyAndHeaders(model);
153
- if (!auth.ok) {
154
- const authMessage = "error" in auth ? auth.error : "authentication failed";
155
- return { kind: "auth", message: authMessage };
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 responseStream = stream(
170
- effectiveModel,
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
- apiKey: auth.apiKey,
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
- for await (const event of responseStream) {
210
- // Reset idle on ANY event (text_* and thinking_*), not just text — a
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
package/src/types.ts CHANGED
@@ -194,8 +194,8 @@ export const ROLLING_WINDOW_PRESETS: { value: string; label: string }[] = [
194
194
  export const MIN_BATCH_CHARS_PRESETS: { value: string; label: string }[] = [
195
195
  { value: "0", label: "0 (disabled)" },
196
196
  { value: "500", label: "500" },
197
- { value: "1000", label: "1000 (default)" },
198
- { value: "2000", label: "2000" },
197
+ { value: "1000", label: "1000" },
198
+ { value: "2000", label: "2000 (default)" },
199
199
  { value: "5000", label: "5000" },
200
200
  ];
201
201
 
@@ -567,7 +567,7 @@ export const DEFAULT_CONFIG: ContextPruneConfig = {
567
567
  pruneOn: "agent-message",
568
568
  batchingMode: "turn",
569
569
  quietOversizedSkips: false,
570
- minBatchChars: 1000,
570
+ minBatchChars: 2000,
571
571
  recoveryGraceTurns: 3,
572
572
  summarizerIdleTimeoutMs: 20000,
573
573
  summarizerMaxTimeoutMs: 180000,