Skip to content

Commit a41815f

Browse files
committed
feat(preemptive-compaction): add smart multi-phase compaction with DCP and truncation
- Add DCP (Dynamic Context Pruning) as first phase before summarization - Add truncation phase to remove large tool outputs before expensive summarization - Protect recent N messages from truncation (configurable via truncation_protection_messages) - Add compaction logging for debugging and monitoring - Only fall back to full summarization if DCP + truncation don't free enough tokens This reduces API costs by avoiding unnecessary summarization calls when simpler strategies (pruning duplicates, truncating large outputs) are sufficient.
1 parent dc5a24a commit a41815f

File tree

6 files changed

+354
-14
lines changed

6 files changed

+354
-14
lines changed

assets/oh-my-opencode.schema.json

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1516,6 +1516,11 @@
15161516
},
15171517
"dcp_for_compaction": {
15181518
"type": "boolean"
1519+
},
1520+
"truncation_protection_messages": {
1521+
"type": "number",
1522+
"minimum": 1,
1523+
"maximum": 10
15191524
}
15201525
}
15211526
},

src/config/schema.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -183,6 +183,8 @@ export const ExperimentalConfigSchema = z.object({
183183
dynamic_context_pruning: DynamicContextPruningConfigSchema.optional(),
184184
/** Enable DCP (Dynamic Context Pruning) for compaction - runs first when token limit exceeded (default: false) */
185185
dcp_for_compaction: z.boolean().optional(),
186+
/** Number of recent messages to protect from truncation (default: 3) */
187+
truncation_protection_messages: z.number().min(1).max(10).optional(),
186188
})
187189

