pi-condense 2.6.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.
package/index.ts CHANGED
@@ -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";
@@ -42,6 +52,8 @@ import { spillOversizedBatch } from "./src/spill.js";
42
52
  import { occKey } from "./src/occurrence-key.js";
43
53
  import { DiagnosticSink } from "./src/diagnostics.js";
44
54
 
55
+ const EMPTY_METRICS_SNAPSHOT: ContextMetricsSnapshot = { openCycleThinkingTokens: 0, largestChainSharePct: 0, frontierGapTokens: 0 };
56
+
45
57
  export default function (pi: ExtensionAPI) {
46
58
  // Shared mutable config reference — updated by /pruner commands
47
59
  const currentConfig: { value: ContextPruneConfig } = {
@@ -74,6 +86,45 @@ export default function (pi: ExtensionAPI) {
74
86
  const pendingBatches: CapturedBatch[] = [];
75
87
  let isFlushing = false;
76
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
+ };
77
128
 
78
129
  type FlushResult =
79
130
  | { ok: true; reason: "flushed" | "skipped-oversized" | "skipped-trivial" | "skipped-deduped"; batchCount: number; toolCallCount: number; rawCharCount: number; summaryCharCount: number; dedupedCount?: number }
@@ -135,12 +186,18 @@ export default function (pi: ExtensionAPI) {
135
186
  // ── Helper: capture + trim + group pending batches (no LLM work) ──────────
136
187
  // Exposed to commands.ts via registerCommands so /pruner now can preview the
137
188
  // queue before opening the multi-row progress overlay.
138
- 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[] => {
139
195
  let batches: CapturedBatch[] = [];
140
196
  try {
141
197
  const branch = ctx.sessionManager.getBranch();
142
198
  batches = captureUnindexedBatchesFromSession(branch, indexer, protectionPredicate);
143
- } catch {
199
+ } catch (err) {
200
+ if (opts?.rethrow) throw err;
144
201
  batches = pendingBatches.slice();
145
202
  }
146
203
  batches = batches
@@ -175,47 +232,97 @@ export default function (pi: ExtensionAPI) {
175
232
  const flushPending = async (ctx: any, options: FlushOptions = {}): Promise<FlushResult> => {
176
233
  if (isFlushing) return { ok: false, reason: "already-flushing" };
177
234
 
178
- // Use pre-captured batches if provided (avoids double-capture when the
179
- // caller previewed the queue before opening the progress overlay).
180
- 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;
181
238
 
182
- 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";
183
245
 
184
- // Bail out before we drain pendingBatches so they don't need restoring.
185
- 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
+ };
186
274
 
187
- // Draining the queue since we've captured the state via session or slice.
188
- // We drain BEFORE the await so concurrent calls (though guarded by isFlushing)
189
- // or rapid turn-ends don't result in double-summarization.
190
- 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
+ }
191
291
 
192
- 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;
193
296
 
194
- const delivery = options.delivery ?? "runtime";
195
- let sessionManager: SessionAppender | undefined;
196
- if (delivery === "session") {
197
- try {
198
- sessionManager = ctx.sessionManager as unknown as SessionAppender;
199
- } catch (err) {
200
- restoreBatches(batches);
201
- isFlushing = false;
202
- 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" };
203
300
  }
204
- }
205
301
 
206
- const appendEntry = (customType: string, data?: unknown) => sessionManager!.appendCustomEntry(customType, data);
207
- const appendSummaryMessage = (content: string, details: unknown) =>
208
- 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
+ }
209
307
 
210
- // Routes alias persistence through whichever delivery is active so the
211
- // dedup pre-flush pass writes CUSTOM_TYPE_DEDUP_ALIAS entries via the
212
- // same path the rest of the flush uses.
213
- const persistAlias: (customType: string, data?: unknown) => void =
214
- delivery === "runtime"
215
- ? (type, data) => pi.appendEntry(type, data)
216
- : 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!;
217
325
 
218
- try {
219
326
  // ── Pre-flush content-hash dedup pass ────────────────────────────
220
327
  // For each tool call, check the indexer's contentHashToOriginal map.
221
328
  // A hit means an identical (toolName, normalized resultText) pair has
@@ -431,7 +538,7 @@ export default function (pi: ExtensionAPI) {
431
538
  } else {
432
539
  appendSummaryMessage(summaryText, batchDetails);
433
540
  indexer.registerSummaryRefs(summaryRefs);
434
- indexer.addBatch(batch, appendEntry);
541
+ indexer.addBatch(batch, appendEntry!);
435
542
  }
436
543
  // Keep the in-memory summary-body registry current so chain compression
437
544
  // can build synthetic chain messages without rescanning session entries.
@@ -459,7 +566,8 @@ export default function (pi: ExtensionAPI) {
459
566
 
460
567
  if (processedBatches.length === 0) {
461
568
  // Nothing was persisted (all calls failed or first call failed)
462
- setPruneStatusWidget(ctx, currentConfig.value, statsAccum.getLiveReclaim(), diagnostics.counts());
569
+ setPruneStatusWidget(ctx, currentConfig.value, statsAccum.getLiveReclaim(), diagnostics.counts(), metricsCache);
570
+ outcome = "error";
463
571
  return { ok: false, reason: "summarizer-failed" };
464
572
  }
465
573
 
@@ -521,18 +629,22 @@ export default function (pi: ExtensionAPI) {
521
629
  statsAccum.persist(pi);
522
630
  } else {
523
631
  frontier.advance(frontierSnapshot);
524
- appendEntry(CUSTOM_TYPE_FRONTIER, frontierSnapshot);
632
+ appendEntry!(CUSTOM_TYPE_FRONTIER, frontierSnapshot);
525
633
  try {
526
- appendEntry(CUSTOM_TYPE_STATS, statsAccum.getStats());
634
+ appendEntry!(CUSTOM_TYPE_STATS, statsAccum.getStats());
527
635
  } catch {
528
636
  // Ignore stats persistence failures; the prune result and frontier are the contract.
529
637
  }
530
638
  }
531
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";
532
644
  return { ok: false, reason: isStaleContextError(err) ? "stale-context" : "failed", error: errorMessage(err) };
533
645
  }
534
646
 
535
- setPruneStatusWidget(ctx, currentConfig.value, statsAccum.getLiveReclaim(), diagnostics.counts());
647
+ setPruneStatusWidget(ctx, currentConfig.value, statsAccum.getLiveReclaim(), diagnostics.counts(), metricsCache);
536
648
  emitExternalCost(pi, statsAccum);
537
649
 
538
650
  // Chain compression — compress closed chains beyond the rolling window.
@@ -620,6 +732,12 @@ export default function (pi: ExtensionAPI) {
620
732
  }
621
733
  }
622
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
+
623
741
  const returnReason: "flushed" | "skipped-oversized" | "skipped-trivial" | "skipped-deduped" =
624
742
  actuallyFlushedCount > 0
625
743
  ? "flushed"
@@ -640,10 +758,11 @@ export default function (pi: ExtensionAPI) {
640
758
  };
641
759
  } catch (err) {
642
760
  restoreBatches(batches);
761
+ outcome = "error";
643
762
  // When the abort signal fired, summarizeBatch rethrows rather than
644
763
  // swallowing the error. Don't show a UI error — the user intended this.
645
764
  if (options.signal?.aborted) {
646
- setPruneStatusWidget(ctx, currentConfig.value, statsAccum.getLiveReclaim(), diagnostics.counts());
765
+ setPruneStatusWidget(ctx, currentConfig.value, statsAccum.getLiveReclaim(), diagnostics.counts(), metricsCache);
647
766
  return { ok: false, reason: "aborted" };
648
767
  }
649
768
  if (isStaleContextError(err)) {
@@ -653,6 +772,7 @@ export default function (pi: ExtensionAPI) {
653
772
  return { ok: false, reason: "failed", error: errorMessage(err) };
654
773
  } finally {
655
774
  isFlushing = false;
775
+ emitFlushMetricsOnce();
656
776
  }
657
777
  };
658
778
 
@@ -678,9 +798,19 @@ export default function (pi: ExtensionAPI) {
678
798
  // Clear any batches queued before the session reload
679
799
  pendingBatches.length = 0;
680
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);
681
811
 
682
812
  // Update footer status
683
- setPruneStatusWidget(ctx, currentConfig.value, statsAccum.getLiveReclaim(), diagnostics.counts());
813
+ setPruneStatusWidget(ctx, currentConfig.value, statsAccum.getLiveReclaim(), diagnostics.counts(), metricsCache);
684
814
 
685
815
  ctx.ui.setWidget(
686
816
  "pruner-boot",
@@ -708,6 +838,17 @@ export default function (pi: ExtensionAPI) {
708
838
  // Pending batches belong to the old branch — discard them
709
839
  pendingBatches.length = 0;
710
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);
711
852
  });
712
853
 
713
854
  // ── turn_end: capture batch, flush immediately or queue ──────────────────
@@ -716,73 +857,91 @@ export default function (pi: ExtensionAPI) {
716
857
 
717
858
  const hasToolResults = event.toolResults && event.toolResults.length > 0;
718
859
 
719
- if (!hasToolResults) {
720
- // Text-only final turns are handled by message_end in agent-message mode.
721
- // In print mode, turn_end can fire after session shutdown, so do not start
722
- // deferred LLM work from this late lifecycle event.
723
- return;
724
- }
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
+ };
725
885
 
726
- const capturedBatch = captureBatch(
727
- event.message,
728
- event.toolResults,
729
- event.turnIndex,
730
- Date.now()
731
- );
732
- // Drop user-protected tool/path results so they stay verbatim in context.
733
- // Filtering at capture time keeps the
734
- // underlying assistant `toolCall` block AND its `ToolResultMessage`
735
- // untouched in Pi's session/event stream — only the in-memory
736
- // CapturedBatch is pruned, which is exactly what we want.
737
- const filtered = {
738
- ...capturedBatch,
739
- toolCalls: capturedBatch.toolCalls.filter((tc) => !isProtected(tc.toolName, tc.args, currentConfig.value)),
740
- };
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
+ }
741
906
 
742
- // Eager spill: offload oversized single results to sidecar files before they
743
- // ever reach a request. addBatch inside marks them isSummarized, so
744
- // trimBatchToPendingRange drops them from the pending set below. Best-effort:
745
- // a spill failure leaves the result inline for the normal flush pipeline.
746
- try {
747
- await spillOversizedBatch({
748
- batch: filtered,
749
- indexer,
750
- config: {
751
- spillThreshold: currentConfig.value.spillThreshold,
752
- spillPreviewBytes: currentConfig.value.spillPreviewBytes,
753
- dedupByContentHash: currentConfig.value.dedupByContentHash,
754
- },
755
- sessionDir: ctx.sessionManager.getSessionDir(),
756
- sessionId: ctx.sessionManager.getSessionId(),
757
- appendEntry: (type, data) => (ctx.sessionManager as unknown as SessionAppender).appendCustomEntry(type, data),
758
- });
759
- } catch {
760
- // 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
+ }
761
926
  }
