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/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
|
+
}
|