Files
my-pi/pi-condense/index.ts
T

1089 lines
50 KiB
TypeScript

/**
* context-prune — Pi extension entry point
*
* Wires together all modules:
* config — load/save <agent-dir>/settings.json `contextPrune` namespace (honors PI_CODING_AGENT_DIR)
* batch-capture — serialize turn_end event into CapturedBatch
* summarizer — call LLM to summarize a CapturedBatch
* indexer — maintain Map<occurrenceKey, ToolCallRecord> + session persistence
* pruner — filter context event messages
* query-tool — register context_tree_query tool
* commands — register /pruner command + message renderer
*
* Usage: pi -e .
*/
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
import { loadConfig } from "./src/config.js";
import { captureBatch, captureUnindexedBatchesFromSession, groupBatchesByMode } from "./src/batch-capture.js";
import { summarizeBatch, summarizeBatches, summarizeRange } from "./src/summarizer.js";
import { FallbackController } from "./src/summarizer-fallback.js";
import { ToolCallIndexer } from "./src/indexer.js";
import { pruneMessages } from "./src/pruner.js";
import { isProtected } from "./src/protected.js";
import { registerQueryTool } from "./src/query-tool.js";
import { registerCommands, setPruneStatusWidget } from "./src/commands.js";
import { formatSummaryToolCallRefs, makeSummaryDetails, substituteInlineRefs } from "./src/summary-refs.js";
import type {
ContextPruneConfig,
CapturedBatch,
PruneFrontier,
FlushOptions,
ContextMetricsSnapshot,
FlushMetricsEntry,
FlushTrigger,
} from "./src/types.js";
import {
DEFAULT_CONFIG,
CUSTOM_TYPE_SUMMARY,
CUSTOM_TYPE_STATS,
CUSTOM_TYPE_FRONTIER,
CUSTOM_TYPE_FLUSH_METRICS,
} from "./src/types.js";
import { computeContextMetrics } from "./src/context-metrics.js";
import { StatsAccumulator, emitExternalCost } from "./src/stats.js";
import { PruneFrontierTracker } from "./src/frontier.js";
import { BlockRefIssuer } from "./src/block-refs.js";
import { compressEligible } from "./src/chain-compressor.js";
import { detectChains, withClosingMessage } from "./src/chain-detector.js";
import { inGraceRecoveryToolCallIds } from "./src/recovery-grace.js";
import { shouldBudgetFlush, shouldDeltaFlush, usageFraction } from "./src/budget.js";
import { spillOversizedBatch } from "./src/spill.js";
import { occKey } from "./src/occurrence-key.js";
import { DiagnosticSink } from "./src/diagnostics.js";
const EMPTY_METRICS_SNAPSHOT: ContextMetricsSnapshot = { openCycleThinkingTokens: 0, largestChainSharePct: 0, frontierGapTokens: 0 };
export default function (pi: ExtensionAPI) {
// Shared mutable config reference — updated by /pruner commands
const currentConfig: { value: ContextPruneConfig } = {
value: { ...DEFAULT_CONFIG },
};
const protectionPredicate = (name: string, args: unknown) => isProtected(name, args, currentConfig.value);
// Shared indexer — rebuilt from session on every session_start / session_tree
const indexer = new ToolCallIndexer();
// Shared stats accumulator — tracks cumulative token/cost stats for summarizer calls
const statsAccum = new StatsAccumulator();
// Session-scoped summarizer outage-fallback controller (in-memory; reset on session_start).
const fallbackController = new FallbackController();
// Shared prune frontier — tracks the last completed prune attempt boundary
const frontier = new PruneFrontierTracker();
// Shared block-ref issuer — issues monotonic b<N> IDs for compressed chains;
// rebuilt from session on session_start / session_tree
const blockRefs = new BlockRefIssuer();
// Session-scoped diagnostic sink — tracks recovery-path anomaly counters
// (dedup'd across the session's lifetime, not per-render).
const diagnostics = new DiagnosticSink((type, data) => pi.appendEntry(type, data));
// Pending batches — accumulated until the prune trigger fires
const pendingBatches: CapturedBatch[] = [];
let isFlushing = false;
let previousFraction: number | null = null;
// Set on session_start/session_tree when the branch rescan finds recoverable
// work but pendingBatches was just zeroed (reload/tree-switch). Lets the
// turn_end budget gate fire without a freshly pushed batch. Boolean only —
// no queue reconstruction; flushPending's own rescan is the data path.
// Cleared on every non-concurrent flushPending invocation.
let rearmedPending = false;
// Latest ContextMetricsSnapshot, recomputed at reload probes, batch capture,
// and flush entry. Cached (rather than recomputed on every widget refresh)
// because computeContextMetrics walks the full branch.
let metricsCache: ContextMetricsSnapshot | undefined;
const computeMetricsSnapshot = (ctx: any): ContextMetricsSnapshot | undefined => {
try {
// Includes persisted custom_message entries (e.g. this extension's own
// summary messages) alongside plain "message" entries: both are retained
// LLM context, so both belong in the largest-chain-share denominator.
// Projected inline (rather than importing pi-coding-agent's
// createCustomMessage) because that helper isn't re-exported from the
// package's "." export map -- shape mirrors createCustomMessage's output
// (role "custom"), which never matches the user/assistant/toolResult
// roles computeContextMetrics keys off, so it only inflates totalChars.
const branch = ctx.sessionManager.getBranch()
.filter((e: any) => (e.type === "message" && e.message) || e.type === "custom_message")
.map((e: any) =>
e.type === "custom_message"
? { role: "custom", customType: e.customType, content: e.content, display: e.display, details: e.details, timestamp: new Date(e.timestamp).getTime() }
: e.message,
);
metricsCache = computeContextMetrics(
branch,
frontier.get(),
(k: string) => indexer.isSummarized(k),
protectionPredicate,
);
} catch (err) {
console.error("pi-condense: context metrics computation failed", err);
}
return metricsCache;
};
type FlushResult =
| { ok: true; reason: "flushed" | "skipped-oversized" | "skipped-trivial" | "skipped-deduped"; batchCount: number; toolCallCount: number; rawCharCount: number; summaryCharCount: number; dedupedCount?: number }
| { ok: false; reason: "empty" | "already-flushing" | "summarizer-failed" | "stale-context" | "failed" | "aborted"; error?: string };
type SessionAppender = {
appendCustomEntry(customType: string, data?: unknown): string;
appendCustomMessageEntry(customType: string, content: string, display: boolean, details?: unknown): string;
};
const isStaleContextError = (err: unknown) =>
err instanceof Error && err.message.includes("This extension ctx is stale");
const errorMessage = (err: unknown) => (err instanceof Error ? err.message : String(err));
const safeNotify = (ctx: any, message: string, type: "info" | "warning" | "error" = "info") => {
try {
ctx.ui.notify(message, type);
} catch (err) {
if (!isStaleContextError(err)) throw err;
}
};
const assistantMessageHasToolCalls = (message: any) =>
message?.role === "assistant" &&
Array.isArray(message.content) &&
message.content.some((block: any) => block?.type === "toolCall");
const isFinalAssistantMessage = (message: any) => message?.role === "assistant" && !assistantMessageHasToolCalls(message);
const trimBatchToPendingRange = (batch: CapturedBatch): CapturedBatch | null => {
const currentFrontier = frontier.get();
let toolCalls = batch.toolCalls;
// The indexer tells us what was successfully summarized earlier.
toolCalls = toolCalls.filter((tc) => !indexer.isSummarized(occKey(tc.toolCallId, tc.resultTimestamp)));
if (toolCalls.length === 0) return null;
// The frontier tells us the last attempted boundary even when the attempt did
// not persist index entries (e.g. skipped-oversized). When the LLM prunes in
// the middle of a long tool chain, keep later tool calls from the same turn
// instead of dropping the whole batch on the floor.
if (!currentFrontier) return { ...batch, toolCalls };
if (batch.turnIndex < currentFrontier.lastAttemptedTurnIndex) return null;
if (batch.turnIndex > currentFrontier.lastAttemptedTurnIndex) return { ...batch, toolCalls };
const originalIndex = toolCalls.findIndex((tc) => tc.toolCallId === currentFrontier.lastAttemptedToolCallId);
if (originalIndex < 0) return { ...batch, toolCalls };
const remaining = toolCalls.slice(originalIndex + 1);
if (remaining.length === 0) return null;
return { ...batch, toolCalls: remaining };
};
const restoreBatches = (batches: CapturedBatch[]) => {
pendingBatches.unshift(...batches);
};
// ── Helper: capture + trim + group pending batches (no LLM work) ──────────
// Exposed to commands.ts via registerCommands so /pruner now can preview the
// queue before opening the multi-row progress overlay.
// `rethrow` is for the reload rearm probe only (session_start/session_tree):
// it needs to observe a rescan failure so it can console.error and leave
// rearmedPending false, per spec. Every other caller (turn_end capture path,
// flushPending, /pruner commands) keeps the existing swallow-and-fall-back
// behavior so a transient getBranch failure there never blocks the turn.
const capturePendingBatches = (ctx: any, opts?: { rethrow?: boolean }): CapturedBatch[] => {
let batches: CapturedBatch[] = [];
try {
const branch = ctx.sessionManager.getBranch();
batches = captureUnindexedBatchesFromSession(branch, indexer, protectionPredicate);
} catch (err) {
if (opts?.rethrow) throw err;
batches = pendingBatches.slice();
}
batches = batches
.map((batch) => trimBatchToPendingRange(batch))
.filter((batch): batch is CapturedBatch => batch !== null);
return groupBatchesByMode(batches, currentConfig.value.batchingMode);
};
// Summarizes + indexes all pending batches.
// When options.onProgress is provided batches are processed sequentially
// (one LLM call each) so the caller can update per-row UI. Otherwise all
// batches are summarized in parallel (one summarizeBatches call).
// Runtime delivery is used while the agent/tool loop is active so Pi can place
// steer messages at protocol-safe boundaries. Session delivery is used only for
// agent-message's final-message flush, where print-mode Pi may invalidate pi.*
// while the summarizer LLM call is in flight.
// Range-summary fuser injected into compressEligible (B). Returns undefined
// when fuseRangeSummary is off so the compressor keeps the per-batch concat.
// Each successful fusion folds its usage + bumps the rangesSummarized counter.
const makeFuseRange = (ctx: any): ((text: string) => Promise<string | null>) | undefined => {
if (!currentConfig.value.chainCompression.fuseRangeSummary) return undefined;
return async (text: string) => {
const r = await summarizeRange(text, currentConfig.value, ctx, { controller: fallbackController });
if (r) {
statsAccum.add(r.usage);
statsAccum.addRangesSummarized(1);
}
return r?.summaryText ?? null;
};
};
const flushPending = async (ctx: any, options: FlushOptions = {}): Promise<FlushResult> => {
if (isFlushing) return { ok: false, reason: "already-flushing" };
// Clear on every non-concurrent invocation, regardless of outcome — the
// rearm is a one-shot nudge for the very next eligible gate check.
rearmedPending = false;
// Pre-flush pressure snapshot — recorded once at flush entry so the
// observability entry reflects what triggered this attempt, not what's
// left after it ran.
const entryMetrics: ContextMetricsSnapshot = computeMetricsSnapshot(ctx) ?? EMPTY_METRICS_SNAPSHOT;
const trigger: FlushTrigger = options.trigger ?? "manual";
const delivery = options.delivery ?? "runtime";
// One-entry-per-attempt tracking, emitted once from the outer `finally`
// below. `appendEntry` is assigned only once `sessionManager` is captured
// (session delivery); until then (empty/aborted/pre-capture-failure exits)
// the emitter falls back to pi.appendEntry.
let capturedBatches = 0;
let processedCount = 0;
let outcome: FlushMetricsEntry["outcome"] = "empty";
let appendEntry: ((customType: string, data?: unknown) => void) | undefined;
// Non-fatal by construction: observability must never affect the flush outcome.
const emitFlushMetricsOnce = () => {
const entry: FlushMetricsEntry = {
ts: Date.now(),
trigger,
capturedBatches,
processedBatches: processedCount,
outcome,
metrics: entryMetrics,
};
const appender: (type: string, data: unknown) => void = appendEntry
? delivery === "runtime" ? (type, data) => pi.appendEntry(type, data) : appendEntry
: (type, data) => pi.appendEntry(type, data);
try {
appender(CUSTOM_TYPE_FLUSH_METRICS, entry);
} catch {
// non-fatal: observability must never fail the flush
}
};
let batches: CapturedBatch[] = [];
let sessionManager: SessionAppender | undefined;
try {
// Bind the session appender as soon as delivery is known, BEFORE the
// empty-capture/aborted exits below — so emitFlushMetricsOnce's finally
// emit routes through sessionManager for those exits too, instead of
// falling back to the (possibly stale, print-mode) pi.appendEntry.
if (delivery === "session") {
try {
sessionManager = ctx.sessionManager as unknown as SessionAppender;
appendEntry = (customType: string, data?: unknown) => sessionManager!.appendCustomEntry(customType, data);
} catch (err) {
outcome = "error";
return { ok: false, reason: isStaleContextError(err) ? "stale-context" : "failed", error: errorMessage(err) };
}
}
// Use pre-captured batches if provided (avoids double-capture when the
// caller previewed the queue before opening the progress overlay).
batches = options.previewedBatches ?? capturePendingBatches(ctx);
capturedBatches = batches.length;
if (batches.length === 0) {
outcome = "empty";
return { ok: false, reason: "empty" };
}
// Bail out before we drain pendingBatches so they don't need restoring.
if (options.signal?.aborted) {
outcome = "error";
return { ok: false, reason: "aborted" };
}
// Draining the queue since we've captured the state via session or slice.
// We drain BEFORE the await so concurrent calls (though guarded by isFlushing)
// or rapid turn-ends don't result in double-summarization.
pendingBatches.length = 0;
isFlushing = true;
const appendSummaryMessage = (content: string, details: unknown) =>
sessionManager!.appendCustomMessageEntry(CUSTOM_TYPE_SUMMARY, content, false, details);
// Routes alias persistence through whichever delivery is active so the
// dedup pre-flush pass writes CUSTOM_TYPE_DEDUP_ALIAS entries via the
// same path the rest of the flush uses.
const persistAlias: (customType: string, data?: unknown) => void =
delivery === "runtime"
? (type, data) => pi.appendEntry(type, data)
: appendEntry!;
// ── Pre-flush content-hash dedup pass ────────────────────────────
// For each tool call, check the indexer's contentHashToOriginal map.
// A hit means an identical (toolName, normalized resultText) pair has
// already been summarized in an earlier flush. Register the duplicate
// as an alias of the original (so pruneMessages stub-replaces its
// ToolResultMessage with the original's short ref) and drop it from
// the batch BEFORE the summarizer / trivial classifier runs.
//
// We track per-batch deduped counts so we can:
// - count dedup'd tool calls toward `totalToolCallCount` and
// `totalRawCharCount` (they were addressed by this flush even
// though no LLM call was made for them),
// - tag fully-dedup'd batches with a `"deduped"` ResultSlot so the
// existing result loop treats them the same way it treats trivial
// batches (advance the frontier without writing a summary).
const dedupedPerBatch: { toolCalls: import("./src/types.js").CapturedToolCall[]; rawChars: number }[] = batches.map(() => ({ toolCalls: [], rawChars: 0 }));
const dedupEnabled = currentConfig.value.dedupByContentHash;
if (dedupEnabled) {
for (let i = 0; i < batches.length; i++) {
const batch = batches[i];
const remaining: typeof batch.toolCalls = [];
for (const tc of batch.toolCalls) {
const originalId = indexer.lookupByContent(tc.toolName, tc.resultText);
const key = occKey(tc.toolCallId, tc.resultTimestamp);
if (originalId && originalId !== key) {
indexer.registerDuplicate(key, originalId, persistAlias);
dedupedPerBatch[i].toolCalls.push(tc);
dedupedPerBatch[i].rawChars += tc.resultText.length;
} else {
remaining.push(tc);
}
}
// Shallow-clone the batch so we don't mutate the captured array
// (pendingBatches consumers retain the original shape on retry).
batches[i] = { ...batch, toolCalls: remaining };
}
}
// ── Pre-flush trivial filter ─────────────────────────────────
// Classify each batch by total raw resultText chars BEFORE any LLM call.
// Batches below minBatchChars are marked trivial: the summarizer is
// skipped entirely, the frontier still advances, and the original
// tool-result messages stay verbatim in context. minBatchChars === 0
// disables the guard (every batch goes to the summarizer).
//
// A batch whose entire toolCalls array was just deduped is flagged
// `isFullyDeduped` so the result loop slots it as "deduped" without
// confusing it with the trivial path (different outcome + notification).
const minChars = currentConfig.value.minBatchChars;
const batchRawChars = batches.map((b) =>
b.toolCalls.reduce((s, tc) => s + tc.resultText.length, 0),
);
const isFullyDeduped = batches.map((b, i) =>
dedupedPerBatch[i].toolCalls.length > 0 && b.toolCalls.length === 0,
);
const isTrivial = batchRawChars.map(
(c, i) => !isFullyDeduped[i] && minChars > 0 && c < minChars && batches[i].toolCalls.length > 0,
);
const nonTrivialIndices: number[] = [];
for (let i = 0; i < batches.length; i++) {
if (!isTrivial[i] && !isFullyDeduped[i]) nonTrivialIndices.push(i);
}
// Only show "summarizing…" if at least one batch will actually be sent
// to the LLM. An all-trivial flush is purely bookkeeping.
if (nonTrivialIndices.length > 0) {
setPruneStatusWidget(ctx, currentConfig.value, "prune: summarizing…");
}
const reportBatchTextProgress = (index: number, total: number, batch: CapturedBatch, receivedChars: number) => {
options.onBatchTextProgress?.(index, total, batch, receivedChars);
};
// Summarize the non-trivial subset. When onProgress is provided
// (/pruner now overlay) we process sequentially so each row can be
// checked off as its LLM call completes. Trivial and fully-deduped
// batches emit a "skipped" progress event immediately, with no
// spinner / no LLM call. The final `results` array is index-aligned
// to `batches`, with possible values: SummarizeResult (success),
// null (LLM failure), "trivial" (pre-flush small-batch skip), or
// "deduped" (pre-flush dedup ate every tool call in this batch).
type ResultSlot = import("./src/types.js").SummarizeResult | null | "trivial" | "deduped";
const results: ResultSlot[] = new Array(batches.length).fill(null);
if (options.onProgress) {
for (let i = 0; i < batches.length; i++) {
if (isFullyDeduped[i]) {
options.onProgress(i, batches.length, batches[i], "skipped");
results[i] = "deduped";
continue;
}
if (isTrivial[i]) {
options.onProgress(i, batches.length, batches[i], "skipped");
results[i] = "trivial";
continue;
}
options.onProgress(i, batches.length, batches[i], "start");
const r = await summarizeBatch(batches[i], currentConfig.value, ctx, {
signal: options.signal,
controller: fallbackController,
onTextProgress: (receivedChars) => {
reportBatchTextProgress(i, batches.length, batches[i], receivedChars);
},
});
results[i] = r;
options.onProgress(i, batches.length, batches[i], r ? "done" : "skipped");
}
} else {
// Mark all trivial + fully-deduped slots up front, then call
// summarizeBatches with only the remaining batches (parallel — one
// LLM call each).
for (let i = 0; i < batches.length; i++) {
if (isFullyDeduped[i]) results[i] = "deduped";
else if (isTrivial[i]) results[i] = "trivial";
}
if (nonTrivialIndices.length > 0) {
const nonTrivialBatches = nonTrivialIndices.map((i) => batches[i]);
const ntResults = await summarizeBatches(nonTrivialBatches, currentConfig.value, ctx, {
onBatchTextProgress: (ntIndex, _ntTotal, batch, receivedChars) => {
const origIndex = nonTrivialIndices[ntIndex];
reportBatchTextProgress(origIndex, batches.length, batch, receivedChars);
},
signal: options.signal,
controller: fallbackController,
});
for (let k = 0; k < nonTrivialIndices.length; k++) {
results[nonTrivialIndices[k]] = ntResults[k];
}
}
}
// Process results in order; stop at first null (individual call failure).
// Batches before the first failure are persisted; remaining are restored to
// pendingBatches so they are retried on the next flush.
const processedBatches: CapturedBatch[] = [];
let totalRawCharCount = 0;
let totalSummaryCharCount = 0;
let totalToolCallCount = 0;
let totalDedupedCount = 0;
const oversizedBatches: CapturedBatch[] = [];
const trivialBatches: CapturedBatch[] = [];
const dedupedBatches: CapturedBatch[] = [];
let firstFailureIndex = -1;
for (let i = 0; i < batches.length; i++) {
const result = results[i];
if (result === null) {
firstFailureIndex = i;
break;
}
const batch = batches[i];
const batchRawCharCount = batchRawChars[i];
const dedupCount = dedupedPerBatch[i].toolCalls.length;
const dedupRawChars = dedupedPerBatch[i].rawChars;
// Fully-deduped batches: every tool call matched an existing
// indexed record. The alias entries are already persisted; we just
// need to advance the frontier past this turn and count the
// dedup'd raw chars toward the flush totals so the user sees the
// savings.
if (result === "deduped") {
totalRawCharCount += dedupRawChars;
totalToolCallCount += dedupCount;
totalDedupedCount += dedupCount;
dedupedBatches.push(batch);
processedBatches.push(batch);
continue;
}
// Trivial batches: no summary text, no index entry, no stats usage —
// just bookkeeping so the frontier can advance past this range and
// the next flush does not reconsider these tool calls.
if (result === "trivial") {
// Count dedup'd tool calls (if any) on a partial-dedup batch even
// though the rest of the batch was below minBatchChars.
totalRawCharCount += batchRawCharCount + dedupRawChars;
totalToolCallCount += batch.toolCalls.length + dedupCount;
totalDedupedCount += dedupCount;
trivialBatches.push(batch);
processedBatches.push(batch);
continue;
}
const summaryRefs = indexer.allocateSummaryRefs(batch);
const toolNames = batch.toolCalls.map((tc) => tc.toolName);
const decorated = substituteInlineRefs(result.summaryText, summaryRefs, toolNames);
const summaryText = decorated + formatSummaryToolCallRefs(summaryRefs);
const shouldSkipOversized = summaryText.length > batchRawCharCount;
statsAccum.add(result.usage);
totalRawCharCount += batchRawCharCount + dedupRawChars;
totalSummaryCharCount += summaryText.length;
totalToolCallCount += batch.toolCalls.length + dedupCount;
totalDedupedCount += dedupCount;
const batchDetails = makeSummaryDetails(batch, summaryRefs);
try {
if (!shouldSkipOversized) {
// Write one hidden summary message per turn and index its tool calls.
// `display: false` keeps the summary in future LLM context (convertToLlm
// ignores `display`) while suppressing the full markdown block from Pi's
// main window; rebuild keys on customType, not display.
const batchOccurrenceKeys = batch.toolCalls.map((tc) => occKey(tc.toolCallId, tc.resultTimestamp));
if (delivery === "runtime") {
pi.sendMessage(
{ customType: CUSTOM_TYPE_SUMMARY, content: summaryText, display: false, details: batchDetails },
{ deliverAs: "steer" }
);
indexer.registerSummaryRefs(summaryRefs);
indexer.addBatch(batch, (type, data) => pi.appendEntry(type, data));
} else {
appendSummaryMessage(summaryText, batchDetails);
indexer.registerSummaryRefs(summaryRefs);
indexer.addBatch(batch, appendEntry!);
}
// Keep the in-memory summary-body registry current so chain compression
// can build synthetic chain messages without rescanning session entries.
indexer.registerSummaryBody(batchOccurrenceKeys, summaryText);
} else {
oversizedBatches.push(batch);
}
} catch (err) {
// Persistence error mid-loop: stop here, restore this and remaining batches.
if (isStaleContextError(err)) {
restoreBatches(batches.slice(i));
// Advance frontier to what we managed to persist before this point
break;
}
throw err;
}
processedBatches.push(batch);
}
// Restore unprocessed batches (those at and after the first failure)
if (firstFailureIndex >= 0) {
restoreBatches(batches.slice(firstFailureIndex));
}
if (processedBatches.length === 0) {
// Nothing was persisted (all calls failed or first call failed)
setPruneStatusWidget(ctx, currentConfig.value, statsAccum.getLiveReclaim(), diagnostics.counts(), metricsCache);
outcome = "error";
return { ok: false, reason: "summarizer-failed" };
}
// Advance frontier to the last batch we actually processed. A fully
// deduped batch has `toolCalls === []` (the dedup pass shallow-cloned
// the batch with only the remaining non-dup calls). In that case, fall
// back to the matching `dedupedPerBatch[i].toolCalls` so the frontier
// anchor still points at a real tool call — otherwise we'd dereference
// `undefined.toolCallId` and the whole flush would throw, silently
// dropping the dedup-alias write's effect on subsequent flushes.
const lastBatch = processedBatches[processedBatches.length - 1];
const lastBatchOrigIndex = batches.indexOf(lastBatch);
const lastBatchAllTCs =
lastBatch.toolCalls.length > 0
? lastBatch.toolCalls
: (lastBatchOrigIndex >= 0 ? dedupedPerBatch[lastBatchOrigIndex].toolCalls : []);
const lastTC = lastBatchAllTCs[lastBatchAllTCs.length - 1];
// Outcome precedence: any actual summary wins; oversized beats deduped
// beats trivial. (Trivial and deduped are both zero-LLM-cost; deduped
// is the more interesting signal because it implies the indexer caught
// a redundancy, so it wins the tiebreaker.)
const actuallyFlushedCount =
processedBatches.length - trivialBatches.length - oversizedBatches.length - dedupedBatches.length;
const flushOutcome: PruneFrontier["outcome"] =
actuallyFlushedCount > 0
? "summarized"
: oversizedBatches.length > 0
? "skipped-oversized"
: dedupedBatches.length > 0
? "skipped-deduped"
: "skipped-trivial";
// Raw session branch, unwrapped once for the chain-compression block below.
// Only materialized when chain compression is enabled.
let branchMessages: any[] | undefined;
if (currentConfig.value.chainCompression.enabled) {
branchMessages = ctx.sessionManager.getBranch()
.filter((e: any) => e.type === "message" && e.message)
.map((e: any) => e.message);
}
const frontierSnapshot: PruneFrontier = {
lastAttemptedToolCallId: lastTC.toolCallId,
lastAttemptedToolName: lastTC.toolName,
lastAttemptedTurnIndex: lastBatch.turnIndex,
lastAttemptedTimestamp: lastBatch.timestamp,
attemptedBatchCount: processedBatches.length,
attemptedToolCallCount: totalToolCallCount,
rawCharCount: totalRawCharCount,
summaryCharCount: totalSummaryCharCount,
outcome: flushOutcome,
};
try {
if (delivery === "runtime") {
frontier.advance(frontierSnapshot);
frontier.persist(pi);
statsAccum.persist(pi);
} else {
frontier.advance(frontierSnapshot);
appendEntry!(CUSTOM_TYPE_FRONTIER, frontierSnapshot);
try {
appendEntry!(CUSTOM_TYPE_STATS, statsAccum.getStats());
} catch {
// Ignore stats persistence failures; the prune result and frontier are the contract.
}
}
} catch (err) {
// Batches were summarized/persisted before the frontier/stats write failed;
// reflect that in processedBatches rather than reporting 0.
processedCount = processedBatches.length;
outcome = "error";
return { ok: false, reason: isStaleContextError(err) ? "stale-context" : "failed", error: errorMessage(err) };
}
setPruneStatusWidget(ctx, currentConfig.value, statsAccum.getLiveReclaim(), diagnostics.counts(), metricsCache);
emitExternalCost(pi, statsAccum);
// Chain compression — compress closed chains beyond the rolling window.
// Runs after the per-batch summarization so summaryBodies are up to date.
// Non-fatal: a failure here does not roll back the successful summarization.
if (currentConfig.value.chainCompression.enabled) {
try {
// message_end fires before pi persists the closing assistant, so thread it
// in here; otherwise the newest chain reads as open and K over-retains by 1.
// branchMessages was unwrapped once above, gated on chainCompression.enabled.
const detectionMessages = withClosingMessage(branchMessages!, options.closingMessage);
const chains = detectChains(detectionMessages, protectionPredicate);
const inGrace = inGraceRecoveryToolCallIds(branchMessages!, currentConfig.value.recoveryGraceTurns);
const { compressedEntries } = await compressEligible(
chains,
currentConfig.value.chainCompression.rollingWindow,
{
indexer,
blockRefs,
appendEntry: persistAlias,
now: () => Date.now(),
fuseRange: makeFuseRange(ctx),
messages: detectionMessages,
diagnostics,
backfill: {
spillThreshold: currentConfig.value.spillThreshold,
spillPreviewBytes: currentConfig.value.spillPreviewBytes,
sessionDir: ctx.sessionManager.getSessionDir(),
sessionId: ctx.sessionManager.getSessionId(),
},
},
inGrace,
);
if (compressedEntries.length > 0) {
statsAccum.addChainsCompressed(compressedEntries.length);
statsAccum.persist(pi);
emitExternalCost(pi, statsAccum);
safeNotify(
ctx,
`pruner: compressed ${compressedEntries.length} chain${compressedEntries.length === 1 ? "" : "s"} (${compressedEntries.map((e) => e.blockId).join(", ")})`,
"info",
);
}
} catch (err) {
if (!isStaleContextError(err)) {
safeNotify(ctx, `pruner: chain compression failed: ${errorMessage(err)}`, "warning");
}
}
}
// Notify about any batches that were skipped — either oversized or
// trivial. Neither is an error: the pruner correctly chose not to grow
// context (oversized) or to skip the LLM call entirely (trivial). Both
// are silenced by `quietOversizedSkips`, which acts as a single
// "quiet all non-error skips" toggle.
if (!currentConfig.value.quietOversizedSkips) {
for (const batch of oversizedBatches) {
const batchRaw = batch.toolCalls.reduce((s, tc) => s + tc.resultText.length, 0);
const slot = results[batches.indexOf(batch)];
const batchSummaryLen = slot && slot !== "trivial" && slot !== "deduped" ? slot.summaryText.length : 0;
safeNotify(
ctx,
`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`,
"info"
);
}
for (const batch of trivialBatches) {
const batchRaw = batch.toolCalls.reduce((s, tc) => s + tc.resultText.length, 0);
safeNotify(
ctx,
`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`,
"info"
);
}
for (const batch of dedupedBatches) {
const idx = batches.indexOf(batch);
const n = dedupedPerBatch[idx].toolCalls.length;
const chars = dedupedPerBatch[idx].rawChars;
safeNotify(
ctx,
`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`,
"info"
);
}
if (totalDedupedCount > 0 && dedupedBatches.length === 0) {
// Partial-dedup case: some tool calls were dedup'd but the rest
// of the batch went through the summarizer. Surface a single
// aggregate notification so users see the savings.
safeNotify(
ctx,
`pruner: deduplicated ${totalDedupedCount} tool call${totalDedupedCount === 1 ? "" : "s"} against earlier prunes (no LLM call for those); remaining tool calls were summarized normally.`,
"info"
);
}
}
// Very end of the try block, deliberately after (and outside) the
// chain-compression block's own try/catch above: a compression failure
// must not eat this entry — the summarization phase already succeeded.
processedCount = processedBatches.length;
outcome = flushOutcome;
const returnReason: "flushed" | "skipped-oversized" | "skipped-trivial" | "skipped-deduped" =
actuallyFlushedCount > 0
? "flushed"
: oversizedBatches.length > 0
? "skipped-oversized"
: dedupedBatches.length > 0
? "skipped-deduped"
: "skipped-trivial";
return {
ok: true,
reason: returnReason,
batchCount: processedBatches.length,
toolCallCount: totalToolCallCount,
rawCharCount: totalRawCharCount,
summaryCharCount: totalSummaryCharCount,
dedupedCount: totalDedupedCount,
};
} catch (err) {
restoreBatches(batches);
outcome = "error";
// When the abort signal fired, summarizeBatch rethrows rather than
// swallowing the error. Don't show a UI error — the user intended this.
if (options.signal?.aborted) {
setPruneStatusWidget(ctx, currentConfig.value, statsAccum.getLiveReclaim(), diagnostics.counts(), metricsCache);
return { ok: false, reason: "aborted" };
}
if (isStaleContextError(err)) {
return { ok: false, reason: "stale-context", error: errorMessage(err) };
}
safeNotify(ctx, `pruner: summarization failed: ${errorMessage(err)}`, "error");
return { ok: false, reason: "failed", error: errorMessage(err) };
} finally {
isFlushing = false;
emitFlushMetricsOnce();
}
};
// ── session_start: restore config + index + stats ────────────────────────────────
pi.on("session_start", async (_event, ctx) => {
// Load config from <agent-dir>/settings.json `contextPrune` key (honors PI_CODING_AGENT_DIR)
currentConfig.value = await loadConfig();
// Rebuild in-memory index from persisted session entries
indexer.reconstructFromSession(ctx);
// Rebuild block-ref counter so new chain IDs don't collide with existing ones
blockRefs.rebuildFrom(indexer.getChainEntries().map((e) => e.blockId));
// Rebuild stats accumulator from persisted session entries
statsAccum.reconstructFromSession(ctx);
fallbackController.reset();
diagnostics.reset();
// Rebuild prune frontier from persisted session entries
frontier.reconstructFromSession(ctx);
// Clear any batches queued before the session reload
pendingBatches.length = 0;
previousFraction = null;
rearmedPending = false;
if (currentConfig.value.enabled) {
try {
rearmedPending = capturePendingBatches(ctx, { rethrow: true }).length > 0;
} catch (err) {
console.error("pi-condense: reload rearm probe failed", err);
}
}
computeMetricsSnapshot(ctx);
// Update footer status
setPruneStatusWidget(ctx, currentConfig.value, statsAccum.getLiveReclaim(), diagnostics.counts(), metricsCache);
ctx.ui.setWidget(
"pruner-boot",
[
`pruner loaded — pruning ${currentConfig.value.enabled ? "ON" : "OFF"} | model: ${currentConfig.value.summarizerModel}`,
],
{ placement: "belowEditor" },
);
setTimeout(() => {
try {
ctx.ui.setWidget("pruner-boot", undefined);
} catch {
// UI owner may be gone after session replacement.
}
}, 10000).unref?.();
});
// Rebuild index and stats after tree navigation too (branch may have different history)
pi.on("session_tree", async (_event, ctx) => {
indexer.reconstructFromSession(ctx);
blockRefs.rebuildFrom(indexer.getChainEntries().map((e) => e.blockId));
statsAccum.reconstructFromSession(ctx);
diagnostics.reset();
frontier.reconstructFromSession(ctx);
// Pending batches belong to the old branch — discard them
pendingBatches.length = 0;
previousFraction = null;
rearmedPending = false;
if (currentConfig.value.enabled) {
try {
rearmedPending = capturePendingBatches(ctx, { rethrow: true }).length > 0;
} catch (err) {
console.error("pi-condense: reload rearm probe failed", err);
}
}
computeMetricsSnapshot(ctx);
setPruneStatusWidget(ctx, currentConfig.value, statsAccum.getLiveReclaim(), diagnostics.counts(), metricsCache);
});
// ── turn_end: capture batch, flush immediately or queue ──────────────────
pi.on("turn_end", async (event, ctx) => {
if (!currentConfig.value.enabled) return;
const hasToolResults = event.toolResults && event.toolResults.length > 0;
// Text-only final turns are handled by message_end in agent-message mode.
// In print mode, turn_end can fire after session shutdown, so do not start
// deferred LLM work from this late lifecycle event — UNLESS a reload probe
// (session_start/session_tree) found recoverable pending work: that flag
// must still reach the budget/delta gate below without a freshly captured
// batch on this turn.
if (!hasToolResults && !rearmedPending) return;
let pushedBatch = false;
if (hasToolResults) {
const capturedBatch = captureBatch(
event.message,
event.toolResults,
event.turnIndex,
Date.now()
);
// Drop user-protected tool/path results so they stay verbatim in context.
// Filtering at capture time keeps the
// underlying assistant `toolCall` block AND its `ToolResultMessage`
// untouched in Pi's session/event stream — only the in-memory
// CapturedBatch is pruned, which is exactly what we want.
const filtered = {
...capturedBatch,
toolCalls: capturedBatch.toolCalls.filter((tc) => !isProtected(tc.toolName, tc.args, currentConfig.value)),
};
// Eager spill: offload oversized single results to sidecar files before they
// ever reach a request. addBatch inside marks them isSummarized, so
// trimBatchToPendingRange drops them from the pending set below. Best-effort:
// a spill failure leaves the result inline for the normal flush pipeline.
try {
await spillOversizedBatch({
batch: filtered,
indexer,
config: {
spillThreshold: currentConfig.value.spillThreshold,
spillPreviewBytes: currentConfig.value.spillPreviewBytes,
dedupByContentHash: currentConfig.value.dedupByContentHash,
},
sessionDir: ctx.sessionManager.getSessionDir(),
sessionId: ctx.sessionManager.getSessionId(),
appendEntry: (type, data) => (ctx.sessionManager as unknown as SessionAppender).appendCustomEntry(type, data),
});
} catch {
// best-effort; never block the turn
}
const batch = trimBatchToPendingRange(filtered);
if (batch) {
pushedBatch = true;
pendingBatches.push(batch);
// Let the user know a batch is queued
const n = pendingBatches.length;
const trigger = currentConfig.value.pruneOn === "agent-message"
? "agent's next text response"
: "/pruner now";
if (currentConfig.value.showPruneStatusLine) {
setPruneStatusWidget(ctx, currentConfig.value, `prune: ${n} pending`);
safeNotify(
ctx,
`pruner: ${n} turn${n === 1 ? "" : "s"} queued — will summarize on ${trigger}`,
"info"
);
}
}
}
// Recompute regardless of whether trim produced a batch: a turn whose
// toolResults are all protected/spilled/summarized/trimmed-empty still
// changes the branch (thinking, open-segment size), so the cache must not
// go stale on it. Placed before the pushedBatch/rearmedPending early
// return below — a cache write is not gate evaluation.
if (hasToolResults) computeMetricsSnapshot(ctx);
// Mirrors main's `if (!batch) return;`: no freshly pushed batch this turn
// means no gate evaluation, regardless of leftover pendingBatches from an
// earlier turn — UNLESS a reload probe armed rearmedPending, in which case
// the gate below must still run.
if (!pushedBatch && !rearmedPending) return;
// Token-budget auto-flush: an additional, mode-independent trigger. When context
// usage crosses autoBudgetThreshold, compact the queued batches now instead of
// waiting for this mode's flush boundary. The pendingBatches.length-or-rearmed
// guard makes an already-drained, non-rearmed queue a no-op.
const usage = ctx.getContextUsage?.();
const budgetHit = shouldBudgetFlush(usage, currentConfig.value.autoBudgetThreshold);
const deltaHit = shouldDeltaFlush(usage, previousFraction, currentConfig.value.budgetTurnDelta);
// Update the per-turn baseline; leave it unchanged when tokens is null (e.g.
// right after a compaction) so the next real reading compares to the last known.
const f = usageFraction(usage);
if (f != null) previousFraction = f;
const n = pendingBatches.length;
if ((n > 0 || rearmedPending) && !isFlushing && (budgetHit || deltaHit)) {
// Always surface this flush (even when the routine status line is off): it's a
// significant, infrequent event — context crossed a threshold or jumped sharply
// this turn — and it self-throttles because pendingBatches is drained right after.
safeNotify(
ctx,
n > 0
? `pruner: ${budgetHit ? "context budget reached" : "context jumped this turn"} — compacting ${n} pending turn${n === 1 ? "" : "s"}`
: `pruner: ${budgetHit ? "context budget reached" : "context jumped this turn"} — compacting work recovered after reload`,
"info",
);
await flushPending(ctx, { delivery: "session", trigger: n === 0 ? "rearmed" : budgetHit ? "budget" : "delta" });
}
});
// ── message_end: flush after the final assistant response in agent-message mode ──
// A final assistant message is the earliest reliable boundary where the agent has
// finished using the raw tool results. flushPending captures the SessionManager
// before awaiting summarization so print-mode shutdown cannot invalidate the
// persistence path while the summarizer model is running.
pi.on("message_end", async (event, ctx) => {
if (!currentConfig.value.enabled) return;
if (currentConfig.value.pruneOn !== "agent-message") return;
if (!isFinalAssistantMessage(event.message)) return;
await flushPending(ctx, { delivery: "session", closingMessage: event.message, trigger: "message-end" });
});
// ── agent_end: last-chance cleanup only ─────────────────────────────────────
// agent-message normally flushes on message_end. By agent_end, print-mode Pi may
// already be disposing the session, so avoid starting a best-effort LLM call here.
pi.on("agent_end", async (_event, ctx) => {
if (!currentConfig.value.enabled) return;
if (pendingBatches.length === 0 && !rearmedPending) return;
setPruneStatusWidget(
ctx,
currentConfig.value,
pendingBatches.length > 0 ? `prune: ${pendingBatches.length} pending` : "prune: recovered pending (reload)",
);
});
// ── context: prune summarized tool results from next LLM call ─────────────
pi.on("context", async (event, ctx) => {
if (!currentConfig.value.enabled) return undefined;
let messages = event.messages;
let changed = false;
// pruneMessages is the single source of truth for "is there work to do".
// It returns the original array reference (pruned: false) only when none of
// the four phases changed anything; index/registry emptiness alone does not
// imply a no-op, since error-purge (phase 2) prunes independently of them.
// Calling it unconditionally is safe and avoids a split gate here.
const result = pruneMessages(
messages,
indexer,
currentConfig.value.chainCompression,
currentConfig.value.purgeErrors,
currentConfig.value,
currentConfig.value.recoveryGraceTurns,
diagnostics,
);
if (result.pruned) {
messages = result.messages;
changed = true;
statsAccum.setLiveReclaim(result.beforeChars, result.afterChars);
}
setPruneStatusWidget(ctx, currentConfig.value, statsAccum.getLiveReclaim(), diagnostics.counts(), metricsCache);
if (!changed) return undefined;
return { messages };
});
// ── Register context_tree_query tool ──────────────────────────────────────
registerQueryTool(pi, indexer);
// ── Register /pruner command + summary message renderer ────────────
const compactChains = async (ctx: any) => {
const branch = ctx.sessionManager.getBranch();
const branchMessages = branch
.filter((e: any) => e.type === "message" && e.message)
.map((e: any) => e.message);
const chains = detectChains(branchMessages, protectionPredicate);
const inGrace = inGraceRecoveryToolCallIds(branchMessages, currentConfig.value.recoveryGraceTurns);
const result = await compressEligible(
chains,
0, // effectiveK=0: compress every closed chain not already compressed
{
indexer,
blockRefs,
appendEntry: (type: string, data: unknown) => pi.appendEntry(type, data),
now: () => Date.now(),
fuseRange: makeFuseRange(ctx),
messages: branchMessages,
diagnostics,
backfill: {
spillThreshold: currentConfig.value.spillThreshold,
spillPreviewBytes: currentConfig.value.spillPreviewBytes,
sessionDir: ctx.sessionManager.getSessionDir(),
sessionId: ctx.sessionManager.getSessionId(),
},
},
inGrace,
);
if (result.compressedEntries.length > 0) {
statsAccum.addChainsCompressed(result.compressedEntries.length);
statsAccum.persist(pi);
emitExternalCost(pi, statsAccum);
}
return { compressedEntries: result.compressedEntries, skipped: result.skipped.filter((s) => s.reason === "no-summary").length };
};
registerCommands(
pi,
currentConfig,
flushPending,
capturePendingBatches,
() => statsAccum.getStats(),
() => statsAccum.getLiveReclaim(),
indexer,
compactChains,
() => diagnostics.counts(),
(ctx: any) => computeMetricsSnapshot(ctx) ?? EMPTY_METRICS_SNAPSHOT,
() => metricsCache,
() => rearmedPending,
);
}