pi-condense 2.5.0 → 2.7.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.
Files changed (44) hide show
  1. package/CHANGELOG.md +20 -0
  2. package/PRUNING.md +138 -23
  3. package/README.md +17 -1
  4. package/index.ts +305 -116
  5. package/package.json +1 -1
  6. package/src/batch-capture.test.ts +75 -1
  7. package/src/batch-capture.ts +22 -13
  8. package/src/chain-compressor.test.ts +114 -0
  9. package/src/chain-compressor.ts +29 -4
  10. package/src/chain-detector.test.ts +49 -0
  11. package/src/chain-detector.ts +7 -0
  12. package/src/chain-range-prune.test.ts +342 -7
  13. package/src/chain-range-prune.ts +161 -48
  14. package/src/commands.test.ts +168 -5
  15. package/src/commands.ts +44 -11
  16. package/src/context-metrics.test.ts +335 -0
  17. package/src/context-metrics.ts +152 -0
  18. package/src/diagnostics.test.ts +114 -0
  19. package/src/diagnostics.ts +46 -0
  20. package/src/frontier.test.ts +1 -0
  21. package/src/id-collision.integration.test.ts +251 -0
  22. package/src/indexer.test.ts +336 -0
  23. package/src/indexer.ts +168 -55
  24. package/src/occurrence-key.test.ts +57 -0
  25. package/src/occurrence-key.ts +36 -0
  26. package/src/orphan-sweep.test.ts +67 -0
  27. package/src/orphan-sweep.ts +40 -0
  28. package/src/oversized-spill.integration.test.ts +7 -2
  29. package/src/pruner.test.ts +456 -25
  30. package/src/pruner.ts +84 -36
  31. package/src/query-tool.test.ts +117 -0
  32. package/src/query-tool.ts +47 -31
  33. package/src/range-compression.integration.test.ts +6 -1
  34. package/src/recovery-grace.test.ts +13 -0
  35. package/src/recovery-grace.ts +12 -3
  36. package/src/reload-rearm.integration.test.ts +647 -0
  37. package/src/spill.test.ts +108 -1
  38. package/src/spill.ts +5 -3
  39. package/src/summarizer-wiring.test.ts +2 -0
  40. package/src/summary-refs.test.ts +51 -1
  41. package/src/summary-refs.ts +15 -4
  42. package/src/test-support.ts +54 -0
  43. package/src/tree-browser.ts +2 -1
  44. package/src/types.ts +89 -10
package/index.ts CHANGED
@@ -5,7 +5,7 @@
5
5
  * config — load/save <agent-dir>/settings.json `contextPrune` namespace (honors PI_CODING_AGENT_DIR)
6
6
  * batch-capture — serialize turn_end event into CapturedBatch
7
7
  * summarizer — call LLM to summarize a CapturedBatch
8
- * indexer — maintain Map<toolCallId, ToolCallRecord> + session persistence
8
+ * indexer — maintain Map<occurrenceKey, ToolCallRecord> + session persistence
9
9
  * pruner — filter context event messages
10
10
  * query-tool — register context_tree_query tool
11
11
  * commands — register /pruner command + message renderer
@@ -24,13 +24,23 @@ import { isProtected } from "./src/protected.js";
24
24
  import { registerQueryTool } from "./src/query-tool.js";
25
25
  import { registerCommands, setPruneStatusWidget } from "./src/commands.js";
26
26
  import { formatSummaryToolCallRefs, makeSummaryDetails, substituteInlineRefs } from "./src/summary-refs.js";
27
- import type { ContextPruneConfig, CapturedBatch, PruneFrontier, FlushOptions } from "./src/types.js";
27
+ import type {
28
+ ContextPruneConfig,
29
+ CapturedBatch,
30
+ PruneFrontier,
31
+ FlushOptions,
32
+ ContextMetricsSnapshot,
33
+ FlushMetricsEntry,
34
+ FlushTrigger,
35
+ } from "./src/types.js";
28
36
  import {
29
37
  DEFAULT_CONFIG,
30
38
  CUSTOM_TYPE_SUMMARY,
31
39
  CUSTOM_TYPE_STATS,
32
40
  CUSTOM_TYPE_FRONTIER,
41
+ CUSTOM_TYPE_FLUSH_METRICS,
33
42
  } from "./src/types.js";
43
+ import { computeContextMetrics } from "./src/context-metrics.js";
34
44
  import { StatsAccumulator, emitExternalCost } from "./src/stats.js";
35
45
  import { PruneFrontierTracker } from "./src/frontier.js";
36
46
  import { BlockRefIssuer } from "./src/block-refs.js";
@@ -39,6 +49,10 @@ import { detectChains, withClosingMessage } from "./src/chain-detector.js";
39
49
  import { inGraceRecoveryToolCallIds } from "./src/recovery-grace.js";
40
50
  import { shouldBudgetFlush, shouldDeltaFlush, usageFraction } from "./src/budget.js";
