pi-condense 2.2.1 → 2.4.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.
@@ -2,11 +2,11 @@ import { describe, it, expect, mock } from "bun:test";
2
2
 
3
3
  // Stub pi-ai's `stream` so runSummarization can be exercised without a network
4
4
  // call. `streamImpl` is swapped per test to simulate primary/fallback outcomes.
5
- let streamImpl: (model: any) => any = () => {
5
+ let streamImpl: (model: any, input?: any, opts?: any) => any = () => {
6
6
  throw new Error("streamImpl not set");
7
7
  };
8
8
  mock.module("@earendil-works/pi-ai", () => ({
9
- stream: (model: any) => streamImpl(model),
9
+ stream: (...args: any[]) => streamImpl(...args),
10
10
  }));
11
11
 
12
12
  const { summarizeBatch } = await import("./summarizer.js");
@@ -45,6 +45,61 @@ function errStream(message: string) {
45
45
  };
46
46
  }
47
47
 
48
+ // Hangs until `opts.signal` (the combined caller+timeout signal runOnce
49
+ // passes to stream()) aborts. With no signal it never settles.
50
+ function hangingStream(opts: any) {
51
+ const signal: AbortSignal | undefined = opts?.signal;
52
+ const untilAbort = () =>
53
+ new Promise<never>((_, reject) => {
54
+ if (!signal) return; // no signal => never settles
55
+ if (signal.aborted) return reject(new Error("aborted"));
56
+ signal.addEventListener("abort", () => reject(new Error("aborted")), { once: true });
57
+ });
58
+ return {
59
+ async *[Symbol.asyncIterator]() {
60
+ await untilAbort();
61
+ },
62
+ async result() {
63
+ return untilAbort();
64
+ },
65
+ };
66
+ }
67
+
68
+ // Emits `events` thinking_delta events spaced `gapMs` apart, then completes
69
+ // successfully — UNLESS `opts.signal` aborts mid-drip, in which case the
70
+ // current sleep rejects, exactly like a real provider stream cancelling on
71
+ // abort. This is what gives the idle-reset test teeth: if runOnce's in-loop
72
+ // bumpIdle() is ever removed, the idle timer fires at the configured window
73
+ // and the combined signal aborts, so this stream rejects instead of
74
+ // completing — the test then fails instead of passing vacuously.
75
+ function drippingStream(opts: any, text: string, events: number, gapMs: number) {
76
+ const signal: AbortSignal | undefined = opts?.signal;
77
+ const sleepOrAbort = (ms: number) =>
78
+ new Promise<void>((resolve, reject) => {
79
+ if (signal?.aborted) return reject(new Error("aborted"));
80
+ const timer = setTimeout(resolve, ms);
81
+ signal?.addEventListener(
82
+ "abort",
83
+ () => {
84
+ clearTimeout(timer);
85
+ reject(new Error("aborted"));
86
+ },
87
+ { once: true }
88
+ );
89
+ });
90
+ return {
91
+ async *[Symbol.asyncIterator]() {
92
+ for (let i = 0; i < events; i++) {
93
+ await sleepOrAbort(gapMs);
94
+ yield { type: "thinking_delta" };
95
+ }
96
+ },
97
+ async result() {
98
+ return { stopReason: "stop", content: [{ type: "text", text }], usage: USAGE };
99
+ },
100
+ };
101
+ }
102
+
48
103
  interface Note {
49
104
  msg: string;
50
105
  level: string;
@@ -163,3 +218,90 @@ describe("runSummarization wiring — abort", () => {
163
218
  ).rejects.toThrow();
164
219
  });
165
220
  });
