pi-condense 2.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (45) hide show
  1. package/CHANGELOG.md +73 -0
  2. package/LICENSE +22 -0
  3. package/PRUNING.md +1028 -0
  4. package/README.md +243 -0
  5. package/index.ts +858 -0
  6. package/package.json +56 -0
  7. package/src/batch-capture.ts +226 -0
  8. package/src/block-refs.test.ts +42 -0
  9. package/src/block-refs.ts +16 -0
  10. package/src/budget.test.ts +66 -0
  11. package/src/budget.ts +39 -0
  12. package/src/chain-compressor.test.ts +283 -0
  13. package/src/chain-compressor.ts +132 -0
  14. package/src/chain-detector.test.ts +302 -0
  15. package/src/chain-detector.ts +128 -0
  16. package/src/chain-range-prune.test.ts +522 -0
  17. package/src/chain-range-prune.ts +128 -0
  18. package/src/commands.test.ts +67 -0
  19. package/src/commands.ts +1207 -0
  20. package/src/config.ts +126 -0
  21. package/src/content-hash.ts +35 -0
  22. package/src/error-purge.test.ts +186 -0
  23. package/src/error-purge.ts +71 -0
  24. package/src/frontier.ts +62 -0
  25. package/src/indexer.ts +393 -0
  26. package/src/nested-placeholders.test.ts +82 -0
  27. package/src/nested-placeholders.ts +20 -0
  28. package/src/oversized-spill.integration.test.ts +73 -0
  29. package/src/protected.test.ts +62 -0
  30. package/src/protected.ts +51 -0
  31. package/src/pruner.test.ts +508 -0
  32. package/src/pruner.ts +156 -0
  33. package/src/query-tool.ts +78 -0
  34. package/src/range-compression.integration.test.ts +252 -0
  35. package/src/spill.test.ts +102 -0
  36. package/src/spill.ts +90 -0
  37. package/src/stats.test.ts +114 -0
  38. package/src/stats.ts +190 -0
  39. package/src/summarizer.test.ts +17 -0
  40. package/src/summarizer.ts +262 -0
  41. package/src/summary-refs.ts +61 -0
  42. package/src/thinking-strip.test.ts +175 -0
  43. package/src/thinking-strip.ts +42 -0
  44. package/src/tree-browser.ts +382 -0
  45. package/src/types.ts +764 -0