762
927
 
763
- const batch = trimBatchToPendingRange(filtered);
764
- 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);
765
934
 
766
- pendingBatches.push(batch);
767
-
768
- // Let the user know a batch is queued
769
- const n = pendingBatches.length;
770
- const trigger = currentConfig.value.pruneOn === "agent-message"
771
- ? "agent's next text response"
772
- : "/pruner now";
773
- if (currentConfig.value.showPruneStatusLine) {
774
- setPruneStatusWidget(ctx, currentConfig.value, `prune: ${n} pending`);
775
- safeNotify(
776
- ctx,
777
- `pruner: ${n} turn${n === 1 ? "" : "s"} queued — will summarize on ${trigger}`,
778
- "info"
779
- );
780
- }
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;
781
940
 
782
941
  // Token-budget auto-flush: an additional, mode-independent trigger. When context
783
942
  // usage crosses autoBudgetThreshold, compact the queued batches now instead of
784
- // waiting for this mode's flush boundary. The pendingBatches.length guard makes
785
- // 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.
786
945
  const usage = ctx.getContextUsage?.();
787
946
  const budgetHit = shouldBudgetFlush(usage, currentConfig.value.autoBudgetThreshold);
788
947
  const deltaHit = shouldDeltaFlush(usage, previousFraction, currentConfig.value.budgetTurnDelta);