221
+
222
+ describe("runSummarization wiring — timeouts", () => {
223
+ it("idle timeout (default model): transient warning, returns null", async () => {
224
+ streamImpl = (_m, _i, opts) => hangingStream(opts);
225
+ const notes: Note[] = [];
226
+ const ctx = makeCtx(notes);
227
+ const cfg = { ...DEFAULT_CONFIG, summarizerModel: "default", summarizerIdleTimeoutMs: 20, summarizerMaxTimeoutMs: 0 };
228
+ const r = await summarizeBatch(makeBatch(), cfg, ctx, {});
229
+ expect(r).toBeNull();
230
+ const warnings = notes.filter((n) => n.level === "warning");
231
+ expect(warnings).toHaveLength(1);
232
+ expect(warnings[0].msg).toMatch(/stalled/);
233
+ expect(notes.filter((n) => n.level === "error")).toHaveLength(0);
234
+ });
235
+
236
+ it("ceiling timeout (idle disabled): transient warning mentioning ceiling", async () => {
237
+ streamImpl = (_m, _i, opts) => hangingStream(opts);
238
+ const notes: Note[] = [];
239
+ const ctx = makeCtx(notes);
240
+ const cfg = { ...DEFAULT_CONFIG, summarizerModel: "default", summarizerIdleTimeoutMs: 0, summarizerMaxTimeoutMs: 20 };
241
+ const r = await summarizeBatch(makeBatch(), cfg, ctx, {});
242
+ expect(r).toBeNull();
243
+ const warnings = notes.filter((n) => n.level === "warning");
244
+ expect(warnings).toHaveLength(1);
245
+ expect(warnings[0].msg).toMatch(/ceiling/);
246
+ });
247
+
248
+ it("option B: primary idle-times-out, session model rescues", async () => {
249
+ streamImpl = (model, _i, opts) => (model.id === PRIMARY.id ? hangingStream(opts) : okStream("- rescued"));
250
+ const notes: Note[] = [];
251
+ const ctx = makeCtx(notes);
252
+ const controller = new FallbackController();
253
+ const cfg = { ...distinctConfig, summarizerIdleTimeoutMs: 20 };
254
+ const r = await summarizeBatch(makeBatch(), cfg, ctx, { controller });
255
+ expect(r?.summaryText).toBe("- rescued");
256
+ expect(controller.inFallback).toBe(true);
257
+ expect(notes.filter((n) => n.level === "warning")).toHaveLength(1); // generic "enter" fallback warning
258
+ expect(notes.filter((n) => n.level === "error")).toHaveLength(0);
259
+ });
260
+
261
+ it("both time out: null, both-down notice at warning severity", async () => {
262
+ streamImpl = (_m, _i, opts) => hangingStream(opts);
263
+ const notes: Note[] = [];
264
+ const ctx = makeCtx(notes);
265
+ const controller = new FallbackController();
266
+ const cfg = { ...distinctConfig, summarizerIdleTimeoutMs: 20 };
267
+ const r = await summarizeBatch(makeBatch(), cfg, ctx, { controller });
268
+ expect(r).toBeNull();
269
+ expect(notes.filter((n) => n.level === "warning")).toHaveLength(1);
270
+ expect(notes.filter((n) => n.level === "error")).toHaveLength(0);
271
+ });
272
+
273
+ it("pre-aborted signal is not a timeout (throws, no warning)", async () => {
274
+ streamImpl = (_m, _i, opts) => hangingStream(opts);
275
+ const notes: Note[] = [];
276
+ const ctx = makeCtx(notes);
277
+ const ac = new AbortController();
278
+ ac.abort();
279
+ const cfg = { ...distinctConfig, summarizerIdleTimeoutMs: 20 };
280
+ await expect(summarizeBatch(makeBatch(), cfg, ctx, { signal: ac.signal })).rejects.toThrow();
281
+ expect(notes.filter((n) => n.level === "warning")).toHaveLength(0);
282
+ });
283
+
284
+ it("both timeouts disabled: okStream succeeds unchanged", async () => {
285
+ streamImpl = () => okStream("- ok");
286
+ const notes: Note[] = [];
287
+ const ctx = makeCtx(notes);
288
+ const cfg = { ...DEFAULT_CONFIG, summarizerModel: "default", summarizerIdleTimeoutMs: 0, summarizerMaxTimeoutMs: 0 };
289
+ const r = await summarizeBatch(makeBatch(), cfg, ctx, {});
290
+ expect(r?.summaryText).toBe("- ok");
291
+ expect(notes).toHaveLength(0);
292
+ });
293
+ });
294
+
295
+ describe("runSummarization wiring — idle reset keeps a flowing stream alive", () => {
296
+ it("does not time out while events keep arriving within the idle window", async () => {
297
+ // 6 events, 10ms apart = 60ms total > 25ms idle window; only survives if
298
+ // the idle timer resets on every event (bumpIdle() inside the loop).
299
+ streamImpl = (_m, _i, opts) => drippingStream(opts, "- flowing summary", 6, 10);
300
+ const notes: Note[] = [];
301
+ const ctx = makeCtx(notes);
302
+ const cfg = { ...DEFAULT_CONFIG, summarizerModel: "default", summarizerIdleTimeoutMs: 25, summarizerMaxTimeoutMs: 0 };
303
+ const r = await summarizeBatch(makeBatch(), cfg, ctx, {});
304
+ expect(r?.summaryText).toBe("- flowing summary");
305
+ expect(notes.filter((n) => n.level === "warning")).toHaveLength(0);
306
+ });
307
+ });
package/src/summarizer.ts CHANGED
@@ -90,7 +90,7 @@ type RunOutcome =
90
90
  | { kind: "ok"; result: SummarizeResult }