package/index.ts ADDED
@@ -0,0 +1,858 @@
1
+ /**
2
+ * context-prune — Pi extension entry point
3
+ *
4
+ * Wires together all modules:
5
+ * config — load/save <agent-dir>/settings.json `contextPrune` namespace (honors PI_CODING_AGENT_DIR)
6
+ * batch-capture — serialize turn_end event into CapturedBatch
7
+ * summarizer — call LLM to summarize a CapturedBatch
8
+ * indexer — maintain Map<toolCallId, ToolCallRecord> + session persistence
9
+ * pruner — filter context event messages
10
+ * query-tool — register context_tree_query tool
11
+ * commands — register /pruner command + message renderer
12
+ *
13
+ * Usage: pi -e .
14
+ */
15
+
16
+ import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
17
+ import { loadConfig } from "./src/config.js";
18
+ import { captureBatch, captureUnindexedBatchesFromSession, groupBatchesByMode } from "./src/batch-capture.js";
19
+ import { summarizeBatch, summarizeBatches, summarizeRange } from "./src/summarizer.js";
20
+ import { ToolCallIndexer } from "./src/indexer.js";
21
+ import { pruneMessages } from "./src/pruner.js";
22
+ import { isProtected } from "./src/protected.js";
23
+ import { registerQueryTool } from "./src/query-tool.js";
24
+ import { registerCommands, setPruneStatusWidget } from "./src/commands.js";
25
+ import { formatSummaryToolCallRefs, makeSummaryDetails } from "./src/summary-refs.js";
26
+ import type { ContextPruneConfig, CapturedBatch, PruneFrontier, FlushOptions } from "./src/types.js";
27
+ import {
28
+ DEFAULT_CONFIG,
29
+ CUSTOM_TYPE_SUMMARY,
30
+ CUSTOM_TYPE_STATS,
31
+ CUSTOM_TYPE_FRONTIER,
32
+ } from "./src/types.js";
33
+ import { StatsAccumulator, emitExternalCost } from "./src/stats.js";
34
+ import { PruneFrontierTracker } from "./src/frontier.js";
35
+ import { BlockRefIssuer } from "./src/block-refs.js";
36
+ import { compressEligible } from "./src/chain-compressor.js";
37
+ import { detectChains, withClosingMessage } from "./src/chain-detector.js";
38
+ import { shouldBudgetFlush, shouldDeltaFlush, usageFraction } from "./src/budget.js";
39
+ import { spillOversizedBatch } from "./src/spill.js";
40
+
41
+ export default function (pi: ExtensionAPI) {
42
+ // Shared mutable config reference — updated by /pruner commands
43
+ const currentConfig: { value: ContextPruneConfig } = {
44
+ value: { ...DEFAULT_CONFIG },
45
+ };
46
+
47
+ const protectionPredicate = (name: string, args: unknown) => isProtected(name, args, currentConfig.value);
48
+
49
+ // Shared indexer — rebuilt from session on every session_start / session_tree
50
+ const indexer = new ToolCallIndexer();
51
+
52
+ // Shared stats accumulator — tracks cumulative token/cost stats for summarizer calls
53
+ const statsAccum = new StatsAccumulator();
54
+
55
+ // Shared prune frontier — tracks the last completed prune attempt boundary
56
+ const frontier = new PruneFrontierTracker();
57
+
58
+ // Shared block-ref issuer — issues monotonic b<N> IDs for compressed chains;
59
+ // rebuilt from session on session_start / session_tree
60
+ const blockRefs = new BlockRefIssuer();
61
+
62
+ // Pending batches — accumulated until the prune trigger fires
63
+ const pendingBatches: CapturedBatch[] = [];
64
+ let isFlushing = false;
65
+ let previousFraction: number | null = null;
66
+
67
+ type FlushResult =
68
+ | { ok: true; reason: "flushed" | "skipped-oversized" | "skipped-trivial" | "skipped-deduped"; batchCount: number; toolCallCount: number; rawCharCount: number; summaryCharCount: number; dedupedCount?: number }
69
+ | { ok: false; reason: "empty" | "already-flushing" | "summarizer-failed" | "stale-context" | "failed" | "aborted"; error?: string };
70
+
71
+ type SessionAppender = {
72
+ appendCustomEntry(customType: string, data?: unknown): string;
73
+ appendCustomMessageEntry(customType: string, content: string, display: boolean, details?: unknown): string;
74
+ };
75
+
76
+ const isStaleContextError = (err: unknown) =>
77
+ err instanceof Error && err.message.includes("This extension ctx is stale");
78
+
79
+ const errorMessage = (err: unknown) => (err instanceof Error ? err.message : String(err));
80
+
81
+ const safeNotify = (ctx: any, message: string, type: "info" | "warning" | "error" = "info") => {
82
+ try {
83
+ ctx.ui.notify(message, type);
84
+ } catch (err) {
85
+ if (!isStaleContextError(err)) throw err;
86
+ }
87
+ };
88
+
89
+ const assistantMessageHasToolCalls = (message: any) =>
90
+ message?.role === "assistant" &&
91
+ Array.isArray(message.content) &&
92
+ message.content.some((block: any) => block?.type === "toolCall");
93
+
94
+ const isFinalAssistantMessage = (message: any) => message?.role === "assistant" && !assistantMessageHasToolCalls(message);
95
+
96
+ const trimBatchToPendingRange = (batch: CapturedBatch): CapturedBatch | null => {
97
+ const currentFrontier = frontier.get();
98
+ let toolCalls = batch.toolCalls;
99
+
100
+ // The indexer tells us what was successfully summarized earlier.
101
+ toolCalls = toolCalls.filter((tc) => !indexer.isSummarized(tc.toolCallId));
102
+ if (toolCalls.length === 0) return null;
103
+
104
+ // The frontier tells us the last attempted boundary even when the attempt did
105
+ // not persist index entries (e.g. skipped-oversized). When the LLM prunes in
106
+ // the middle of a long tool chain, keep later tool calls from the same turn
107
+ // instead of dropping the whole batch on the floor.
108
+ if (!currentFrontier) return { ...batch, toolCalls };
109
+ if (batch.turnIndex < currentFrontier.lastAttemptedTurnIndex) return null;
110
+ if (batch.turnIndex > currentFrontier.lastAttemptedTurnIndex) return { ...batch, toolCalls };
111
+
112
+ const originalIndex = toolCalls.findIndex((tc) => tc.toolCallId === currentFrontier.lastAttemptedToolCallId);
113
+ if (originalIndex < 0) return { ...batch, toolCalls };
114
+
115
+ const remaining = toolCalls.slice(originalIndex + 1);
116
+ if (remaining.length === 0) return null;
117
+ return { ...batch, toolCalls: remaining };
118
+ };
119
+
120
+ const restoreBatches = (batches: CapturedBatch[]) => {
121
+ pendingBatches.unshift(...batches);
122
+ };
123
+
124
+ // ── Helper: capture + trim + group pending batches (no LLM work) ──────────
125
+ // Exposed to commands.ts via registerCommands so /pruner now can preview the
126
+ // queue before opening the multi-row progress overlay.
127
+ const capturePendingBatches = (ctx: any): CapturedBatch[] => {
128
+ let batches: CapturedBatch[] = [];
129
+ try {
130
+ const branch = ctx.sessionManager.getBranch();
131
+ batches = captureUnindexedBatchesFromSession(branch, indexer, protectionPredicate);
132
+ } catch {
133
+ batches = pendingBatches.slice();
134
+ }
135
+ batches = batches
136
+ .map((batch) => trimBatchToPendingRange(batch))
137
+ .filter((batch): batch is CapturedBatch => batch !== null);
138
+ return groupBatchesByMode(batches, currentConfig.value.batchingMode);
139
+ };
140
+
141
+ // Summarizes + indexes all pending batches.
142
+ // When options.onProgress is provided batches are processed sequentially
143
+ // (one LLM call each) so the caller can update per-row UI. Otherwise all
144
+ // batches are summarized in parallel (one summarizeBatches call).
145
+ // Runtime delivery is used while the agent/tool loop is active so Pi can place
146
+ // steer messages at protocol-safe boundaries. Session delivery is used only for
147
+ // agent-message's final-message flush, where print-mode Pi may invalidate pi.*
148
+ // while the summarizer LLM call is in flight.
149
+ // Range-summary fuser injected into compressEligible (B). Returns undefined
150
+ // when fuseRangeSummary is off so the compressor keeps the per-batch concat.
151
+ // Each successful fusion folds its usage + bumps the rangesSummarized counter.
152
+ const makeFuseRange = (ctx: any): ((text: string) => Promise<string | null>) | undefined => {
153
+ if (!currentConfig.value.chainCompression.fuseRangeSummary) return undefined;
154
+ return async (text: string) => {
155
+ const r = await summarizeRange(text, currentConfig.value, ctx, {});
156
+ if (r) {
157
+ statsAccum.add(r.usage);
158
+ statsAccum.addRangesSummarized(1);
159
+ }
160
+ return r?.summaryText ?? null;
161
+ };
162
+ };
163
+
164
+ const flushPending = async (ctx: any, options: FlushOptions = {}): Promise<FlushResult> => {
165
+ if (isFlushing) return { ok: false, reason: "already-flushing" };
166
+
167
+ // Use pre-captured batches if provided (avoids double-capture when the
168
+ // caller previewed the queue before opening the progress overlay).
169
+ let batches: CapturedBatch[] = options.previewedBatches ?? capturePendingBatches(ctx);
170
+
171
+ if (batches.length === 0) return { ok: false, reason: "empty" };
172
+
173
+ // Bail out before we drain pendingBatches so they don't need restoring.
174
+ if (options.signal?.aborted) return { ok: false, reason: "aborted" };
175
+
176
+ // Draining the queue since we've captured the state via session or slice.
177
+ // We drain BEFORE the await so concurrent calls (though guarded by isFlushing)
178
+ // or rapid turn-ends don't result in double-summarization.
179
+ pendingBatches.length = 0;
180
+
181
+ isFlushing = true;
182
+
183
+ const delivery = options.delivery ?? "runtime";
184
+ let sessionManager: SessionAppender | undefined;
185
+ if (delivery === "session") {
186
+ try {
187
+ sessionManager = ctx.sessionManager as unknown as SessionAppender;
188
+ } catch (err) {
189
+ restoreBatches(batches);
190
+ isFlushing = false;
191
+ return { ok: false, reason: isStaleContextError(err) ? "stale-context" : "failed", error: errorMessage(err) };
192
+ }
193
+ }
194
+
195
+ const appendEntry = (customType: string, data?: unknown) => sessionManager!.appendCustomEntry(customType, data);
196
+ const appendSummaryMessage = (content: string, details: unknown) =>
197
+ sessionManager!.appendCustomMessageEntry(CUSTOM_TYPE_SUMMARY, content, true, details);
198
+
199
+ // Routes alias persistence through whichever delivery is active so the
200
+ // dedup pre-flush pass writes CUSTOM_TYPE_DEDUP_ALIAS entries via the
201
+ // same path the rest of the flush uses.
202
+ const persistAlias: (customType: string, data?: unknown) => void =
203
+ delivery === "runtime"
204
+ ? (type, data) => pi.appendEntry(type, data)
205
+ : appendEntry;
206
+
207
+ try {
208
+ // ── Pre-flush content-hash dedup pass ────────────────────────────
209
+ // For each tool call, check the indexer's contentHashToOriginal map.
210
+ // A hit means an identical (toolName, normalized resultText) pair has
211
+ // already been summarized in an earlier flush. Register the duplicate
212
+ // as an alias of the original (so pruneMessages stub-replaces its
213
+ // ToolResultMessage with the original's short ref) and drop it from
214
+ // the batch BEFORE the summarizer / trivial classifier runs.
215
+ //
216
+ // We track per-batch deduped counts so we can:
217
+ // - count dedup'd tool calls toward `totalToolCallCount` and
218
+ // `totalRawCharCount` (they were addressed by this flush even
219
+ // though no LLM call was made for them),
220
+ // - tag fully-dedup'd batches with a `"deduped"` ResultSlot so the
221
+ // existing result loop treats them the same way it treats trivial
222
+ // batches (advance the frontier without writing a summary).
223
+ const dedupedPerBatch: { toolCalls: import("./src/types.js").CapturedToolCall[]; rawChars: number }[] = batches.map(() => ({ toolCalls: [], rawChars: 0 }));
224
+ const dedupEnabled = currentConfig.value.dedupByContentHash;
225
+ if (dedupEnabled) {
226
+ for (let i = 0; i < batches.length; i++) {
227
+ const batch = batches[i];
228
+ const remaining: typeof batch.toolCalls = [];
229
+ for (const tc of batch.toolCalls) {
230
+ const originalId = indexer.lookupByContent(tc.toolName, tc.resultText);
231
+ if (originalId && originalId !== tc.toolCallId) {
232
+ indexer.registerDuplicate(tc.toolCallId, originalId, persistAlias);
233
+ dedupedPerBatch[i].toolCalls.push(tc);
234
+ dedupedPerBatch[i].rawChars += tc.resultText.length;
235
+ } else {
236
+ remaining.push(tc);
237
+ }
238
+ }
239
+ // Shallow-clone the batch so we don't mutate the captured array
240
+ // (pendingBatches consumers retain the original shape on retry).
241
+ batches[i] = { ...batch, toolCalls: remaining };
242
+ }
243
+ }
244
+
245
+ // ── Pre-flush trivial filter ─────────────────────────────────
246
+ // Classify each batch by total raw resultText chars BEFORE any LLM call.
247
+ // Batches below minBatchChars are marked trivial: the summarizer is
248
+ // skipped entirely, the frontier still advances, and the original
249
+ // tool-result messages stay verbatim in context. minBatchChars === 0
250
+ // disables the guard (every batch goes to the summarizer).
251
+ //
252
+ // A batch whose entire toolCalls array was just deduped is flagged
253
+ // `isFullyDeduped` so the result loop slots it as "deduped" without
254
+ // confusing it with the trivial path (different outcome + notification).
255
+ const minChars = currentConfig.value.minBatchChars;
256
+ const batchRawChars = batches.map((b) =>
257
+ b.toolCalls.reduce((s, tc) => s + tc.resultText.length, 0),
258
+ );
259
+ const isFullyDeduped = batches.map((b, i) =>
260
+ dedupedPerBatch[i].toolCalls.length > 0 && b.toolCalls.length === 0,
261
+ );
262
+ const isTrivial = batchRawChars.map(
263
+ (c, i) => !isFullyDeduped[i] && minChars > 0 && c < minChars && batches[i].toolCalls.length > 0,
264
+ );
265
+ const nonTrivialIndices: number[] = [];
266
+ for (let i = 0; i < batches.length; i++) {
267
+ if (!isTrivial[i] && !isFullyDeduped[i]) nonTrivialIndices.push(i);
268
+ }
269
+
270
+ // Only show "summarizing…" if at least one batch will actually be sent
271
+ // to the LLM. An all-trivial flush is purely bookkeeping.
272
+ if (nonTrivialIndices.length > 0) {
273
+ setPruneStatusWidget(ctx, currentConfig.value, "prune: summarizing…");
274
+ }
275
+
276
+ const reportBatchTextProgress = (index: number, total: number, batch: CapturedBatch, receivedChars: number) => {
277
+ options.onBatchTextProgress?.(index, total, batch, receivedChars);
278
+ };
279
+
280
+ // Summarize the non-trivial subset. When onProgress is provided
281
+ // (/pruner now overlay) we process sequentially so each row can be
282
+ // checked off as its LLM call completes. Trivial and fully-deduped
283
+ // batches emit a "skipped" progress event immediately, with no
284
+ // spinner / no LLM call. The final `results` array is index-aligned
285
+ // to `batches`, with possible values: SummarizeResult (success),
286
+ // null (LLM failure), "trivial" (pre-flush small-batch skip), or
287
+ // "deduped" (pre-flush dedup ate every tool call in this batch).
288
+ type ResultSlot = import("./src/types.js").SummarizeResult | null | "trivial" | "deduped";
289
+ const results: ResultSlot[] = new Array(batches.length).fill(null);
290
+
291
+ if (options.onProgress) {
292
+ for (let i = 0; i < batches.length; i++) {
293
+ if (isFullyDeduped[i]) {
294
+ options.onProgress(i, batches.length, batches[i], "skipped");
295
+ results[i] = "deduped";
296
+ continue;
297
+ }
298
+ if (isTrivial[i]) {
299
+ options.onProgress(i, batches.length, batches[i], "skipped");
300
+ results[i] = "trivial";
301
+ continue;
302
+ }
303
+ options.onProgress(i, batches.length, batches[i], "start");
304
+ const r = await summarizeBatch(batches[i], currentConfig.value, ctx, {
305
+ signal: options.signal,
306
+ onTextProgress: (receivedChars) => {
307
+ reportBatchTextProgress(i, batches.length, batches[i], receivedChars);
308
+ },
309
+ });
310
+ results[i] = r;
311
+ options.onProgress(i, batches.length, batches[i], r ? "done" : "skipped");
312
+ }
313
+ } else {
314
+ // Mark all trivial + fully-deduped slots up front, then call
315
+ // summarizeBatches with only the remaining batches (parallel — one
316
+ // LLM call each).
317
+ for (let i = 0; i < batches.length; i++) {
318
+ if (isFullyDeduped[i]) results[i] = "deduped";
319
+ else if (isTrivial[i]) results[i] = "trivial";
320
+ }
321
+ if (nonTrivialIndices.length > 0) {
322
+ const nonTrivialBatches = nonTrivialIndices.map((i) => batches[i]);
323
+ const ntResults = await summarizeBatches(nonTrivialBatches, currentConfig.value, ctx, {
324
+ onBatchTextProgress: (ntIndex, _ntTotal, batch, receivedChars) => {
325
+ const origIndex = nonTrivialIndices[ntIndex];
326
+ reportBatchTextProgress(origIndex, batches.length, batch, receivedChars);
327
+ },
328
+ signal: options.signal,
329
+ });
330
+ for (let k = 0; k < nonTrivialIndices.length; k++) {
331
+ results[nonTrivialIndices[k]] = ntResults[k];
332
+ }
333
+ }
334
+ }
335
+
336
+ // Process results in order; stop at first null (individual call failure).
337
+ // Batches before the first failure are persisted; remaining are restored to
338
+ // pendingBatches so they are retried on the next flush.
339
+ const processedBatches: CapturedBatch[] = [];
340
+ let totalRawCharCount = 0;
341
+ let totalSummaryCharCount = 0;
342
+ let totalToolCallCount = 0;
343
+ let totalDedupedCount = 0;
344
+ const oversizedBatches: CapturedBatch[] = [];
345
+ const trivialBatches: CapturedBatch[] = [];
346
+ const dedupedBatches: CapturedBatch[] = [];
347
+ let firstFailureIndex = -1;
348
+
349
+ for (let i = 0; i < batches.length; i++) {
350
+ const result = results[i];
351
+ if (result === null) {
352
+ firstFailureIndex = i;
353
+ break;
354
+ }
355
+
356
+ const batch = batches[i];
357
+ const batchRawCharCount = batchRawChars[i];
358
+ const dedupCount = dedupedPerBatch[i].toolCalls.length;
359
+ const dedupRawChars = dedupedPerBatch[i].rawChars;
360
+
361
+ // Fully-deduped batches: every tool call matched an existing
362
+ // indexed record. The alias entries are already persisted; we just
363
+ // need to advance the frontier past this turn and count the
364
+ // dedup'd raw chars toward the flush totals so the user sees the
365
+ // savings.
366
+ if (result === "deduped") {
367
+ totalRawCharCount += dedupRawChars;
368
+ totalToolCallCount += dedupCount;
369
+ totalDedupedCount += dedupCount;
370
+ dedupedBatches.push(batch);
371
+ processedBatches.push(batch);
372
+ continue;
373
+ }
374
+
375
+ // Trivial batches: no summary text, no index entry, no stats usage —
376
+ // just bookkeeping so the frontier can advance past this range and
377
+ // the next flush does not reconsider these tool calls.
378
+ if (result === "trivial") {
379
+ // Count dedup'd tool calls (if any) on a partial-dedup batch even
380
+ // though the rest of the batch was below minBatchChars.
381
+ totalRawCharCount += batchRawCharCount + dedupRawChars;
382
+ totalToolCallCount += batch.toolCalls.length + dedupCount;
383
+ totalDedupedCount += dedupCount;
384
+ trivialBatches.push(batch);
385
+ processedBatches.push(batch);
386
+ continue;
387
+ }
388
+
389
+ const summaryRefs = indexer.allocateSummaryRefs(batch);
390
+ const summaryText = result.summaryText + formatSummaryToolCallRefs(summaryRefs);
391
+ const shouldSkipOversized = summaryText.length > batchRawCharCount;
392
+
393
+ statsAccum.add(result.usage);
394
+ totalRawCharCount += batchRawCharCount + dedupRawChars;
395
+ totalSummaryCharCount += summaryText.length;
396
+ totalToolCallCount += batch.toolCalls.length + dedupCount;
397
+ totalDedupedCount += dedupCount;
398
+
399
+ const batchDetails = makeSummaryDetails(batch, summaryRefs);
400
+
401
+ try {
402
+ if (!shouldSkipOversized) {
403
+ // Write one summary message per turn and index its tool calls.
404
+ const batchToolCallIds = batch.toolCalls.map((tc) => tc.toolCallId);
405
+ if (delivery === "runtime") {
406
+ pi.sendMessage(
407
+ { customType: CUSTOM_TYPE_SUMMARY, content: summaryText, display: true, details: batchDetails },
408
+ { deliverAs: "steer" }
409
+ );
410
+ indexer.registerSummaryRefs(summaryRefs);
411
+ indexer.addBatch(batch, (type, data) => pi.appendEntry(type, data));
412
+ } else {
413
+ appendSummaryMessage(summaryText, batchDetails);
414
+ indexer.registerSummaryRefs(summaryRefs);
415
+ indexer.addBatch(batch, appendEntry);
416
+ }
417
+ // Keep the in-memory summary-body registry current so chain compression
418
+ // can build synthetic chain messages without rescanning session entries.
419
+ indexer.registerSummaryBody(batchToolCallIds, summaryText);
420
+ } else {
421
+ oversizedBatches.push(batch);
422
+ }
423
+ } catch (err) {
424
+ // Persistence error mid-loop: stop here, restore this and remaining batches.
425
+ if (isStaleContextError(err)) {
426
+ restoreBatches(batches.slice(i));
427
+ // Advance frontier to what we managed to persist before this point
428
+ break;
429
+ }
430
+ throw err;
431
+ }
432
+
433
+ processedBatches.push(batch);
434
+ }
435
+
436
+ // Restore unprocessed batches (those at and after the first failure)
437
+ if (firstFailureIndex >= 0) {
438
+ restoreBatches(batches.slice(firstFailureIndex));
439
+ }
440
+
441
+ if (processedBatches.length === 0) {
442
+ // Nothing was persisted (all calls failed or first call failed)
443
+ setPruneStatusWidget(ctx, currentConfig.value, statsAccum.getLiveReclaim());
444
+ return { ok: false, reason: "summarizer-failed" };
445
+ }
446
+
447
+ // Advance frontier to the last batch we actually processed. A fully
448
+ // deduped batch has `toolCalls === []` (the dedup pass shallow-cloned
449
+ // the batch with only the remaining non-dup calls). In that case, fall
450
+ // back to the matching `dedupedPerBatch[i].toolCalls` so the frontier
451
+ // anchor still points at a real tool call — otherwise we'd dereference
452
+ // `undefined.toolCallId` and the whole flush would throw, silently
453
+ // dropping the dedup-alias write's effect on subsequent flushes.
454
+ const lastBatch = processedBatches[processedBatches.length - 1];
455
+ const lastBatchOrigIndex = batches.indexOf(lastBatch);
456
+ const lastBatchAllTCs =
457
+ lastBatch.toolCalls.length > 0
458
+ ? lastBatch.toolCalls
459
+ : (lastBatchOrigIndex >= 0 ? dedupedPerBatch[lastBatchOrigIndex].toolCalls : []);
460
+ const lastTC = lastBatchAllTCs[lastBatchAllTCs.length - 1];
461
+
462
+ // Outcome precedence: any actual summary wins; oversized beats deduped
463
+ // beats trivial. (Trivial and deduped are both zero-LLM-cost; deduped
464
+ // is the more interesting signal because it implies the indexer caught
465
+ // a redundancy, so it wins the tiebreaker.)
466
+ const actuallyFlushedCount =
467
+ processedBatches.length - trivialBatches.length - oversizedBatches.length - dedupedBatches.length;
468
+ const flushOutcome: PruneFrontier["outcome"] =
469
+ actuallyFlushedCount > 0
470
+ ? "summarized"
471
+ : oversizedBatches.length > 0
472
+ ? "skipped-oversized"
473
+ : dedupedBatches.length > 0
474
+ ? "skipped-deduped"
475
+ : "skipped-trivial";
476
+
477
+ const frontierSnapshot: PruneFrontier = {
478
+ lastAttemptedToolCallId: lastTC.toolCallId,
479
+ lastAttemptedToolName: lastTC.toolName,
480
+ lastAttemptedTurnIndex: lastBatch.turnIndex,
481
+ lastAttemptedTimestamp: lastBatch.timestamp,
482
+ attemptedBatchCount: processedBatches.length,
483
+ attemptedToolCallCount: totalToolCallCount,
484
+ rawCharCount: totalRawCharCount,
485
+ summaryCharCount: totalSummaryCharCount,
486
+ outcome: flushOutcome,
487
+ };
488
+
489
+ try {
490
+ if (delivery === "runtime") {
491
+ frontier.advance(frontierSnapshot);
492
+ frontier.persist(pi);
493
+ statsAccum.persist(pi);
494
+ } else {
495
+ frontier.advance(frontierSnapshot);
496
+ appendEntry(CUSTOM_TYPE_FRONTIER, frontierSnapshot);
497
+ try {
498
+ appendEntry(CUSTOM_TYPE_STATS, statsAccum.getStats());
499
+ } catch {
500
+ // Ignore stats persistence failures; the prune result and frontier are the contract.
501
+ }
502
+ }
503
+ } catch (err) {
504
+ return { ok: false, reason: isStaleContextError(err) ? "stale-context" : "failed", error: errorMessage(err) };
505
+ }
506
+
507
+ setPruneStatusWidget(ctx, currentConfig.value, statsAccum.getLiveReclaim());
508
+ emitExternalCost(pi, statsAccum);
509
+
510
+ // Chain compression — compress closed chains beyond the rolling window.
511
+ // Runs after the per-batch summarization so summaryBodies are up to date.
512
+ // Non-fatal: a failure here does not roll back the successful summarization.
513
+ if (currentConfig.value.chainCompression.enabled) {
514
+ try {
515
+ const branch = ctx.sessionManager.getBranch();
516
+ const branchMessages = branch
517
+ .filter((e: any) => e.type === "message" && e.message)
518
+ .map((e: any) => e.message);
519
+ // message_end fires before pi persists the closing assistant, so thread it
520
+ // in here; otherwise the newest chain reads as open and K over-retains by 1.
521
+ const chains = detectChains(withClosingMessage(branchMessages, options.closingMessage), protectionPredicate);
522
+ const { compressedEntries } = await compressEligible(
523
+ chains,
524
+ currentConfig.value.chainCompression.rollingWindow,
525
+ {
526
+ indexer,
527
+ blockRefs,
528
+ appendEntry: persistAlias,
529
+ now: () => Date.now(),
530
+ fuseRange: makeFuseRange(ctx),
531
+ },
532
+ );
533
+ if (compressedEntries.length > 0) {
534
+ statsAccum.addChainsCompressed(compressedEntries.length);
535
+ statsAccum.persist(pi);
536
+ emitExternalCost(pi, statsAccum);
537
+ safeNotify(
538
+ ctx,
539
+ `pruner: compressed ${compressedEntries.length} chain${compressedEntries.length === 1 ? "" : "s"} (${compressedEntries.map((e) => e.blockId).join(", ")})`,
540
+ "info",
541
+ );
542
+ }
543
+ } catch (err) {
544
+ if (!isStaleContextError(err)) {
545
+ safeNotify(ctx, `pruner: chain compression failed: ${errorMessage(err)}`, "warning");
546
+ }
547
+ }
548
+ }
549
+
550
+ // Notify about any batches that were skipped — either oversized or
551
+ // trivial. Neither is an error: the pruner correctly chose not to grow
552
+ // context (oversized) or to skip the LLM call entirely (trivial). Both
553
+ // are silenced by `quietOversizedSkips`, which acts as a single
554
+ // "quiet all non-error skips" toggle.
555
+ if (!currentConfig.value.quietOversizedSkips) {
556
+ for (const batch of oversizedBatches) {
557
+ const batchRaw = batch.toolCalls.reduce((s, tc) => s + tc.resultText.length, 0);
558
+ const slot = results[batches.indexOf(batch)];
559
+ const batchSummaryLen = slot && slot !== "trivial" && slot !== "deduped" ? slot.summaryText.length : 0;
560
+ safeNotify(
561
+ ctx,
562
+ `pruner: skipped pruning turn ${batch.turnIndex} (${batch.toolCalls.length} tool call${batch.toolCalls.length === 1 ? "" : "s"}) — summary was ${batchSummaryLen} chars vs ${batchRaw} raw chars; frontier advanced past this range`,
563
+ "info"
564
+ );
565
+ }
566
+ for (const batch of trivialBatches) {
567
+ const batchRaw = batch.toolCalls.reduce((s, tc) => s + tc.resultText.length, 0);
568
+ safeNotify(
569
+ ctx,
570
+ `pruner: skipped pruning turn ${batch.turnIndex} (${batch.toolCalls.length} tool call${batch.toolCalls.length === 1 ? "" : "s"}) — only ${batchRaw} raw chars (< minBatchChars=${minChars}); no LLM call made; frontier advanced past this range`,
571
+ "info"
572
+ );
573
+ }
574
+ for (const batch of dedupedBatches) {
575
+ const idx = batches.indexOf(batch);
576
+ const n = dedupedPerBatch[idx].toolCalls.length;
577
+ const chars = dedupedPerBatch[idx].rawChars;
578
+ safeNotify(
579
+ ctx,
580
+ `pruner: deduplicated ${n} tool call${n === 1 ? "" : "s"} (turn ${batch.turnIndex}, ${chars} raw chars) against earlier prunes; no LLM call made; frontier advanced past this range`,
581
+ "info"
582
+ );
583
+ }
584
+ if (totalDedupedCount > 0 && dedupedBatches.length === 0) {
585
+ // Partial-dedup case: some tool calls were dedup'd but the rest
586
+ // of the batch went through the summarizer. Surface a single
587
+ // aggregate notification so users see the savings.
588
+ safeNotify(
589
+ ctx,
590
+ `pruner: deduplicated ${totalDedupedCount} tool call${totalDedupedCount === 1 ? "" : "s"} against earlier prunes (no LLM call for those); remaining tool calls were summarized normally.`,
591
+ "info"
592
+ );
593
+ }
594
+ }
595
+
596
+ const returnReason: "flushed" | "skipped-oversized" | "skipped-trivial" | "skipped-deduped" =
597
+ actuallyFlushedCount > 0
598
+ ? "flushed"
599
+ : oversizedBatches.length > 0
600
+ ? "skipped-oversized"
601
+ : dedupedBatches.length > 0
602
+ ? "skipped-deduped"
603
+ : "skipped-trivial";
604
+
605
+ return {
606
+ ok: true,
607
+ reason: returnReason,
608
+ batchCount: processedBatches.length,
609
+ toolCallCount: totalToolCallCount,
610
+ rawCharCount: totalRawCharCount,
611
+ summaryCharCount: totalSummaryCharCount,
612
+ dedupedCount: totalDedupedCount,
613
+ };
614
+ } catch (err) {
615
+ restoreBatches(batches);
616
+ // When the abort signal fired, summarizeBatch rethrows rather than
617
+ // swallowing the error. Don't show a UI error — the user intended this.
618
+ if (options.signal?.aborted) {
619
+ setPruneStatusWidget(ctx, currentConfig.value, statsAccum.getLiveReclaim());
620
+ return { ok: false, reason: "aborted" };
621
+ }
622
+ if (isStaleContextError(err)) {
623
+ return { ok: false, reason: "stale-context", error: errorMessage(err) };
624
+ }
625
+ safeNotify(ctx, `pruner: summarization failed: ${errorMessage(err)}`, "error");
626
+ return { ok: false, reason: "failed", error: errorMessage(err) };
627
+ } finally {
628
+ isFlushing = false;
629
+ }
630
+ };
631
+
632
+ // ── session_start: restore config + index + stats ────────────────────────────────
633
+ pi.on("session_start", async (_event, ctx) => {
634
+ // Load config from <agent-dir>/settings.json `contextPrune` key (honors PI_CODING_AGENT_DIR)
635
+ currentConfig.value = await loadConfig();
636
+
637
+ // Rebuild in-memory index from persisted session entries
638
+ indexer.reconstructFromSession(ctx);
639
+
640
+ // Rebuild block-ref counter so new chain IDs don't collide with existing ones
641
+ blockRefs.rebuildFrom(indexer.getChainEntries().map((e) => e.blockId));
642
+
643
+ // Rebuild stats accumulator from persisted session entries
644
+ statsAccum.reconstructFromSession(ctx);
645
+
646
+ // Rebuild prune frontier from persisted session entries
647
+ frontier.reconstructFromSession(ctx);
648
+
649
+ // Clear any batches queued before the session reload
650
+ pendingBatches.length = 0;
651
+ previousFraction = null;
652
+
653
+ // Update footer status
654
+ setPruneStatusWidget(ctx, currentConfig.value, statsAccum.getLiveReclaim());
655
+
656
+ ctx.ui.setWidget(
657
+ "pruner-boot",
658
+ [
659
+ `pruner loaded — pruning ${currentConfig.value.enabled ? "ON" : "OFF"} | model: ${currentConfig.value.summarizerModel}`,
660
+ ],
661
+ { placement: "belowEditor" },
662
+ );
663
+ setTimeout(() => {
664
+ try {
665
+ ctx.ui.setWidget("pruner-boot", undefined);
666
+ } catch {
667
+ // UI owner may be gone after session replacement.
668
+ }
669
+ }, 10000).unref?.();
670
+ });
671
+
672
+ // Rebuild index and stats after tree navigation too (branch may have different history)
673
+ pi.on("session_tree", async (_event, ctx) => {
674
+ indexer.reconstructFromSession(ctx);
675
+ blockRefs.rebuildFrom(indexer.getChainEntries().map((e) => e.blockId));
676
+ statsAccum.reconstructFromSession(ctx);
677
+ frontier.reconstructFromSession(ctx);
678
+ // Pending batches belong to the old branch — discard them
679
+ pendingBatches.length = 0;
680
+ previousFraction = null;
681
+ });
682
+
683
+ // ── turn_end: capture batch, flush immediately or queue ──────────────────
684
+ pi.on("turn_end", async (event, ctx) => {
685
+ if (!currentConfig.value.enabled) return;
686
+
687
+ const hasToolResults = event.toolResults && event.toolResults.length > 0;
688
+
689
+ if (!hasToolResults) {
690
+ // Text-only final turns are handled by message_end in agent-message mode.
691
+ // In print mode, turn_end can fire after session shutdown, so do not start
692
+ // deferred LLM work from this late lifecycle event.
693
+ return;
694
+ }
695
+
696
+ const capturedBatch = captureBatch(
697
+ event.message,
698
+ event.toolResults,
699
+ event.turnIndex,
700
+ Date.now()
701
+ );
702
+ // Drop user-protected tool/path results so they stay verbatim in context.
703
+ // Filtering at capture time keeps the
704
+ // underlying assistant `toolCall` block AND its `ToolResultMessage`
705
+ // untouched in Pi's session/event stream — only the in-memory
706
+ // CapturedBatch is pruned, which is exactly what we want.
707
+ const filtered = {
708
+ ...capturedBatch,
709
+ toolCalls: capturedBatch.toolCalls.filter((tc) => !isProtected(tc.toolName, tc.args, currentConfig.value)),
710
+ };
711
+
712
+ // Eager spill: offload oversized single results to sidecar files before they
713
+ // ever reach a request. addBatch inside marks them isSummarized, so
714
+ // trimBatchToPendingRange drops them from the pending set below. Best-effort:
715
+ // a spill failure leaves the result inline for the normal flush pipeline.
716
+ try {
717
+ await spillOversizedBatch({
718
+ batch: filtered,
719
+ indexer,
720
+ config: {
721
+ spillThreshold: currentConfig.value.spillThreshold,
722
+ spillPreviewBytes: currentConfig.value.spillPreviewBytes,
723
+ dedupByContentHash: currentConfig.value.dedupByContentHash,
724
+ },
725
+ sessionDir: ctx.sessionManager.getSessionDir(),
726
+ sessionId: ctx.sessionManager.getSessionId(),
727
+ appendEntry: (type, data) => (ctx.sessionManager as unknown as SessionAppender).appendCustomEntry(type, data),
728
+ });
729
+ } catch {
730
+ // best-effort; never block the turn
731
+ }
732
+
733
+ const batch = trimBatchToPendingRange(filtered);
734
+ if (!batch) return;
735
+
736
+ pendingBatches.push(batch);
737
+
738
+ // Let the user know a batch is queued
739
+ const n = pendingBatches.length;
740
+ const trigger = currentConfig.value.pruneOn === "agent-message"
741
+ ? "agent's next text response"
742
+ : "/pruner now";
743
+ if (currentConfig.value.showPruneStatusLine) {
744
+ setPruneStatusWidget(ctx, currentConfig.value, `prune: ${n} pending`);
745
+ safeNotify(
746
+ ctx,
747
+ `pruner: ${n} turn${n === 1 ? "" : "s"} queued — will summarize on ${trigger}`,
748
+ "info"
749
+ );
750
+ }
751
+
752
+ // Token-budget auto-flush: an additional, mode-independent trigger. When context
753
+ // usage crosses autoBudgetThreshold, compact the queued batches now instead of
754
+ // waiting for this mode's flush boundary. The pendingBatches.length guard makes
755
+ // an already-drained queue a no-op.
756
+ const usage = ctx.getContextUsage?.();
757
+ const budgetHit = shouldBudgetFlush(usage, currentConfig.value.autoBudgetThreshold);
758
+ const deltaHit = shouldDeltaFlush(usage, previousFraction, currentConfig.value.budgetTurnDelta);
759
+ // Update the per-turn baseline; leave it unchanged when tokens is null (e.g.
760
+ // right after a compaction) so the next real reading compares to the last known.
761
+ const f = usageFraction(usage);
762
+ if (f != null) previousFraction = f;
763
+
764
+ if (pendingBatches.length > 0 && !isFlushing && (budgetHit || deltaHit)) {
765
+ // Always surface this flush (even when the routine status line is off): it's a
766
+ // significant, infrequent event — context crossed a threshold or jumped sharply
767
+ // this turn — and it self-throttles because pendingBatches is drained right after.
768
+ safeNotify(
769
+ ctx,
770
+ `pruner: ${budgetHit ? "context budget reached" : "context jumped this turn"} — compacting ${n} pending turn${n === 1 ? "" : "s"}`,
771
+ "info",
772
+ );
773
+ await flushPending(ctx, { delivery: "session" });
774
+ }
775
+ });
776
+
777
+ // ── message_end: flush after the final assistant response in agent-message mode ──
778
+ // A final assistant message is the earliest reliable boundary where the agent has
779
+ // finished using the raw tool results. flushPending captures the SessionManager
780
+ // before awaiting summarization so print-mode shutdown cannot invalidate the
781
+ // persistence path while the summarizer model is running.
782
+ pi.on("message_end", async (event, ctx) => {
783
+ if (!currentConfig.value.enabled) return;
784
+ if (currentConfig.value.pruneOn !== "agent-message") return;
785
+ if (!isFinalAssistantMessage(event.message)) return;
786
+ await flushPending(ctx, { delivery: "session", closingMessage: event.message });
787
+ });
788
+
789
+ // ── agent_end: last-chance cleanup only ─────────────────────────────────────
790
+ // agent-message normally flushes on message_end. By agent_end, print-mode Pi may
791
+ // already be disposing the session, so avoid starting a best-effort LLM call here.
792
+ pi.on("agent_end", async (_event, ctx) => {
793
+ if (!currentConfig.value.enabled) return;
794
+ if (pendingBatches.length === 0) return;
795
+ setPruneStatusWidget(ctx, currentConfig.value, `prune: ${pendingBatches.length} pending`);
796
+ });
797
+
798
+ // ── context: prune summarized tool results from next LLM call ─────────────
799
+ pi.on("context", async (event, ctx) => {
800
+ if (!currentConfig.value.enabled) return undefined;
801
+
802
+ let messages = event.messages;
803
+ let changed = false;
804
+
805
+ // pruneMessages is the single source of truth for "is there work to do".
806
+ // It fast-paths (returns the original array reference) when both the
807
+ // tool-call index and chain registry are empty, so calling it
808
+ // unconditionally is safe and avoids a split gate here.
809
+ const result = pruneMessages(
810
+ messages,
811
+ indexer,
812
+ currentConfig.value.chainCompression,
813
+ currentConfig.value.purgeErrors,
814
+ currentConfig.value.thinkingStrip,
815
+ currentConfig.value,
816
+ );
817
+ if (result.pruned) {
818
+ messages = result.messages;
819
+ changed = true;
820
+ statsAccum.setLiveReclaim(result.beforeChars, result.afterChars);
821
+ }
822
+ setPruneStatusWidget(ctx, currentConfig.value, statsAccum.getLiveReclaim());
823
+
824
+ if (!changed) return undefined;
825
+ return { messages };
826
+ });
827
+
828
+ // ── Register context_tree_query tool ──────────────────────────────────────
829
+ registerQueryTool(pi, indexer);
830
+
831
+ // ── Register /pruner command + summary message renderer ────────────
832
+ const compactChains = async (ctx: any) => {
833
+ const branch = ctx.sessionManager.getBranch();
834
+ const branchMessages = branch
835
+ .filter((e: any) => e.type === "message" && e.message)
836
+ .map((e: any) => e.message);
837
+ const chains = detectChains(branchMessages, protectionPredicate);
838
+ const result = await compressEligible(
839
+ chains,
840
+ 0, // effectiveK=0: compress every closed chain not already compressed
841
+ {
842
+ indexer,
843
+ blockRefs,
844
+ appendEntry: (type: string, data: unknown) => pi.appendEntry(type, data),
845
+ now: () => Date.now(),
846
+ fuseRange: makeFuseRange(ctx),
847
+ },
848
+ );
849
+ if (result.compressedEntries.length > 0) {
850
+ statsAccum.addChainsCompressed(result.compressedEntries.length);
851
+ statsAccum.persist(pi);
852
+ emitExternalCost(pi, statsAccum);
853
+ }
854
+ return { compressedEntries: result.compressedEntries, skipped: result.skipped.filter((s) => s.reason === "no-summary").length };
855
+ };
856
+
857
+ registerCommands(pi, currentConfig, flushPending, capturePendingBatches, () => statsAccum.getStats(), () => statsAccum.getLiveReclaim(), indexer, compactChains);
858
+ }