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.
- package/CHANGELOG.md +73 -0
- package/LICENSE +22 -0
- package/PRUNING.md +1028 -0
- package/README.md +243 -0
- package/index.ts +858 -0
- package/package.json +56 -0
- package/src/batch-capture.ts +226 -0
- package/src/block-refs.test.ts +42 -0
- package/src/block-refs.ts +16 -0
- package/src/budget.test.ts +66 -0
- package/src/budget.ts +39 -0
- package/src/chain-compressor.test.ts +283 -0
- package/src/chain-compressor.ts +132 -0
- package/src/chain-detector.test.ts +302 -0
- package/src/chain-detector.ts +128 -0
- package/src/chain-range-prune.test.ts +522 -0
- package/src/chain-range-prune.ts +128 -0
- package/src/commands.test.ts +67 -0
- package/src/commands.ts +1207 -0
- package/src/config.ts +126 -0
- package/src/content-hash.ts +35 -0
- package/src/error-purge.test.ts +186 -0
- package/src/error-purge.ts +71 -0
- package/src/frontier.ts +62 -0
- package/src/indexer.ts +393 -0
- package/src/nested-placeholders.test.ts +82 -0
- package/src/nested-placeholders.ts +20 -0
- package/src/oversized-spill.integration.test.ts +73 -0
- package/src/protected.test.ts +62 -0
- package/src/protected.ts +51 -0
- package/src/pruner.test.ts +508 -0
- package/src/pruner.ts +156 -0
- package/src/query-tool.ts +78 -0
- package/src/range-compression.integration.test.ts +252 -0
- package/src/spill.test.ts +102 -0
- package/src/spill.ts +90 -0
- package/src/stats.test.ts +114 -0
- package/src/stats.ts +190 -0
- package/src/summarizer.test.ts +17 -0
- package/src/summarizer.ts +262 -0
- package/src/summary-refs.ts +61 -0
- package/src/thinking-strip.test.ts +175 -0
- package/src/thinking-strip.ts +42 -0
- package/src/tree-browser.ts +382 -0
- 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
|
+
}
|