@@ -791,16 +950,19 @@ export default function (pi: ExtensionAPI) {
791
950
  const f = usageFraction(usage);
792
951
  if (f != null) previousFraction = f;
793
952
 
794
- if (pendingBatches.length > 0 && !isFlushing && (budgetHit || deltaHit)) {
953
+ const n = pendingBatches.length;
954
+ if ((n > 0 || rearmedPending) && !isFlushing && (budgetHit || deltaHit)) {
795
955
  // Always surface this flush (even when the routine status line is off): it's a
796
956
  // significant, infrequent event — context crossed a threshold or jumped sharply
797
957
  // this turn — and it self-throttles because pendingBatches is drained right after.
798
958
  safeNotify(
799
959
  ctx,
800
- `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`,
801
963
  "info",
802
964
  );
803
- await flushPending(ctx, { delivery: "session" });
965
+ await flushPending(ctx, { delivery: "session", trigger: n === 0 ? "rearmed" : budgetHit ? "budget" : "delta" });
804
966
  }
805
967
  });
806
968
 
@@ -813,7 +975,7 @@ export default function (pi: ExtensionAPI) {
813
975
  if (!currentConfig.value.enabled) return;
814
976
  if (currentConfig.value.pruneOn !== "agent-message") return;
815
977
  if (!isFinalAssistantMessage(event.message)) return;
816
- await flushPending(ctx, { delivery: "session", closingMessage: event.message });
978
+ await flushPending(ctx, { delivery: "session", closingMessage: event.message, trigger: "message-end" });
817
979
  });
