diff --git a/assets/oh-my-opencode.schema.json b/assets/oh-my-opencode.schema.json index 327c2d919..fc3fe4cef 100644 --- a/assets/oh-my-opencode.schema.json +++ b/assets/oh-my-opencode.schema.json @@ -2069,6 +2069,11 @@ }, "dcp_for_compaction": { "type": "boolean" + }, + "truncation_protection_messages": { + "type": "number", + "minimum": 1, + "maximum": 10 } } }, diff --git a/src/config/schema.ts b/src/config/schema.ts index 07600afb4..fbdc223ea 100644 --- a/src/config/schema.ts +++ b/src/config/schema.ts @@ -230,6 +230,8 @@ export const ExperimentalConfigSchema = z.object({ dynamic_context_pruning: DynamicContextPruningConfigSchema.optional(), /** Enable DCP (Dynamic Context Pruning) for compaction - runs first when token limit exceeded (default: false) */ dcp_for_compaction: z.boolean().optional(), + /** Number of recent messages to protect from truncation (default: 3) */ + truncation_protection_messages: z.number().min(1).max(10).optional(), }) export const SkillSourceSchema = z.union([ diff --git a/src/hooks/anthropic-context-window-limit-recovery/executor.ts b/src/hooks/anthropic-context-window-limit-recovery/executor.ts index 8508e3c46..75cabf2ee 100644 --- a/src/hooks/anthropic-context-window-limit-recovery/executor.ts +++ b/src/hooks/anthropic-context-window-limit-recovery/executor.ts @@ -368,12 +368,14 @@ export async function executeCompact( targetRatio: TRUNCATE_CONFIG.targetTokenRatio, }); + // In error recovery (over 100%), bypass message protection to truncate aggressively const aggressiveResult = truncateUntilTargetTokens( sessionID, errorData.currentTokens, errorData.maxTokens, TRUNCATE_CONFIG.targetTokenRatio, TRUNCATE_CONFIG.charsPerToken, + 0, ); if (aggressiveResult.truncatedCount > 0) { diff --git a/src/hooks/anthropic-context-window-limit-recovery/storage.ts b/src/hooks/anthropic-context-window-limit-recovery/storage.ts index e1a771aca..95aa66ff6 100644 --- a/src/hooks/anthropic-context-window-limit-recovery/storage.ts +++ b/src/hooks/anthropic-context-window-limit-recovery/storage.ts @@ -1,4 +1,4 @@ -import { existsSync, readdirSync, readFileSync, writeFileSync } from "node:fs" +import { existsSync, readdirSync, readFileSync, writeFileSync, statSync } from "node:fs" import { join } from "node:path" import { getOpenCodeStorageDir } from "../../shared/data-path" @@ -71,11 +71,44 @@ function getMessageIds(sessionID: string): string[] { return messageIds } -export function findToolResultsBySize(sessionID: string): ToolResultInfo[] { +export function findToolResultsBySize( + sessionID: string, + protectedMessageCount: number = 0 +): ToolResultInfo[] { const messageIds = getMessageIds(sessionID) const results: ToolResultInfo[] = [] + // Protect the last N messages from truncation + // Message IDs are typically ordered, but we sort by the message file's mtime to be safe + const protectedMessageIds = new Set() + if (protectedMessageCount > 0 && messageIds.length > 0) { + const messageDir = getMessageDirForSession(sessionID) + if (messageDir) { + const messageTimestamps: Array<{ id: string; mtime: number }> = [] + for (const msgId of messageIds) { + try { + const msgPath = join(messageDir, `${msgId}.json`) + if (existsSync(msgPath)) { + const stat = statSync(msgPath) + messageTimestamps.push({ id: msgId, mtime: stat.mtimeMs }) + } + } catch { + continue + } + } + // Sort by mtime descending (newest first) + messageTimestamps.sort((a, b) => b.mtime - a.mtime) + // Protect the most recent N messages + for (let i = 0; i < Math.min(protectedMessageCount, messageTimestamps.length); i++) { + protectedMessageIds.add(messageTimestamps[i].id) + } + } + } + for (const messageID of messageIds) { + // Skip protected messages + if (protectedMessageIds.has(messageID)) continue + const partDir = join(PART_STORAGE, messageID) if (!existsSync(partDir)) continue @@ -104,6 +137,20 @@ export function findToolResultsBySize(sessionID: string): ToolResultInfo[] { return results.sort((a, b) => b.outputSize - a.outputSize) } +function getMessageDirForSession(sessionID: string): string | null { + if (!existsSync(MESSAGE_STORAGE)) return null + + const directPath = join(MESSAGE_STORAGE, sessionID) + if (existsSync(directPath)) return directPath + + for (const dir of readdirSync(MESSAGE_STORAGE)) { + const sessionPath = join(MESSAGE_STORAGE, dir, sessionID) + if (existsSync(sessionPath)) return sessionPath + } + + return null +} + export function findLargestToolResult(sessionID: string): ToolResultInfo | null { const results = findToolResultsBySize(sessionID) return results.length > 0 ? results[0] : null @@ -186,7 +233,8 @@ export function truncateUntilTargetTokens( currentTokens: number, maxTokens: number, targetRatio: number = 0.8, - charsPerToken: number = 4 + charsPerToken: number = 4, + protectedMessageCount: number = 3 ): AggressiveTruncateResult { const targetTokens = Math.floor(maxTokens * targetRatio) const tokensToReduce = currentTokens - targetTokens @@ -203,7 +251,7 @@ export function truncateUntilTargetTokens( } } - const results = findToolResultsBySize(sessionID) + const results = findToolResultsBySize(sessionID, protectedMessageCount) if (results.length === 0) { return { diff --git a/src/hooks/preemptive-compaction/compaction-logger.test.ts b/src/hooks/preemptive-compaction/compaction-logger.test.ts new file mode 100644 index 000000000..6e89c3be9 --- /dev/null +++ b/src/hooks/preemptive-compaction/compaction-logger.test.ts @@ -0,0 +1,29 @@ +import { describe, it, expect, mock, beforeEach } from "bun:test" +import { getCompactionLogPath } from "./compaction-logger" + +// Since we can't easily mock node:fs for appendFileSync in bun:test, +// we'll test the pure functions and verify the log path structure + +describe("compaction-logger", () => { + describe("getCompactionLogPath", () => { + it("#then should return a path ending with compaction.log", () => { + const logPath = getCompactionLogPath() + expect(logPath).toContain("compaction.log") + }) + + it("#then should be in opencode storage directory", () => { + const logPath = getCompactionLogPath() + expect(logPath).toContain("opencode") + }) + }) + + // Note: logCompaction and clearCompactionLog are thin wrappers around fs operations + // with silent error handling. Testing them requires file system access or complex + // module mocking. The key behaviors are: + // 1. Formats log entries correctly (covered by integration) + // 2. Never throws errors (error handling is silent) + // 3. Uses correct log file path (verified above) + + // The formatting logic is verified through integration testing and manual inspection + // of the log output format documented in the implementation. +}) diff --git a/src/hooks/preemptive-compaction/compaction-logger.ts b/src/hooks/preemptive-compaction/compaction-logger.ts new file mode 100644 index 000000000..9ac4beeaf --- /dev/null +++ b/src/hooks/preemptive-compaction/compaction-logger.ts @@ -0,0 +1,97 @@ +import * as fs from "node:fs" +import * as path from "node:path" +import { getOpenCodeStorageDir } from "../../shared/data-path" + +const COMPACTION_LOG_FILE = path.join(getOpenCodeStorageDir(), "compaction.log") + +export interface CompactionLogEntry { + timestamp: string + sessionID: string + phase: "triggered" | "dcp" | "truncation" | "decision" | "summarized" | "skipped" + data: Record +} + +function formatBytes(bytes: number): string { + if (bytes < 1024) return `${bytes}B` + if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)}KB` + return `${(bytes / (1024 * 1024)).toFixed(1)}MB` +} + +function formatTokens(tokens: number): string { + if (tokens < 1000) return `${tokens}` + return `${(tokens / 1000).toFixed(1)}k` +} + +export function logCompaction(entry: CompactionLogEntry): void { + try { + const { timestamp, sessionID, phase, data } = entry + const shortSessionID = sessionID.slice(0, 8) + + let line = `[${timestamp}] [${shortSessionID}] ` + + switch (phase) { + case "triggered": + line += `๐Ÿ“Š COMPACTION TRIGGERED\n` + line += ` โ”œโ”€ Tokens: ${formatTokens(data.totalUsed as number)} / ${formatTokens(data.contextLimit as number)}\n` + line += ` โ”œโ”€ Usage: ${((data.usageRatio as number) * 100).toFixed(1)}%\n` + line += ` โ””โ”€ Threshold: ${((data.threshold as number) * 100).toFixed(0)}%\n` + break + + case "dcp": + line += `๐Ÿงน DCP COMPLETED\n` + line += ` โ”œโ”€ Items Pruned: ${data.itemsPruned}\n` + line += ` โ”œโ”€ Tokens Saved: ${formatTokens(data.tokensSaved as number)}\n` + if (data.strategies) { + const s = data.strategies as { deduplication: number; supersedeWrites: number; purgeErrors: number } + line += ` โ””โ”€ Breakdown: dedup=${s.deduplication}, supersede=${s.supersedeWrites}, purge=${s.purgeErrors}\n` + } + break + + case "truncation": + line += `โœ‚๏ธ TRUNCATION COMPLETED\n` + line += ` โ”œโ”€ Outputs Truncated: ${data.truncatedCount}\n` + line += ` โ”œโ”€ Bytes Removed: ${formatBytes(data.bytesRemoved as number)}\n` + line += ` โ”œโ”€ Tokens Saved: ${formatTokens(data.tokensSaved as number)}\n` + if (data.tools && (data.tools as string[]).length > 0) { + line += ` โ””โ”€ Tools: ${(data.tools as string[]).join(", ")}\n` + } + break + + case "decision": + line += `๐Ÿ“ˆ POST-PRUNING STATUS\n` + line += ` โ”œโ”€ Original: ${formatTokens(data.originalTokens as number)}\n` + line += ` โ”œโ”€ Saved: ${formatTokens(data.tokensSaved as number)}\n` + line += ` โ”œโ”€ Current: ${formatTokens(data.currentTokens as number)}\n` + line += ` โ”œโ”€ New Usage: ${((data.newUsageRatio as number) * 100).toFixed(1)}%\n` + line += ` โ””โ”€ Decision: ${data.needsSummarize ? "โš ๏ธ NEEDS SUMMARIZE" : "โœ… SKIP SUMMARIZE"}\n` + break + + case "skipped": + line += `โœ… COMPACTION SKIPPED - Pruning was sufficient\n` + line += ` โ””โ”€ Final Usage: ${((data.finalUsageRatio as number) * 100).toFixed(1)}%\n` + break + + case "summarized": + line += `๐Ÿ“ SUMMARIZATION COMPLETED\n` + line += ` โ””โ”€ Session compacted and resumed\n` + break + } + + line += "\n" + fs.appendFileSync(COMPACTION_LOG_FILE, line) + } catch { + // Silent fail - logging should never break the main flow + } +} + +export function getCompactionLogPath(): string { + return COMPACTION_LOG_FILE +} + +export function clearCompactionLog(): void { + try { + fs.writeFileSync(COMPACTION_LOG_FILE, "") + } catch { + // Silent fail + } +} diff --git a/src/hooks/preemptive-compaction/index.test.ts b/src/hooks/preemptive-compaction/index.test.ts new file mode 100644 index 000000000..ca5e79f3e --- /dev/null +++ b/src/hooks/preemptive-compaction/index.test.ts @@ -0,0 +1,522 @@ +import { describe, it, expect, beforeEach, mock, spyOn } from "bun:test" +import { createPreemptiveCompactionHook } from "./index" +import * as pruningExecutor from "../anthropic-context-window-limit-recovery/pruning-executor" +import * as storage from "../anthropic-context-window-limit-recovery/storage" +import * as compactionLogger from "./compaction-logger" + +describe("createPreemptiveCompactionHook", () => { + let mockClient: { + tui: { showToast: ReturnType } + session: { + summarize: ReturnType + messages: ReturnType + promptAsync: ReturnType + } + } + let mockCtx: { client: typeof mockClient; directory: string } + let mockExecuteDynamicContextPruning: ReturnType + let mockTruncateUntilTargetTokens: ReturnType + let mockLogCompaction: ReturnType + + beforeEach(() => { + mockClient = { + tui: { + showToast: mock(() => Promise.resolve()), + }, + session: { + summarize: mock(() => Promise.resolve()), + messages: mock(() => Promise.resolve([])), + promptAsync: mock(() => Promise.resolve()), + }, + } + mockCtx = { + client: mockClient, + directory: "/test/dir", + } + + // Spy on external dependencies + mockExecuteDynamicContextPruning = spyOn(pruningExecutor, "executeDynamicContextPruning") + mockTruncateUntilTargetTokens = spyOn(storage, "truncateUntilTargetTokens") + mockLogCompaction = spyOn(compactionLogger, "logCompaction") + + // Reset all mocks + mockExecuteDynamicContextPruning.mockReset() + mockTruncateUntilTargetTokens.mockReset() + mockLogCompaction.mockReset() + mockClient.tui.showToast.mockClear() + mockClient.session.summarize.mockClear() + mockClient.session.messages.mockClear() + mockClient.session.promptAsync.mockClear() + + // Default mock implementations + mockExecuteDynamicContextPruning.mockResolvedValue({ + itemsPruned: 0, + totalTokensSaved: 0, + strategies: { deduplication: 0, supersedeWrites: 0, purgeErrors: 0 }, + }) + mockTruncateUntilTargetTokens.mockReturnValue({ + success: false, + sufficient: false, + truncatedCount: 0, + totalBytesRemoved: 0, + targetBytesToRemove: 0, + truncatedTools: [], + }) + mockLogCompaction.mockImplementation(() => {}) + }) + + describe("#given preemptive_compaction explicitly disabled", () => { + it("#then should return no-op event handler", async () => { + const hook = createPreemptiveCompactionHook(mockCtx as never, { + experimental: { preemptive_compaction: false }, + }) + + // Should not throw and do nothing + await hook.event({ event: { type: "message.updated", properties: {} } }) + expect(mockClient.session.summarize).not.toHaveBeenCalled() + }) + }) + + describe("#given default configuration (enabled)", () => { + let hook: ReturnType + + beforeEach(() => { + hook = createPreemptiveCompactionHook(mockCtx as never) + }) + + describe("#when message.updated event received", () => { + describe("#and role is not assistant", () => { + it("#then should not trigger compaction", async () => { + await hook.event({ + event: { + type: "message.updated", + properties: { + info: { + id: "msg1", + role: "user", + sessionID: "session1", + finish: true, + }, + }, + }, + }) + + expect(mockClient.session.summarize).not.toHaveBeenCalled() + }) + }) + + describe("#and message is not finished", () => { + it("#then should not trigger compaction", async () => { + await hook.event({ + event: { + type: "message.updated", + properties: { + info: { + id: "msg1", + role: "assistant", + sessionID: "session1", + finish: false, + modelID: "claude-sonnet-4", + providerID: "anthropic", + tokens: { input: 100000, output: 1000, cache: { read: 70000, write: 0 } }, + }, + }, + }, + }) + + expect(mockClient.session.summarize).not.toHaveBeenCalled() + }) + }) + + describe("#and message is a summary", () => { + it("#then should not trigger compaction", async () => { + await hook.event({ + event: { + type: "message.updated", + properties: { + info: { + id: "msg1", + role: "assistant", + sessionID: "session1", + finish: true, + summary: true, + modelID: "claude-sonnet-4", + providerID: "anthropic", + tokens: { input: 100000, output: 1000, cache: { read: 70000, write: 0 } }, + }, + }, + }, + }) + + expect(mockClient.session.summarize).not.toHaveBeenCalled() + }) + }) + + describe("#and model is not supported", () => { + it("#then should not trigger compaction", async () => { + await hook.event({ + event: { + type: "message.updated", + properties: { + info: { + id: "msg1", + role: "assistant", + sessionID: "session1", + finish: true, + modelID: "gpt-4", + providerID: "openai", + tokens: { input: 100000, output: 1000, cache: { read: 70000, write: 0 } }, + }, + }, + }, + }) + + expect(mockClient.session.summarize).not.toHaveBeenCalled() + }) + }) + + describe("#and tokens below minimum", () => { + it("#then should not trigger compaction", async () => { + await hook.event({ + event: { + type: "message.updated", + properties: { + info: { + id: "msg1", + role: "assistant", + sessionID: "session1", + finish: true, + modelID: "claude-sonnet-4", + providerID: "anthropic", + tokens: { input: 10000, output: 1000, cache: { read: 5000, write: 0 } }, + }, + }, + }, + }) + + expect(mockClient.session.summarize).not.toHaveBeenCalled() + }) + }) + + describe("#and usage below threshold", () => { + it("#then should not trigger compaction", async () => { + await hook.event({ + event: { + type: "message.updated", + properties: { + info: { + id: "msg1", + role: "assistant", + sessionID: "session1", + finish: true, + modelID: "claude-sonnet-4", + providerID: "anthropic", + // 100k / 200k = 50% usage, below 85% threshold + tokens: { input: 50000, output: 1000, cache: { read: 49000, write: 0 } }, + }, + }, + }, + }) + + expect(mockClient.session.summarize).not.toHaveBeenCalled() + }) + }) + + describe("#and usage above threshold without DCP enabled", () => { + it("#then should trigger summarization directly", async () => { + await hook.event({ + event: { + type: "message.updated", + properties: { + info: { + id: "msg1", + role: "assistant", + sessionID: "session1", + finish: true, + modelID: "claude-sonnet-4", + providerID: "anthropic", + // 175k / 200k = 87.5% usage, above 85% threshold + tokens: { input: 100000, output: 5000, cache: { read: 70000, write: 0 } }, + }, + }, + }, + }) + + expect(mockClient.session.summarize).toHaveBeenCalledTimes(1) + expect(mockClient.session.summarize).toHaveBeenCalledWith({ + path: { id: "session1" }, + body: { providerID: "anthropic", modelID: "claude-sonnet-4" }, + query: { directory: "/test/dir" }, + }) + }) + }) + }) + + describe("#when session.deleted event received", () => { + it("#then should clean up session state", async () => { + // This is mainly to ensure no errors - state cleanup is internal + await hook.event({ + event: { + type: "session.deleted", + properties: { + info: { id: "session1" }, + }, + }, + }) + + // No error thrown means success + expect(true).toBe(true) + }) + }) + }) + + describe("#given DCP enabled configuration", () => { + let hook: ReturnType + + beforeEach(() => { + hook = createPreemptiveCompactionHook(mockCtx as never, { + experimental: { + dcp_for_compaction: true, + truncation_protection_messages: 3, + }, + }) + }) + + describe("#when compaction triggered", () => { + const triggerCompaction = () => + hook.event({ + event: { + type: "message.updated", + properties: { + info: { + id: "msg1", + role: "assistant", + sessionID: "session-dcp", + finish: true, + modelID: "claude-sonnet-4", + providerID: "anthropic", + // 175k / 200k = 87.5% usage + tokens: { input: 100000, output: 5000, cache: { read: 70000, write: 0 } }, + }, + }, + }, + }) + + describe("#and DCP + truncation reduce below threshold", () => { + it("#then should skip summarization", async () => { + mockExecuteDynamicContextPruning.mockResolvedValue({ + itemsPruned: 5, + totalTokensSaved: 20000, + strategies: { deduplication: 2, supersedeWrites: 2, purgeErrors: 1 }, + }) + mockTruncateUntilTargetTokens.mockReturnValue({ + success: true, + sufficient: true, + truncatedCount: 3, + totalBytesRemoved: 40000, // ~10k tokens at 4 chars/token + targetBytesToRemove: 40000, + truncatedTools: [{ toolName: "grep", originalSize: 40000 }], + }) + + await triggerCompaction() + + expect(mockExecuteDynamicContextPruning).toHaveBeenCalledTimes(1) + expect(mockTruncateUntilTargetTokens).toHaveBeenCalledTimes(1) + expect(mockClient.session.summarize).not.toHaveBeenCalled() + + // Check that "skipped" phase was logged + const skippedCall = mockLogCompaction.mock.calls.find( + (call: unknown[]) => (call[0] as { phase: string }).phase === "skipped" + ) + expect(skippedCall).toBeDefined() + }) + }) + + describe("#and DCP + truncation not sufficient", () => { + it("#then should proceed to summarization", async () => { + // With 175k tokens used and 200k limit: + // DCP saves 1k, truncation saves 1k (4000 bytes / 4) + // Total saved: 2k, new total: 173k + // Usage ratio: 173k / 200k = 86.5%, still above 85% threshold + mockExecuteDynamicContextPruning.mockResolvedValue({ + itemsPruned: 1, + totalTokensSaved: 1000, + strategies: { deduplication: 1, supersedeWrites: 0, purgeErrors: 0 }, + }) + mockTruncateUntilTargetTokens.mockReturnValue({ + success: true, + sufficient: false, + truncatedCount: 1, + totalBytesRemoved: 4000, // ~1k tokens at 4 chars/token + targetBytesToRemove: 40000, + truncatedTools: [{ toolName: "read", originalSize: 4000 }], + }) + + await triggerCompaction() + + expect(mockExecuteDynamicContextPruning).toHaveBeenCalledTimes(1) + expect(mockTruncateUntilTargetTokens).toHaveBeenCalledTimes(1) + expect(mockClient.session.summarize).toHaveBeenCalledTimes(1) + + // Check that "summarized" phase was logged + const summarizedCall = mockLogCompaction.mock.calls.find( + (call: unknown[]) => (call[0] as { phase: string }).phase === "summarized" + ) + expect(summarizedCall).toBeDefined() + }) + }) + + describe("#and DCP fails", () => { + it("#then should continue with truncation and summarization", async () => { + mockExecuteDynamicContextPruning.mockRejectedValue(new Error("DCP failed")) + mockTruncateUntilTargetTokens.mockReturnValue({ + success: false, + sufficient: false, + truncatedCount: 0, + totalBytesRemoved: 0, + targetBytesToRemove: 40000, + truncatedTools: [], + }) + + await triggerCompaction() + + expect(mockExecuteDynamicContextPruning).toHaveBeenCalledTimes(1) + expect(mockTruncateUntilTargetTokens).toHaveBeenCalledTimes(1) + expect(mockClient.session.summarize).toHaveBeenCalledTimes(1) + }) + }) + }) + }) + + describe("#given custom threshold", () => { + it("#then should use custom threshold for decision", async () => { + const hook = createPreemptiveCompactionHook(mockCtx as never, { + experimental: { + preemptive_compaction_threshold: 0.7, + }, + }) + + // 145k / 200k = 72.5% usage, above 70% custom threshold + await hook.event({ + event: { + type: "message.updated", + properties: { + info: { + id: "msg1", + role: "assistant", + sessionID: "session-custom", + finish: true, + modelID: "claude-sonnet-4", + providerID: "anthropic", + tokens: { input: 80000, output: 5000, cache: { read: 60000, write: 0 } }, + }, + }, + }, + }) + + expect(mockClient.session.summarize).toHaveBeenCalledTimes(1) + }) + }) + + describe("#given custom model limit callback", () => { + it("#then should use custom limit for calculation", async () => { + const hook = createPreemptiveCompactionHook(mockCtx as never, { + getModelLimit: (providerID, modelID) => { + if (providerID === "anthropic" && modelID.includes("sonnet")) { + return 100000 // 100k limit + } + return undefined + }, + }) + + // 85k / 100k = 85% usage, at threshold + await hook.event({ + event: { + type: "message.updated", + properties: { + info: { + id: "msg1", + role: "assistant", + sessionID: "session-limit", + finish: true, + modelID: "claude-sonnet-4", + providerID: "anthropic", + tokens: { input: 50000, output: 5000, cache: { read: 30000, write: 0 } }, + }, + }, + }, + }) + + expect(mockClient.session.summarize).toHaveBeenCalledTimes(1) + }) + }) + + describe("#given onBeforeSummarize callback", () => { + it("#then should call callback before summarization", async () => { + const onBeforeSummarize = mock(async () => {}) + const hook = createPreemptiveCompactionHook(mockCtx as never, { + onBeforeSummarize, + }) + + await hook.event({ + event: { + type: "message.updated", + properties: { + info: { + id: "msg1", + role: "assistant", + sessionID: "session-callback", + finish: true, + modelID: "claude-sonnet-4", + providerID: "anthropic", + tokens: { input: 100000, output: 5000, cache: { read: 70000, write: 0 } }, + }, + }, + }, + }) + + expect(onBeforeSummarize).toHaveBeenCalledTimes(1) + expect(onBeforeSummarize).toHaveBeenCalledWith({ + sessionID: "session-callback", + providerID: "anthropic", + modelID: "claude-sonnet-4", + usageRatio: expect.any(Number), + directory: "/test/dir", + }) + expect(mockClient.session.summarize).toHaveBeenCalledTimes(1) + }) + }) + + describe("cooldown mechanism", () => { + it("#then should prevent rapid consecutive compactions", async () => { + const hook = createPreemptiveCompactionHook(mockCtx as never) + + const triggerEvent = { + event: { + type: "message.updated", + properties: { + info: { + id: "msg1", + role: "assistant", + sessionID: "session-cooldown", + finish: true, + modelID: "claude-sonnet-4", + providerID: "anthropic", + tokens: { input: 100000, output: 5000, cache: { read: 70000, write: 0 } }, + }, + }, + }, + } + + // First trigger should work + await hook.event(triggerEvent) + expect(mockClient.session.summarize).toHaveBeenCalledTimes(1) + + mockClient.session.summarize.mockClear() + + // Second immediate trigger should be blocked by cooldown + await hook.event(triggerEvent) + expect(mockClient.session.summarize).not.toHaveBeenCalled() + }) + }) +}) diff --git a/src/hooks/preemptive-compaction/index.ts b/src/hooks/preemptive-compaction/index.ts index 58b5a8223..bcca76df8 100644 --- a/src/hooks/preemptive-compaction/index.ts +++ b/src/hooks/preemptive-compaction/index.ts @@ -1,7 +1,7 @@ import { existsSync, readdirSync } from "node:fs" import { join } from "node:path" import type { PluginInput } from "@opencode-ai/plugin" -import type { ExperimentalConfig } from "../../config" +import type { ExperimentalConfig, DynamicContextPruningConfig } from "../../config" import type { PreemptiveCompactionState, TokenInfo } from "./types" import { DEFAULT_THRESHOLD, @@ -12,7 +12,10 @@ import { findNearestMessageWithFields, MESSAGE_STORAGE, } from "../../features/hook-message-injector" +import { executeDynamicContextPruning } from "../anthropic-context-window-limit-recovery/pruning-executor" +import { truncateUntilTargetTokens } from "../anthropic-context-window-limit-recovery/storage" import { log } from "../../shared/logger" +import { logCompaction } from "./compaction-logger" export interface SummarizeContext { sessionID: string @@ -32,6 +35,9 @@ export interface PreemptiveCompactionOptions { getModelLimit?: GetModelLimitCallback } +// Default chars per token for estimation +const CHARS_PER_TOKEN = 4 + interface MessageInfo { id: string role: string @@ -125,7 +131,7 @@ export function createPreemptiveCompactionHook( if (totalUsed < MIN_TOKENS_FOR_COMPACTION) return - const usageRatio = totalUsed / contextLimit + let usageRatio = totalUsed / contextLimit log("[preemptive-compaction] checking", { sessionID, @@ -145,16 +151,189 @@ export function createPreemptiveCompactionHook( return } - await ctx.client.tui - .showToast({ - body: { - title: "Preemptive Compaction", - message: `Context at ${(usageRatio * 100).toFixed(0)}% - compacting to prevent overflow...`, - variant: "warning", - duration: 3000, + const timestamp = new Date().toISOString() + + logCompaction({ + timestamp, + sessionID, + phase: "triggered", + data: { + totalUsed, + contextLimit, + usageRatio, + threshold, + }, + }) + + let tokensSaved = 0 + let currentTokens = totalUsed + const dcpEnabled = experimental?.dynamic_context_pruning?.enabled || experimental?.dcp_for_compaction + + if (dcpEnabled) { + await ctx.client.tui + .showToast({ + body: { + title: "Smart Compaction", + message: `Context at ${(usageRatio * 100).toFixed(0)}% - running DCP + truncation first...`, + variant: "warning", + duration: 3000, + }, + }) + .catch(() => {}) + + log("[preemptive-compaction] Phase 1: DCP", { sessionID, currentTokens }) + + const dcpConfig: DynamicContextPruningConfig = experimental?.dynamic_context_pruning ?? { + enabled: true, + notification: "detailed", + protected_tools: [ + "task", "todowrite", "todoread", + "lsp_rename", "lsp_code_action_resolve", + ], + } + + try { + const pruningResult = await executeDynamicContextPruning( + sessionID, + dcpConfig, + ctx.client + ) + + if (pruningResult.itemsPruned > 0) { + tokensSaved += pruningResult.totalTokensSaved + log("[preemptive-compaction] DCP completed", { + itemsPruned: pruningResult.itemsPruned, + tokensSaved: pruningResult.totalTokensSaved, + }) + + logCompaction({ + timestamp, + sessionID, + phase: "dcp", + data: { + itemsPruned: pruningResult.itemsPruned, + tokensSaved: pruningResult.totalTokensSaved, + strategies: pruningResult.strategies, + }, + }) + } + } catch (error) { + log("[preemptive-compaction] DCP failed", { error: String(error) }) + } + + log("[preemptive-compaction] Phase 2: Truncation", { sessionID }) + + const protectedMessages = experimental?.truncation_protection_messages ?? 3 + const truncationResult = truncateUntilTargetTokens( + sessionID, + currentTokens - tokensSaved, + contextLimit, + threshold, + CHARS_PER_TOKEN, + protectedMessages + ) + + if (truncationResult.truncatedCount > 0) { + const truncationTokensSaved = Math.floor(truncationResult.totalBytesRemoved / CHARS_PER_TOKEN) + tokensSaved += truncationTokensSaved + log("[preemptive-compaction] Truncation completed", { + truncatedCount: truncationResult.truncatedCount, + bytesRemoved: truncationResult.totalBytesRemoved, + tokensSaved: truncationTokensSaved, + }) + + logCompaction({ + timestamp, + sessionID, + phase: "truncation", + data: { + truncatedCount: truncationResult.truncatedCount, + bytesRemoved: truncationResult.totalBytesRemoved, + tokensSaved: truncationTokensSaved, + tools: truncationResult.truncatedTools.map(t => t.toolName), + }, + }) + } + + currentTokens = totalUsed - tokensSaved + usageRatio = currentTokens / contextLimit + + log("[preemptive-compaction] After DCP + Truncation", { + originalTokens: totalUsed, + tokensSaved, + currentTokens, + newUsageRatio: usageRatio.toFixed(2), + threshold, + }) + + logCompaction({ + timestamp, + sessionID, + phase: "decision", + data: { + originalTokens: totalUsed, + tokensSaved, + currentTokens, + newUsageRatio: usageRatio, + threshold, + needsSummarize: usageRatio >= threshold, }, }) - .catch(() => {}) + + if (usageRatio < threshold) { + await ctx.client.tui + .showToast({ + body: { + title: "Smart Compaction Success", + message: `Reduced to ${(usageRatio * 100).toFixed(0)}% via DCP + truncation. No summarization needed.`, + variant: "success", + duration: 4000, + }, + }) + .catch(() => {}) + + log("[preemptive-compaction] Skipping summarization - pruning was sufficient", { + sessionID, + tokensSaved, + newUsageRatio: usageRatio.toFixed(2), + }) + + logCompaction({ + timestamp, + sessionID, + phase: "skipped", + data: { + finalUsageRatio: usageRatio, + tokensSaved, + }, + }) + + state.compactionInProgress.delete(sessionID) + return + } + + await ctx.client.tui + .showToast({ + body: { + title: "Smart Compaction", + message: `Still at ${(usageRatio * 100).toFixed(0)}% after pruning. Summarizing...`, + variant: "warning", + duration: 3000, + }, + }) + .catch(() => {}) + } else { + await ctx.client.tui + .showToast({ + body: { + title: "Preemptive Compaction", + message: `Context at ${(usageRatio * 100).toFixed(0)}% - compacting to prevent overflow...`, + variant: "warning", + duration: 3000, + }, + }) + .catch(() => {}) + } log("[preemptive-compaction] triggering compaction", { sessionID, usageRatio }) @@ -187,6 +366,13 @@ export function createPreemptiveCompactionHook( }) .catch(() => {}) + logCompaction({ + timestamp, + sessionID, + phase: "summarized", + data: {}, + }) + state.compactionInProgress.delete(sessionID) return } catch (err) {