41
51
  import { spillOversizedBatch } from "./src/spill.js";
52
+ import { occKey } from "./src/occurrence-key.js";
53
+ import { DiagnosticSink } from "./src/diagnostics.js";
54
+
55
+ const EMPTY_METRICS_SNAPSHOT: ContextMetricsSnapshot = { openCycleThinkingTokens: 0, largestChainSharePct: 0, frontierGapTokens: 0 };
42
56
 
43
57
  export default function (pi: ExtensionAPI) {
44
58
  // Shared mutable config reference — updated by /pruner commands
@@ -64,10 +78,53 @@ export default function (pi: ExtensionAPI) {
64
78
  // rebuilt from session on session_start / session_tree
65
79
  const blockRefs = new BlockRefIssuer();
66
80
 
81
+ // Session-scoped diagnostic sink — tracks recovery-path anomaly counters
82
+ // (dedup'd across the session's lifetime, not per-render).
83
+ const diagnostics = new DiagnosticSink((type, data) => pi.appendEntry(type, data));
84
+
67
85
  // Pending batches — accumulated until the prune trigger fires
68
86
  const pendingBatches: CapturedBatch[] = [];
69
87
  let isFlushing = false;
70
88
  let previousFraction: number | null = null;
89
+ // Set on session_start/session_tree when the branch rescan finds recoverable
90
+ // work but pendingBatches was just zeroed (reload/tree-switch). Lets the
91
+ // turn_end budget gate fire without a freshly pushed batch. Boolean only —
92
+ // no queue reconstruction; flushPending's own rescan is the data path.
93
+ // Cleared on every non-concurrent flushPending invocation.
94
+ let rearmedPending = false;
95
+
96
+ // Latest ContextMetricsSnapshot, recomputed at reload probes, batch capture,
97
+ // and flush entry. Cached (rather than recomputed on every widget refresh)
98
+ // because computeContextMetrics walks the full branch.
99
+ let metricsCache: ContextMetricsSnapshot | undefined;
100
+ const computeMetricsSnapshot = (ctx: any): ContextMetricsSnapshot | undefined => {
101
+ try {
102
+ // Includes persisted custom_message entries (e.g. this extension's own
103
+ // summary messages) alongside plain "message" entries: both are retained
104
+ // LLM context, so both belong in the largest-chain-share denominator.
105
+ // Projected inline (rather than importing pi-coding-agent's
106
+ // createCustomMessage) because that helper isn't re-exported from the
107
+ // package's "." export map -- shape mirrors createCustomMessage's output
108
+ // (role "custom"), which never matches the user/assistant/toolResult
109
+ // roles computeContextMetrics keys off, so it only inflates totalChars.
110
+ const branch = ctx.sessionManager.getBranch()
111
+ .filter((e: any) => (e.type === "message" && e.message) || e.type === "custom_message")
112
+ .map((e: any) =>
113
+ e.type === "custom_message"
114
+ ? { role: "custom", customType: e.customType, content: e.content, display: e.display, details: e.details, timestamp: new Date(e.timestamp).getTime() }
115
+ : e.message,
116
+ );
117
+ metricsCache = computeContextMetrics(
118
+ branch,
119
+ frontier.get(),
120
+ (k: string) => indexer.isSummarized(k),
121
+ protectionPredicate,
122
+ );
123
+ } catch (err) {
124
+ console.error("pi-condense: context metrics computation failed", err);
125
+ }
126
+ return metricsCache;
127
+ };
71
128
 
72
129
  type FlushResult =
73
130
  | { ok: true; reason: "flushed" | "skipped-oversized" | "skipped-trivial" | "skipped-deduped"; batchCount: number; toolCallCount: number; rawCharCount: number; summaryCharCount: number; dedupedCount?: number }
@@ -103,7 +160,7 @@ export default function (pi: ExtensionAPI) {
103
160
  let toolCalls = batch.toolCalls;
104
161
 
105
162
  // The indexer tells us what was successfully summarized earlier.
106
- toolCalls = toolCalls.filter((tc) => !indexer.isSummarized(tc.toolCallId));
163
+ toolCalls = toolCalls.filter((tc) => !indexer.isSummarized(occKey(tc.toolCallId, tc.resultTimestamp)));
107
164
  if (toolCalls.length === 0) return null;
108
165
 
109
166
  // The frontier tells us the last attempted boundary even when the attempt did
@@ -129,12 +186,18 @@ export default function (pi: ExtensionAPI) {
129
186
  // ── Helper: capture + trim + group pending batches (no LLM work) ──────────
130
187
  // Exposed to commands.ts via registerCommands so /pruner now can preview the
131
188
  // queue before opening the multi-row progress overlay.
132
- const capturePendingBatches = (ctx: any): CapturedBatch[] => {
189
+ // `rethrow` is for the reload rearm probe only (session_start/session_tree):
190
+ // it needs to observe a rescan failure so it can console.error and leave
191
+ // rearmedPending false, per spec. Every other caller (turn_end capture path,
192
+ // flushPending, /pruner commands) keeps the existing swallow-and-fall-back
193
+ // behavior so a transient getBranch failure there never blocks the turn.
194
+ const capturePendingBatches = (ctx: any, opts?: { rethrow?: boolean }): CapturedBatch[] => {
133
195
  let batches: CapturedBatch[] = [];
134
196
  try {
135
197
  const branch = ctx.sessionManager.getBranch();
136
198
  batches = captureUnindexedBatchesFromSession(branch, indexer, protectionPredicate);
137
- } catch {
199
+ } catch (err) {
200
+ if (opts?.rethrow) throw err;
138
201
  batches = pendingBatches.slice();
139
202
  }
140
203
  batches = batches
@@ -169,47 +232,97 @@ export default function (pi: ExtensionAPI) {
169
232
  const flushPending = async (ctx: any, options: FlushOptions = {}): Promise<FlushResult> => {
170
233
  if (isFlushing) return { ok: false, reason: "already-flushing" };
171
234
 
172
- // Use pre-captured batches if provided (avoids double-capture when the
173
- // caller previewed the queue before opening the progress overlay).
174
- const batches: CapturedBatch[] = options.previewedBatches ?? capturePendingBatches(ctx);
235
+ // Clear on every non-concurrent invocation, regardless of outcome the
236
+ // rearm is a one-shot nudge for the very next eligible gate check.
237
+ rearmedPending = false;
175
238
 
176
- if (batches.length === 0) return { ok: false, reason: "empty" };
239
+ // Pre-flush pressure snapshot recorded once at flush entry so the
240
+ // observability entry reflects what triggered this attempt, not what's
241
+ // left after it ran.
242
+ const entryMetrics: ContextMetricsSnapshot = computeMetricsSnapshot(ctx) ?? EMPTY_METRICS_SNAPSHOT;
243
+ const trigger: FlushTrigger = options.trigger ?? "manual";
244
+ const delivery = options.delivery ?? "runtime";
177
245
 
178
- // Bail out before we drain pendingBatches so they don't need restoring.
179
- if (options.signal?.aborted) return { ok: false, reason: "aborted" };
246
+ // One-entry-per-attempt tracking, emitted once from the outer `finally`
247
+ // below. `appendEntry` is assigned only once `sessionManager` is captured
248
+ // (session delivery); until then (empty/aborted/pre-capture-failure exits)
249
+ // the emitter falls back to pi.appendEntry.
250
+ let capturedBatches = 0;
251
+ let processedCount = 0;
252
+ let outcome: FlushMetricsEntry["outcome"] = "empty";
253
+ let appendEntry: ((customType: string, data?: unknown) => void) | undefined;
254
+
255
+ // Non-fatal by construction: observability must never affect the flush outcome.
256
+ const emitFlushMetricsOnce = () => {
257
+ const entry: FlushMetricsEntry = {
258
+ ts: Date.now(),
259
+ trigger,
260
+ capturedBatches,
261
+ processedBatches: processedCount,
262
+ outcome,
263
+ metrics: entryMetrics,
264
+ };
265
+ const appender: (type: string, data: unknown) => void = appendEntry
266
+ ? delivery === "runtime" ? (type, data) => pi.appendEntry(type, data) : appendEntry
267
+ : (type, data) => pi.appendEntry(type, data);
268
+ try {
269
+ appender(CUSTOM_TYPE_FLUSH_METRICS, entry);
270
+ } catch {
271
+ // non-fatal: observability must never fail the flush
272
+ }
273
+ };
180
274
 
181
- // Draining the queue since we've captured the state via session or slice.
182
- // We drain BEFORE the await so concurrent calls (though guarded by isFlushing)
183
- // or rapid turn-ends don't result in double-summarization.
184
- pendingBatches.length = 0;
275
+ let batches: CapturedBatch[] = [];
276
+ let sessionManager: SessionAppender | undefined;
277
+ try {
278
+ // Bind the session appender as soon as delivery is known, BEFORE the
279
+ // empty-capture/aborted exits below — so emitFlushMetricsOnce's finally
280
+ // emit routes through sessionManager for those exits too, instead of
281
+ // falling back to the (possibly stale, print-mode) pi.appendEntry.
282
+ if (delivery === "session") {
283
+ try {
284
+ sessionManager = ctx.sessionManager as unknown as SessionAppender;
285
+ appendEntry = (customType: string, data?: unknown) => sessionManager!.appendCustomEntry(customType, data);
286
+ } catch (err) {
287
+ outcome = "error";
288
+ return { ok: false, reason: isStaleContextError(err) ? "stale-context" : "failed", error: errorMessage(err) };
289
+ }
290
+ }
185
291
 
186
- isFlushing = true;
292
+ // Use pre-captured batches if provided (avoids double-capture when the
293
+ // caller previewed the queue before opening the progress overlay).
294
+ batches = options.previewedBatches ?? capturePendingBatches(ctx);
295
+ capturedBatches = batches.length;
187
296
 
188
- const delivery = options.delivery ?? "runtime";
189
- let sessionManager: SessionAppender | undefined;
190
- if (delivery === "session") {
191
- try {
192
- sessionManager = ctx.sessionManager as unknown as SessionAppender;
193
- } catch (err) {
194
- restoreBatches(batches);
195
- isFlushing = false;
196
- return { ok: false, reason: isStaleContextError(err) ? "stale-context" : "failed", error: errorMessage(err) };
297
+ if (batches.length === 0) {
298
+ outcome = "empty";
299
+ return { ok: false, reason: "empty" };
197
300
  }
198
- }
199
301
 
200
- const appendEntry = (customType: string, data?: unknown) => sessionManager!.appendCustomEntry(customType, data);
201
- const appendSummaryMessage = (content: string, details: unknown) =>
202
- sessionManager!.appendCustomMessageEntry(CUSTOM_TYPE_SUMMARY, content, false, details);
302
+ // Bail out before we drain pendingBatches so they don't need restoring.
303
+ if (options.signal?.aborted) {
304
+ outcome = "error";
305
+ return { ok: false, reason: "aborted" };
306
+ }
203
307
 
204
- // Routes alias persistence through whichever delivery is active so the
205
- // dedup pre-flush pass writes CUSTOM_TYPE_DEDUP_ALIAS entries via the
206
- // same path the rest of the flush uses.
207
- const persistAlias: (customType: string, data?: unknown) => void =
208
- delivery === "runtime"
209
- ? (type, data) => pi.appendEntry(type, data)
210
- : appendEntry;
308
+ // Draining the queue since we've captured the state via session or slice.
309
+ // We drain BEFORE the await so concurrent calls (though guarded by isFlushing)
310
+ // or rapid turn-ends don't result in double-summarization.
311
+ pendingBatches.length = 0;
312
+
313
+ isFlushing = true;
314
+
315
+ const appendSummaryMessage = (content: string, details: unknown) =>
316
+ sessionManager!.appendCustomMessageEntry(CUSTOM_TYPE_SUMMARY, content, false, details);
317
+
318
+ // Routes alias persistence through whichever delivery is active so the
319
+ // dedup pre-flush pass writes CUSTOM_TYPE_DEDUP_ALIAS entries via the
320
+ // same path the rest of the flush uses.
321
+ const persistAlias: (customType: string, data?: unknown) => void =
322
+ delivery === "runtime"
323
+ ? (type, data) => pi.appendEntry(type, data)
324
+ : appendEntry!;
211
325
 
212
- try {
213
326
  // ── Pre-flush content-hash dedup pass ────────────────────────────
214
327
  // For each tool call, check the indexer's contentHashToOriginal map.
215
328
  // A hit means an identical (toolName, normalized resultText) pair has
@@ -233,8 +346,9 @@ export default function (pi: ExtensionAPI) {
233
346
  const remaining: typeof batch.toolCalls = [];
234
347
  for (const tc of batch.toolCalls) {
235
348
  const originalId = indexer.lookupByContent(tc.toolName, tc.resultText);
236
- if (originalId && originalId !== tc.toolCallId) {
237
- indexer.registerDuplicate(tc.toolCallId, originalId, persistAlias);
349
+ const key = occKey(tc.toolCallId, tc.resultTimestamp);
350
+ if (originalId && originalId !== key) {
351
+ indexer.registerDuplicate(key, originalId, persistAlias);
238
352
  dedupedPerBatch[i].toolCalls.push(tc);
239
353
  dedupedPerBatch[i].rawChars += tc.resultText.length;
240
354
  } else {
@@ -413,7 +527,7 @@ export default function (pi: ExtensionAPI) {
413
527
  // `display: false` keeps the summary in future LLM context (convertToLlm
414
528
  // ignores `display`) while suppressing the full markdown block from Pi's
415
529
  // main window; rebuild keys on customType, not display.
416
- const batchToolCallIds = batch.toolCalls.map((tc) => tc.toolCallId);
530
+ const batchOccurrenceKeys = batch.toolCalls.map((tc) => occKey(tc.toolCallId, tc.resultTimestamp));
417
531
  if (delivery === "runtime") {
418
532
  pi.sendMessage(
419
533
  { customType: CUSTOM_TYPE_SUMMARY, content: summaryText, display: false, details: batchDetails },
@@ -424,11 +538,11 @@ export default function (pi: ExtensionAPI) {
424
538
  } else {
425
539
  appendSummaryMessage(summaryText, batchDetails);
426
540
  indexer.registerSummaryRefs(summaryRefs);
427
- indexer.addBatch(batch, appendEntry);
541
+ indexer.addBatch(batch, appendEntry!);
428
542
  }
429
543
  // Keep the in-memory summary-body registry current so chain compression
430
544
  // can build synthetic chain messages without rescanning session entries.
431
- indexer.registerSummaryBody(batchToolCallIds, summaryText);
545
+ indexer.registerSummaryBody(batchOccurrenceKeys, summaryText);
432
546
  } else {
433
547
  oversizedBatches.push(batch);
434
548
  }
@@ -452,7 +566,8 @@ export default function (pi: ExtensionAPI) {
452
566
 
453
567
  if (processedBatches.length === 0) {
454
568
  // Nothing was persisted (all calls failed or first call failed)
455
- setPruneStatusWidget(ctx, currentConfig.value, statsAccum.getLiveReclaim());
569
+ setPruneStatusWidget(ctx, currentConfig.value, statsAccum.getLiveReclaim(), diagnostics.counts(), metricsCache);
570
+ outcome = "error";
456
571
  return { ok: false, reason: "summarizer-failed" };
457
572
  }
458
573
 
@@ -514,18 +629,22 @@ export default function (pi: ExtensionAPI) {
514
629
  statsAccum.persist(pi);
515
630
  } else {
516
631
  frontier.advance(frontierSnapshot);
517
- appendEntry(CUSTOM_TYPE_FRONTIER, frontierSnapshot);
632
+ appendEntry!(CUSTOM_TYPE_FRONTIER, frontierSnapshot);
518
633
  try {
519
- appendEntry(CUSTOM_TYPE_STATS, statsAccum.getStats());
634
+ appendEntry!(CUSTOM_TYPE_STATS, statsAccum.getStats());
520
635
  } catch {
521
636
  // Ignore stats persistence failures; the prune result and frontier are the contract.
522
637
  }
523
638
  }
524
639
  } catch (err) {
640
+ // Batches were summarized/persisted before the frontier/stats write failed;
641
+ // reflect that in processedBatches rather than reporting 0.
642
+ processedCount = processedBatches.length;
643
+ outcome = "error";
525
644
  return { ok: false, reason: isStaleContextError(err) ? "stale-context" : "failed", error: errorMessage(err) };
526
645
  }
527
646
 
528
- setPruneStatusWidget(ctx, currentConfig.value, statsAccum.getLiveReclaim());
647
+ setPruneStatusWidget(ctx, currentConfig.value, statsAccum.getLiveReclaim(), diagnostics.counts(), metricsCache);
529
648
  emitExternalCost(pi, statsAccum);
530
649
 
531
650
  // Chain compression — compress closed chains beyond the rolling window.
@@ -613,6 +732,12 @@ export default function (pi: ExtensionAPI) {
613
732
  }
614
733
  }
615
734
 
735
+ // Very end of the try block, deliberately after (and outside) the
736
+ // chain-compression block's own try/catch above: a compression failure
737
+ // must not eat this entry — the summarization phase already succeeded.
738
+ processedCount = processedBatches.length;
739
+ outcome = flushOutcome;
740
+
616
741
  const returnReason: "flushed" | "skipped-oversized" | "skipped-trivial" | "skipped-deduped" =
617
742
  actuallyFlushedCount > 0
618
743
  ? "flushed"
@@ -633,10 +758,11 @@ export default function (pi: ExtensionAPI) {
633
758
  };
634
759
  } catch (err) {
635
760
  restoreBatches(batches);
761
+ outcome = "error";
636
762
  // When the abort signal fired, summarizeBatch rethrows rather than
637
763
  // swallowing the error. Don't show a UI error — the user intended this.
638
764
  if (options.signal?.aborted) {
639
- setPruneStatusWidget(ctx, currentConfig.value, statsAccum.getLiveReclaim());
765
+ setPruneStatusWidget(ctx, currentConfig.value, statsAccum.getLiveReclaim(), diagnostics.counts(), metricsCache);
640
766
  return { ok: false, reason: "aborted" };
641
767
  }
642
768
  if (isStaleContextError(err)) {
@@ -646,6 +772,7 @@ export default function (pi: ExtensionAPI) {
646
772
  return { ok: false, reason: "failed", error: errorMessage(err) };
647
773
  } finally {
648
774
  isFlushing = false;
775
+ emitFlushMetricsOnce();
649
776
  }
650
777
  };
651
778
 
@@ -663,6 +790,7 @@ export default function (pi: ExtensionAPI) {
663
790
  // Rebuild stats accumulator from persisted session entries
664
791
  statsAccum.reconstructFromSession(ctx);
665
792
  fallbackController.reset();
793
+ diagnostics.reset();
666
794
 
667
795
  // Rebuild prune frontier from persisted session entries
668
796
  frontier.reconstructFromSession(ctx);
@@ -670,9 +798,19 @@ export default function (pi: ExtensionAPI) {
670
798
  // Clear any batches queued before the session reload
671
799
  pendingBatches.length = 0;
672
800
  previousFraction = null;
801
+ rearmedPending = false;
802
+ if (currentConfig.value.enabled) {
803
+ try {
804
+ rearmedPending = capturePendingBatches(ctx, { rethrow: true }).length > 0;
805
+ } catch (err) {
806
+ console.error("pi-condense: reload rearm probe failed", err);
807
+ }
808
+ }
809
+
810
+ computeMetricsSnapshot(ctx);
673
811
 
674
812
  // Update footer status
675
- setPruneStatusWidget(ctx, currentConfig.value, statsAccum.getLiveReclaim());
813
+ setPruneStatusWidget(ctx, currentConfig.value, statsAccum.getLiveReclaim(), diagnostics.counts(), metricsCache);
676
814
 
677
815
  ctx.ui.setWidget(
678
816
  "pruner-boot",
@@ -695,10 +833,22 @@ export default function (pi: ExtensionAPI) {
695
833
  indexer.reconstructFromSession(ctx);
696
834
  blockRefs.rebuildFrom(indexer.getChainEntries().map((e) => e.blockId));
697
835
  statsAccum.reconstructFromSession(ctx);
836
+ diagnostics.reset();
698
837
  frontier.reconstructFromSession(ctx);
699
838
  // Pending batches belong to the old branch — discard them
700
839
  pendingBatches.length = 0;
701
840
  previousFraction = null;
841
+ rearmedPending = false;
842
+ if (currentConfig.value.enabled) {
843
+ try {
844
+ rearmedPending = capturePendingBatches(ctx, { rethrow: true }).length > 0;
845
+ } catch (err) {
846
+ console.error("pi-condense: reload rearm probe failed", err);
847
+ }
848
+ }
849
+
850
+ computeMetricsSnapshot(ctx);
851
+ setPruneStatusWidget(ctx, currentConfig.value, statsAccum.getLiveReclaim(), diagnostics.counts(), metricsCache);
702
852
  });
703
853
 
704
854
  // ── turn_end: capture batch, flush immediately or queue ──────────────────
@@ -707,73 +857,91 @@ export default function (pi: ExtensionAPI) {
707
857
 
708
858
  const hasToolResults = event.toolResults && event.toolResults.length > 0;
709
859
 
710
- if (!hasToolResults) {
711
- // Text-only final turns are handled by message_end in agent-message mode.
712
- // In print mode, turn_end can fire after session shutdown, so do not start
713
- // deferred LLM work from this late lifecycle event.
714
- return;
715
- }
860
+ // Text-only final turns are handled by message_end in agent-message mode.
861
+ // In print mode, turn_end can fire after session shutdown, so do not start
862
+ // deferred LLM work from this late lifecycle event UNLESS a reload probe
863
+ // (session_start/session_tree) found recoverable pending work: that flag
864
+ // must still reach the budget/delta gate below without a freshly captured
865
+ // batch on this turn.
866
+ if (!hasToolResults && !rearmedPending) return;
867
+
868
+ let pushedBatch = false;
869
+ if (hasToolResults) {
870
+ const capturedBatch = captureBatch(
871
+ event.message,
872
+ event.toolResults,
873
+ event.turnIndex,
874
+ Date.now()
875
+ );
876
+ // Drop user-protected tool/path results so they stay verbatim in context.
877
+ // Filtering at capture time keeps the
878
+ // underlying assistant `toolCall` block AND its `ToolResultMessage`
879
+ // untouched in Pi's session/event stream — only the in-memory
880
+ // CapturedBatch is pruned, which is exactly what we want.
881
+ const filtered = {
882
+ ...capturedBatch,
883
+ toolCalls: capturedBatch.toolCalls.filter((tc) => !isProtected(tc.toolName, tc.args, currentConfig.value)),
884
+ };
716
885
 
717
- const capturedBatch = captureBatch(
718
- event.message,
719
- event.toolResults,
720
- event.turnIndex,
721
- Date.now()
722
- );
723
- // Drop user-protected tool/path results so they stay verbatim in context.
724
- // Filtering at capture time keeps the
725
- // underlying assistant `toolCall` block AND its `ToolResultMessage`
726
- // untouched in Pi's session/event stream — only the in-memory
727
- // CapturedBatch is pruned, which is exactly what we want.
728
- const filtered = {
729
- ...capturedBatch,
730
- toolCalls: capturedBatch.toolCalls.filter((tc) => !isProtected(tc.toolName, tc.args, currentConfig.value)),
731
- };
886
+ // Eager spill: offload oversized single results to sidecar files before they
887
+ // ever reach a request. addBatch inside marks them isSummarized, so
888
+ // trimBatchToPendingRange drops them from the pending set below. Best-effort:
889
+ // a spill failure leaves the result inline for the normal flush pipeline.
890
+ try {
891
+ await spillOversizedBatch({
892
+ batch: filtered,
893
+ indexer,
894
+ config: {
895
+ spillThreshold: currentConfig.value.spillThreshold,
896
+ spillPreviewBytes: currentConfig.value.spillPreviewBytes,
897
+ dedupByContentHash: currentConfig.value.dedupByContentHash,
898
+ },
899
+ sessionDir: ctx.sessionManager.getSessionDir(),
900
+ sessionId: ctx.sessionManager.getSessionId(),
901
+ appendEntry: (type, data) => (ctx.sessionManager as unknown as SessionAppender).appendCustomEntry(type, data),
902
+ });
903
+ } catch {
904
+ // best-effort; never block the turn
905
+ }
732
906
 
733
- // Eager spill: offload oversized single results to sidecar files before they
734
- // ever reach a request. addBatch inside marks them isSummarized, so
735
- // trimBatchToPendingRange drops them from the pending set below. Best-effort:
736
- // a spill failure leaves the result inline for the normal flush pipeline.
737
- try {
738
- await spillOversizedBatch({
739
- batch: filtered,
740
- indexer,
741
- config: {
742
- spillThreshold: currentConfig.value.spillThreshold,
743
- spillPreviewBytes: currentConfig.value.spillPreviewBytes,
744
- dedupByContentHash: currentConfig.value.dedupByContentHash,
745
- },
746
- sessionDir: ctx.sessionManager.getSessionDir(),
747
- sessionId: ctx.sessionManager.getSessionId(),
748
- appendEntry: (type, data) => (ctx.sessionManager as unknown as SessionAppender).appendCustomEntry(type, data),
749
- });
750
- } catch {
751
- // best-effort; never block the turn
907
+ const batch = trimBatchToPendingRange(filtered);
908
+ if (batch) {
909
+ pushedBatch = true;
910
+ pendingBatches.push(batch);
911
+
912
+ // Let the user know a batch is queued
913
+ const n = pendingBatches.length;
914
+ const trigger = currentConfig.value.pruneOn === "agent-message"
915
+ ? "agent's next text response"
916
+ : "/pruner now";
917
+ if (currentConfig.value.showPruneStatusLine) {
918
+ setPruneStatusWidget(ctx, currentConfig.value, `prune: ${n} pending`);
919
+ safeNotify(
920
+ ctx,
921
+ `pruner: ${n} turn${n === 1 ? "" : "s"} queued — will summarize on ${trigger}`,
922
+ "info"
923
+ );
924
+ }
925
+ }
752
926
  }
753
927
 
754
- const batch = trimBatchToPendingRange(filtered);
755
- if (!batch) return;
928
+ // Recompute regardless of whether trim produced a batch: a turn whose
929
+ // toolResults are all protected/spilled/summarized/trimmed-empty still
930
+ // changes the branch (thinking, open-segment size), so the cache must not
931
+ // go stale on it. Placed before the pushedBatch/rearmedPending early
932
+ // return below — a cache write is not gate evaluation.
933
+ if (hasToolResults) computeMetricsSnapshot(ctx);
756
934
 
757
- pendingBatches.push(batch);
758
-
759
- // Let the user know a batch is queued
760
- const n = pendingBatches.length;
761
- const trigger = currentConfig.value.pruneOn === "agent-message"
762
- ? "agent's next text response"
763
- : "/pruner now";
764
- if (currentConfig.value.showPruneStatusLine) {
765
- setPruneStatusWidget(ctx, currentConfig.value, `prune: ${n} pending`);
766
- safeNotify(
767
- ctx,
768
- `pruner: ${n} turn${n === 1 ? "" : "s"} queued — will summarize on ${trigger}`,
769
- "info"
770
- );
771
- }
935
+ // Mirrors main's `if (!batch) return;`: no freshly pushed batch this turn
936
+ // means no gate evaluation, regardless of leftover pendingBatches from an
937
+ // earlier turn UNLESS a reload probe armed rearmedPending, in which case
938
+ // the gate below must still run.
939
+ if (!pushedBatch && !rearmedPending) return;
772
940
 
773
941
  // Token-budget auto-flush: an additional, mode-independent trigger. When context
774
942
  // usage crosses autoBudgetThreshold, compact the queued batches now instead of
775
- // waiting for this mode's flush boundary. The pendingBatches.length guard makes
776
- // an already-drained queue a no-op.
943
+ // waiting for this mode's flush boundary. The pendingBatches.length-or-rearmed
944
+ // guard makes an already-drained, non-rearmed queue a no-op.
777
945
  const usage = ctx.getContextUsage?.();
778
946
  const budgetHit = shouldBudgetFlush(usage, currentConfig.value.autoBudgetThreshold);
779
947
  const deltaHit = shouldDeltaFlush(usage, previousFraction, currentConfig.value.budgetTurnDelta);
@@ -782,16 +950,19 @@ export default function (pi: ExtensionAPI) {
782
950
  const f = usageFraction(usage);
783
951
  if (f != null) previousFraction = f;
784
952
 
785
- if (pendingBatches.length > 0 && !isFlushing && (budgetHit || deltaHit)) {
953
+ const n = pendingBatches.length;
954
+ if ((n > 0 || rearmedPending) && !isFlushing && (budgetHit || deltaHit)) {
786
955
  // Always surface this flush (even when the routine status line is off): it's a
787
956
  // significant, infrequent event — context crossed a threshold or jumped sharply
788
957
  // this turn — and it self-throttles because pendingBatches is drained right after.
789
958
  safeNotify(
790
959
  ctx,
791
- `pruner: ${budgetHit ? "context budget reached" : "context jumped this turn"} — compacting ${n} pending turn${n === 1 ? "" : "s"}`,
960
+ n > 0
961
+ ? `pruner: ${budgetHit ? "context budget reached" : "context jumped this turn"} — compacting ${n} pending turn${n === 1 ? "" : "s"}`
962
+ : `pruner: ${budgetHit ? "context budget reached" : "context jumped this turn"} — compacting work recovered after reload`,
792
963
  "info",
793
964
  );
794
- await flushPending(ctx, { delivery: "session" });
965
+ await flushPending(ctx, { delivery: "session", trigger: n === 0 ? "rearmed" : budgetHit ? "budget" : "delta" });
795
966
  }
796
967
  });
797
968
 
@@ -804,7 +975,7 @@ export default function (pi: ExtensionAPI) {
804
975
  if (!currentConfig.value.enabled) return;
805
976
  if (currentConfig.value.pruneOn !== "agent-message") return;
806
977
  if (!isFinalAssistantMessage(event.message)) return;
807
- await flushPending(ctx, { delivery: "session", closingMessage: event.message });
978
+ await flushPending(ctx, { delivery: "session", closingMessage: event.message, trigger: "message-end" });
808
979
  });
809
980
 
810
981
  // ── agent_end: last-chance cleanup only ─────────────────────────────────────
@@ -812,8 +983,12 @@ export default function (pi: ExtensionAPI) {
812
983
  // already be disposing the session, so avoid starting a best-effort LLM call here.
813
984
  pi.on("agent_end", async (_event, ctx) => {
814
985
  if (!currentConfig.value.enabled) return;
815
- if (pendingBatches.length === 0) return;
816
- setPruneStatusWidget(ctx, currentConfig.value, `prune: ${pendingBatches.length} pending`);
986
+ if (pendingBatches.length === 0 && !rearmedPending) return;
987
+ setPruneStatusWidget(
988
+ ctx,
989
+ currentConfig.value,
990
+ pendingBatches.length > 0 ? `prune: ${pendingBatches.length} pending` : "prune: recovered pending (reload)",
991
+ );
817
992
  });
818
993
 
819
994
  // ── context: prune summarized tool results from next LLM call ─────────────
@@ -825,7 +1000,7 @@ export default function (pi: ExtensionAPI) {
825
1000
 
826
1001
  // pruneMessages is the single source of truth for "is there work to do".
827
1002
  // It returns the original array reference (pruned: false) only when none of
828
- // the three phases changed anything; index/registry emptiness alone does not
1003
+ // the four phases changed anything; index/registry emptiness alone does not
829
1004
  // imply a no-op, since error-purge (phase 2) prunes independently of them.
830
1005
  // Calling it unconditionally is safe and avoids a split gate here.
831
1006
  const result = pruneMessages(
@@ -835,13 +1010,14 @@ export default function (pi: ExtensionAPI) {
835
1010
  currentConfig.value.purgeErrors,
836
1011
  currentConfig.value,
837
1012
  currentConfig.value.recoveryGraceTurns,
1013
+ diagnostics,
838
1014
  );
839
1015
  if (result.pruned) {
840
1016
  messages = result.messages;
841
1017
  changed = true;
842
1018
  statsAccum.setLiveReclaim(result.beforeChars, result.afterChars);
843
1019
  }
844
- setPruneStatusWidget(ctx, currentConfig.value, statsAccum.getLiveReclaim());
1020
+ setPruneStatusWidget(ctx, currentConfig.value, statsAccum.getLiveReclaim(), diagnostics.counts(), metricsCache);
845
1021
 
846
1022
  if (!changed) return undefined;
847
1023
  return { messages };
@@ -878,5 +1054,18 @@ export default function (pi: ExtensionAPI) {
878
1054
  return { compressedEntries: result.compressedEntries, skipped: result.skipped.filter((s) => s.reason === "no-summary").length };
879
1055
  };
880
1056
 
881
- registerCommands(pi, currentConfig, flushPending, capturePendingBatches, () => statsAccum.getStats(), () => statsAccum.getLiveReclaim(), indexer, compactChains);
1057
+ registerCommands(
1058
+ pi,
1059
+ currentConfig,
1060
+ flushPending,
1061
+ capturePendingBatches,
1062
+ () => statsAccum.getStats(),
1063
+ () => statsAccum.getLiveReclaim(),
1064
+ indexer,
1065
+ compactChains,
1066
+ () => diagnostics.counts(),
1067
+ (ctx: any) => computeMetricsSnapshot(ctx) ?? EMPTY_METRICS_SNAPSHOT,
1068
+ () => metricsCache,
1069
+ () => rearmedPending,
1070
+ );
882
1071
  }