pi-condense 2.11.5 → 2.13.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.
@@ -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
@@ -7,9 +7,9 @@ import {
7
7
  findSuperseded,
8
8
  lowerFloor,
9
9
  supersededStub,
10
- type SupersedeState,
11
10
  } from "./supersede.js";
12
11
  import { isProtected } from "./protected.js";
12
+ import { expectNoOrphanToolResults } from "./test-support.js";
13
13
  import { occKey } from "./occurrence-key.js";
14
14
 
15
15
  const cfg = { protectedTools: ["phase_tracker"], protectedPaths: ["**/skills/**/*.md"] };
@@ -42,9 +42,15 @@ describe("findSuperseded", () => {
42
42
  expect(findSuperseded(msgs, prot).map((c) => c.toolCallId)).toEqual(["a", "b"]);
43
43
  });
44
44
 
45
- test("different offset/limit slices of one path -> older is a candidate", () => {
45
+ test("different offset/limit slices of one path -> neither is a candidate", () => {
46
46
  const msgs = [...call("a", 10, { path: SKILL, offset: 1, limit: 400 }), ...call("b", 20, { path: SKILL, offset: 400, limit: 50 })];
47
- expect(findSuperseded(msgs, prot).map((c) => c.toolCallId)).toEqual(["a"]);
47
+ expectNoOrphanToolResults(msgs);
48
+ expect(findSuperseded(msgs, prot)).toEqual([]);
49
+ const state = createSupersedeState();
50
+ state.floor = 0;
51
+ const out = applySupersede(msgs, state, prot);
52
+ expect(out).toBe(msgs);
53
+ expectNoOrphanToolResults(out);
48
54
  });
49
55
 
50
56
  test("two paths interleaved -> per-path winners and candidates", () => {
@@ -153,6 +159,70 @@ describe("findSuperseded", () => {
153
159
  });
154
160
  });
155
161
 
162
+ type RangeRead = { offset?: unknown; limit?: unknown; cap?: string; text?: string; path?: string };
163
+ const oversizedLine = `[Line 1 is 61.2KB, exceeds 50.0KB limit. Use bash: sed -n '1p' ${SKILL} | head -c 51200]`;
164
+ const cap1800 = "[Showing lines 1-1800 of 3000 (50.0KB limit). Use offset=1801 to continue.]";
165
+ const cap2000 = "[Showing lines 1-2000 of 3000. Use offset=2001 to continue.]";
166
+
167
+ describe("delivered range coverage", () => {
168
+ const cases: [string, RangeRead[], string[]][] = [
169
+ ["full/full", [{}, {}], ["r0"]],
170
+ ["tail then oversized-first-line warning", [{ offset: 2001 }, { text: oversizedLine }], []],
171
+ ["oversized-first-line warning then full", [{ text: oversizedLine }, {}], ["r0"]],
172
+ ["disjoint chunks", [{ limit: 2000 }, { offset: 2001 }], []],
173
+ ["chunk then capped full", [{ limit: 2000 }, { cap: cap2000 }], ["r0"]],
174
+ ["tail then capped full", [{ offset: 2001, limit: 1000 }, { cap: cap2000 }], []],
175
+ ["full then chunk", [{}, { limit: 50 }], []],
176
+ ["chunk1/chunk2/chunk1", [{ limit: 2000 }, { offset: 2001 }, { limit: 2000 }], ["r0"]],
177
+ ["equal capped full reads", [{ cap: cap2000 }, { cap: cap2000 }], ["r0"]],
178
+ ["byte-capped limit then capped full", [{ limit: 2500, cap: cap1800 }, { cap: cap2000 }], ["r0"]],
179
+ ["capped full then byte-capped limit", [{ cap: cap2000 }, { limit: 2500, cap: cap1800 }], []],
180
+ ["zero offset", [{ offset: 0, limit: 100 }, {}], ["r0"]],
181
+ ["null offset full then chunk", [{ offset: null }, { limit: 50 }], []],
182
+ ["null offset limited", [{ limit: 50 }, { offset: null, limit: 50 }], ["r0"]],
183
+ ["negative offset", [{ offset: -10, limit: 50 }, { limit: 50 }], ["r0"]],
184
+ ["null limit is open", [{ limit: null }, { limit: 50 }], []],
185
+ ["string values are open", [{ offset: "2001", limit: "50" }, { limit: 50 }], []],
186
+ ["later string values cover full", [{}, { offset: "2001", limit: "50" }], ["r0"]],
187
+ ["later null limit covers full", [{}, { limit: null }], ["r0"]],
188
+ ["zero limit covers nothing", [{ limit: 50 }, { limit: 0 }], []],
189
+ ["negative limit covers nothing", [{ limit: 50 }, { limit: -1 }], []],
190
+ ["empty cannot cover empty", [{ limit: 0 }, { limit: -1 }], []],
191
+ ["nonempty covers empty", [{ offset: 2001, limit: 0 }, { limit: 50 }], ["r0"]],
192
+ ["partial overlap", [{ offset: 10, limit: 50 }, { offset: 20, limit: 50 }], []],
193
+ ["equal end with earlier start", [{ offset: 10, limit: 41 }, { limit: 50 }], ["r0"]],
194
+ ["different paths", [{ limit: 50 }, { path: OTHER }], []],
195
+ ["intermediate covering read", [{ limit: 50 }, { limit: 100 }, { offset: 2001 }], ["r0"]],
196
+ ["offset byte cap", [{ offset: 1001, limit: 2500, cap: "[Showing lines 1001-2800 of 4000 (50.0KB limit). Use offset=2801 to continue.]" }, { offset: 1001, limit: 1800 }], ["r0"]],
197
+ ["ordinary continuation keeps limit", [{ limit: 50, cap: "[2950 more lines in file. Use offset=51 to continue.]" }, { limit: 50 }], ["r0"]],
198
+ ["nonterminal notice is body text", [{ cap: cap2000 + "\nmore body" }, { limit: 2000 }], []],
199
+ ];
200
+
201
+ test.each(cases)("%s", (_name, reads, expected) => {
202
+ const msgs = reads.flatMap(({ cap, text, ...args }, i) => {
203
+ const pair = call(`r${i}`, i * 10, { path: SKILL, ...args });
204
+ if (text) pair[1].content[0].text = text;
205
+ if (cap) pair[1].content[0].text += `\n\n${cap}`;
206
+ return pair;
207
+ });
208
+ expectNoOrphanToolResults(msgs);
209
+ expect(findSuperseded(msgs, prot).map((c) => c.toolCallId)).toEqual(expected);
210
+ const state = createSupersedeState();
211
+ expect(applySupersede(msgs, state, prot)).toBe(msgs);
212
+ state.floor = 0;
213
+ const out = applySupersede(msgs, state, prot);
214
+ expectNoOrphanToolResults(out);
215
+ if (expected.length === 0) expect(out).toBe(msgs);
216
+ for (let i = 0; i < reads.length; i++) {
217
+ if (expected.includes(`r${i}`)) expect(out[i * 2 + 1].content[0].text).toBe(supersededStub(SKILL));
218
+ else expect(out[i * 2 + 1]).toBe(msgs[i * 2 + 1]);
219
+ }
220
+ const sticky = applySupersede(msgs, state, prot);
221
+ expect(sticky).toEqual(out);
222
+ expectNoOrphanToolResults(sticky);
223
+ });
224
+ });
225
+
156
226
  describe("floor helpers", () => {
157
227
  test("lowerFloor sets when undefined, lowers monotonically, ignores undefined input", () => {
158
228
  const s = createSupersedeState();
package/src/supersede.ts CHANGED
@@ -1,11 +1,7 @@
1
1
  import { normalizePath } from "./protected.js";
2
2
  import { occKey, resultTimestampOf } from "./occurrence-key.js";
3
3
 
4
- /**
5
- * Protected reads are never indexed, so nothing else in the pipeline ever
6
- * collapses a re-read of the same skill file. This module keeps only the
7
- * newest protected read per `args.path` verbatim (spec 2026-09-07).
8
- */
4
+ /** Protected reads stay verbatim until a later same-path read covers their delivered lines. */
9
5
  export interface SupersededCandidate {
10
6
  toolCallId: string;
11
7
  path: string;
@@ -49,11 +45,34 @@ export function supersededStub(path: string): string {
49
45
 
50
46
  export type IsProtectedFn = (toolName: string, args: unknown) => boolean;
51
47
 
48
+ interface DeliveredRange {
49
+ start: number;
50
+ end: number;
51
+ }
52
+
53
+ function deliveredRange(args: Record<string, unknown>, result: any): DeliveredRange {
54
+ const start = typeof args.offset === "number" ? Math.max(1, args.offset) : 1;
55
+ const limit = args.limit;
56
+ let end = typeof limit === "number" ? (limit <= 0 ? start - 1 : start + limit - 1) : Infinity;
57
+ for (const block of result.content ?? []) {
58
+ if (block?.type !== "text" || typeof block.text !== "string") continue;
59
+ if (/^\[Line \d+ is [^,]+, exceeds [^ ]+ limit\. Use bash: .*\]$/.test(block.text)) return { start, end: start - 1 };
60
+ const cap = block.text.match(/(?:^|\n)\[Showing lines \d+-(\d+) of \d+(?: \([^)]*limit\))?\. Use offset=\d+ to continue\.\]$/);
61
+ if (cap) end = Math.min(end, Number(cap[1]));
62
+ }
63
+ return { start, end };
64
+ }
65
+
66
+ function covers(later: DeliveredRange, earlier: DeliveredRange): boolean {
67
+ if (later.end < later.start) return false;
68
+ return earlier.end < earlier.start || (later.start <= earlier.start && later.end >= earlier.end);
69
+ }
70
+
52
71
  export function findSuperseded(messages: any[], isProtected: IsProtectedFn): SupersededCandidate[] {
53
72
  // Provider ids repeat across turns and an aborted call has no result, so pairing
54
73
  // uses the same per-turn open-set model as orphan-sweep, not a global per-id cursor.
55
74
  let open = new Map<string, any>();
56
- const byPath = new Map<string, SupersededCandidate[]>();
75
+ const byPath = new Map<string, { candidate: SupersededCandidate; range: DeliveredRange }[]>();
57
76
 
58
77
  for (let i = 0; i < messages.length; i++) {
59
78
  const m = messages[i];
@@ -77,16 +96,25 @@ export function findSuperseded(messages: any[], isProtected: IsProtectedFn): Sup
77
96
  timestamp: resultTimestampOf(m.timestamp),
78
97
  resultIndex: i,
79
98
  };
99
+ const occurrence = { candidate: cand, range: deliveredRange(args, m) };
80
100
  const list = byPath.get(path);
81
- if (list) list.push(cand);
82
- else byPath.set(path, [cand]);
101
+ if (list) list.push(occurrence);
102
+ else byPath.set(path, [occurrence]);
83
103
  continue;
84
104
  }
85
105
  open = new Map();
86
106
  }
87
107
 
88
108
  const out: SupersededCandidate[] = [];
89
- for (const list of byPath.values()) for (let i = 0; i < list.length - 1; i++) out.push(list[i]);
109
+ for (const list of byPath.values()) {
110
+ for (let i = 0; i < list.length - 1; i++) {
111
+ for (let j = i + 1; j < list.length; j++) {
112
+ if (!covers(list[j].range, list[i].range)) continue;
113
+ out.push(list[i].candidate);
114
+ break;
115
+ }
116
+ }
117
+ }
90
118
  out.sort((a, b) => a.resultIndex - b.resultIndex);
91
119
  return out;
92
120
  }