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/src/types.ts ADDED
@@ -0,0 +1,764 @@
1
+ /**
2
+ * Shared types for the context-prune extension.
3
+ *
4
+ * Design decisions (Phase 1):
5
+ *
6
+ * SUMMARIZATION BATCH (Ph1 step 2):
7
+ * One batch = one completed assistant turn with tool calls, captured from
8
+ * the `turn_end` event when event.toolResults.length > 0.
9
+ * event.message = AssistantMessage (contains ToolCall content blocks with ids)
10
+ * event.toolResults = ToolResultMessage[] (one per tool call in this turn)
11
+ *
12
+ * STATE MODEL (Ph1 step 3):
13
+ * - Runtime state: Map<toolCallId, ToolCallRecord> rebuilt on session_start
14
+ * - Session metadata: pi.appendEntry("context-prune-index", IndexEntryData)
15
+ * stored once per summarized batch; NOT in LLM context
16
+ * - User config: .pi/settings.json → "contextPrune" key (JSON merge safe,
17
+ * Pi preserves unknown keys when rewriting settings files)
18
+ *
19
+ * CONFIG FORMAT (Ph1 step 4):
20
+ * { "contextPrune": { "enabled": false, "summarizerModel": "default", "showPruneStatusLine": true } }
21
+ * summarizerModel: "default" = use current active model (ctx.model)
22
+ * "provider/model-id" = explicit model via ctx.modelRegistry.find()
23
+ *
24
+ * SUMMARY MESSAGE FORMAT (Ph1 step 5):
25
+ * customType: "context-prune-summary"
26
+ * content: markdown with one bullet per tool call + short-id footer
27
+ * details: SummaryMessageDetails (toolCallRefs, toolNames, turnIndex, timestamp)
28
+ * The content itself includes short alias IDs in plain text so the model can
29
+ * reference them in future context_tree_query calls without needing details.
30
+ *
31
+ * API CONSTRAINTS (Ph1 step 6):
32
+ * - Pruning MUST happen in the `context` event via { messages: filtered },
33
+ * never by mutating session history (pi.appendEntry / session file untouched)
34
+ * - Summary injection uses pi.sendMessage(..., { deliverAs: "steer" }) from
35
+ * inside the turn_end handler so it lands before the next LLM call
36
+ * - Original full tool outputs are preserved in IndexEntryData (session custom
37
+ * entries) and accessible via context_tree_query at any time
38
+ * - v1 prunes only ToolResultMessage entries; the AssistantMessage tool-call
39
+ * blocks (which carry the toolCallIds) are intentionally kept so the model
40
+ * can still reference them when calling context_tree_query
41
+ * - "default" summarizer = ctx.model (current active model + its credentials),
42
+ * NOT a hidden side-channel. It makes an explicit LLM call from turn_end.
43
+ */
44
+
45
+ // ── Constants ──────────────────────────────────────────────────────────────
46
+
47
+ /** customType for summary custom_message entries (appear in LLM context) */
48
+ export const CUSTOM_TYPE_SUMMARY = "context-prune-summary";
49
+
50
+ /** customType for index persistence entries (NOT in LLM context) */
51
+ export const CUSTOM_TYPE_INDEX = "context-prune-index";
52
+
53
+ /** customType for stats persistence entries (NOT in LLM context) */
54
+ export const CUSTOM_TYPE_STATS = "context-prune-stats";
55
+
56
+ /** customType for prune-frontier persistence entries (NOT in LLM context) */
57
+ export const CUSTOM_TYPE_FRONTIER = "context-prune-frontier";
58
+
59
+ /**
60
+ * customType for content-hash dedup alias entries (NOT in LLM context).
61
+ *
62
+ * One entry per duplicate tool call detected by the pre-flush dedup pass.
63
+ * The new toolCallId is registered as an alias of an already-indexed
64
+ * original toolCallId. The original's record (in CUSTOM_TYPE_INDEX) is
65
+ * the source of truth for the result text. See
66
+ * src/content-hash.ts and src/indexer.ts for the dedup machinery.
67
+ */
68
+ export const CUSTOM_TYPE_DEDUP_ALIAS = "context-prune-dedup-alias";
69
+
70
+ /**
71
+ * customType for chain-compression entries (NOT in LLM context).
72
+ *
73
+ * One entry per closed chain that has been range-dropped from LLM context.
74
+ * Rebuilt on `session_start` to repopulate the chain registry.
75
+ * Written by `chain-compressor.compressEligible` at the tail of `flushPending` and via `/pruner compact`.
76
+ */
77
+ export const CUSTOM_TYPE_CHAIN = "context-prune-chain";
78
+
79
+ /** pi.events channel for cross-extension cost contributions (an aggregator like pi-subagents folds these into one total). */
80
+ export const EXTERNAL_COST_CHANNEL = "cost:external";
81
+
82
+ /** Stable producer id for this extension's cost contributions. */
83
+ export const EXTERNAL_COST_SOURCE = "pi-condense";
84
+
85
+ /** Footer status widget ID */
86
+ export const STATUS_WIDGET_ID = "context-prune";
87
+
88
+ /**
89
+ * Widget ID for the live /pruner now progress panel shown above the editor.
90
+ */
91
+ export const PROGRESS_WIDGET_ID = "context-prune-progress";
92
+ // ── Config ─────────────────────────────────────────────────────────────────
93
+
94
+ /**
95
+ * When summarization (and context pruning) is triggered.
96
+ * - "agent-message" : batches up turns and flushes when the agent sends a final text response
97
+ * (a turn with no tool calls), or when the agent loop ends (default)
98
+ * - "on-demand" : only when the user runs /pruner now
99
+ */
100
+ export type PruneOn = "on-demand" | "agent-message";
101
+
102
+ /**
103
+ * Granularity of pruning batches.
104
+ * - "turn" : one summary per assistant turn (default; current behavior)
105
+ * - "agent-message" : one summary per full user → final-agent-message span
106
+ * (merges all turns between two consecutive user messages)
107
+ */
108
+ export type BatchingMode = "turn" | "agent-message";
109
+
110
+ /** Thinking/reasoning level requested for summarizer LLM calls. */
111
+ export type SummarizerThinking = "default" | "off" | "minimal" | "low" | "medium" | "high" | "xhigh";
112
+
113
+ /** Choices for the summarizer thinking setting (used by commands and settings overlay) */
114
+ export const SUMMARIZER_THINKING_LEVELS: { value: SummarizerThinking; label: string }[] = [
115
+ { value: "default", label: "Default" },
116
+ { value: "off", label: "Off" },
117
+ { value: "minimal", label: "Minimal" },
118
+ { value: "low", label: "Low" },
119
+ { value: "medium", label: "Medium" },
120
+ { value: "high", label: "High" },
121
+ { value: "xhigh", label: "XHigh" },
122
+ ];
123
+
124
+ /** Cycling presets for the `purgeErrors.cooldownTurns` setting. */
125
+ export const PURGE_COOLDOWN_PRESETS: { value: string; label: string }[] = [
126
+ { value: "1", label: "1" },
127
+ { value: "2", label: "2 (default)" },
128
+ { value: "3", label: "3" },
129
+ { value: "5", label: "5" },
130
+ { value: "10", label: "10" },
131
+ ];
132
+
133
+ /** Cycling presets for the `purgeErrors.minArgChars` setting. */
134
+ export const PURGE_MIN_ARG_PRESETS: { value: string; label: string }[] = [
135
+ { value: "100", label: "100" },
136
+ { value: "500", label: "500 (default)" },
137
+ { value: "1000", label: "1000" },
138
+ { value: "5000", label: "5000" },
139
+ ];
140
+
141
+ /** Choices for the batching-mode setting (used by commands and settings overlay) */
142
+ export const BATCHING_MODES: { value: BatchingMode; label: string }[] = [
143
+ { value: "turn", label: "Per turn" },
144
+ { value: "agent-message", label: "Per agent message" },
145
+ ];
146
+
147
+ /**
148
+ * Cycling preset values for the `chainCompression.rollingWindow` setting.
149
+ * Stored as strings because SettingsList cycles string values; converted to
150
+ * number when applied.
151
+ */
152
+ export const ROLLING_WINDOW_PRESETS: { value: string; label: string }[] = [
153
+ { value: "1", label: "1" },
154
+ { value: "2", label: "2" },
155
+ { value: "3", label: "3 (default)" },
156
+ { value: "5", label: "5" },
157
+ { value: "10", label: "10" },
158
+ ];
159
+
160
+ /**
161
+ * Cycling preset values for the `thinkingStrip.keepLastTurns` setting.
162
+ * Stored as strings because SettingsList cycles string values; converted to
163
+ * number when applied. Counts ASSISTANT turns (messages), not closed chains.
164
+ */
165
+ export const KEEP_LAST_TURNS_PRESETS: { value: string; label: string }[] = [
166
+ { value: "4", label: "4" },
167
+ { value: "8", label: "8" },
168
+ { value: "16", label: "16 (default)" },
169
+ { value: "32", label: "32" },
170
+ { value: "64", label: "64" },
171
+ ];
172
+
173
+ /**
174
+ * Cycling preset values for the `minBatchChars` setting in the SettingsList.
175
+ * Stored as strings because SettingsList cycles string values; converted to
176
+ * number when applied. `"0"` is the disabled sentinel.
177
+ */
178
+ export const MIN_BATCH_CHARS_PRESETS: { value: string; label: string }[] = [
179
+ { value: "0", label: "0 (disabled)" },
180
+ { value: "500", label: "500" },
181
+ { value: "1000", label: "1000 (default)" },
182
+ { value: "2000", label: "2000" },
183
+ { value: "5000", label: "5000" },
184
+ ];
185
+
186
+ /**
187
+ * Cycling presets for the `autoBudgetThreshold` setting (stored as strings;
188
+ * the settings UI cycles string values). "0" is the disabled sentinel → null.
189
+ * Other values are 0–1 fractions of the context window (e.g. "0.8" = flush at 80%).
190
+ */
191
+ export const AUTO_BUDGET_PRESETS: { value: string; label: string }[] = [
192
+ { value: "0", label: "Off (default)" },
193
+ { value: "0.6", label: "60%" },
194
+ { value: "0.7", label: "70%" },
195
+ { value: "0.8", label: "80%" },
196
+ { value: "0.9", label: "90%" },
197
+ ];
198
+
199
+ /** Choices for the prune-on setting (used by commands and settings overlay) */
200
+ export const PRUNE_ON_MODES: { value: PruneOn; label: string }[] = [
201
+ { value: "agent-message", label: "On agent message" },
202
+ { value: "on-demand", label: "On demand" },
203
+ ];
204
+
205
+ /** Extension config stored under the `contextPrune` key in `<agent-dir>/settings.json` (agent-dir honors `PI_CODING_AGENT_DIR`). */
206
+ export interface ContextPruneConfig {
207
+ /** Whether to prune raw tool outputs from future LLM context */
208
+ enabled: boolean;
209
+ /** Whether to show the prune footer status line and queued turn messages */
210
+ showPruneStatusLine: boolean;
211
+ /**
212
+ * Which model to use for summarization.
213
+ * "default" = current active Pi model (ctx.model)
214
+ * "provider/model-id" = explicit model (e.g. "anthropic/claude-haiku-3-5")
215
+ */
216
+ summarizerModel: string;
217
+ /** Thinking/reasoning level to request for summarizer calls. */
218
+ summarizerThinking: SummarizerThinking;
219
+ /** When to trigger summarization and pruning */
220
+ pruneOn: PruneOn;
221
+ /**
222
+ * Granularity of each pruning batch.
223
+ * - "turn" : one summary per assistant turn (default)
224
+ * - "agent-message" : one summary per user → final-agent-message span
225
+ * (all turns between two user messages are merged)
226
+ */
227
+ batchingMode: BatchingMode;
228
+ /**
229
+ * Suppress the UI notification emitted when a batch is skipped — for either
230
+ * reason: (a) the summary would have been larger than the raw tool-result
231
+ * text (oversized), or (b) the batch was below `minBatchChars` and never
232
+ * sent to the summarizer (trivial). The frontier still advances in both
233
+ * cases; only the notification is silenced. Useful for sessions dominated
234
+ * by small tool calls where one or both fire on nearly every turn.
235
+ */
236
+ quietOversizedSkips: boolean;
237
+ /**
238
+ * Pre-flush guard. If the total raw `resultText` character count across all
239
+ * tool calls in a batch is below this threshold, the batch is skipped: no
240
+ * summarizer LLM call is made, no index entry is written, no summary
241
+ * message is injected, and the prune frontier advances past the batch so
242
+ * the same tool calls are not reconsidered on the next flush.
243
+ *
244
+ * Rationale: a short summary like "Tool X did Y" can already be 50–150
245
+ * chars per call. For very small batches (e.g. a 200-byte file read) the
246
+ * summary is near-identical in size or even larger than the raw input, so
247
+ * calling the LLM is wasted cost. The existing post-call `skipped-oversized`
248
+ * mechanism catches this AFTER the LLM round-trip; `minBatchChars` catches
249
+ * the obvious cases BEFORE it, at zero LLM cost.
250
+ *
251
+ * Set to `0` to disable the pre-flush guard entirely (every batch is sent
252
+ * to the summarizer; oversized skipping still applies after the fact).
253
+ *
254
+ * Default: 1000.
255
+ */
256
+ minBatchChars: number;
257
+ /**
258
+ * Tool names whose outputs must NEVER be pruned or summarized. Tool calls
259
+ * with matching `toolName` are filtered out of the pruning capture path so
260
+ * their original `ToolResultMessage` stays verbatim in future LLM context.
261
+ *
262
+ * Use for tools whose raw output the agent must keep reading verbatim
263
+ * across turns — for example `todowrite` / `todoread` carrying plan state,
264
+ * or any tool returning a structured handle the agent expects to find
265
+ * unchanged later.
266
+ *
267
+ * Default is `[]` (empty) so behavior is preserved for existing configs and
268
+ * we do not assume which skill-provided tools (e.g. todo*) the user has
269
+ * loaded. Users opt in via `/pruner protected-tools` or the settings file.
270
+ *
271
+ * Matched names are compared by exact tool name; missing / typoed names
272
+ * are silently ignored (they simply never match any captured tool call).
273
+ */
274
+ protectedTools: string[];
275
+ /**
276
+ * Glob patterns matched against a tool call's `args.path`. Matching calls are
277
+ * protected with identical semantics to protectedTools. Default protects
278
+ * skill files and their sibling reference docs under any `skills/` dir.
279
+ * Kill switch: set to [] in settings.json (`contextPrune.protectedPaths`).
280
+ */
281
+ protectedPaths: string[];
282
+ /** Chain-level range compression for old closed chains beyond the rolling window. */
283
+ chainCompression: ChainCompressionConfig;
284
+ /** Replace failed toolCall argument bodies with compact stubs after a cooldown window. */
285
+ purgeErrors: ErrorPurgeConfig;
286
+ /** Rolling main-loop thinking-block strip: keep thinking only on the last K assistant turns. */
287
+ thinkingStrip: ThinkingStripConfig;
288
+ /**
289
+ * Pre-flush content-hash dedup pass. When `true`, each captured tool call
290
+ * is hashed by `(toolName, normalize(resultText))` and compared against
291
+ * records already in the indexer. Matches are registered as aliases of the
292
+ * original via `CUSTOM_TYPE_DEDUP_ALIAS` and removed from the batch BEFORE
293
+ * any summarizer LLM call. The duplicate's `ToolResultMessage` is then
294
+ * stub-replaced by `pruneMessages` using the original's short ref, and
295
+ * `context_tree_query` resolves the duplicate's id back to the original
296
+ * record via the alias map.
297
+ *
298
+ * Normalization is conservative: line-ending normalization (`\r\n` → `\n`),
299
+ * per-line trailing whitespace stripping, plus a final `trim()`. Internal
300
+ * whitespace, tabs, and capitalization are preserved so hashes only match
301
+ * for exact-content duplicates.
302
+ *
303
+ * V1 deliberately dedupes only against records ALREADY in the indexer
304
+ * (i.e. from earlier flushes). Intra-flush dedup is not yet implemented to
305
+ * avoid the case where a "canonical" batch is skipped as oversized or
306
+ * trivial, leaving dangling aliases.
307
+ *
308
+ * Default: `true` — low-risk free win. Set to `false` if you want to keep
309
+ * redundant raw outputs verbatim (e.g. debugging two reads of the same
310
+ * file).
311
+ */
312
+ dedupByContentHash: boolean;
313
+ /**
314
+ * Token-budget auto-flush trigger. A fraction in (0, 1] (a 0–1 share of the
315
+ * context window, NOT a 0–100 percentage; e.g. 0.8 = flush at 80% of the
316
+ * window). When set, a flush of all pending batches is forced at the end of
317
+ * any tool-using turn once context usage (tokens / contextWindow) reaches the
318
+ * threshold — regardless of `pruneOn`. An ADDITIONAL trigger on top of
319
+ * `pruneOn`, not a replacement.
320
+ *
321
+ * null (default) = disabled, preserving pre-feature behavior. Out-of-range
322
+ * values (<= 0 or > 1) normalize to null.
323
+ */
324
+ autoBudgetThreshold: number | null;
325
+ /** Min chars (resultText.length) for a single tool result to spill to a sidecar file. */
326
+ spillThreshold: number;
327
+ /** Head-preview size in bytes kept inline as resultPreview on a spilled record. */
328
+ spillPreviewBytes: number;
329
+ /**
330
+ * Per-turn usage-fraction increase (0–1) that forces a flush, independent of
331
+ * autoBudgetThreshold. null (default) = disabled. Out-of-range (<= 0 or > 1) normalizes to null.
332
+ */
333
+ budgetTurnDelta: number | null;
334
+ }
335
+
336
+ /**
337
+ * Detected (pre-decision) shape emitted by chain-detector.
338
+ * Distinct from ChainCompressionEntry (the persisted post-decision shape).
339
+ *
340
+ * NOTE: AgentMessage has no `.id` field, so chains are identified by
341
+ * `timestamp` (for user/final-assistant boundaries) and `toolCallId` sets
342
+ * (for middle tool-using turns). The chain-compressor promotes ChainRange
343
+ * into a ChainCompressionEntry by adding blockId, toolRefs, and compressedAt.
344
+ */
345
+ export interface ChainRange {
346
+ /** Timestamp of the user message that opens the chain. */
347
+ startUserTimestamp: number;
348
+ /**
349
+ * All toolCallIds in the chain's middle (deduplicated).
350
+ * Collected from both AssistantMessage ToolCall blocks AND matching
351
+ * ToolResultMessages. Used to: (1) drop ToolResultMessages, (2) identify
352
+ * and drop middle AssistantMessages, (3) suppress per-batch summary
353
+ * CustomMessages whose toolCallRefs overlap.
354
+ */
355
+ middleToolCallIds: string[];
356
+ /**
357
+ * Subset of middleToolCallIds whose tool name ∈ protectedTools (detection-time
358
+ * fact). The detector always emits it ([] when no protected tool ran); optional
359
+ * so hand-built ChainRange fixtures need not set it.
360
+ */
361
+ protectedToolCallIds?: string[];
362
+ /** Timestamp of the final text-only assistant message, or null if truncated/open. */
363
+ finalAssistantTimestamp: number | null;
364
+ }
365
+
366
+ /**
367
+ * Persisted per chain that has been range-dropped from LLM context.
368
+ * Written via pi.appendEntry(CUSTOM_TYPE_CHAIN, entry).
369
+ * Rebuilt into the chain registry on session_start.
370
+ */
371
+ export interface ChainCompressionEntry {
372
+ /** Stable block ID, monotonic per session: "b1", "b2", ... */
373
+ blockId: string;
374
+ /** Timestamp of the user message that opens the chain. Keep raw; synthetic inserted after. */
375
+ startUserTimestamp: number;
376
+ /**
377
+ * ToolCallIds of all dropped middle messages.
378
+ * Used at context-transform time to: drop matching ToolResultMessages,
379
+ * drop AssistantMessages that contain any of these as ToolCall blocks,
380
+ * and suppress per-batch summary messages whose toolCallRefs overlap.
381
+ */
382
+ droppedToolCallIds: string[];
383
+ /**
384
+ * Subset of droppedToolCallIds whose tool was user-protected. Membership is decided
385
+ * per call by tool name (every call whose name ∈ protectedTools), not a per-id allowlist.
386
+ * Their verbatim ToolResultMessage text is relocated into the synthetic
387
+ * <compressed-chain> body at render time (pulled live from the raw branch) instead
388
+ * of being dropped. Absent/empty ⇒ no protected outputs (identical to pre-feature render).
389
+ */
390
+ protectedToolCallIds?: string[];
391
+ /**
392
+ * Timestamp of the final text-only assistant in the chain.
393
+ * Kept in context but with thinking blocks stripped.
394
+ * Null when the chain was truncated (no text-only close found).
395
+ */
396
+ finalAssistantTimestamp: number | null;
397
+ /** Short t<N> refs for the tool calls in this chain, surfaced in the synthetic message's `tools="..."` attribute. */
398
+ toolRefs: string[];
399
+ /** Epoch ms when the compression decision was recorded. */
400
+ compressedAt: number;
401
+ /**
402
+ * Cohesive LLM range summary fusing the chain's per-batch summaries
403
+ * (set when `chainCompression.fuseRangeSummary` is on and the span has >= 2
404
+ * per-batch summaries to fuse). When present, the renderer uses this as the
405
+ * synthetic `<compressed-chain>` body instead of the per-batch concatenation.
406
+ * Absent on fusion failure / single-batch spans → renderer falls back to concat.
407
+ */
408
+ rangeSummaryText?: string;
409
+ }
410
+
411
+ export interface ChainCompressionConfig {
412
+ enabled: boolean;
413
+ /** Number of most-recently-closed chains to keep raw (not compressed). Default 3. */
414
+ rollingWindow: number;
415
+ /** Strip thinking blocks from the kept final text-only assistant. Default true. */
416
+ stripFinalAssistantThinking: boolean;
417
+ /**
418
+ * Fuse a compressed chain's per-batch summaries into one cohesive LLM range
419
+ * summary (one extra summarizer call per multi-batch span at compression
420
+ * time). Off → the synthetic message keeps the per-batch concatenation.
421
+ * Default true.
422
+ */
423
+ fuseRangeSummary: boolean;
424
+ }
425
+
426
+ export interface ErrorPurgeConfig {
427
+ enabled: boolean;
428
+ /** Wait this many turns after the error before purging the toolCall argument body. Default 2. */
429
+ cooldownTurns: number;
430
+ /** Only purge arg bodies larger than this many chars. Default 500. */
431
+ minArgChars: number;
432
+ }
433
+
434
+ export interface ThinkingStripConfig {
435
+ enabled: boolean;
436
+ /**
437
+ * Keep `thinking` blocks on the last K assistant turns; strip them from
438
+ * older assistant messages (preserving text + toolCall blocks). Counts
439
+ * assistant messages, not closed chains. Clamped to >= 1 so the most-recent
440
+ * assistant turn always keeps its thinking (Anthropic requires the last
441
+ * assistant turn's thinking during tool use). Default 16.
442
+ */
443
+ keepLastTurns: number;
444
+ }
445
+
446
+ export const DEFAULT_CONFIG: ContextPruneConfig = {
447
+ enabled: false,
448
+ showPruneStatusLine: true,
449
+ summarizerModel: "default",
450
+ summarizerThinking: "default",
451
+ pruneOn: "agent-message",
452
+ batchingMode: "turn",
453
+ quietOversizedSkips: false,
454
+ minBatchChars: 1000,
455
+ protectedTools: [],
456
+ protectedPaths: ["**/skills/**/*.md"],
457
+ chainCompression: {
458
+ enabled: true,
459
+ rollingWindow: 3,
460
+ stripFinalAssistantThinking: true,
461
+ fuseRangeSummary: true,
462
+ },
463
+ purgeErrors: {
464
+ enabled: true,
465
+ cooldownTurns: 2,
466
+ minArgChars: 500,
467
+ },
468
+ thinkingStrip: {
469
+ enabled: true,
470
+ keepLastTurns: 16,
471
+ },
472
+ dedupByContentHash: true,
473
+ autoBudgetThreshold: null,
474
+ spillThreshold: 65536,
475
+ spillPreviewBytes: 2048,
476
+ budgetTurnDelta: null,
477
+ };
478
+
479
+ // ── Captured batch ─────────────────────────────────────────────────────────
480
+
481
+ /** A single tool call + its result as captured from turn_end */
482
+ export interface CapturedToolCall {
483
+ toolCallId: string;
484
+ toolName: string;
485
+ args: Record<string, unknown>;
486
+ resultText: string;
487
+ isError: boolean;
488
+ spillPath?: string;
489
+ spillBytes?: number;
490
+ resultPreview?: string;
491
+ contentHash?: string;
492
+ }
493
+
494
+ /**
495
+ * One complete batch from a single turn_end event.
496
+ * Represents one assistant turn that contained tool calls.
497
+ */
498
+ export interface CapturedBatch {
499
+ turnIndex: number;
500
+ timestamp: number;
501
+ /** Any non-tool-call text from the assistant message (may be empty) */
502
+ assistantText: string;
503
+ toolCalls: CapturedToolCall[];
504
+ /**
505
+ * Grouping key assigned by `captureUnindexedBatchesFromSession`.
506
+ * Increments for each user message seen while walking the branch.
507
+ * Batches from the live `turn_end` path do NOT have this field set
508
+ * (they are always emitted one-per-turn regardless of batchingMode).
509
+ * Used by `groupBatchesByMode` to merge turns within the same
510
+ * user → agent-message span when batchingMode === "agent-message".
511
+ */
512
+ userTurnGroup?: number;
513
+ }
514
+
515
+ // ── Index record ───────────────────────────────────────────────────────────
516
+
517
+ /**
518
+ * A single tool call record stored in the runtime index.
519
+ * Contains the full original tool output for context_tree_query recovery.
520
+ */
521
+ export interface ToolCallRecord {
522
+ toolCallId: string;
523
+ toolName: string;
524
+ args: Record<string, unknown>;
525
+ /** Full original result text. Empty ("") for spilled records — body lives in the sidecar file at spillPath. */
526
+ resultText: string;
527
+ isError: boolean;
528
+ turnIndex: number;
529
+ timestamp: number;
530
+ /** Absolute path to the sidecar blob holding the full body (set only when the result was spilled). */
531
+ spillPath?: string;
532
+ /** Full byte length of the spilled body. */
533
+ spillBytes?: number;
534
+ /** Head preview kept inline when spilled (resultText is "" in that case). */
535
+ resultPreview?: string;
536
+ /** Dedup hash of the FULL body, persisted so reconstruct/addBatch skip rehashing the empty resultText. */
537
+ contentHash?: string;
538
+ }
539
+
540
+ // ── Session persistence types ──────────────────────────────────────────────
541
+
542
+ /**
543
+ * Data stored via pi.appendEntry(CUSTOM_TYPE_INDEX, data).
544
+ * One entry per summarized batch; reconstructed into the runtime index on session_start.
545
+ */
546
+ export interface IndexEntryData {
547
+ toolCalls: ToolCallRecord[];
548
+ }
549
+
550
+ /**
551
+ * Data stored via pi.appendEntry(CUSTOM_TYPE_DEDUP_ALIAS, data).
552
+ *
553
+ * Each entry maps a duplicate toolCallId to the original (already-indexed)
554
+ * toolCallId whose (toolName, normalized resultText) hash it matched.
555
+ *
556
+ * - pruneMessages stub-replaces the duplicate's ToolResultMessage using the
557
+ * original's short ref (via the indexer's toolCallIdToAlias map).
558
+ * - context_tree_query resolves the duplicate's id back to the original
559
+ * record via the indexer's dedup alias map.
560
+ *
561
+ * `hash` is optional and stored only for debugging; reconstruction works
562
+ * without it because the original record is re-hashed when its
563
+ * CUSTOM_TYPE_INDEX entry is replayed.
564
+ */
565
+ export interface DedupAliasEntryData {
566
+ newToolCallId: string;
567
+ originalToolCallId: string;
568
+ hash?: string;
569
+ }
570
+
571
+ /**
572
+ * Short alias used in the summary message text plus the real toolCallId it
573
+ * maps back to for future recovery through context_tree_query.
574
+ */
575
+ export interface SummaryToolCallRef {
576
+ shortId: string;
577
+ toolCallId: string;
578
+ }
579
+
580
+ /**
581
+ * Details stored in the custom summary message's `details` field.
582
+ * Machine-readable metadata so renderers and extensions can inspect summaries.
583
+ */
584
+ export interface SummaryMessageDetails {
585
+ toolCallRefs: SummaryToolCallRef[];
586
+ toolNames: string[];
587
+ turnIndex: number;
588
+ timestamp: number;
589
+ }
590
+
591
+ // ── Summarizer stats ────────────────────────────────────────────────────────
592
+
593
+ /**
594
+ * Cumulative token/cost stats for summarizer LLM calls and chain compression.
595
+ * Persisted via pi.appendEntry(CUSTOM_TYPE_STATS, ...) so stats survive
596
+ * restarts and branch navigation.
597
+ */
598
+ export interface SummarizerStats {
599
+ /** Cumulative input tokens across all summarizer calls */
600
+ totalInputTokens: number;
601
+ /** Cumulative output tokens across all summarizer calls */
602
+ totalOutputTokens: number;
603
+ /** Cumulative cost in USD across all summarizer calls */
604
+ totalCost: number;
605
+ /** Number of summarizer LLM calls made */
606
+ callCount: number;
607
+ /** Cumulative number of chains range-compressed across all flushes */
608
+ chainsCompressed: number;
609
+ /** Cumulative number of chains given a fused LLM range summary */
610
+ rangesSummarized: number;
611
+ }
612
+
613
+ /**
614
+ * Cumulative-per-source cost contribution emitted on EXTERNAL_COST_CHANNEL.
615
+ * "Cumulative" = for the CURRENT session, not all-time. Idempotent: an
616
+ * aggregator keys by `source` and overwrites, so a re-emit never double-counts.
617
+ */
618
+ export interface ExternalCostUpdate {
619
+ source: string;
620
+ totalCost: number;
621
+ inputTokens?: number;
622
+ outputTokens?: number;
623
+ }
624
+
625
+ /** Transient before/after context-size measurement from the last prune (chars). */
626
+ export interface LiveReclaim {
627
+ beforeChars: number;
628
+ afterChars: number;
629
+ }
630
+
631
+ /** Outcome of the most recent completed prune attempt. */
632
+ export type PruneFrontierOutcome =
633
+ | "summarized"
634
+ | "skipped-oversized"
635
+ | "skipped-trivial"
636
+ | "skipped-deduped";
637
+
638
+ /**
639
+ * Snapshot of the last successfully completed prune attempt boundary.
640
+ *
641
+ * This advances both when pruning succeeds and when a summary is rejected for
642
+ * being larger than the raw tool-result text it would replace. Operational
643
+ * failures do not advance the frontier.
644
+ */
645
+ export interface PruneFrontier {
646
+ /** Last tool call included in the completed prune attempt */
647
+ lastAttemptedToolCallId: string;
648
+ /** Name of the last tool call included in the completed prune attempt */
649
+ lastAttemptedToolName: string;
650
+ /** Assistant turn index containing the last attempted tool call */
651
+ lastAttemptedTurnIndex: number;
652
+ /** Timestamp captured when that last attempted tool call batch was recorded */
653
+ lastAttemptedTimestamp: number;
654
+ /** Number of batches included in the completed prune attempt */
655
+ attemptedBatchCount: number;
656
+ /** Number of tool calls included in the completed prune attempt */
657
+ attemptedToolCallCount: number;
658
+ /** Character count of the raw tool-result text that was eligible for pruning */
659
+ rawCharCount: number;
660
+ /** Character count of the rendered summary text that was produced */
661
+ summaryCharCount: number;
662
+ /** Whether the attempt actually pruned or was skipped for being oversized */
663
+ outcome: PruneFrontierOutcome;
664
+ }
665
+
666
+ /**
667
+ * Progress callback invoked by `flushPending` when processing batches sequentially.
668
+ * Only fired when the caller passes `onProgress` in `FlushOptions` (i.e. `/pruner now`).
669
+ */
670
+ export type ProgressCallback = (
671
+ index: number,
672
+ total: number,
673
+ batch: CapturedBatch,
674
+ stage: "start" | "done" | "skipped",
675
+ ) => void;
676
+
677
+ /** Live text-progress callback for a batch currently being summarized. */
678
+ export type BatchTextProgressCallback = (
679
+ index: number,
680
+ total: number,
681
+ batch: CapturedBatch,
682
+ receivedChars: number,
683
+ ) => void;
684
+
685
+ /** Options accepted by `flushPending`. */
686
+ export interface FlushOptions {
687
+ /** Delivery path: "runtime" uses sendMessage/steer (default); "session" writes directly to session. */
688
+ delivery?: "runtime" | "session";
689
+ /**
690
+ * When provided, batches are processed sequentially (one LLM call each) instead of
691
+ * in parallel, and this callback is invoked before/after each batch. Used by
692
+ * `/pruner now` to drive the multi-row progress overlay.
693
+ */
694
+ onProgress?: ProgressCallback;
695
+ /**
696
+ * When provided, receives the number of summary characters streamed so far for
697
+ * the currently-running batch. Used by `/pruner now` to show live progress.
698
+ */
699
+ onBatchTextProgress?: BatchTextProgressCallback;
700
+ /**
701
+ * Pre-captured batches from a prior `capturePendingBatches()` call.
702
+ * When set, `flushPending` skips the internal capture step and uses these directly.
703
+ * Avoids double-capture when the caller needs to know the batch count before
704
+ * opening the progress overlay.
705
+ */
706
+ previewedBatches?: CapturedBatch[];
707
+ /**
708
+ * Abort signal — when fired the in-flight summarization is cancelled and
709
+ * `flushPending` returns `{ ok: false, reason: "aborted" }` without advancing
710
+ * the frontier. All pending batches are restored so the next flush can retry.
711
+ */
712
+ signal?: AbortSignal;
713
+ /**
714
+ * The final text-only assistant message that triggered an agent-message flush.
715
+ * pi emits `message_end` to extensions before persisting it to the session, so it
716
+ * is threaded in here to close the newest chain for compression (see
717
+ * `withClosingMessage`). Only set on the message_end path.
718
+ */
719
+ closingMessage?: any;
720
+ }
721
+
722
+ /** Options for a single summarizeBatch() call. */
723
+ export interface SummarizeBatchOptions {
724
+ /** Receives the number of summary text characters streamed so far. */
725
+ onTextProgress?: (receivedChars: number) => void;
726
+ /**
727
+ * Abort signal — when fired the in-flight stream call is cancelled and the
728
+ * batch is treated as aborted (not a summarizer failure).
729
+ */
730
+ signal?: AbortSignal;
731
+ }
732
+
733
+ /** Options for summarizeBatches() when callers want live per-batch text progress. */
734
+ export interface SummarizeBatchesOptions {
735
+ /** Receives streamed summary text character counts for each batch. */
736
+ onBatchTextProgress?: BatchTextProgressCallback;
737
+ /**
738
+ * Abort signal forwarded to every individual summarizeBatch() call.
739
+ * When fired, all in-flight stream calls are cancelled.
740
+ */
741
+ signal?: AbortSignal;
742
+ }
743
+
744
+ /**
745
+ * Result of a summarization call — the summary text plus LLM usage data.
746
+ */
747
+ export interface SummarizeResult {
748
+ summaryText: string;
749
+ /** Usage data from the LLM response (tokens + cost) */
750
+ usage: {
751
+ input: number;
752
+ output: number;
753
+ cacheRead: number;
754
+ cacheWrite: number;
755
+ totalTokens: number;
756
+ cost: {
757
+ input: number;
758
+ output: number;
759
+ cacheRead: number;
760
+ cacheWrite: number;
761
+ total: number;
762
+ };
763
+ };
764
+ }