91
91
  | { kind: "auth"; message: string }
92
92
  | { kind: "unusable" }
93
- | { kind: "transient"; message: string };
93
+ | { kind: "transient"; message: string; timedOut?: boolean };
94
94
 
95
95
  /** Human label for a model in notify text: prefer name, fall back to provider/id. */
96
96
  function modelLabel(model: any): string {
@@ -98,6 +98,14 @@ function modelLabel(model: any): string {
98
98
  return model.name || `${model.provider}/${model.id}`;
99
99
  }
100
100
 
101
+ /** Combines any present abort signals into one; undefined if none are given. */
102
+ function combineSignals(...signals: (AbortSignal | undefined)[]): AbortSignal | undefined {
103
+ const present = signals.filter((s): s is AbortSignal => !!s);
104
+ if (present.length === 0) return undefined;
105
+ if (present.length === 1) return present[0];
106
+ return AbortSignal.any(present); // Node 20+; host runtime is node 24.5.0
107
+ }
108
+
101
109
  /**
102
110
  * One summarization attempt against a specific model. Returns a classified
103
111
  * outcome instead of throwing (except aborts, which propagate so flushPending
@@ -113,6 +121,29 @@ async function runOnce(
113
121
  ctx: ExtensionContext,
114
122
  options: SummarizeBatchOptions
115
123
  ): Promise<RunOutcome> {
124
+ const idleMs = config.summarizerIdleTimeoutMs;
125
+ const maxMs = config.summarizerMaxTimeoutMs;
126
+ const timeoutController = new AbortController();
127
+ let timedOut = false;
128
+ let timeoutKind: "idle" | "ceiling" | null = null;
129
+ let idleTimerId: ReturnType<typeof setTimeout> | null = null;
130
+ let ceilingTimerId: ReturnType<typeof setTimeout> | null = null;
131
+
132
+ const bumpIdle = () => {
133
+ if (idleTimerId !== null) clearTimeout(idleTimerId);
134
+ if (idleMs > 0) {
135
+ idleTimerId = setTimeout(() => {
136
+ timedOut = true;
137
+ timeoutKind = "idle";
138
+ timeoutController.abort();
139
+ }, idleMs);
140
+ }
141
+ };
142
+ const timeoutMessage = () =>
143
+ timeoutKind === "ceiling"
144
+ ? `summarizer ${modelLabel(model)} exceeded ${Math.round(maxMs / 1000)}s ceiling`
145
+ : `summarizer ${modelLabel(model)} stalled (no output for ${Math.round(idleMs / 1000)}s)`;
146
+
116
147
  try {
117
148
  const auth = await ctx.modelRegistry.getApiKeyAndHeaders(model);
118
149
  if (!auth.ok) {
@@ -120,8 +151,8 @@ async function runOnce(
120
151
  return { kind: "auth", message: authMessage };
121
152
  }
122
153
 
123
- // Pass the abort signal so the underlying fetch is cancelled immediately
124
- // when the user presses Esc while the tool is running.
154
+ // Pass the combined signal so the underlying fetch is cancelled immediately
155
+ // either when the user presses Esc, or when an idle/ceiling timeout fires.
125
156
  const responseStream = stream(
126
157
  model,
127
158
  {
@@ -133,9 +164,25 @@ async function runOnce(
133
164
  },
134
165
  ],
135
166
  },
136
- { apiKey: auth.apiKey, headers: auth.headers, signal: options.signal, ...summarizerThinkingOptions(config) }
167
+ {
168
+ apiKey: auth.apiKey,
169
+ headers: auth.headers,
170
+ signal: combineSignals(options.signal, timeoutController.signal),
171
+ ...summarizerThinkingOptions(config),
172
+ }
137
173
  );
138
174
 
175
+ // Ceiling arms once at call start; idle arms/resets on every stream event
176
+ // (including before the first one, so it also bounds time-to-first-token).
177
+ if (maxMs > 0) {
178
+ ceilingTimerId = setTimeout(() => {
179
+ timedOut = true;
180
+ timeoutKind ??= "ceiling";
181
+ timeoutController.abort();
182
+ }, maxMs);
183
+ }
184
+ bumpIdle();
185
+
139
186
  let lastReportedChars = -1;
140
187
  options.onTextProgress?.(0);
141
188
  const reportTextProgress = (message: AssistantMessage) => {
@@ -147,6 +194,10 @@ async function runOnce(
147
194
  };
148
195
 
149
196
  for await (const event of responseStream) {
197
+ // Reset idle on ANY event (text_* and thinking_*), not just text — a
198
+ // reasoning-heavy model stays alive via thinking_delta and is never
199
+ // false-aborted for being quiet on text while it reasons.
200
+ bumpIdle();
150
201
  // Belt-and-suspenders: break early when signal fires mid-stream.
151
202
  if (options.signal?.aborted) break;
152
203
  if (event.type === "text_start" || event.type === "text_delta" || event.type === "text_end") {
@@ -167,6 +218,7 @@ async function runOnce(
167
218
  throw new Error("summarize: stream stopped with reason aborted");
168
219
  }
169
220
  if (response.stopReason === "error") {
221
+ if (timedOut) return { kind: "transient", message: timeoutMessage(), timedOut: true };
170
222
  return { kind: "transient", message: response.errorMessage ?? "Summarizer stopped with reason: error" };
171
223
  }
172
224
 
@@ -182,7 +234,11 @@ async function runOnce(
182
234
  // Propagate abort errors upward so flushPending can check signal.aborted
183
235
  // and return { ok: false, reason: "aborted" } without showing a UI error.
184
236
  if (options.signal?.aborted) throw err;
237
+ if (timedOut) return { kind: "transient", message: timeoutMessage(), timedOut: true };
185
238
  return { kind: "transient", message: err.message };
239
+ } finally {
240
+ if (idleTimerId !== null) clearTimeout(idleTimerId);
241
+ if (ceilingTimerId !== null) clearTimeout(ceilingTimerId);
186
242
  }
187
243
  }
188
244
 
@@ -211,8 +267,13 @@ async function runSummarization(
211
267
  const controller = options.controller;
212
268
  const sessionModel = ctx.model;
213
269
 
214
- const notifyError = (msg: string) =>
215
- ctx.ui.notify(`pruner: summarization failed: ${msg}`, "error");
270
+ const notifyFailure = (o: { message: string; timedOut?: boolean }) =>
271
+ ctx.ui.notify(
272
+ o.timedOut
273
+ ? `pi-condense: ${o.message}; summarizer call abandoned`
274
+ : `pruner: summarization failed: ${o.message}`,
275
+ o.timedOut ? "warning" : "error",
276
+ );
216
277
 
217
278
  // No controller or no distinct fallback: single attempt, legacy behavior.
218
279
  if (!controller || !FallbackController.hasDistinctFallback(primary, sessionModel)) {
@@ -222,7 +283,7 @@ async function runSummarization(
222
283
  return r.result;
223
284
  case "auth":
224
285
  case "transient":
225
- notifyError(r.message);
286
+ notifyFailure(r);
226
287
  return null;
227
288
  case "unusable":
228
289
  return null;
@@ -250,14 +311,14 @@ async function runSummarization(
250
311
  else emit(controller.onFallbackSuccess());
251
312
  return r.result;
252
313
  case "auth":
253
- notifyError(r.message); // auth never trips the controller
314
+ notifyFailure(r); // auth never trips the controller
254
315
  return null;
255
316
  case "unusable":
256
317
  return null; // probe unusable => stay (no state change)
257
318
  case "transient": {
258
319
  if (decision.target === "fallback") {
259
320
  controller.onFallbackOnlyFail();
260
- notifyError(r.message);
321
+ notifyFailure(r);
261
322
  return null;
262
323
  }
263
324
  // target was primary (initial detection or probe): retry once on the session model.
@@ -267,7 +328,7 @@ async function runSummarization(
267
328
  return r2.result; // suppress the legacy error notify — fallback rescued the call
268
329
  }
269
330
  controller.onBothDown();
270
- notifyError(r2.kind === "transient" || r2.kind === "auth" ? r2.message : r.message);
331
+ notifyFailure(r2.kind === "transient" || r2.kind === "auth" ? r2 : r);
271
332
  return null;
272
333
  }
273
334
  }
package/src/types.ts CHANGED
@@ -78,6 +78,10 @@ export const CUSTOM_TYPE_DEDUP_ALIAS = "context-prune-dedup-alias";
78
78
  */
79
79
  export const CUSTOM_TYPE_CHAIN = "context-prune-chain";
80
80
 
81
+ /** The registered name of the recovery tool (src/query-tool.ts). Shared so the
82
+ * grace checks in pruner.ts / chain-compressor.ts cannot drift from registration. */
83
+ export const QUERY_TOOL_NAME = "context_tree_query";
84
+
81
85
  /** pi.events channel for cross-extension cost contributions (an aggregator like pi-subagents folds these into one total). */
82
86
  export const EXTERNAL_COST_CHANNEL = "cost:external";
83
87
 
@@ -185,6 +189,43 @@ export const MIN_BATCH_CHARS_PRESETS: { value: string; label: string }[] = [
185
189
  { value: "5000", label: "5000" },
186
190
  ];
187
191
 
192
+ /**
193
+ * Cycling presets for the `recoveryGraceTurns` setting in the SettingsList.
194
+ * Stored as strings; converted to number when applied. "0" disables the grace
195
+ * (recovery output stubs immediately, pre-feature behavior).
196
+ */
197
+ export const RECOVERY_GRACE_PRESETS: { value: string; label: string }[] = [
198
+ { value: "0", label: "0 (disabled)" },
199
+ { value: "1", label: "1" },
200
+ { value: "3", label: "3 (default)" },
201
+ { value: "5", label: "5" },
202
+ { value: "8", label: "8" },
203
+ ];
204
+
205
+ /**
206
+ * Cycling presets for `summarizerIdleTimeoutMs` (stored as strings; the
207
+ * settings UI cycles string values). "0" is the disabling sentinel.
208
+ */
209
+ export const SUMMARIZER_IDLE_TIMEOUT_PRESETS: { value: string; label: string }[] = [
210
+ { value: "0", label: "0 (disabled)" },
211
+ { value: "10000", label: "10s" },
212
+ { value: "20000", label: "20s (default)" },
213
+ { value: "45000", label: "45s" },
214
+ { value: "90000", label: "90s" },
215
+ ];
216
+
217
+ /**
218
+ * Cycling presets for `summarizerMaxTimeoutMs` (stored as strings). "0" is
219
+ * the disabling sentinel - no total-duration ceiling.
220
+ */
221
+ export const SUMMARIZER_MAX_TIMEOUT_PRESETS: { value: string; label: string }[] = [
222
+ { value: "0", label: "0 (disabled)" },
223
+ { value: "120000", label: "120s" },
224
+ { value: "180000", label: "180s (default)" },
225
+ { value: "300000", label: "300s" },
226
+ { value: "600000", label: "600s" },
227
+ ];
228
+
188
229
  /**
189
230
  * Cycling presets for the `autoBudgetThreshold` setting (stored as strings;
190
231
  * the settings UI cycles string values). "0" is the disabled sentinel → null.
@@ -256,6 +297,29 @@ export interface ContextPruneConfig {
256
297
  * Default: 1000.
257
298
  */
258
299
  minBatchChars: number;
300
+ /**
301
+ * User-turn-groups a `context_tree_query` (recovery) output stays verbatim in
302
+ * context after recovery, before it reverts to the normal stub. Bounds the
303
+ * retrieve->re-stub->re-query loop without permanent retention. 0 disables
304
+ * (recovery output stubs immediately). Enforced at render time in pruner.ts
305
+ * (Phase 1) and chain-compressor.ts (eligibility), not at capture.
306
+ */
307
+ recoveryGraceTurns: number;
308
+ /**
309
+ * Idle (inactivity) timeout for a single summarizer stream call, in ms.
310
+ * Reset on every received stream event; armed before the first event so it
311
+ * also bounds time-to-first-token. If no event arrives within this window
312
+ * the call is aborted and classified transient (feeds the outage-fallback
313
+ * retry). 0 disables the idle timer. Default 20000.
314
+ */
315
+ summarizerIdleTimeoutMs: number;
316
+ /**
317
+ * Total-duration ceiling for a single summarizer stream call, in ms. Armed
318
+ * once at call start, never reset - a hard upper bound catching a stream
319
+ * that keeps dribbling events but never completes. Same transient/warning
320
+ * handling as the idle timeout. 0 disables the ceiling. Default 180000.
321
+ */
322
+ summarizerMaxTimeoutMs: number;
259
323
  /**
260
324
  * Tool names whose outputs must NEVER be pruned or summarized. Tool calls
261
325
  * with matching `toolName` are filtered out of the pruning capture path so
@@ -454,6 +518,9 @@ export const DEFAULT_CONFIG: ContextPruneConfig = {
454
518
  batchingMode: "turn",
455
519
  quietOversizedSkips: false,
456
520
  minBatchChars: 1000,
521
+ recoveryGraceTurns: 3,
522
+ summarizerIdleTimeoutMs: 20000,
523
+ summarizerMaxTimeoutMs: 180000,
457
524
  protectedTools: [],
458
525
  protectedPaths: ["**/skills/**/*.md"],
459
526
  chainCompression: {