188190
export const SkillSourceSchema = z.union([

src/hooks/anthropic-context-window-limit-recovery/executor.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -368,12 +368,14 @@ export async function executeCompact(
368368
targetRatio: TRUNCATE_CONFIG.targetTokenRatio,
369369
});
370370

371+
// In error recovery (over 100%), bypass message protection to truncate aggressively
371372
const aggressiveResult = truncateUntilTargetTokens(
372373
sessionID,
373374
errorData.currentTokens,
374375
errorData.maxTokens,
375376
TRUNCATE_CONFIG.targetTokenRatio,
376377
TRUNCATE_CONFIG.charsPerToken,
378+
0,
377379
);
378380

379381
if (aggressiveResult.truncatedCount > 0) {

src/hooks/anthropic-context-window-limit-recovery/storage.ts

Lines changed: 52 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { existsSync, readdirSync, readFileSync, writeFileSync } from "node:fs"
1+
import { existsSync, readdirSync, readFileSync, writeFileSync, statSync } from "node:fs"
22
import { join } from "node:path"
33
import { getOpenCodeStorageDir } from "../../shared/data-path"
44

@@ -71,11 +71,44 @@ function getMessageIds(sessionID: string): string[] {
7171
return messageIds
7272
}
7373

74-
export function findToolResultsBySize(sessionID: string): ToolResultInfo[] {
74+
export function findToolResultsBySize(
75+
sessionID: string,
76+
protectedMessageCount: number = 0
77+
): ToolResultInfo[] {
7578
const messageIds = getMessageIds(sessionID)
7679
const results: ToolResultInfo[] = []
7780

81+
// Protect the last N messages from truncation
82+
// Message IDs are typically ordered, but we sort by the message file's mtime to be safe
83+
const protectedMessageIds = new Set<string>()
84+
if (protectedMessageCount > 0 && messageIds.length > 0) {
85+
const messageDir = getMessageDirForSession(sessionID)
86+
if (messageDir) {
87+
const messageTimestamps: Array<{ id: string; mtime: number }> = []
88+
for (const msgId of messageIds) {
89+
try {
90+
const msgPath = join(messageDir, `${msgId}.json`)
91+
if (existsSync(msgPath)) {
92+
const stat = statSync(msgPath)
93+
messageTimestamps.push({ id: msgId, mtime: stat.mtimeMs })
94+
}
95+
} catch {
96+
continue
97+
}
98+
}
99+
// Sort by mtime descending (newest first)
100+
messageTimestamps.sort((a, b) => b.mtime - a.mtime)
101+
// Protect the most recent N messages
102+
for (let i = 0; i < Math.min(protectedMessageCount, messageTimestamps.length); i++) {
103+
protectedMessageIds.add(messageTimestamps[i].id)
104+
}
105+
}
106+
}
107+
78108
for (const messageID of messageIds) {
109+
// Skip protected messages
110+
if (protectedMessageIds.has(messageID)) continue
111+
79112
const partDir = join(PART_STORAGE, messageID)
80113
if (!existsSync(partDir)) continue
81114

@@ -104,6 +137,20 @@ export function findToolResultsBySize(sessionID: string): ToolResultInfo[] {
104137
return results.sort((a, b) => b.outputSize - a.outputSize)
105138
}
106139

140+
function getMessageDirForSession(sessionID: string): string | null {
141+
if (!existsSync(MESSAGE_STORAGE)) return null
142+
143+
const directPath = join(MESSAGE_STORAGE, sessionID)
144+
if (existsSync(directPath)) return directPath
145+
146+
for (const dir of readdirSync(MESSAGE_STORAGE)) {
147+
const sessionPath = join(MESSAGE_STORAGE, dir, sessionID)
148+
if (existsSync(sessionPath)) return sessionPath
149+
}
150+
151+
return null
152+
}
153+
107154
export function findLargestToolResult(sessionID: string): ToolResultInfo | null {
108155
const results = findToolResultsBySize(sessionID)
109156
return results.length > 0 ? results[0] : null
@@ -186,7 +233,8 @@ export function truncateUntilTargetTokens(
186233
currentTokens: number,
187234
maxTokens: number,
188235
targetRatio: number = 0.8,
189-
charsPerToken: number = 4
236+
charsPerToken: number = 4,
237+
protectedMessageCount: number = 3
190238
): AggressiveTruncateResult {
191239
const targetTokens = Math.floor(maxTokens * targetRatio)
192240
const tokensToReduce = currentTokens - targetTokens
@@ -203,7 +251,7 @@ export function truncateUntilTargetTokens(
203251
}
204252
}
205253

206-
const results = findToolResultsBySize(sessionID)
254+
const results = findToolResultsBySize(sessionID, protectedMessageCount)
207255

208256
if (results.length === 0) {
209257
return {
Lines changed: 97 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,97 @@
1+
import * as fs from "node:fs"
2+
import * as path from "node:path"
3+
import { getOpenCodeStorageDir } from "../../shared/data-path"
4+
5+
const COMPACTION_LOG_FILE = path.join(getOpenCodeStorageDir(), "compaction.log")
6+
7+
export interface CompactionLogEntry {
8+
timestamp: string
9+
sessionID: string
10+
phase: "triggered" | "dcp" | "truncation" | "decision" | "summarized" | "skipped"
11+
data: Record<string, unknown>
12+
}
13+
14+
function formatBytes(bytes: number): string {
15+
if (bytes < 1024) return `${bytes}B`
16+
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)}KB`
17+
return `${(bytes / (1024 * 1024)).toFixed(1)}MB`
18+
}
19+
20+
function formatTokens(tokens: number): string {
21+
if (tokens < 1000) return `${tokens}`
22+
return `${(tokens / 1000).toFixed(1)}k`
23+
}
24+
25+
export function logCompaction(entry: CompactionLogEntry): void {
26+
try {
27+
const { timestamp, sessionID, phase, data } = entry
28+
const shortSessionID = sessionID.slice(0, 8)
29+
30+
let line = `[${timestamp}] [${shortSessionID}] `
31+
32+
switch (phase) {
33+
case "triggered":
34+
line += `📊 COMPACTION TRIGGERED\n`
35+
line += ` ├─ Tokens: ${formatTokens(data.totalUsed as number)} / ${formatTokens(data.contextLimit as number)}\n`
36+
line += ` ├─ Usage: ${((data.usageRatio as number) * 100).toFixed(1)}%\n`
37+
line += ` └─ Threshold: ${((data.threshold as number) * 100).toFixed(0)}%\n`
38+
break
39+
40+
case "dcp":
41+
line += `🧹 DCP COMPLETED\n`
42+
line += ` ├─ Items Pruned: ${data.itemsPruned}\n`
43+
line += ` ├─ Tokens Saved: ${formatTokens(data.tokensSaved as number)}\n`
44+
if (data.strategies) {
45+
const s = data.strategies as { deduplication: number; supersedeWrites: number; purgeErrors: number }
46+
line += ` └─ Breakdown: dedup=${s.deduplication}, supersede=${s.supersedeWrites}, purge=${s.purgeErrors}\n`
47+
}
48+
break
49+
50+
case "truncation":
51+
line += `✂️ TRUNCATION COMPLETED\n`
52+
line += ` ├─ Outputs Truncated: ${data.truncatedCount}\n`
53+
line += ` ├─ Bytes Removed: ${formatBytes(data.bytesRemoved as number)}\n`
54+
line += ` ├─ Tokens Saved: ${formatTokens(data.tokensSaved as number)}\n`
55+
if (data.tools && (data.tools as string[]).length > 0) {
56+
line += ` └─ Tools: ${(data.tools as string[]).join(", ")}\n`
57+
}
58+
break
59+
60+
case "decision":
61+
line += `📈 POST-PRUNING STATUS\n`
62+
line += ` ├─ Original: ${formatTokens(data.originalTokens as number)}\n`
63+
line += ` ├─ Saved: ${formatTokens(data.tokensSaved as number)}\n`
64+
line += ` ├─ Current: ${formatTokens(data.currentTokens as number)}\n`
65+
line += ` ├─ New Usage: ${((data.newUsageRatio as number) * 100).toFixed(1)}%\n`
66+
line += ` └─ Decision: ${data.needsSummarize ? "⚠️ NEEDS SUMMARIZE" : "✅ SKIP SUMMARIZE"}\n`
67+
break
68+
69+
case "skipped":
70+
line += `✅ COMPACTION SKIPPED - Pruning was sufficient\n`
71+
line += ` └─ Final Usage: ${((data.finalUsageRatio as number) * 100).toFixed(1)}%\n`
72+
break
73+
74+
case "summarized":
75+
line += `📝 SUMMARIZATION COMPLETED\n`
76+
line += ` └─ Session compacted and resumed\n`
77+
break
78+
}
79+
80+
line += "\n"
81+
fs.appendFileSync(COMPACTION_LOG_FILE, line)
82+
} catch {
83+
// Silent fail - logging should never break the main flow
84+
}
85+
}
86+
87+
export function getCompactionLogPath(): string {
88+
return COMPACTION_LOG_FILE
89+
}
90+
91+
export function clearCompactionLog(): void {
92+
try {
93+
fs.writeFileSync(COMPACTION_LOG_FILE, "")
94+
} catch {
95+
// Silent fail
96+
}
97+
}

0 commit comments

Comments
 (0)