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.
- package/CHANGELOG.md +20 -0
- package/PRUNING.md +138 -23
- package/README.md +17 -1
- package/index.ts +305 -116
- package/package.json +1 -1
- package/src/batch-capture.test.ts +75 -1
- package/src/batch-capture.ts +22 -13
- package/src/chain-compressor.test.ts +114 -0
- package/src/chain-compressor.ts +29 -4
- package/src/chain-detector.test.ts +49 -0
- package/src/chain-detector.ts +7 -0
- package/src/chain-range-prune.test.ts +342 -7
- package/src/chain-range-prune.ts +161 -48
- package/src/commands.test.ts +168 -5
- package/src/commands.ts +44 -11
- package/src/context-metrics.test.ts +335 -0
- package/src/context-metrics.ts +152 -0
- package/src/diagnostics.test.ts +114 -0
- package/src/diagnostics.ts +46 -0
- package/src/frontier.test.ts +1 -0
- package/src/id-collision.integration.test.ts +251 -0
- package/src/indexer.test.ts +336 -0
- package/src/indexer.ts +168 -55
- package/src/occurrence-key.test.ts +57 -0
- package/src/occurrence-key.ts +36 -0
- package/src/orphan-sweep.test.ts +67 -0
- package/src/orphan-sweep.ts +40 -0
- package/src/oversized-spill.integration.test.ts +7 -2
- package/src/pruner.test.ts +456 -25
- package/src/pruner.ts +84 -36
- package/src/query-tool.test.ts +117 -0
- package/src/query-tool.ts +47 -31
- package/src/range-compression.integration.test.ts +6 -1
- package/src/recovery-grace.test.ts +13 -0
- package/src/recovery-grace.ts +12 -3
- package/src/reload-rearm.integration.test.ts +647 -0
- package/src/spill.test.ts +108 -1
- package/src/spill.ts +5 -3
- package/src/summarizer-wiring.test.ts +2 -0
- package/src/summary-refs.test.ts +51 -1
- package/src/summary-refs.ts +15 -4
- package/src/test-support.ts +54 -0
- package/src/tree-browser.ts +2 -1
- 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<
|
|
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 {
|
|
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
|
-
|
|
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
|
-
//
|
|
173
|
-
//
|
|
174
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
179
|
-
|
|
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
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
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
|
-
|
|
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
|
-
|
|
189
|
-
|
|
190
|
-
|
|
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
|
-
|
|
201
|
-
|
|
202
|
-
|
|
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
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
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
|
-
|
|
237
|
-
|
|
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
|
|
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(
|
|
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
|
-
|
|
711
|
-
|
|
712
|
-
|
|
713
|
-
|
|
714
|
-
|
|
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
|
-
|
|
718
|
-
|
|
719
|
-
|
|
720
|
-
|
|
721
|
-
|
|
722
|
-
|
|
723
|
-
|
|
724
|
-
|
|
725
|
-
|
|
726
|
-
|
|
727
|
-
|
|
728
|
-
|
|
729
|
-
|
|
730
|
-
|
|
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
|
-
|
|
734
|
-
|
|
735
|
-
|
|
736
|
-
|
|
737
|
-
|
|
738
|
-
|
|
739
|
-
|
|
740
|
-
|
|
741
|
-
|
|
742
|
-
|
|
743
|
-
|
|
744
|
-
|
|
745
|
-
|
|
746
|
-
|
|
747
|
-
|
|
748
|
-
|
|
749
|
-
|
|
750
|
-
|
|
751
|
-
|
|
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
|
-
|
|
755
|
-
|
|
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
|
-
|
|
758
|
-
|
|
759
|
-
//
|
|
760
|
-
|
|
761
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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(
|
|
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
|
|
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(
|
|
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
|
}
|