cprune-pi-extension 0.2.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/src/cprune.ts ADDED
@@ -0,0 +1,2164 @@
1
+ import { createHash } from "node:crypto";
2
+ import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
3
+ import { Type } from "typebox";
4
+
5
+ type AnyMessage = any;
6
+ type TextContent = { type: "text"; text: string };
7
+ type ImageContent = { type: "image"; [key: string]: unknown };
8
+ type Content = TextContent | ImageContent | Record<string, unknown>;
9
+
10
+ type SeenOutput = {
11
+ hash: string;
12
+ toolName: string;
13
+ input: string;
14
+ firstSeenAt: number;
15
+ count: number;
16
+ chars: number;
17
+ normalizedHash?: string;
18
+ normalizedChars?: number;
19
+ normalizedLineCount?: number;
20
+ };
21
+
22
+ type OutputFingerprint = SeenOutput & { index?: number };
23
+
24
+ type AppendMatch = {
25
+ prior: OutputFingerprint;
26
+ startBoundary: number;
27
+ endBoundary: number;
28
+ kind: "exact-prefix" | "normalized-prefix" | "normalized-contained";
29
+ };
30
+
31
+ type CpruneStats = {
32
+ toolResultsSeen: number;
33
+ toolResultsDeduped: number;
34
+ toolResultsNormalizedDeduped: number;
35
+ toolResultsAppendPruned: number;
36
+ toolResultsTruncated: number;
37
+ contextPasses: number;
38
+ contextMessagesTouched: number;
39
+ contextStaleReads: number;
40
+ contextDuplicates: number;
41
+ contextAppendPruned: number;
42
+ contextSupersededCommands: number;
43
+ contextSupersededToolResults: number;
44
+ contextChunkPruned: number;
45
+ contextCustomMessagesPruned: number;
46
+ contextEntityPruned: number;
47
+ contextToolCallArgsPruned: number;
48
+ contextToolCallsCompacted: number;
49
+ contextTruncations: number;
50
+ manualContextOmissions: number;
51
+ thinkingBlocksDropped: number;
52
+ approxCharsSaved: number;
53
+ savedThinkingChars: number;
54
+ savedStaleReadChars: number;
55
+ savedDuplicateChars: number;
56
+ savedAppendChars: number;
57
+ savedSupersededCommandChars: number;
58
+ savedSupersededToolResultChars: number;
59
+ savedChunkChars: number;
60
+ savedCustomChars: number;
61
+ savedEntityChars: number;
62
+ savedToolCallArgChars: number;
63
+ savedTruncationChars: number;
64
+ savedManualOmissionChars: number;
65
+ entityFamilyPruned: Record<string, number>;
66
+ entityFamilySavedChars: Record<string, number>;
67
+ compactionsTriggered: number;
68
+ };
69
+
70
+ const CUSTOM_TYPE = "cprune:state";
71
+
72
+ type CpruneMode = "off" | "safe" | "full";
73
+
74
+ const config = {
75
+ // Persist-time pruning: affects what gets stored in the session from now on.
76
+ minDuplicateChars: 1_000,
77
+ minAppendPrefixChars: 1_000,
78
+ maxAppendedSuffixChars: 8_000,
79
+ maxPersistedToolResultChars: 12_000,
80
+
81
+ // Send-time pruning: non-destructive; only affects the LLM request context.
82
+ keepRecentMessagesUntouched: 24,
83
+ maxContextToolResultChars: 6_000,
84
+ maxContextCustomMessageChars: 4_000,
85
+ maxEntityPreviewChars: 900,
86
+ minEntityPruneChars: 700,
87
+ maxToolCallArgStringChars: 800,
88
+ maxPriorityToolCallArgStringChars: 300,
89
+ minHistoricalToolCallArgChars: 500,
90
+ minStructuredNoticeChars: 160,
91
+ minSupersededToolResultChars: 500,
92
+ minRepeatedChunkLines: 24,
93
+ minRepeatedChunkChars: 1_200,
94
+ maxSeenHashes: 300,
95
+ maxReviewCandidates: 20,
96
+ reviewCommandPageSize: 10,
97
+
98
+ // Background compaction trigger. Set to 0 to disable.
99
+ autoCompactAtPercent: 82,
100
+ compactCooldownMs: 10 * 60 * 1_000,
101
+ };
102
+
103
+ const stats: CpruneStats = {
104
+ toolResultsSeen: 0,
105
+ toolResultsDeduped: 0,
106
+ toolResultsNormalizedDeduped: 0,
107
+ toolResultsAppendPruned: 0,
108
+ toolResultsTruncated: 0,
109
+ contextPasses: 0,
110
+ contextMessagesTouched: 0,
111
+ contextStaleReads: 0,
112
+ contextDuplicates: 0,
113
+ contextAppendPruned: 0,
114
+ contextSupersededCommands: 0,
115
+ contextSupersededToolResults: 0,
116
+ contextChunkPruned: 0,
117
+ contextCustomMessagesPruned: 0,
118
+ contextEntityPruned: 0,
119
+ contextToolCallArgsPruned: 0,
120
+ contextToolCallsCompacted: 0,
121
+ contextTruncations: 0,
122
+ manualContextOmissions: 0,
123
+ thinkingBlocksDropped: 0,
124
+ approxCharsSaved: 0,
125
+ savedThinkingChars: 0,
126
+ savedStaleReadChars: 0,
127
+ savedDuplicateChars: 0,
128
+ savedAppendChars: 0,
129
+ savedSupersededCommandChars: 0,
130
+ savedSupersededToolResultChars: 0,
131
+ savedChunkChars: 0,
132
+ savedCustomChars: 0,
133
+ savedEntityChars: 0,
134
+ savedToolCallArgChars: 0,
135
+ savedTruncationChars: 0,
136
+ savedManualOmissionChars: 0,
137
+ entityFamilyPruned: {},
138
+ entityFamilySavedChars: {},
139
+ compactionsTriggered: 0,
140
+ };
141
+
142
+ type ManualOmission = {
143
+ hash: string;
144
+ label: string;
145
+ role: string;
146
+ chars: number;
147
+ preview: string;
148
+ createdAt: number;
149
+ };
150
+
151
+ type ManualTurnOmission = {
152
+ hash: string;
153
+ label: string;
154
+ chars: number;
155
+ messageCount: number;
156
+ preview: string;
157
+ createdAt: number;
158
+ };
159
+
160
+ const seenOutputs = new Map<string, SeenOutput>();
161
+ const manualOmissions = new Map<string, ManualOmission>();
162
+ const manualTurnOmissions = new Map<string, ManualTurnOmission>();
163
+ let lastCompactAt = 0;
164
+ let compactInFlight = false;
165
+ let mode: CpruneMode = "full";
166
+
167
+ function hashText(text: string): string {
168
+ return createHash("sha256").update(text).digest("hex");
169
+ }
170
+
171
+ function shortHash(hash: string): string {
172
+ return hash.slice(0, 16);
173
+ }
174
+
175
+ function stripAnsi(text: string): string {
176
+ // eslint-disable-next-line no-control-regex
177
+ return text.replace(/\x1b\[[0-?]*[ -/]*[@-~]/g, "");
178
+ }
179
+
180
+ function normalizedLinesForAppend(text: string): string[] {
181
+ const lines = stripAnsi(text)
182
+ .replace(/\r\n?/g, "\n")
183
+ .split("\n")
184
+ .map((line) => line.trimEnd());
185
+
186
+ // Treat a final newline as a line terminator, not as meaningful empty content.
187
+ while (lines.length > 0 && lines[lines.length - 1] === "") lines.pop();
188
+ return lines;
189
+ }
190
+
191
+ function appendFingerprint(text: string) {
192
+ const lines = normalizedLinesForAppend(text);
193
+ const normalizedText = lines.join("\n");
194
+ return {
195
+ normalizedText,
196
+ normalizedHash: hashText(normalizedText),
197
+ normalizedChars: normalizedText.length,
198
+ normalizedLineCount: lines.length,
199
+ };
200
+ }
201
+
202
+ function rawBoundaryAfterNormalizedLines(text: string, lineCount: number): number {
203
+ if (lineCount <= 0) return 0;
204
+ let seenNewlines = 0;
205
+ for (let i = 0; i < text.length; i++) {
206
+ if (text[i] === "\n") {
207
+ seenNewlines++;
208
+ if (seenNewlines === lineCount) return i + 1;
209
+ }
210
+ }
211
+ return text.length;
212
+ }
213
+
214
+ function rawLineStart(text: string, lineIndex: number): number {
215
+ if (lineIndex <= 0) return 0;
216
+ let seenNewlines = 0;
217
+ for (let i = 0; i < text.length; i++) {
218
+ if (text[i] === "\n") {
219
+ seenNewlines++;
220
+ if (seenNewlines === lineIndex) return i + 1;
221
+ }
222
+ }
223
+ return text.length;
224
+ }
225
+
226
+ function isSnapshotCommand(command: unknown): boolean {
227
+ if (typeof command !== "string") return false;
228
+ const normalized = command.trim().replace(/\s+/g, " ");
229
+ return /^(ls|find|rg|grep|wc|git status|git diff(?!\s+apply\b)|git ls-files|git grep)\b/.test(normalized);
230
+ }
231
+
232
+ function safeJson(value: unknown, max = 220): string {
233
+ let text: string;
234
+ try {
235
+ text = JSON.stringify(value);
236
+ } catch {
237
+ text = String(value);
238
+ }
239
+ if (!text) return "{}";
240
+ return text.length > max ? `${text.slice(0, max)}…` : text;
241
+ }
242
+
243
+ function textFromContent(content: unknown): string {
244
+ if (typeof content === "string") return content;
245
+ if (!Array.isArray(content)) return "";
246
+ return content
247
+ .filter((block): block is TextContent => block && block.type === "text" && typeof block.text === "string")
248
+ .map((block) => block.text)
249
+ .join("\n");
250
+ }
251
+
252
+ function textContent(text: string): TextContent[] {
253
+ return [{ type: "text", text }];
254
+ }
255
+
256
+ function truncateMiddle(text: string, maxChars: number, label: string): { text: string; saved: number } {
257
+ if (text.length <= maxChars) return { text, saved: 0 };
258
+
259
+ const marker = `\n\n[cprune: omitted ${text.length - maxChars} chars from ${label}]\n\n`;
260
+ const room = Math.max(0, maxChars - marker.length);
261
+ const head = Math.ceil(room * 0.65);
262
+ const tail = Math.max(0, room - head);
263
+ return {
264
+ text: `${text.slice(0, head)}${marker}${tail > 0 ? text.slice(-tail) : ""}`,
265
+ saved: text.length - maxChars,
266
+ };
267
+ }
268
+
269
+ const ENTITY_ID_RE = /\b(?:TASK|SPEC|DISC|ISSUE|BUG|PR|MR|EPIC|INIT|MILESTONE|REQ|DOC)-\d+\b/gi;
270
+
271
+ type EntitySeen = { index: number; hash: string; ids: string[]; chars: number };
272
+
273
+ function extractEntityIds(text: string): string[] {
274
+ return [...new Set([...text.matchAll(ENTITY_ID_RE)].map((match) => match[0].toUpperCase()))];
275
+ }
276
+
277
+ function entityFamily(id: string): string {
278
+ const prefix = id.split("-")[0]?.toUpperCase() || "ENTITY";
279
+ return `${prefix}-*`;
280
+ }
281
+
282
+ function recordEntityFamilySavings(ids: string[], saved: number) {
283
+ if (saved <= 0 || ids.length === 0) return;
284
+ const families = [...new Set(ids.map(entityFamily))];
285
+ for (const family of families) {
286
+ stats.entityFamilyPruned[family] = (stats.entityFamilyPruned[family] ?? 0) + 1;
287
+ stats.entityFamilySavedChars[family] = (stats.entityFamilySavedChars[family] ?? 0) + saved;
288
+ }
289
+ }
290
+
291
+ function normalizeEntityText(text: string): string {
292
+ return stripAnsi(text)
293
+ .replace(/\r\n?/g, "\n")
294
+ .replace(/^\s*\[[^\]]{0,160}\]\s*/gm, "")
295
+ .replace(/\b\d{4}-\d{2}-\d{2}[T ][0-9:.+-Z]*\b/g, "<time>")
296
+ .replace(/\b[0-9a-f]{7,40}\b/gi, "<hash>")
297
+ .replace(/[ \t]+/g, " ")
298
+ .replace(/\n{3,}/g, "\n\n")
299
+ .trim()
300
+ .toLowerCase();
301
+ }
302
+
303
+ function previewEntityText(text: string, maxChars = config.maxEntityPreviewChars): string {
304
+ const lines = text
305
+ .split(/\r?\n/)
306
+ .map((line) => line.trimEnd())
307
+ .filter((line, index) => index < 2 || /\b(?:TASK|SPEC|DISC|ISSUE|BUG|PR|MR|EPIC|INIT|MILESTONE|REQ|DOC)-\d+\b/i.test(line));
308
+ const preview = lines.slice(0, 8).join("\n").trim() || text.slice(0, maxChars);
309
+ return truncateMiddle(preview, maxChars, "entity preview").text;
310
+ }
311
+
312
+ function uniqueSorted(values: string[]): string[] {
313
+ return [...new Set(values.filter(Boolean))].sort();
314
+ }
315
+
316
+ function extractPaths(value: unknown, depth = 0): string[] {
317
+ if (depth > 4 || value == null) return [];
318
+ if (typeof value === "string") {
319
+ const normalized = normalizePath(value);
320
+ if (!normalized) return [];
321
+ if (/[/\\]/.test(value) || /\.[a-z0-9]{1,8}$/i.test(value)) return [normalized];
322
+ return [];
323
+ }
324
+ if (Array.isArray(value)) return value.flatMap((item) => extractPaths(item, depth + 1));
325
+ if (typeof value === "object") {
326
+ return Object.entries(value as Record<string, unknown>).flatMap(([key, item]) => {
327
+ if (/path|file|dir|cwd/i.test(key) && typeof item === "string") return extractPaths(item, depth + 1);
328
+ return extractPaths(item, depth + 1);
329
+ });
330
+ }
331
+ return [];
332
+ }
333
+
334
+ function isCoreToolName(toolName: string): boolean {
335
+ return ["read", "bash", "edit", "write", "multi_tool_use.parallel"].includes(toolName);
336
+ }
337
+
338
+ function stripBoilerplateNoticeTails(text: string): { text: string; saved: number; stripped: boolean } {
339
+ const lines = text.split(/\r?\n/);
340
+ const kept = lines.filter((line) => {
341
+ const normalized = line.trim();
342
+ return !/^Run\s+\w+(?:_\w+)?\s+show\b.*(?:full|context|details|latest|current)/i.test(normalized)
343
+ && !/^Use\s+\w+(?:_\w+)?\s+show\b.*(?:full|context|details|latest|current)/i.test(normalized)
344
+ && !/^For\s+full\s+context,?\s+run\b/i.test(normalized);
345
+ });
346
+ const next = kept.join("\n");
347
+ return { text: next, saved: Math.max(0, text.length - next.length), stripped: next !== text };
348
+ }
349
+
350
+ function structuredNoticeEvent(text: string): string {
351
+ const firstLine = text.split(/\r?\n/, 1)[0]?.trim() ?? "notice";
352
+ const bracket = firstLine.match(/^\[([^:\]]+)(?::|\])/);
353
+ const event = text.match(/\b(assigned|unassigned|picked|started|blocked|unblocked|review|reviewed|done|completed|closed|created|updated|commented|mentioned|notified|notification)\b/i)?.[1];
354
+ return [bracket?.[1], event?.toLowerCase()].filter(Boolean).join("/") || "structured notice";
355
+ }
356
+
357
+ function pruneStructuredEntityNotice(
358
+ text: string,
359
+ label: string,
360
+ index: number,
361
+ latestEntityIndex: Map<string, number>,
362
+ ): { text: string; saved: number; pruned: boolean; strippedBoilerplate: boolean } {
363
+ const boilerplate = stripBoilerplateNoticeTails(text);
364
+ const working = boilerplate.text;
365
+ const ids = extractEntityIds(working);
366
+ const latest = ids.length ? Math.max(...ids.map((id) => latestEntityIndex.get(id) ?? index)) : index;
367
+ const looksStructured = /^\s*\[[a-z][^\]]{0,120}\]/i.test(working) || /\b(?:Run|Use)\s+\w+(?:_\w+)?\s+show\b/i.test(text);
368
+
369
+ if (ids.length > 0 && looksStructured && working.length >= config.minStructuredNoticeChars && latest > index) {
370
+ const hash = hashText(normalizeEntityText(working) || working);
371
+ const preview = previewEntityText(working, 260);
372
+ const replacement = `[cprune: older structured ${label} notice compacted; event=${structuredNoticeEvent(working)}; entities=${ids.join(",")}; newer mention appears at message index ${latest}; hash=${shortHash(hash)}; original=${text.length} chars.]\n${preview}`;
373
+ return {
374
+ text: replacement,
375
+ saved: Math.max(0, text.length - replacement.length),
376
+ pruned: true,
377
+ strippedBoilerplate: boilerplate.stripped,
378
+ };
379
+ }
380
+
381
+ if (boilerplate.stripped && boilerplate.saved > 0) {
382
+ return { text: working, saved: boilerplate.saved, pruned: true, strippedBoilerplate: true };
383
+ }
384
+
385
+ return { text, saved: 0, pruned: false, strippedBoilerplate: false };
386
+ }
387
+
388
+ function toolEntityIds(toolName: string, text: string, args: unknown): string[] {
389
+ if (isCoreToolName(toolName)) return [];
390
+ return uniqueSorted([...extractEntityIds(text), ...extractEntityIds(safeJson(args, 20_000))]);
391
+ }
392
+
393
+ function compactHistoricalToolCall(block: any): { block: any; saved: number; pruned: boolean } {
394
+ const args = block?.arguments ?? {};
395
+ const original = safeJson(args, 40_000);
396
+ if (original.length < config.minHistoricalToolCallArgChars) return { block, saved: 0, pruned: false };
397
+
398
+ const ids = extractEntityIds(original);
399
+ const paths = uniqueSorted(extractPaths(args)).slice(0, 8);
400
+ const replacementArgs: Record<string, unknown> = {
401
+ _cprune: "successful historical tool call arguments compacted",
402
+ hash: shortHash(hashText(original)),
403
+ originalChars: original.length,
404
+ };
405
+ if (ids.length > 0) replacementArgs.entities = ids;
406
+ if (paths.length > 0) replacementArgs.paths = paths;
407
+ replacementArgs.preview = truncateMiddle(original, 220, "tool call arg preview").text;
408
+
409
+ const replacementChars = safeJson(replacementArgs, 40_000).length;
410
+ const saved = original.length - replacementChars;
411
+ if (saved <= 0) return { block, saved: 0, pruned: false };
412
+ return { block: { ...block, arguments: replacementArgs }, saved, pruned: true };
413
+ }
414
+
415
+ function messageTextForEntities(message: AnyMessage): string {
416
+ if (!message) return "";
417
+ if (message.role === "assistant" && Array.isArray(message.content)) {
418
+ return message.content
419
+ .map((block: any) => {
420
+ if (block?.type === "text") return block.text ?? "";
421
+ if (block?.type === "toolCall") return safeJson(block.arguments ?? {}, 20_000);
422
+ return "";
423
+ })
424
+ .filter(Boolean)
425
+ .join("\n");
426
+ }
427
+ if (message.role === "user" || message.role === "toolResult" || message.role === "custom") {
428
+ return textFromContent(message.content);
429
+ }
430
+ if (message.role === "compactionSummary" || message.role === "branchSummary") return String(message.summary ?? "");
431
+ return "";
432
+ }
433
+
434
+ function pruneEntityText(
435
+ text: string,
436
+ label: string,
437
+ index: number,
438
+ latestEntityIndex: Map<string, number>,
439
+ seenEntities: Map<string, EntitySeen>,
440
+ ): { text: string; saved: number; pruned: boolean; kind?: "duplicate" | "superseded" } {
441
+ if (text.length < config.minEntityPruneChars) return { text, saved: 0, pruned: false };
442
+ const ids = extractEntityIds(text);
443
+ if (ids.length === 0) return { text, saved: 0, pruned: false };
444
+
445
+ const normalized = normalizeEntityText(text);
446
+ if (!normalized) return { text, saved: 0, pruned: false };
447
+ const hash = hashText(normalized);
448
+ const duplicate = ids
449
+ .map((id) => seenEntities.get(`${id}:${hash}`))
450
+ .find((seen): seen is EntitySeen => seen !== undefined);
451
+
452
+ for (const id of ids) {
453
+ seenEntities.set(`${id}:${hash}`, { index, hash, ids, chars: text.length });
454
+ }
455
+
456
+ if (duplicate) {
457
+ const replacement = `[cprune: duplicate entity content omitted from ${label}; entities=${ids.join(",")}; same normalized content appeared at message index ${duplicate.index}; hash=${shortHash(hash)}.]`;
458
+ return { text: replacement, saved: Math.max(0, text.length - replacement.length), pruned: true, kind: "duplicate" };
459
+ }
460
+
461
+ const latest = Math.max(...ids.map((id) => latestEntityIndex.get(id) ?? index));
462
+ if (latest > index) {
463
+ const preview = previewEntityText(text);
464
+ const replacement = `[cprune: older entity snapshot compacted from ${label}; entities=${ids.join(",")}; newer mention appears at message index ${latest}; hash=${shortHash(hash)}; original=${text.length} chars.]\n${preview}`;
465
+ return { text: replacement, saved: Math.max(0, text.length - replacement.length), pruned: true, kind: "superseded" };
466
+ }
467
+
468
+ return { text, saved: 0, pruned: false };
469
+ }
470
+
471
+ function recordSavings(saved: number, field?: keyof CpruneStats) {
472
+ if (saved <= 0) return;
473
+ stats.approxCharsSaved += saved;
474
+ if (field) {
475
+ const current = stats[field];
476
+ if (typeof current === "number") {
477
+ (stats as any)[field] = current + saved;
478
+ }
479
+ }
480
+ }
481
+
482
+ function mergeStats(saved: number) {
483
+ recordSavings(saved);
484
+ }
485
+
486
+ function rememberOutput(hash: string, toolName: string, input: unknown, chars: number, text?: string): SeenOutput | undefined {
487
+ const existing = seenOutputs.get(hash);
488
+ if (existing) {
489
+ existing.count++;
490
+ return existing;
491
+ }
492
+
493
+ const fp = text ? appendFingerprint(text) : undefined;
494
+ seenOutputs.set(hash, {
495
+ hash,
496
+ toolName,
497
+ input: safeJson(input),
498
+ firstSeenAt: Date.now(),
499
+ count: 1,
500
+ chars,
501
+ normalizedHash: fp?.normalizedHash,
502
+ normalizedChars: fp?.normalizedChars,
503
+ normalizedLineCount: fp?.normalizedLineCount,
504
+ });
505
+
506
+ while (seenOutputs.size > config.maxSeenHashes) {
507
+ const oldest = seenOutputs.keys().next().value;
508
+ if (!oldest) break;
509
+ seenOutputs.delete(oldest);
510
+ }
511
+
512
+ return undefined;
513
+ }
514
+
515
+ function findAppendedOutput(text: string, toolName: string, candidates: OutputFingerprint[]): AppendMatch | undefined {
516
+ // First pass: exact byte prefix. This is the safest and catches pure append-only growth.
517
+ const exactCandidates = candidates
518
+ .filter(
519
+ (seen) =>
520
+ seen.toolName === toolName &&
521
+ seen.chars >= config.minAppendPrefixChars &&
522
+ seen.chars < text.length,
523
+ )
524
+ .sort((a, b) => b.chars - a.chars);
525
+
526
+ for (const seen of exactCandidates) {
527
+ if (hashText(text.slice(0, seen.chars)) === seen.hash) {
528
+ return { prior: seen, startBoundary: 0, endBoundary: seen.chars, kind: "exact-prefix" };
529
+ }
530
+ }
531
+
532
+ // Second pass: normalized line-prefix. This catches append-only output where ANSI escapes,
533
+ // CRLF/LF, or trailing whitespace differ, while still requiring the old content to be a prefix.
534
+ const currentFp = appendFingerprint(text);
535
+ const normalizedCandidates = candidates
536
+ .filter(
537
+ (seen) =>
538
+ seen.toolName === toolName &&
539
+ (seen.normalizedChars ?? 0) >= config.minAppendPrefixChars &&
540
+ (seen.normalizedChars ?? 0) < currentFp.normalizedChars &&
541
+ (seen.normalizedLineCount ?? 0) > 0 &&
542
+ (seen.normalizedLineCount ?? 0) < currentFp.normalizedLineCount,
543
+ )
544
+ .sort((a, b) => (b.normalizedChars ?? 0) - (a.normalizedChars ?? 0));
545
+
546
+ for (const seen of normalizedCandidates) {
547
+ const normalizedChars = seen.normalizedChars ?? 0;
548
+ if (hashText(currentFp.normalizedText.slice(0, normalizedChars)) === seen.normalizedHash) {
549
+ return {
550
+ prior: seen,
551
+ startBoundary: 0,
552
+ endBoundary: rawBoundaryAfterNormalizedLines(text, seen.normalizedLineCount ?? 0),
553
+ kind: "normalized-prefix",
554
+ };
555
+ }
556
+ }
557
+
558
+ // Third pass: contained normalized block. This catches wrappers like
559
+ // "command header + previous output + appended tail". It is deliberately
560
+ // limited to substantial multi-line outputs to avoid over-pruning short snippets.
561
+ const currentLines = normalizedLinesForAppend(text);
562
+ const containedCandidates = normalizedCandidates.filter((seen) => (seen.normalizedLineCount ?? 0) >= 20);
563
+ for (const seen of containedCandidates) {
564
+ const lineCount = seen.normalizedLineCount ?? 0;
565
+ const maxStart = currentLines.length - lineCount;
566
+ if (maxStart <= 0) continue;
567
+
568
+ for (let startLine = 1; startLine <= maxStart; startLine++) {
569
+ const windowHash = hashText(currentLines.slice(startLine, startLine + lineCount).join("\n"));
570
+ if (windowHash === seen.normalizedHash) {
571
+ return {
572
+ prior: seen,
573
+ startBoundary: rawLineStart(text, startLine),
574
+ endBoundary: rawBoundaryAfterNormalizedLines(text, startLine + lineCount),
575
+ kind: "normalized-contained",
576
+ };
577
+ }
578
+ }
579
+ }
580
+
581
+ return undefined;
582
+ }
583
+
584
+ function findAppendedSeenOutput(text: string, toolName: string): AppendMatch | undefined {
585
+ return findAppendedOutput(text, toolName, [...seenOutputs.values()]);
586
+ }
587
+
588
+ function findNormalizedDuplicateSeenOutput(text: string, toolName: string): { prior: SeenOutput; fingerprint: ReturnType<typeof appendFingerprint> } | undefined {
589
+ const fingerprint = appendFingerprint(text);
590
+ if (fingerprint.normalizedChars < config.minDuplicateChars) return undefined;
591
+
592
+ for (const prior of seenOutputs.values()) {
593
+ if (prior.toolName !== toolName) continue;
594
+ if (prior.normalizedHash !== fingerprint.normalizedHash) continue;
595
+ if (prior.normalizedChars !== fingerprint.normalizedChars) continue;
596
+ if (prior.normalizedLineCount !== fingerprint.normalizedLineCount) continue;
597
+ return { prior, fingerprint };
598
+ }
599
+
600
+ return undefined;
601
+ }
602
+
603
+ function appendedReplacement(text: string, toolName: string, match: AppendMatch): { text: string; saved: number } {
604
+ const start = Math.max(0, Math.min(match.startBoundary, text.length));
605
+ const end = Math.max(start, Math.min(match.endBoundary, text.length));
606
+ const omittedChars = end - start;
607
+ const prefix = text.slice(0, start);
608
+ const suffix = text.slice(end);
609
+ const suffixTrimmed = truncateMiddle(suffix, config.maxAppendedSuffixChars, `newly appended ${toolName} output`);
610
+ const location = start === 0 ? "prefix" : "contained block";
611
+ const header =
612
+ match.prior.index === undefined
613
+ ? `[cprune: omitted ${omittedChars} repeated ${location} chars from ${toolName} result; match=${match.kind}; hash=${shortHash(match.prior.hash)}; first input=${match.prior.input}. New/non-repeated output follows.]\n`
614
+ : `[cprune: omitted ${omittedChars} repeated ${location} chars from ${toolName} result; match=${match.kind}; same block appeared at message index ${match.prior.index}; hash=${shortHash(match.prior.hash)}. New/non-repeated output follows.]\n`;
615
+
616
+ return {
617
+ text: `${prefix}${header}${suffixTrimmed.text}`,
618
+ saved: Math.max(0, omittedChars - header.length) + suffixTrimmed.saved,
619
+ };
620
+ }
621
+
622
+ function persistedTruncation(text: string, maxChars: number, toolName: string): { text: string; saved: number; hash: string } {
623
+ const hash = hashText(text);
624
+ if (text.length <= maxChars) return { text, saved: 0, hash };
625
+
626
+ const marker = `\n\n[cprune: omitted ${text.length - maxChars} chars from ${toolName} result before persistence; hash=${shortHash(hash)}; original=${text.length} chars.]\n\n`;
627
+ const room = Math.max(0, maxChars - marker.length);
628
+ const head = Math.ceil(room * 0.65);
629
+ const tail = Math.max(0, room - head);
630
+ return {
631
+ text: `${text.slice(0, head)}${marker}${tail > 0 ? text.slice(-tail) : ""}`,
632
+ saved: text.length - maxChars,
633
+ hash,
634
+ };
635
+ }
636
+
637
+ function loadStateFromSession(ctx: any) {
638
+ const entries = ctx.sessionManager.getEntries?.() ?? [];
639
+ const stateEntry = [...entries]
640
+ .reverse()
641
+ .find((entry: any) => entry.type === "custom" && entry.customType === CUSTOM_TYPE && entry.data);
642
+
643
+ if (!stateEntry?.data) return;
644
+
645
+ Object.assign(stats, stateEntry.data.stats ?? {});
646
+ stats.entityFamilyPruned = { ...(stateEntry.data.stats?.entityFamilyPruned ?? {}) };
647
+ stats.entityFamilySavedChars = { ...(stateEntry.data.stats?.entityFamilySavedChars ?? {}) };
648
+ lastCompactAt = stateEntry.data.lastCompactAt ?? 0;
649
+ mode = stateEntry.data.mode ?? (stateEntry.data.enabled === false ? "off" : "full");
650
+
651
+ if (Array.isArray(stateEntry.data.seenOutputs)) {
652
+ seenOutputs.clear();
653
+ for (const item of stateEntry.data.seenOutputs.slice(-config.maxSeenHashes)) {
654
+ if (item?.hash) seenOutputs.set(item.hash, item);
655
+ }
656
+ }
657
+
658
+ if (Array.isArray(stateEntry.data.manualOmissions)) {
659
+ manualOmissions.clear();
660
+ for (const item of stateEntry.data.manualOmissions) {
661
+ if (item?.hash) manualOmissions.set(item.hash, item);
662
+ }
663
+ }
664
+
665
+ if (Array.isArray(stateEntry.data.manualTurnOmissions)) {
666
+ manualTurnOmissions.clear();
667
+ for (const item of stateEntry.data.manualTurnOmissions) {
668
+ if (item?.hash) manualTurnOmissions.set(item.hash, item);
669
+ }
670
+ }
671
+ }
672
+
673
+ function saveState(pi: ExtensionAPI) {
674
+ pi.appendEntry(CUSTOM_TYPE, {
675
+ stats,
676
+ enabled: mode !== "off",
677
+ mode,
678
+ lastCompactAt,
679
+ seenOutputs: [...seenOutputs.values()].slice(-config.maxSeenHashes),
680
+ manualOmissions: [...manualOmissions.values()],
681
+ manualTurnOmissions: [...manualTurnOmissions.values()],
682
+ savedAt: Date.now(),
683
+ });
684
+ }
685
+
686
+ function getAssistantToolCalls(messages: AnyMessage[]) {
687
+ const calls = new Map<string, { name: string; args: Record<string, unknown>; messageIndex: number }>();
688
+
689
+ messages.forEach((message, messageIndex) => {
690
+ if (message?.role !== "assistant" || !Array.isArray(message.content)) return;
691
+ for (const block of message.content) {
692
+ if (block?.type === "toolCall" && typeof block.id === "string") {
693
+ calls.set(block.id, {
694
+ name: String(block.name ?? ""),
695
+ args: (block.arguments ?? {}) as Record<string, unknown>,
696
+ messageIndex,
697
+ });
698
+ }
699
+ }
700
+ });
701
+
702
+ return calls;
703
+ }
704
+
705
+ function normalizePath(value: unknown): string | undefined {
706
+ if (typeof value !== "string" || value.length === 0) return undefined;
707
+ return value.replace(/\\/g, "/");
708
+ }
709
+
710
+ function cloneWithText(message: AnyMessage, text: string): AnyMessage {
711
+ if (message?.role === "compactionSummary" || message?.role === "branchSummary") {
712
+ return { ...message, summary: text };
713
+ }
714
+ if (message?.role === "bashExecution") {
715
+ return { ...message, output: text };
716
+ }
717
+ return {
718
+ ...message,
719
+ content: textContent(text),
720
+ };
721
+ }
722
+
723
+ function pruneAssistantThinking(message: AnyMessage): { message: AnyMessage; saved: number; dropped: number } {
724
+ if (message?.role !== "assistant" || !Array.isArray(message.content)) {
725
+ return { message, saved: 0, dropped: 0 };
726
+ }
727
+
728
+ let saved = 0;
729
+ let dropped = 0;
730
+ const kept: Content[] = [];
731
+
732
+ for (const block of message.content) {
733
+ if (block?.type === "thinking" && typeof block.thinking === "string") {
734
+ saved += block.thinking.length;
735
+ dropped++;
736
+ continue;
737
+ }
738
+ kept.push(block);
739
+ }
740
+
741
+ if (dropped === 0) return { message, saved: 0, dropped: 0 };
742
+
743
+ return {
744
+ message: {
745
+ ...message,
746
+ content: kept.length > 0 ? kept : textContent("[cprune: old assistant reasoning omitted]"),
747
+ },
748
+ saved,
749
+ dropped,
750
+ };
751
+ }
752
+
753
+ function pruneArgValue(value: unknown, path: string): { value: unknown; saved: number; pruned: number } {
754
+ if (typeof value === "string") {
755
+ const key = path.split(/[.[\]]/).filter(Boolean).at(-1)?.toLowerCase() ?? path.toLowerCase();
756
+ const priority = /^(content|summary|description|message|body|text|oldtext|newtext|spec|plan|comment|details|instructions)$/i.test(key);
757
+ const limit = priority ? config.maxPriorityToolCallArgStringChars : config.maxToolCallArgStringChars;
758
+ if (value.length <= limit) return { value, saved: 0, pruned: 0 };
759
+ const ids = extractEntityIds(value);
760
+ const preview = value.slice(0, Math.min(260, limit)).replace(/\s+/g, " ").trim();
761
+ const replacement = `[cprune: omitted prior tool-call argument ${path}; ${value.length} chars; hash=${shortHash(hashText(value))}${ids.length ? `; entities=${ids.join(",")}` : ""}; preview=${JSON.stringify(preview)}]`;
762
+ return { value: replacement, saved: Math.max(0, value.length - replacement.length), pruned: 1 };
763
+ }
764
+
765
+ if (Array.isArray(value)) {
766
+ let saved = 0;
767
+ let pruned = 0;
768
+ const next = value.map((item, index) => {
769
+ const result = pruneArgValue(item, `${path}[${index}]`);
770
+ saved += result.saved;
771
+ pruned += result.pruned;
772
+ return result.value;
773
+ });
774
+ return { value: next, saved, pruned };
775
+ }
776
+
777
+ if (value && typeof value === "object") {
778
+ let saved = 0;
779
+ let pruned = 0;
780
+ const next: Record<string, unknown> = {};
781
+ for (const [key, child] of Object.entries(value as Record<string, unknown>)) {
782
+ const result = pruneArgValue(child, path ? `${path}.${key}` : key);
783
+ saved += result.saved;
784
+ pruned += result.pruned;
785
+ next[key] = result.value;
786
+ }
787
+ return { value: next, saved, pruned };
788
+ }
789
+
790
+ return { value, saved: 0, pruned: 0 };
791
+ }
792
+
793
+ function pruneAssistantContext(
794
+ message: AnyMessage,
795
+ index: number,
796
+ latestEntityIndex: Map<string, number>,
797
+ seenEntities: Map<string, EntitySeen>,
798
+ successfulToolCallIds: Set<string>,
799
+ ): {
800
+ message: AnyMessage;
801
+ saved: number;
802
+ savedEntity: number;
803
+ savedToolArgs: number;
804
+ touched: boolean;
805
+ entityPruned: number;
806
+ toolArgsPruned: number;
807
+ toolCallsCompacted: number;
808
+ } {
809
+ if (message?.role !== "assistant" || !Array.isArray(message.content)) {
810
+ return { message, saved: 0, savedEntity: 0, savedToolArgs: 0, touched: false, entityPruned: 0, toolArgsPruned: 0, toolCallsCompacted: 0 };
811
+ }
812
+
813
+ let saved = 0;
814
+ let savedEntity = 0;
815
+ let savedToolArgs = 0;
816
+ let touched = false;
817
+ let entityPruned = 0;
818
+ let toolArgsPruned = 0;
819
+ let toolCallsCompacted = 0;
820
+
821
+ const content = message.content.map((block: any) => {
822
+ if (block?.type === "text" && typeof block.text === "string") {
823
+ const pruned = pruneEntityText(block.text, "assistant text", index, latestEntityIndex, seenEntities);
824
+ if (pruned.pruned) {
825
+ saved += pruned.saved;
826
+ savedEntity += pruned.saved;
827
+ recordEntityFamilySavings(extractEntityIds(block.text), pruned.saved);
828
+ touched = true;
829
+ entityPruned++;
830
+ return { ...block, text: pruned.text };
831
+ }
832
+ return block;
833
+ }
834
+
835
+ if (block?.type === "toolCall" && successfulToolCallIds.has(block.id)) {
836
+ const compacted = compactHistoricalToolCall(block);
837
+ if (compacted.pruned) {
838
+ saved += compacted.saved;
839
+ savedToolArgs += compacted.saved;
840
+ touched = true;
841
+ toolArgsPruned++;
842
+ toolCallsCompacted++;
843
+ return compacted.block;
844
+ }
845
+
846
+ const result = pruneArgValue(block.arguments ?? {}, `${block.name ?? "tool"}.arguments`);
847
+ if (result.pruned > 0) {
848
+ saved += result.saved;
849
+ savedToolArgs += result.saved;
850
+ touched = true;
851
+ toolArgsPruned += result.pruned;
852
+ return { ...block, arguments: result.value };
853
+ }
854
+ }
855
+
856
+ return block;
857
+ });
858
+
859
+ if (!touched) {
860
+ return { message, saved: 0, savedEntity: 0, savedToolArgs: 0, touched: false, entityPruned: 0, toolArgsPruned: 0, toolCallsCompacted: 0 };
861
+ }
862
+ return { message: { ...message, content }, saved, savedEntity, savedToolArgs, touched, entityPruned, toolArgsPruned, toolCallsCompacted };
863
+ }
864
+
865
+ function rememberContextFingerprint(
866
+ fingerprints: OutputFingerprint[],
867
+ hash: string,
868
+ index: number,
869
+ label: string,
870
+ text: string,
871
+ ) {
872
+ const fp = appendFingerprint(text);
873
+ fingerprints.push({
874
+ hash,
875
+ index,
876
+ toolName: label,
877
+ input: "context",
878
+ firstSeenAt: Date.now(),
879
+ count: 1,
880
+ chars: text.length,
881
+ normalizedHash: fp.normalizedHash,
882
+ normalizedChars: fp.normalizedChars,
883
+ normalizedLineCount: fp.normalizedLineCount,
884
+ });
885
+ }
886
+
887
+ type ChunkSeen = { index: number; label: string; chars: number };
888
+
889
+ function pruneRepeatedLineChunks(
890
+ text: string,
891
+ label: string,
892
+ index: number,
893
+ seenChunks: Map<string, ChunkSeen>,
894
+ ): { text: string; saved: number; pruned: number } {
895
+ const lines = normalizedLinesForAppend(text);
896
+ if (lines.length < config.minRepeatedChunkLines * 2) return { text, saved: 0, pruned: 0 };
897
+
898
+ const rawLines = text.replace(/\r\n?/g, "\n").split("\n");
899
+ const out: string[] = [];
900
+ let saved = 0;
901
+ let pruned = 0;
902
+
903
+ for (let i = 0; i < lines.length; i += config.minRepeatedChunkLines) {
904
+ const chunkLines = lines.slice(i, i + config.minRepeatedChunkLines);
905
+ const chunkText = chunkLines.join("\n");
906
+ const rawChunk = rawLines.slice(i, i + config.minRepeatedChunkLines).join("\n");
907
+
908
+ if (chunkLines.length === config.minRepeatedChunkLines && chunkText.length >= config.minRepeatedChunkChars) {
909
+ const hash = hashText(`${label}\n${chunkText}`);
910
+ const seen = seenChunks.get(hash);
911
+ if (seen) {
912
+ const marker = `[cprune: omitted repeated ${label} chunk (${chunkLines.length} lines, ${rawChunk.length} chars); same chunk appeared at message index ${seen.index}.]`;
913
+ out.push(marker);
914
+ saved += Math.max(0, rawChunk.length - marker.length);
915
+ pruned++;
916
+ continue;
917
+ }
918
+ seenChunks.set(hash, { index, label, chars: rawChunk.length });
919
+ }
920
+
921
+ out.push(rawChunk);
922
+ }
923
+
924
+ if (pruned === 0) return { text, saved: 0, pruned: 0 };
925
+ return { text: out.join("\n"), saved, pruned };
926
+ }
927
+
928
+ function pruneContextMessages(messages: AnyMessage[], pruneMode: CpruneMode = mode): AnyMessage[] {
929
+ if (pruneMode === "off") return messages;
930
+ stats.contextPasses++;
931
+ const fullMode = pruneMode === "full";
932
+
933
+ const turnOmitted = applyManualTurnOmissions(messages);
934
+ messages = turnOmitted.messages;
935
+
936
+ const calls = getAssistantToolCalls(messages);
937
+ const recentStart = Math.max(0, messages.length - config.keepRecentMessagesUntouched);
938
+ const latestReadByPath = new Map<string, number>();
939
+ const latestMutationByPath = new Map<string, number>();
940
+ const latestSnapshotCommand = new Map<string, number>();
941
+ const latestToolEntityResult = new Map<string, number>();
942
+ const latestEntityIndex = new Map<string, number>();
943
+ const successfulToolCallIds = new Set<string>();
944
+
945
+ messages.forEach((message, index) => {
946
+ for (const id of extractEntityIds(messageTextForEntities(message))) {
947
+ latestEntityIndex.set(id, index);
948
+ }
949
+
950
+ if (message?.role === "toolResult" && !message.isError && typeof message.toolCallId === "string") {
951
+ successfulToolCallIds.add(message.toolCallId);
952
+ }
953
+
954
+ if (message?.role === "toolResult") {
955
+ const call = calls.get(message.toolCallId);
956
+ const toolName = call?.name ?? message.toolName ?? "tool";
957
+ if (fullMode && call?.name === "read") {
958
+ const path = normalizePath(call.args.path);
959
+ if (path && !message.isError) latestReadByPath.set(path, index);
960
+ }
961
+ if (call?.name === "bash" && !message.isError && isSnapshotCommand(call.args.command)) {
962
+ latestSnapshotCommand.set(String(call.args.command).trim(), index);
963
+ }
964
+ if (!message.isError) {
965
+ const ids = toolEntityIds(toolName, textFromContent(message.content), call?.args ?? {});
966
+ for (const id of ids) latestToolEntityResult.set(`${toolName}:${id}`, index);
967
+ }
968
+ }
969
+
970
+ if (message?.role === "assistant" && Array.isArray(message.content)) {
971
+ for (const block of message.content) {
972
+ if (block?.type !== "toolCall") continue;
973
+ const toolName = String(block.name ?? "");
974
+ if (toolName !== "edit" && toolName !== "write") continue;
975
+ const path = normalizePath(block.arguments?.path);
976
+ if (path) latestMutationByPath.set(path, index);
977
+ }
978
+ }
979
+ });
980
+
981
+ const contextFingerprints: OutputFingerprint[] = [];
982
+ const seenChunks = new Map<string, ChunkSeen>();
983
+ const seenEntities = new Map<string, EntitySeen>();
984
+ let touched = turnOmitted.touched;
985
+
986
+ return messages.map((message, index) => {
987
+ // Keep the very recent tail pristine so active tool-use protocol stays high fidelity.
988
+ if (index >= recentStart) return message;
989
+
990
+ const manual = isSafeManualOmissionCandidate(message) ? manualOmissions.get(messageOmissionHash(message)) : undefined;
991
+ if (manual) {
992
+ const replacement = manualOmissionReplacement(manual);
993
+ const saved = Math.max(0, roughMessageChars(message) - replacement.length);
994
+ stats.manualContextOmissions++;
995
+ recordSavings(saved, "savedManualOmissionChars");
996
+ touched++;
997
+ return cloneWithText(message, replacement);
998
+ }
999
+
1000
+ let current = message;
1001
+
1002
+ const thinking = fullMode ? pruneAssistantThinking(current) : { message: current, saved: 0, dropped: 0 };
1003
+ if (thinking.dropped > 0) {
1004
+ current = thinking.message;
1005
+ stats.thinkingBlocksDropped += thinking.dropped;
1006
+ recordSavings(thinking.saved, "savedThinkingChars");
1007
+ touched++;
1008
+ }
1009
+
1010
+ const assistant = fullMode
1011
+ ? pruneAssistantContext(current, index, latestEntityIndex, seenEntities, successfulToolCallIds)
1012
+ : { message: current, saved: 0, savedEntity: 0, savedToolArgs: 0, touched: false, entityPruned: 0, toolArgsPruned: 0, toolCallsCompacted: 0 };
1013
+ if (assistant.touched) {
1014
+ current = assistant.message;
1015
+ stats.contextEntityPruned += assistant.entityPruned;
1016
+ stats.contextToolCallArgsPruned += assistant.toolArgsPruned;
1017
+ stats.contextToolCallsCompacted += assistant.toolCallsCompacted;
1018
+ recordSavings(assistant.savedEntity, "savedEntityChars");
1019
+ recordSavings(assistant.savedToolArgs, "savedToolCallArgChars");
1020
+ touched++;
1021
+ }
1022
+
1023
+ if (fullMode && (current?.role === "compactionSummary" || current?.role === "branchSummary")) {
1024
+ const fullText = String(current.summary ?? "");
1025
+ const entityPruned = pruneEntityText(fullText, current.role, index, latestEntityIndex, seenEntities);
1026
+ if (entityPruned.pruned) {
1027
+ stats.contextEntityPruned++;
1028
+ touched++;
1029
+ recordSavings(entityPruned.saved, "savedEntityChars");
1030
+ recordEntityFamilySavings(extractEntityIds(fullText), entityPruned.saved);
1031
+ return { ...current, summary: entityPruned.text };
1032
+ }
1033
+ return current;
1034
+ }
1035
+
1036
+ if (fullMode && current?.role === "user") {
1037
+ const fullText = textFromContent(current.content);
1038
+ if (!fullText) return current;
1039
+ const entityPruned = pruneEntityText(fullText, "user message", index, latestEntityIndex, seenEntities);
1040
+ if (entityPruned.pruned) {
1041
+ stats.contextEntityPruned++;
1042
+ touched++;
1043
+ recordSavings(entityPruned.saved, "savedEntityChars");
1044
+ recordEntityFamilySavings(extractEntityIds(fullText), entityPruned.saved);
1045
+ return cloneWithText(current, entityPruned.text);
1046
+ }
1047
+ return current;
1048
+ }
1049
+
1050
+ if (current?.role === "custom") {
1051
+ const label = `custom:${current.customType ?? "message"}`;
1052
+ const fullText = textFromContent(current.content);
1053
+ if (!fullText) return current;
1054
+
1055
+ const structuredNotice = fullMode
1056
+ ? pruneStructuredEntityNotice(fullText, label, index, latestEntityIndex)
1057
+ : { text: fullText, saved: 0, pruned: false, strippedBoilerplate: false };
1058
+ if (structuredNotice.pruned) {
1059
+ stats.contextCustomMessagesPruned++;
1060
+ const entityCompacted = structuredNotice.text.startsWith("[cprune: older structured ");
1061
+ if (entityCompacted) stats.contextEntityPruned++;
1062
+ touched++;
1063
+ recordSavings(structuredNotice.saved, entityCompacted ? "savedEntityChars" : "savedCustomChars");
1064
+ if (entityCompacted) recordEntityFamilySavings(extractEntityIds(fullText), structuredNotice.saved);
1065
+ return cloneWithText(current, structuredNotice.text);
1066
+ }
1067
+
1068
+ const entityPruned = fullMode
1069
+ ? pruneEntityText(fullText, label, index, latestEntityIndex, seenEntities)
1070
+ : { text: fullText, saved: 0, pruned: false };
1071
+ if (entityPruned.pruned) {
1072
+ stats.contextEntityPruned++;
1073
+ stats.contextCustomMessagesPruned++;
1074
+ touched++;
1075
+ recordSavings(entityPruned.saved, "savedEntityChars");
1076
+ recordEntityFamilySavings(extractEntityIds(fullText), entityPruned.saved);
1077
+ return cloneWithText(current, entityPruned.text);
1078
+ }
1079
+
1080
+ if (fullText.length >= config.minDuplicateChars) {
1081
+ const hash = hashText(fullText);
1082
+ const exact = contextFingerprints.find((fp) => fp.hash === hash && fp.toolName === label);
1083
+ if (exact) {
1084
+ stats.contextDuplicates++;
1085
+ stats.contextCustomMessagesPruned++;
1086
+ touched++;
1087
+ recordSavings(Math.max(0, fullText.length - 170), "savedCustomChars");
1088
+ return cloneWithText(
1089
+ current,
1090
+ `[cprune: duplicate ${label} message omitted; same content appeared earlier at message index ${exact.index}; hash=${shortHash(hash)}.]`,
1091
+ );
1092
+ }
1093
+
1094
+ const appended = findAppendedOutput(fullText, label, contextFingerprints);
1095
+ if (appended) {
1096
+ const replacement = appendedReplacement(fullText, label, appended);
1097
+ stats.contextAppendPruned++;
1098
+ stats.contextCustomMessagesPruned++;
1099
+ touched++;
1100
+ recordSavings(replacement.saved, "savedCustomChars");
1101
+ rememberContextFingerprint(contextFingerprints, hash, index, label, fullText);
1102
+ return cloneWithText(current, replacement.text);
1103
+ }
1104
+
1105
+ rememberContextFingerprint(contextFingerprints, hash, index, label, fullText);
1106
+ }
1107
+
1108
+ const chunked = pruneRepeatedLineChunks(fullText, label, index, seenChunks);
1109
+ if (chunked.pruned > 0) {
1110
+ stats.contextChunkPruned += chunked.pruned;
1111
+ stats.contextCustomMessagesPruned++;
1112
+ touched++;
1113
+ recordSavings(chunked.saved, "savedChunkChars");
1114
+ return cloneWithText(current, chunked.text);
1115
+ }
1116
+
1117
+ const truncated = truncateMiddle(fullText, config.maxContextCustomMessageChars, `${label} message in request context`);
1118
+ if (truncated.saved > 0) {
1119
+ stats.contextTruncations++;
1120
+ stats.contextCustomMessagesPruned++;
1121
+ touched++;
1122
+ recordSavings(truncated.saved, "savedCustomChars");
1123
+ return cloneWithText(current, truncated.text);
1124
+ }
1125
+
1126
+ return current;
1127
+ }
1128
+
1129
+ if (current?.role !== "toolResult") return current;
1130
+
1131
+ const call = calls.get(current.toolCallId);
1132
+ const toolName = call?.name ?? current.toolName ?? "tool";
1133
+ const fullText = textFromContent(current.content);
1134
+ if (!fullText) return current;
1135
+
1136
+ const ids = toolEntityIds(toolName, fullText, call?.args ?? {});
1137
+ if (fullMode && !current.isError && ids.length > 0 && fullText.length >= config.minSupersededToolResultChars) {
1138
+ const latestForSameToolEntity = Math.max(...ids.map((id) => latestToolEntityResult.get(`${toolName}:${id}`) ?? index));
1139
+ if (latestForSameToolEntity > index) {
1140
+ const hash = hashText(normalizeEntityText(fullText) || fullText);
1141
+ const replacement = `[cprune: superseded ${toolName} result omitted; entities=${ids.join(",")}; newer ${toolName} result appears at message index ${latestForSameToolEntity}; hash=${shortHash(hash)}; original=${fullText.length} chars. Re-run/show entity for full current state.]`;
1142
+ stats.contextSupersededToolResults++;
1143
+ touched++;
1144
+ recordSavings(Math.max(0, fullText.length - replacement.length), "savedSupersededToolResultChars");
1145
+ recordEntityFamilySavings(ids, Math.max(0, fullText.length - replacement.length));
1146
+ return cloneWithText(current, replacement);
1147
+ }
1148
+ }
1149
+
1150
+ const entityPruned = fullMode
1151
+ ? pruneEntityText(fullText, `${toolName} result`, index, latestEntityIndex, seenEntities)
1152
+ : { text: fullText, saved: 0, pruned: false };
1153
+ if (entityPruned.pruned) {
1154
+ stats.contextEntityPruned++;
1155
+ touched++;
1156
+ recordSavings(entityPruned.saved, "savedEntityChars");
1157
+ recordEntityFamilySavings(extractEntityIds(fullText), entityPruned.saved);
1158
+ return cloneWithText(current, entityPruned.text);
1159
+ }
1160
+
1161
+ // Stale file reads: an older read is superseded by a newer read of the same file
1162
+ // or by a later edit/write call to that file.
1163
+ if (fullMode && toolName === "read") {
1164
+ const path = normalizePath(call?.args.path);
1165
+ if (path) {
1166
+ const newerRead = latestReadByPath.get(path);
1167
+ const laterMutation = latestMutationByPath.get(path);
1168
+ if ((newerRead !== undefined && newerRead > index) || (laterMutation !== undefined && laterMutation > index)) {
1169
+ const reason = newerRead !== undefined && newerRead > index ? "newer read exists" : "later edit/write exists";
1170
+ stats.contextStaleReads++;
1171
+ touched++;
1172
+ recordSavings(Math.max(0, fullText.length - 120), "savedStaleReadChars");
1173
+ return cloneWithText(
1174
+ current,
1175
+ `[cprune: stale read result omitted for ${path}; ${reason}. Re-read the file if exact old contents are needed.]`,
1176
+ );
1177
+ }
1178
+ }
1179
+ }
1180
+
1181
+ // Superseded read-only snapshot commands. If an old `rg`, `find`, `ls`,
1182
+ // `git status`, etc. command was run again later, the newer snapshot is
1183
+ // usually the one that matters. Keep structure, omit the old bulk.
1184
+ if (fullMode && toolName === "bash" && isSnapshotCommand(call?.args.command)) {
1185
+ const command = String(call?.args.command).trim();
1186
+ const newerRun = latestSnapshotCommand.get(command);
1187
+ if (newerRun !== undefined && newerRun > index) {
1188
+ stats.contextSupersededCommands++;
1189
+ touched++;
1190
+ recordSavings(Math.max(0, fullText.length - 180), "savedSupersededCommandChars");
1191
+ return cloneWithText(
1192
+ current,
1193
+ `[cprune: superseded ${toolName} result omitted; command was run again at message index ${newerRun}. Command: ${command}]`,
1194
+ );
1195
+ }
1196
+ }
1197
+
1198
+ // Duplicate or append-only tool outputs in the request context.
1199
+ if (fullText.length >= config.minDuplicateChars) {
1200
+ const hash = hashText(fullText);
1201
+ const exact = contextFingerprints.find((fp) => fp.hash === hash);
1202
+ if (exact) {
1203
+ stats.contextDuplicates++;
1204
+ touched++;
1205
+ recordSavings(Math.max(0, fullText.length - 160), "savedDuplicateChars");
1206
+ return cloneWithText(
1207
+ current,
1208
+ `[cprune: duplicate ${toolName} result omitted; same output appeared earlier at message index ${exact.index}; hash=${shortHash(hash)}.]`,
1209
+ );
1210
+ }
1211
+
1212
+ const appended = findAppendedOutput(fullText, toolName, contextFingerprints);
1213
+
1214
+ if (appended) {
1215
+ const replacement = appendedReplacement(fullText, toolName, appended);
1216
+ stats.contextAppendPruned++;
1217
+ touched++;
1218
+ recordSavings(replacement.saved, "savedAppendChars");
1219
+ rememberContextFingerprint(contextFingerprints, hash, index, toolName, fullText);
1220
+ return cloneWithText(current, replacement.text);
1221
+ }
1222
+
1223
+ rememberContextFingerprint(contextFingerprints, hash, index, toolName, fullText);
1224
+ }
1225
+
1226
+ // Repeated line chunks catch outputs that are mostly repeated but have changes
1227
+ // inserted in the middle, where prefix/contained whole-output matching misses.
1228
+ const chunked = pruneRepeatedLineChunks(fullText, toolName, index, seenChunks);
1229
+ if (chunked.pruned > 0) {
1230
+ stats.contextChunkPruned += chunked.pruned;
1231
+ touched++;
1232
+ recordSavings(chunked.saved, "savedChunkChars");
1233
+ return cloneWithText(current, chunked.text);
1234
+ }
1235
+
1236
+ // Oversized old tool outputs.
1237
+ const limit = toolName === "bash" ? config.maxContextToolResultChars + 2_000 : config.maxContextToolResultChars;
1238
+ const truncated = truncateMiddle(fullText, limit, `${toolName} result in request context`);
1239
+ if (truncated.saved > 0) {
1240
+ stats.contextTruncations++;
1241
+ touched++;
1242
+ recordSavings(truncated.saved, "savedTruncationChars");
1243
+ return cloneWithText(current, truncated.text);
1244
+ }
1245
+
1246
+ return current;
1247
+ }).map((message) => {
1248
+ // Update once per pass without doing another traversal.
1249
+ return message;
1250
+ }).filter((message, _index, all) => {
1251
+ if (_index === all.length - 1) stats.contextMessagesTouched += touched;
1252
+ return true;
1253
+ });
1254
+ }
1255
+
1256
+ function cloneStats(): CpruneStats {
1257
+ return {
1258
+ ...stats,
1259
+ entityFamilyPruned: { ...stats.entityFamilyPruned },
1260
+ entityFamilySavedChars: { ...stats.entityFamilySavedChars },
1261
+ };
1262
+ }
1263
+
1264
+ function restoreStats(snapshot: CpruneStats) {
1265
+ Object.assign(stats, snapshot);
1266
+ stats.entityFamilyPruned = { ...snapshot.entityFamilyPruned };
1267
+ stats.entityFamilySavedChars = { ...snapshot.entityFamilySavedChars };
1268
+ }
1269
+
1270
+ function diffRecord(after: Record<string, number>, before: Record<string, number>): Record<string, number> {
1271
+ const result: Record<string, number> = {};
1272
+ for (const key of new Set([...Object.keys(after), ...Object.keys(before)])) {
1273
+ const delta = (after[key] ?? 0) - (before[key] ?? 0);
1274
+ if (delta !== 0) result[key] = delta;
1275
+ }
1276
+ return result;
1277
+ }
1278
+
1279
+ function roughMessageChars(message: AnyMessage): number {
1280
+ if (!message) return 0;
1281
+
1282
+ if (message.role === "user" || message.role === "toolResult" || message.role === "custom") {
1283
+ return textFromContent(message.content).length || safeJson(message).length;
1284
+ }
1285
+
1286
+ if (message.role === "assistant" && Array.isArray(message.content)) {
1287
+ return message.content
1288
+ .map((block: any) => {
1289
+ if (block?.type === "text") return block.text?.length ?? 0;
1290
+ if (block?.type === "thinking") return block.thinking?.length ?? 0;
1291
+ if (block?.type === "toolCall") return safeJson(block, 10_000).length;
1292
+ return safeJson(block, 10_000).length;
1293
+ })
1294
+ .reduce((sum: number, n: number) => sum + n, 0);
1295
+ }
1296
+
1297
+ if (message.role === "compactionSummary") return String(message.summary ?? "").length;
1298
+ if (message.role === "branchSummary") return String(message.summary ?? "").length;
1299
+ if (message.role === "bashExecution") return String(message.command ?? "").length + String(message.output ?? "").length;
1300
+
1301
+ return safeJson(message, 50_000).length;
1302
+ }
1303
+
1304
+ function stableMessageText(message: AnyMessage): string {
1305
+ if (!message) return "";
1306
+ if (message.role === "assistant" && Array.isArray(message.content)) {
1307
+ return message.content
1308
+ .map((block: any) => {
1309
+ if (block?.type === "text") return block.text ?? "";
1310
+ if (block?.type === "thinking") return block.thinking ?? "";
1311
+ if (block?.type === "toolCall") return safeJson(block, 20_000);
1312
+ return safeJson(block, 20_000);
1313
+ })
1314
+ .join("\n");
1315
+ }
1316
+ if (message.role === "compactionSummary" || message.role === "branchSummary") return String(message.summary ?? "");
1317
+ if (message.role === "bashExecution") return `${message.command ?? ""}\n${message.output ?? ""}`;
1318
+ return textFromContent(message.content) || safeJson(message, 50_000);
1319
+ }
1320
+
1321
+ function messageOmissionHash(message: AnyMessage): string {
1322
+ return hashText(`${message?.role ?? "unknown"}\n${stableMessageText(message)}`);
1323
+ }
1324
+
1325
+ function messageLabel(message: AnyMessage, index: number): string {
1326
+ if (message?.role === "toolResult") return `#${index} tool result`;
1327
+ if (message?.role === "assistant") return `#${index} assistant`;
1328
+ if (message?.role === "user") return `#${index} user`;
1329
+ if (message?.role === "custom") return `#${index} custom:${message.customType ?? "message"}`;
1330
+ if (message?.role === "compactionSummary") return `#${index} compaction summary`;
1331
+ if (message?.role === "branchSummary") return `#${index} branch summary`;
1332
+ return `#${index} ${message?.role ?? "message"}`;
1333
+ }
1334
+
1335
+ function manualOmissionReplacement(omission: ManualOmission): string {
1336
+ return `[cprune: user-excluded context omitted; label=${omission.label}; hash=${shortHash(omission.hash)}; original=${omission.chars} chars; preview=${JSON.stringify(omission.preview)}.]`;
1337
+ }
1338
+
1339
+ function turnOmissionHash(turnMessages: AnyMessage[]): string {
1340
+ return hashText(`turn\n${turnMessages.map(stableMessageText).join("\n---\n")}`);
1341
+ }
1342
+
1343
+ function isVisibleShellExecution(message: AnyMessage): boolean {
1344
+ return message?.role === "bashExecution" && message.excludeFromContext !== true;
1345
+ }
1346
+
1347
+ function isPromptReviewStart(message: AnyMessage): boolean {
1348
+ return message?.role === "user" || isVisibleShellExecution(message);
1349
+ }
1350
+
1351
+ function manualTurnOmissionReplacement(omission: ManualTurnOmission, actualChars: number): string {
1352
+ return `[cprune: user-excluded prompt/response turn omitted; label=${omission.label}; hash=${shortHash(omission.hash)}; messages=${omission.messageCount}; original=${actualChars} chars; preview=${JSON.stringify(omission.preview)}.]`;
1353
+ }
1354
+
1355
+ function applyManualTurnOmissions(messages: AnyMessage[]): { messages: AnyMessage[]; touched: number } {
1356
+ if (manualTurnOmissions.size === 0) return { messages, touched: 0 };
1357
+
1358
+ const result: AnyMessage[] = [];
1359
+ let touched = 0;
1360
+ for (let index = 0; index < messages.length; ) {
1361
+ const message = messages[index];
1362
+ if (!isPromptReviewStart(message)) {
1363
+ result.push(message);
1364
+ index++;
1365
+ continue;
1366
+ }
1367
+
1368
+ let end = index + 1;
1369
+ if (!isVisibleShellExecution(message)) {
1370
+ while (end < messages.length && !isPromptReviewStart(messages[end])) end++;
1371
+ }
1372
+
1373
+ const omitted = messages.slice(index, end);
1374
+ const omission = manualTurnOmissions.get(turnOmissionHash(omitted));
1375
+ if (!omission) {
1376
+ result.push(message);
1377
+ index++;
1378
+ continue;
1379
+ }
1380
+ const actualChars = omitted.reduce((sum, item) => sum + roughMessageChars(item), 0);
1381
+ const replacement = manualTurnOmissionReplacement(omission, actualChars);
1382
+ result.push({ role: "user", content: textContent(replacement) });
1383
+ stats.manualContextOmissions++;
1384
+ recordSavings(Math.max(0, actualChars - replacement.length), "savedManualOmissionChars");
1385
+ touched++;
1386
+ index = end;
1387
+ }
1388
+
1389
+ return { messages: result, touched };
1390
+ }
1391
+
1392
+ function contextSize(messages: AnyMessage[]) {
1393
+ const chars = messages.reduce((sum, message) => sum + roughMessageChars(message), 0);
1394
+ return {
1395
+ messages: messages.length,
1396
+ chars,
1397
+ approxTokens: Math.ceil(chars / 4),
1398
+ };
1399
+ }
1400
+
1401
+ type Breakdown = Record<string, number>;
1402
+
1403
+ function addBreakdown(target: Breakdown, label: string, chars: number) {
1404
+ if (chars <= 0) return;
1405
+ target[label] = (target[label] ?? 0) + chars;
1406
+ }
1407
+
1408
+ function contextBreakdown(messages: AnyMessage[]): Breakdown {
1409
+ const result: Breakdown = {};
1410
+
1411
+ for (const message of messages) {
1412
+ if (!message) continue;
1413
+
1414
+ if (message.role === "assistant" && Array.isArray(message.content)) {
1415
+ for (const block of message.content) {
1416
+ if (block?.type === "text") addBreakdown(result, "assistant text", String(block.text ?? "").length);
1417
+ else if (block?.type === "thinking") addBreakdown(result, "assistant thinking", String(block.thinking ?? "").length);
1418
+ else if (block?.type === "toolCall") addBreakdown(result, "tool calls", safeJson(block, 50_000).length);
1419
+ else addBreakdown(result, "assistant other", safeJson(block, 50_000).length);
1420
+ }
1421
+ continue;
1422
+ }
1423
+
1424
+ if (message.role === "toolResult") {
1425
+ addBreakdown(result, "tool results", roughMessageChars(message));
1426
+ continue;
1427
+ }
1428
+
1429
+ if (message.role === "user") {
1430
+ addBreakdown(result, "user messages", roughMessageChars(message));
1431
+ continue;
1432
+ }
1433
+
1434
+ if (message.role === "custom") {
1435
+ addBreakdown(result, "custom messages", roughMessageChars(message));
1436
+ continue;
1437
+ }
1438
+
1439
+ if (message.role === "compactionSummary" || message.role === "branchSummary") {
1440
+ addBreakdown(result, "summaries", roughMessageChars(message));
1441
+ continue;
1442
+ }
1443
+
1444
+ if (message.role === "bashExecution") {
1445
+ addBreakdown(result, "bash executions", roughMessageChars(message));
1446
+ continue;
1447
+ }
1448
+
1449
+ addBreakdown(result, "other", roughMessageChars(message));
1450
+ }
1451
+
1452
+ return result;
1453
+ }
1454
+
1455
+ function currentContextMessages(ctx: any): AnyMessage[] {
1456
+ const built = ctx.sessionManager?.buildSessionContext?.();
1457
+ if (Array.isArray(built?.messages)) return built.messages;
1458
+
1459
+ // Fallback for older SDK shapes. This is less exact because it does not convert
1460
+ // compaction/custom entries, but keeps /cprune useful.
1461
+ const branch = ctx.sessionManager?.getBranch?.() ?? [];
1462
+ return branch
1463
+ .map((entry: any) => {
1464
+ if (entry.type === "message") return entry.message;
1465
+ if (entry.type === "custom_message") {
1466
+ return { role: "custom", customType: entry.customType, content: entry.content, display: entry.display, details: entry.details };
1467
+ }
1468
+ if (entry.type === "compaction") return { role: "compactionSummary", summary: entry.summary };
1469
+ if (entry.type === "branch_summary") return { role: "branchSummary", summary: entry.summary };
1470
+ return undefined;
1471
+ })
1472
+ .filter(Boolean);
1473
+ }
1474
+
1475
+ function fmtInt(value: number): string {
1476
+ return value.toLocaleString();
1477
+ }
1478
+
1479
+ function fmtPct(value: number): string {
1480
+ return `${value.toFixed(1)}%`;
1481
+ }
1482
+
1483
+ const PARTIAL_BLOCKS = ["", "▏", "▎", "▍", "▌", "▋", "▊", "▉"];
1484
+ const BRIGHT_TEXT = "\x1b[22m\x1b[39m";
1485
+
1486
+ function blockBar(value: number, max: number, width: number): { filled: string; padding: string } {
1487
+ if (max <= 0 || value <= 0) return { filled: "", padding: " ".repeat(width) };
1488
+
1489
+ const scaled = Math.max(0, Math.min(width, (value / max) * width));
1490
+ let full = Math.floor(scaled);
1491
+ let eighth = Math.round((scaled - full) * 8);
1492
+ if (eighth === 8) {
1493
+ full++;
1494
+ eighth = 0;
1495
+ }
1496
+ full = Math.min(full, width);
1497
+ if (full === width) eighth = 0;
1498
+
1499
+ const partial = PARTIAL_BLOCKS[eighth] ?? "";
1500
+ const usedCells = full + (partial ? 1 : 0);
1501
+ return {
1502
+ filled: `${"█".repeat(full)}${partial}`,
1503
+ padding: " ".repeat(Math.max(0, width - usedCells)),
1504
+ };
1505
+ }
1506
+
1507
+ function bar(value: number, max: number, width = 32): string {
1508
+ const { filled, padding } = blockBar(value, max, width);
1509
+ return `${filled}${padding}`;
1510
+ }
1511
+
1512
+ function colorBar(value: number, max: number, kind: "off" | "safe" | "full" | "before" | "after", width = 24): string {
1513
+ const { filled, padding } = blockBar(value, max, width);
1514
+ if (!filled) return padding;
1515
+
1516
+ // Use simple SGR color codes only. cprune never writes to stdout/stderr; this
1517
+ // string is returned through Pi's normal UI/tool surfaces. The earlier MCP
1518
+ // lifecycle line was from context-mode, not these bars.
1519
+ const color = kind === "off" || kind === "before" ? "\x1b[31m" : kind === "safe" ? "\x1b[38;5;208m" : "\x1b[32m";
1520
+ const reset = "\x1b[0m";
1521
+ return `${color}${filled}${reset}${padding}`;
1522
+ }
1523
+
1524
+ function breakdownLines(before: Breakdown, after: Breakdown): string[] {
1525
+ const labels = [
1526
+ "tool results",
1527
+ "assistant thinking",
1528
+ "assistant text",
1529
+ "tool calls",
1530
+ "custom messages",
1531
+ "user messages",
1532
+ "summaries",
1533
+ "bash executions",
1534
+ "assistant other",
1535
+ "other",
1536
+ ];
1537
+ const present = labels.filter((label) => (before[label] ?? 0) > 0 || (after[label] ?? 0) > 0);
1538
+ const dynamic = [...new Set([...Object.keys(before), ...Object.keys(after)])]
1539
+ .filter((label) => !labels.includes(label))
1540
+ .sort();
1541
+ const ordered = [...present, ...dynamic];
1542
+ const max = Math.max(1, ...ordered.map((label) => Math.max(before[label] ?? 0, after[label] ?? 0)));
1543
+
1544
+ return ordered.flatMap((label) => {
1545
+ const b = before[label] ?? 0;
1546
+ const a = after[label] ?? 0;
1547
+ const saved = Math.max(0, b - a);
1548
+ return [
1549
+ ` ${label.padEnd(20)} ${colorBar(b, max, "off", 12)} ${fmtInt(b).padStart(10)} chars`,
1550
+ ` ${"".padEnd(20)} ${colorBar(a, max, "full", 12)} ${fmtInt(a).padStart(10)} chars saved ${fmtInt(saved)}`,
1551
+ ];
1552
+ });
1553
+ }
1554
+
1555
+ function triBreakdownLines(off: Breakdown, safe: Breakdown, full: Breakdown): string[] {
1556
+ const labels = [
1557
+ "tool results",
1558
+ "assistant thinking",
1559
+ "assistant text",
1560
+ "tool calls",
1561
+ "custom messages",
1562
+ "user messages",
1563
+ "summaries",
1564
+ "bash executions",
1565
+ "assistant other",
1566
+ "other",
1567
+ ];
1568
+ const dynamic = [...new Set([...Object.keys(off), ...Object.keys(safe), ...Object.keys(full)])]
1569
+ .filter((label) => !labels.includes(label))
1570
+ .sort();
1571
+ const ordered = [...labels, ...dynamic].filter(
1572
+ (label) => (off[label] ?? 0) > 0 || (safe[label] ?? 0) > 0 || (full[label] ?? 0) > 0,
1573
+ );
1574
+ const max = Math.max(1, ...ordered.map((label) => Math.max(off[label] ?? 0, safe[label] ?? 0, full[label] ?? 0)));
1575
+
1576
+ return ordered.flatMap((label) => {
1577
+ const o = off[label] ?? 0;
1578
+ const s = safe[label] ?? 0;
1579
+ const f = full[label] ?? 0;
1580
+ return [
1581
+ ` ${label.padEnd(20)} ${colorBar(o, max, "off", 12)} ${fmtInt(o).padStart(10)} chars off`,
1582
+ ` ${"".padEnd(20)} ${colorBar(s, max, "safe", 12)} ${fmtInt(s).padStart(10)} chars safe saved ${fmtInt(Math.max(0, o - s))}`,
1583
+ ` ${"".padEnd(20)} ${colorBar(f, max, "full", 12)} ${fmtInt(f).padStart(10)} chars full saved ${fmtInt(Math.max(0, o - f))}`,
1584
+ ];
1585
+ });
1586
+ }
1587
+
1588
+ function simulatePrunedContext(ctx: any, pruneMode: CpruneMode) {
1589
+ const rawMessages = currentContextMessages(ctx);
1590
+ const before = contextSize(rawMessages);
1591
+
1592
+ const snapshot = cloneStats();
1593
+ const prunedMessages = pruneContextMessages(rawMessages, pruneMode);
1594
+ const passDelta = {
1595
+ staleReads: stats.contextStaleReads - snapshot.contextStaleReads,
1596
+ duplicates: stats.contextDuplicates - snapshot.contextDuplicates,
1597
+ appendPruned: stats.contextAppendPruned - snapshot.contextAppendPruned,
1598
+ supersededCommands: stats.contextSupersededCommands - snapshot.contextSupersededCommands,
1599
+ supersededToolResults: stats.contextSupersededToolResults - snapshot.contextSupersededToolResults,
1600
+ chunks: stats.contextChunkPruned - snapshot.contextChunkPruned,
1601
+ customMessages: stats.contextCustomMessagesPruned - snapshot.contextCustomMessagesPruned,
1602
+ entities: stats.contextEntityPruned - snapshot.contextEntityPruned,
1603
+ toolCallArgs: stats.contextToolCallArgsPruned - snapshot.contextToolCallArgsPruned,
1604
+ truncations: stats.contextTruncations - snapshot.contextTruncations,
1605
+ manualOmissions: stats.manualContextOmissions - snapshot.manualContextOmissions,
1606
+ thinkingBlocks: stats.thinkingBlocksDropped - snapshot.thinkingBlocksDropped,
1607
+ touched: stats.contextMessagesTouched - snapshot.contextMessagesTouched,
1608
+ saved: stats.approxCharsSaved - snapshot.approxCharsSaved,
1609
+ savedThinking: stats.savedThinkingChars - snapshot.savedThinkingChars,
1610
+ savedStaleReads: stats.savedStaleReadChars - snapshot.savedStaleReadChars,
1611
+ savedDuplicates: stats.savedDuplicateChars - snapshot.savedDuplicateChars,
1612
+ savedAppend: stats.savedAppendChars - snapshot.savedAppendChars,
1613
+ savedSupersededCommands: stats.savedSupersededCommandChars - snapshot.savedSupersededCommandChars,
1614
+ savedSupersededToolResults: stats.savedSupersededToolResultChars - snapshot.savedSupersededToolResultChars,
1615
+ savedChunks: stats.savedChunkChars - snapshot.savedChunkChars,
1616
+ savedCustom: stats.savedCustomChars - snapshot.savedCustomChars,
1617
+ savedEntities: stats.savedEntityChars - snapshot.savedEntityChars,
1618
+ savedToolCallArgs: stats.savedToolCallArgChars - snapshot.savedToolCallArgChars,
1619
+ savedTruncations: stats.savedTruncationChars - snapshot.savedTruncationChars,
1620
+ savedManualOmissions: stats.savedManualOmissionChars - snapshot.savedManualOmissionChars,
1621
+ entityFamilyPruned: diffRecord(stats.entityFamilyPruned, snapshot.entityFamilyPruned),
1622
+ entityFamilySavedChars: diffRecord(stats.entityFamilySavedChars, snapshot.entityFamilySavedChars),
1623
+ };
1624
+ restoreStats(snapshot);
1625
+
1626
+ return {
1627
+ mode: pruneMode,
1628
+ before,
1629
+ after: contextSize(prunedMessages),
1630
+ beforeBreakdown: contextBreakdown(rawMessages),
1631
+ afterBreakdown: contextBreakdown(prunedMessages),
1632
+ passDelta,
1633
+ };
1634
+ }
1635
+
1636
+ function contextStatText(ctx: any): string {
1637
+ const off = simulatePrunedContext(ctx, "off");
1638
+ const safe = simulatePrunedContext(ctx, "safe");
1639
+ const full = simulatePrunedContext(ctx, "full");
1640
+ const usage = ctx.getContextUsage?.();
1641
+ const modelUsage = usage
1642
+ ? `${usage.tokens ?? "unknown"}/${usage.contextWindow} tokens (${usage.percent?.toFixed(1) ?? "?"}%)`
1643
+ : "unknown";
1644
+
1645
+ const safeSaved = Math.max(0, off.before.chars - safe.after.chars);
1646
+ const fullSaved = Math.max(0, off.before.chars - full.after.chars);
1647
+ const safePct = off.before.chars > 0 ? (safeSaved / off.before.chars) * 100 : 0;
1648
+ const fullPct = off.before.chars > 0 ? (fullSaved / off.before.chars) * 100 : 0;
1649
+
1650
+ return [
1651
+ `${BRIGHT_TEXT}cprune`,
1652
+ "",
1653
+ "Summary",
1654
+ ` active mode : ${mode}`,
1655
+ ` model context : ${modelUsage}`,
1656
+ ` messages : ${fmtInt(off.before.messages)} total, safe touches ${fmtInt(safe.passDelta.touched)}, full touches ${fmtInt(full.passDelta.touched)}`,
1657
+ ` user exclusions : ${fmtInt(manualOmissions.size + manualTurnOmissions.size)}`,
1658
+ ` persisted savings : ${fmtInt(stats.approxCharsSaved)} chars; tool results dup/norm/append/trunc ${fmtInt(stats.toolResultsDeduped)}/${fmtInt(stats.toolResultsNormalizedDeduped)}/${fmtInt(stats.toolResultsAppendPruned)}/${fmtInt(stats.toolResultsTruncated)}`,
1659
+ "",
1660
+ "Mode comparison",
1661
+ ` off ${colorBar(off.before.chars, off.before.chars, "off")} ${fmtInt(off.before.chars)} chars ~${fmtInt(off.before.approxTokens)} tok 0 saved`,
1662
+ ` safe ${colorBar(safe.after.chars, off.before.chars, "safe")} ${fmtInt(safe.after.chars)} chars ~${fmtInt(safe.after.approxTokens)} tok saved ${fmtInt(safeSaved)} (${fmtPct(safePct)})`,
1663
+ ` full ${colorBar(full.after.chars, off.before.chars, "full")} ${fmtInt(full.after.chars)} chars ~${fmtInt(full.after.approxTokens)} tok saved ${fmtInt(fullSaved)} (${fmtPct(fullPct)})`,
1664
+ "",
1665
+ "Breakdown by context part (off / safe / full)",
1666
+ ...triBreakdownLines(off.beforeBreakdown, safe.afterBreakdown, full.afterBreakdown),
1667
+ ].join("\n");
1668
+ }
1669
+
1670
+ type ReviewCandidate = {
1671
+ index: number;
1672
+ hash: string;
1673
+ label: string;
1674
+ role: string;
1675
+ chars: number;
1676
+ ids: string[];
1677
+ preview: string;
1678
+ };
1679
+
1680
+ function isSafeManualOmissionCandidate(message: AnyMessage): boolean {
1681
+ // Avoid user-directed omission of historical assistant tool-call containers: some
1682
+ // providers expect old tool results to remain paired with their tool-call blocks.
1683
+ // cprune can still compact their large arguments with protocol-aware rules.
1684
+ return !(message?.role === "assistant" && Array.isArray(message.content) && message.content.some((block: any) => block?.type === "toolCall"));
1685
+ }
1686
+
1687
+ function largeContextCandidates(ctx: any): ReviewCandidate[] {
1688
+ const messages = currentContextMessages(ctx);
1689
+ const recentStart = Math.max(0, messages.length - config.keepRecentMessagesUntouched);
1690
+ return messages
1691
+ .map((message, index): ReviewCandidate | undefined => {
1692
+ if (index >= recentStart) return undefined;
1693
+ if (!isSafeManualOmissionCandidate(message)) return undefined;
1694
+ const text = stableMessageText(message);
1695
+ const chars = roughMessageChars(message);
1696
+ if (chars < 800 || !text.trim()) return undefined;
1697
+ const hash = messageOmissionHash(message);
1698
+ if (manualOmissions.has(hash)) return undefined;
1699
+ return {
1700
+ index,
1701
+ hash,
1702
+ label: messageLabel(message, index),
1703
+ role: String(message?.role ?? "message"),
1704
+ chars,
1705
+ ids: extractEntityIds(text),
1706
+ preview: previewEntityText(text, 180),
1707
+ };
1708
+ })
1709
+ .filter((item): item is ReviewCandidate => Boolean(item))
1710
+ .sort((a, b) => b.chars - a.chars)
1711
+ .slice(0, config.maxReviewCandidates);
1712
+ }
1713
+
1714
+ function reviewCandidateOption(candidate: ReviewCandidate): string {
1715
+ const ids = candidate.ids.length ? ` ${candidate.ids.slice(0, 4).join(",")}` : "";
1716
+ return `${fmtInt(candidate.chars).padStart(8)} chars | ${candidate.label}${ids} | ${candidate.preview.replace(/\s+/g, " ").slice(0, 90)}`;
1717
+ }
1718
+
1719
+ type TurnCandidate = {
1720
+ start: number;
1721
+ end: number;
1722
+ hash: string;
1723
+ label: string;
1724
+ chars: number;
1725
+ messageCount: number;
1726
+ preview: string;
1727
+ };
1728
+
1729
+ type ReviewPromptMode = "safe" | "full";
1730
+
1731
+ function reviewHistoryMessages(ctx: any, mode: ReviewPromptMode): AnyMessage[] {
1732
+ const branch = ctx.sessionManager?.getBranch?.();
1733
+ if (!Array.isArray(branch) || branch.length === 0) return currentContextMessages(ctx);
1734
+ return branch
1735
+ .map((entry: any) => {
1736
+ if (entry.type === "message") return entry.message;
1737
+ if (entry.type === "custom_message") {
1738
+ return { role: "custom", customType: entry.customType, content: entry.content, display: entry.display, details: entry.details };
1739
+ }
1740
+ if (entry.type === "compaction") return { role: "compactionSummary", summary: entry.summary };
1741
+ if (entry.type === "branch_summary") return { role: "branchSummary", summary: entry.summary };
1742
+ return undefined;
1743
+ })
1744
+ // Safe mode mirrors Pi prompt behavior by hiding `!!cmd` shell entries with
1745
+ // excludeFromContext=true. Full mode shows raw branch history, including them.
1746
+ .filter((message: any) => Boolean(message) && (mode === "full" || !(message.role === "bashExecution" && message.excludeFromContext === true)));
1747
+ }
1748
+
1749
+ function isPromptReviewShellExecution(message: AnyMessage, mode: ReviewPromptMode): boolean {
1750
+ return message?.role === "bashExecution" && (mode === "full" || message.excludeFromContext !== true);
1751
+ }
1752
+
1753
+ function isPromptReviewStartInMode(message: AnyMessage, mode: ReviewPromptMode): boolean {
1754
+ return message?.role === "user" || isPromptReviewShellExecution(message, mode);
1755
+ }
1756
+
1757
+ function turnCandidates(ctx: any, limit: number, mode: ReviewPromptMode): TurnCandidate[] {
1758
+ const messages = reviewHistoryMessages(ctx, mode);
1759
+ const starts = messages
1760
+ .map((message, index) => (isPromptReviewStartInMode(message, mode) ? index : -1))
1761
+ .filter((index) => index >= 0)
1762
+ .slice(-Math.max(1, limit));
1763
+
1764
+ return starts
1765
+ .map((start): TurnCandidate | undefined => {
1766
+ let end = start + 1;
1767
+ if (!isPromptReviewShellExecution(messages[start], mode)) {
1768
+ while (end < messages.length && !isPromptReviewStartInMode(messages[end], mode)) end++;
1769
+ }
1770
+ const turn = messages.slice(start, end);
1771
+ const userText = stableMessageText(messages[start]).trim();
1772
+ if (!userText) return undefined;
1773
+ const hash = turnOmissionHash(turn);
1774
+ if (manualTurnOmissions.has(hash)) return undefined;
1775
+ const chars = turn.reduce((sum, message) => sum + roughMessageChars(message), 0);
1776
+ return {
1777
+ start,
1778
+ end: end - 1,
1779
+ hash,
1780
+ label: isPromptReviewShellExecution(messages[start], mode)
1781
+ ? `#${start} ${messages[start].excludeFromContext === true ? "hidden shell command" : "shell command"}`
1782
+ : `#${start} prompt/response turn`,
1783
+ chars,
1784
+ messageCount: turn.length,
1785
+ preview: previewEntityText(userText, 180),
1786
+ };
1787
+ })
1788
+ .filter((item): item is TurnCandidate => Boolean(item))
1789
+ .reverse();
1790
+ }
1791
+
1792
+ function turnCandidateOption(candidate: TurnCandidate): string {
1793
+ return `${fmtInt(candidate.chars).padStart(8)} chars | #${candidate.start}-${candidate.end} ${candidate.messageCount} msgs | ${candidate.preview.replace(/\s+/g, " ").slice(0, 100)}`;
1794
+ }
1795
+
1796
+ async function reviewCommandTurns(ctx: any, pi: ExtensionAPI, limit: number, startPage = 1, mode: ReviewPromptMode = "safe") {
1797
+ const candidates = turnCandidates(ctx, limit, mode);
1798
+ if (candidates.length === 0) {
1799
+ ctx.ui.notify(`cprune: no prompt/response turns found in last ${limit} prompts (${mode} mode)`, "info");
1800
+ return;
1801
+ }
1802
+
1803
+ const pageSize = config.reviewCommandPageSize;
1804
+ let page = Math.max(0, Math.min(Math.ceil(candidates.length / pageSize) - 1, startPage - 1));
1805
+
1806
+ while (true) {
1807
+ const start = page * pageSize;
1808
+ const pageCandidates = candidates.slice(start, start + pageSize);
1809
+ const totalPages = Math.max(1, Math.ceil(candidates.length / pageSize));
1810
+ const candidateOptions = pageCandidates.map(turnCandidateOption);
1811
+ const options = [...candidateOptions];
1812
+ if (page > 0) options.push("← Previous newer prompts");
1813
+ if (start + pageSize < candidates.length) options.push("Next older prompts →");
1814
+ options.push("Cancel");
1815
+
1816
+ const choice = await ctx.ui.select(
1817
+ `cprune: choose a prompt/response turn to exclude (${mode} mode, page ${page + 1}/${totalPages}, last ${limit} prompts)`,
1818
+ options,
1819
+ );
1820
+ if (!choice || choice === "Cancel") return;
1821
+ if (choice === "← Previous newer prompts") {
1822
+ page = Math.max(0, page - 1);
1823
+ continue;
1824
+ }
1825
+ if (choice === "Next older prompts →") {
1826
+ page = Math.min(totalPages - 1, page + 1);
1827
+ continue;
1828
+ }
1829
+
1830
+ const index = candidateOptions.indexOf(choice);
1831
+ const candidate = pageCandidates[index];
1832
+ if (!candidate) return;
1833
+
1834
+ const ok = await ctx.ui.confirm(
1835
+ "Exclude this prompt/response turn?",
1836
+ `${candidate.label}\nmessages #${candidate.start}-${candidate.end}\n${fmtInt(candidate.chars)} chars\n\nThis is non-destructive: cprune will omit the selected user prompt and its response/tool-results at prompt time, but it will not rewrite Pi session history.`,
1837
+ );
1838
+ if (!ok) continue;
1839
+
1840
+ manualTurnOmissions.set(candidate.hash, {
1841
+ hash: candidate.hash,
1842
+ label: candidate.label,
1843
+ chars: candidate.chars,
1844
+ messageCount: candidate.messageCount,
1845
+ preview: candidate.preview,
1846
+ createdAt: Date.now(),
1847
+ });
1848
+ saveState(pi);
1849
+ ctx.ui.notify(`cprune: excluded ${candidate.label} from future prompts`, "info");
1850
+ return;
1851
+ }
1852
+ }
1853
+
1854
+ async function reviewLargeContext(ctx: any, pi: ExtensionAPI) {
1855
+ const candidates = largeContextCandidates(ctx);
1856
+ if (candidates.length === 0) {
1857
+ ctx.ui.notify("cprune: no large older context candidates found", "info");
1858
+ return;
1859
+ }
1860
+
1861
+ const options = candidates.map(reviewCandidateOption);
1862
+ options.push("Cancel");
1863
+ const choice = await ctx.ui.select("cprune: choose an older context entry to exclude from future model prompts", options);
1864
+ if (!choice || choice === "Cancel") return;
1865
+
1866
+ const index = options.indexOf(choice);
1867
+ const candidate = candidates[index];
1868
+ if (!candidate) return;
1869
+
1870
+ const ok = await ctx.ui.confirm(
1871
+ "Exclude from future prompts?",
1872
+ `${candidate.label}\n${fmtInt(candidate.chars)} chars\n\nThis is non-destructive: cprune will omit this entry at prompt time, but it will not rewrite Pi session history.`,
1873
+ );
1874
+ if (!ok) return;
1875
+
1876
+ manualOmissions.set(candidate.hash, {
1877
+ hash: candidate.hash,
1878
+ label: candidate.label,
1879
+ role: candidate.role,
1880
+ chars: candidate.chars,
1881
+ preview: candidate.preview,
1882
+ createdAt: Date.now(),
1883
+ });
1884
+ saveState(pi);
1885
+ ctx.ui.notify(`cprune: excluded ${candidate.label} from future prompts`, "info");
1886
+ }
1887
+
1888
+ function cpruneCompactInstructions(reason: string): string {
1889
+ return `cprune ${reason}: create a compact continuation summary that removes duplicate/stale details. Preserve only information needed to continue safely: current user goal, explicit user instructions, decisions, constraints, modified/read files, entity IDs and latest states, blockers/errors, and next steps. Do not preserve repeated tool outputs, stale file snapshots superseded by newer reads/edits, duplicate entity notifications, or verbose historical comments unless they contain unique current instructions or unresolved errors.`;
1890
+ }
1891
+
1892
+ function maybeTriggerCompaction(ctx: any) {
1893
+ if (mode === "off" || !config.autoCompactAtPercent || compactInFlight) return;
1894
+
1895
+ const usage = ctx.getContextUsage?.();
1896
+ if (!usage?.percent || usage.percent < config.autoCompactAtPercent) return;
1897
+
1898
+ // ctx.getContextUsage() is based on Pi's unpruned session state. Because cprune
1899
+ // prunes at the LLM boundary, only compact when the simulated pruned footprint
1900
+ // is also above the threshold. Preserve any model-reported overhead (system
1901
+ // prompt/tool schemas/etc.) by adding it back to the pruned message estimate.
1902
+ const footprint = simulatePrunedContext(ctx, mode);
1903
+ const reportedTokens = typeof usage.tokens === "number" ? usage.tokens : footprint.before.approxTokens;
1904
+ const overheadTokens = Math.max(0, reportedTokens - footprint.before.approxTokens);
1905
+ const prunedEstimatedTokens = overheadTokens + footprint.after.approxTokens;
1906
+ const prunedPercent = usage.contextWindow > 0 ? (prunedEstimatedTokens / usage.contextWindow) * 100 : usage.percent;
1907
+ if (prunedPercent < config.autoCompactAtPercent) return;
1908
+
1909
+ const now = Date.now();
1910
+ if (now - lastCompactAt < config.compactCooldownMs) return;
1911
+
1912
+ compactInFlight = true;
1913
+ lastCompactAt = now;
1914
+ stats.compactionsTriggered++;
1915
+
1916
+ ctx.compact({
1917
+ customInstructions: cpruneCompactInstructions("background compaction"),
1918
+ onComplete: () => {
1919
+ compactInFlight = false;
1920
+ ctx.ui.notify("cprune: background compaction completed", "info");
1921
+ },
1922
+ onError: (error: Error) => {
1923
+ compactInFlight = false;
1924
+ ctx.ui.notify(`cprune: background compaction failed: ${error.message}`, "warning");
1925
+ },
1926
+ });
1927
+ }
1928
+
1929
+ export default function cprune(pi: ExtensionAPI) {
1930
+ pi.on("session_start", (_event, ctx) => {
1931
+ loadStateFromSession(ctx);
1932
+ ctx.ui.setStatus("cprune", `cprune: ${mode}`);
1933
+ });
1934
+
1935
+ pi.on("session_shutdown", () => {
1936
+ saveState(pi);
1937
+ });
1938
+
1939
+ pi.on("tool_result", (event) => {
1940
+ if (mode === "off") return;
1941
+
1942
+ stats.toolResultsSeen++;
1943
+
1944
+ const fullText = textFromContent(event.content);
1945
+ if (!fullText) return;
1946
+
1947
+ if (fullText.length >= config.minDuplicateChars) {
1948
+ const hash = hashText(fullText);
1949
+ const duplicate = seenOutputs.get(hash);
1950
+ if (duplicate) {
1951
+ duplicate.count++;
1952
+ stats.toolResultsDeduped++;
1953
+ const replacement = `[cprune: duplicate ${event.toolName} result omitted. First seen for ${duplicate.toolName}(${duplicate.input}); hash=${shortHash(hash)}; original length=${fullText.length} chars.]`;
1954
+ mergeStats(Math.max(0, fullText.length - replacement.length));
1955
+ return {
1956
+ content: textContent(replacement),
1957
+ details: {
1958
+ ...(typeof event.details === "object" && event.details ? event.details : {}),
1959
+ cprune: { pruned: "duplicate", hash, originalChars: fullText.length },
1960
+ },
1961
+ };
1962
+ }
1963
+
1964
+ const normalizedDuplicate = findNormalizedDuplicateSeenOutput(fullText, event.toolName);
1965
+ if (normalizedDuplicate) {
1966
+ stats.toolResultsNormalizedDeduped++;
1967
+ const replacement = `[cprune: normalized duplicate ${event.toolName} result omitted. Same content after ANSI/CRLF/trailing-whitespace normalization first seen for ${normalizedDuplicate.prior.toolName}(${normalizedDuplicate.prior.input}); normalizedHash=${shortHash(normalizedDuplicate.fingerprint.normalizedHash)}; rawHash=${shortHash(hash)}; original length=${fullText.length} chars.]`;
1968
+ mergeStats(Math.max(0, fullText.length - replacement.length));
1969
+ return {
1970
+ content: textContent(replacement),
1971
+ details: {
1972
+ ...(typeof event.details === "object" && event.details ? event.details : {}),
1973
+ cprune: {
1974
+ pruned: "normalized-duplicate",
1975
+ normalizedHash: normalizedDuplicate.fingerprint.normalizedHash,
1976
+ hash,
1977
+ originalChars: fullText.length,
1978
+ },
1979
+ },
1980
+ };
1981
+ }
1982
+
1983
+ const appended = findAppendedSeenOutput(fullText, event.toolName);
1984
+ rememberOutput(hash, event.toolName, event.input, fullText.length, fullText);
1985
+ if (appended) {
1986
+ stats.toolResultsAppendPruned++;
1987
+ const replacement = appendedReplacement(fullText, event.toolName, appended);
1988
+ mergeStats(replacement.saved);
1989
+ return {
1990
+ content: textContent(replacement.text),
1991
+ details: {
1992
+ ...(typeof event.details === "object" && event.details ? event.details : {}),
1993
+ cprune: {
1994
+ pruned: "appended",
1995
+ match: appended.kind,
1996
+ prefixHash: appended.prior.hash,
1997
+ omittedRepeatedChars: appended.endBoundary - appended.startBoundary,
1998
+ originalChars: fullText.length,
1999
+ },
2000
+ },
2001
+ };
2002
+ }
2003
+ }
2004
+
2005
+ const limit = event.toolName === "bash" ? config.maxPersistedToolResultChars + 4_000 : config.maxPersistedToolResultChars;
2006
+ const truncated = persistedTruncation(fullText, limit, event.toolName);
2007
+ if (truncated.saved > 0) {
2008
+ stats.toolResultsTruncated++;
2009
+ mergeStats(truncated.saved);
2010
+ return {
2011
+ content: textContent(truncated.text),
2012
+ details: {
2013
+ ...(typeof event.details === "object" && event.details ? event.details : {}),
2014
+ cprune: { pruned: "truncated", hash: truncated.hash, originalChars: fullText.length, keptChars: truncated.text.length },
2015
+ },
2016
+ };
2017
+ }
2018
+ });
2019
+
2020
+ pi.on("context", (event) => {
2021
+ if (mode === "off") return;
2022
+ return { messages: pruneContextMessages(event.messages, mode) };
2023
+ });
2024
+
2025
+ pi.on("agent_end", (_event, ctx) => {
2026
+ maybeTriggerCompaction(ctx);
2027
+ });
2028
+
2029
+ pi.registerCommand("cprune", {
2030
+ description: "Control cprune: /cprune [off|safe|full|review|review-prompts|compact]",
2031
+ handler: async (args, ctx) => {
2032
+ const parts = args.trim().split(/\s+/).filter(Boolean);
2033
+ const action = parts[0];
2034
+
2035
+ if (!action) {
2036
+ ctx.ui.notify(contextStatText(ctx), "info");
2037
+ return;
2038
+ }
2039
+
2040
+ if (action === "on" || action === "full") {
2041
+ mode = "full";
2042
+ ctx.ui.setStatus("cprune", "cprune: full");
2043
+ saveState(pi);
2044
+ ctx.ui.notify("cprune: full pruning enabled", "info");
2045
+ return;
2046
+ }
2047
+
2048
+ if (action === "safe") {
2049
+ mode = "safe";
2050
+ ctx.ui.setStatus("cprune", "cprune: safe");
2051
+ saveState(pi);
2052
+ ctx.ui.notify("cprune: safe pruning enabled", "info");
2053
+ return;
2054
+ }
2055
+
2056
+ if (action === "off") {
2057
+ mode = "off";
2058
+ ctx.ui.setStatus("cprune", "cprune: off");
2059
+ saveState(pi);
2060
+ ctx.ui.notify("cprune: pruning disabled. /cprune still simulates safe/full savings.", "info");
2061
+ return;
2062
+ }
2063
+
2064
+ if (action === "review") {
2065
+ await reviewLargeContext(ctx, pi);
2066
+ return;
2067
+ }
2068
+
2069
+ if (action === "review-prompts" || action === "review-command" || action === "review-turns") {
2070
+ const mode: ReviewPromptMode = parts.includes("full") ? "full" : "safe";
2071
+ const numbers = parts
2072
+ .slice(1)
2073
+ .map((part) => Number(part))
2074
+ .filter(Number.isFinite);
2075
+ const limit = Number.isFinite(numbers[0]) ? Math.max(1, Math.min(200, Math.floor(numbers[0]))) : 10;
2076
+ const page = Number.isFinite(numbers[1]) ? Math.max(1, Math.floor(numbers[1])) : 1;
2077
+ await reviewCommandTurns(ctx, pi, limit, page, mode);
2078
+ return;
2079
+ }
2080
+
2081
+ if (action === "clear-exclusions") {
2082
+ const count = manualOmissions.size + manualTurnOmissions.size;
2083
+ manualOmissions.clear();
2084
+ manualTurnOmissions.clear();
2085
+ saveState(pi);
2086
+ ctx.ui.notify(`cprune: cleared ${count} user exclusions`, "info");
2087
+ return;
2088
+ }
2089
+
2090
+ if (action === "compact") {
2091
+ ctx.compact({
2092
+ customInstructions: cpruneCompactInstructions("manual compaction"),
2093
+ onComplete: () => ctx.ui.notify("cprune: compaction completed (lossy summary added)", "info"),
2094
+ onError: (error) => ctx.ui.notify(`cprune: compaction failed: ${error.message}`, "warning"),
2095
+ });
2096
+ return;
2097
+ }
2098
+ ctx.ui.notify("Usage: /cprune [off|safe|full|review|review-prompts [safe|full] [N] [page]|clear-exclusions|compact]", "warning");
2099
+ },
2100
+ });
2101
+
2102
+ pi.registerTool({
2103
+ name: "cprune_status",
2104
+ label: "cprune status",
2105
+ description:
2106
+ "Control cprune and inspect pruning impact. Omit action to show the off/safe/full comparison; actions set mode or compact.",
2107
+ promptSnippet: "Control cprune and report pruning impact",
2108
+ promptGuidelines: [
2109
+ "Call cprune_status with no action when the user asks whether cprune is saving context or whether pruning is effective.",
2110
+ "Use cprune_status with action=\"safe\", action=\"full\", or action=\"off\" when the user asks to set cprune pruning mode.",
2111
+ "Use cprune_status with action=\"compact\" when the user asks to persistently compact/prune context via Pi compaction. This is lossy summarization.",
2112
+ ],
2113
+ parameters: Type.Object({
2114
+ action: Type.Optional(
2115
+ Type.Union(
2116
+ [Type.Literal("on"), Type.Literal("off"), Type.Literal("safe"), Type.Literal("full"), Type.Literal("compact")],
2117
+ {
2118
+ description:
2119
+ "Omit action to compare off/safe/full context. off/safe/full set pruning mode (on aliases full); compact requests persistent cprune-focused lossy compaction.",
2120
+ },
2121
+ ),
2122
+ ),
2123
+ }),
2124
+ async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
2125
+ const action = params.action;
2126
+
2127
+ if (action === "on" || action === "full") {
2128
+ mode = "full";
2129
+ ctx.ui.setStatus("cprune", "cprune: full");
2130
+ saveState(pi);
2131
+ return { content: textContent("cprune: full pruning enabled"), details: { mode, enabled: true, stats } };
2132
+ }
2133
+
2134
+ if (action === "safe") {
2135
+ mode = "safe";
2136
+ ctx.ui.setStatus("cprune", "cprune: safe");
2137
+ saveState(pi);
2138
+ return { content: textContent("cprune: safe pruning enabled"), details: { mode, enabled: true, stats } };
2139
+ }
2140
+
2141
+ if (action === "off") {
2142
+ mode = "off";
2143
+ ctx.ui.setStatus("cprune", "cprune: off");
2144
+ saveState(pi);
2145
+ return {
2146
+ content: textContent("cprune: pruning disabled. Calling cprune_status without an action still simulates safe/full savings."),
2147
+ details: { mode, enabled: mode !== "off", stats },
2148
+ };
2149
+ }
2150
+
2151
+ if (action === "compact") {
2152
+ ctx.compact({
2153
+ customInstructions: cpruneCompactInstructions("tool compaction"),
2154
+ });
2155
+ return {
2156
+ content: textContent("cprune: compact requested (lossy persistent compaction will be added)"),
2157
+ details: { mode, enabled: mode !== "off", stats },
2158
+ };
2159
+ }
2160
+
2161
+ return { content: textContent(contextStatText(ctx)), details: { mode, enabled: mode !== "off", stats, seenOutputHashes: seenOutputs.size } };
2162
+ },
2163
+ });
2164
+ }