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/CHANGELOG.md +10 -0
- package/PRUNING.md +27 -0
- package/README.md +11 -0
- package/index.ts +288 -109
- package/package.json +1 -1
- package/src/commands.test.ts +138 -4
- package/src/commands.ts +27 -9
- package/src/context-metrics.test.ts +335 -0
- package/src/context-metrics.ts +152 -0
- package/src/reload-rearm.integration.test.ts +647 -0
- package/src/summarizer-wiring.test.ts +2 -0
- package/src/types.ts +33 -0
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 {
|
|
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
|
-
|
|
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
|
-
//
|
|
179
|
-
//
|
|
180
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
185
|
-
|
|
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
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
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
|
-
|
|
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
|
-
|
|
195
|
-
|
|
196
|
-
|
|
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
|
-
|
|
207
|
-
|
|
208
|
-
|
|
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
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
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
|
-
|
|
720
|
-
|
|
721
|
-
|
|
722
|
-
|
|
723
|
-
|
|
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
|
-
|
|
727
|
-
|
|
728
|
-
|
|
729
|
-
|
|
730
|
-
|
|
731
|
-
|
|
732
|
-
|
|
733
|
-
|
|
734
|
-
|
|
735
|
-
|
|
736
|
-
|
|
737
|
-
|
|
738
|
-
|
|
739
|
-
|
|
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
|
-
|
|
743
|
-
|
|
744
|
-
|
|
745
|
-
|
|
746
|
-
|
|
747
|
-
|
|
748
|
-
|
|
749
|
-
|
|
750
|
-
|
|
751
|
-
|
|
752
|
-
|
|
753
|
-
|
|
754
|
-
|
|
755
|
-
|
|
756
|
-
|
|
757
|
-
|
|
758
|
-
|
|
759
|
-
|
|
760
|
-
|
|
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
|
-
|
|
764
|
-
|
|
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
|
-
|
|
767
|
-
|
|
768
|
-
//
|
|
769
|
-
|
|
770
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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(
|
|
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(
|
|
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.
|
|
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",
|