818
980
 
819
981
  // ── agent_end: last-chance cleanup only ─────────────────────────────────────
@@ -821,8 +983,12 @@ export default function (pi: ExtensionAPI) {
821
983
  // already be disposing the session, so avoid starting a best-effort LLM call here.
822
984
  pi.on("agent_end", async (_event, ctx) => {
823
985
  if (!currentConfig.value.enabled) return;
824
- if (pendingBatches.length === 0) return;
825
- 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
+ );
826
992
  });
827
993
 
828
994
  // ── context: prune summarized tool results from next LLM call ─────────────
@@ -851,7 +1017,7 @@ export default function (pi: ExtensionAPI) {
851
1017
  changed = true;
852
1018
  statsAccum.setLiveReclaim(result.beforeChars, result.afterChars);
853
1019
  }
854
- setPruneStatusWidget(ctx, currentConfig.value, statsAccum.getLiveReclaim(), diagnostics.counts());
1020
+ setPruneStatusWidget(ctx, currentConfig.value, statsAccum.getLiveReclaim(), diagnostics.counts(), metricsCache);
855
1021
 
856
1022
  if (!changed) return undefined;
857
1023
  return { messages };
@@ -888,5 +1054,18 @@ export default function (pi: ExtensionAPI) {
888
1054
  return { compressedEntries: result.compressedEntries, skipped: result.skipped.filter((s) => s.reason === "no-summary").length };
889
1055
  };
890
1056
 
891
- registerCommands(pi, currentConfig, flushPending, capturePendingBatches, () => statsAccum.getStats(), () => statsAccum.getLiveReclaim(), indexer, compactChains, () => diagnostics.counts());
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
+ );
892
1071
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-condense",
3
- "version": "2.6.0",
3
+ "version": "2.7.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",