import { log } from "../shared/logger" import type { OhMyOpenCodeConfig } from "../config" import { resolveActualContextLimit, type ContextLimitModelCacheState, } from "../shared/context-limit-resolver" import { resolveCompactionModel } from "./shared/compaction-model-resolver" import { createPostCompactionDegradationMonitor } from "./preemptive-compaction-degradation-monitor" const PREEMPTIVE_COMPACTION_TIMEOUT_MS = 120_000 const PREEMPTIVE_COMPACTION_THRESHOLD = 0.78 const PREEMPTIVE_COMPACTION_COOLDOWN_MS = 60_000 declare function setTimeout(handler: () => void, timeout?: number): unknown declare function clearTimeout(timeoutID: unknown): void interface TokenInfo { input: number output: number reasoning: number cache: { read: number; write: number } } interface CachedCompactionState { providerID: string modelID: string tokens: TokenInfo } async function withTimeout( promise: Promise, timeoutMs: number, errorMessage: string, ): Promise { let timeoutID: unknown const timeoutPromise = new Promise((_, reject) => { timeoutID = setTimeout(() => { reject(new Error(errorMessage)) }, timeoutMs) }) return await Promise.race([promise, timeoutPromise]).finally(() => { clearTimeout(timeoutID) }) } type PluginInput = { client: { session: { // eslint-disable-next-line @typescript-eslint/no-explicit-any messages: (...args: any[]) => any // eslint-disable-next-line @typescript-eslint/no-explicit-any summarize: (...args: any[]) => any } tui: { // eslint-disable-next-line @typescript-eslint/no-explicit-any showToast: (...args: any[]) => any } } directory: string } export function createPreemptiveCompactionHook( ctx: PluginInput, pluginConfig: OhMyOpenCodeConfig, modelCacheState?: ContextLimitModelCacheState, ) { const compactionInProgress = new Set() const compactedSessions = new Set() const lastCompactionTime = new Map() const tokenCache = new Map() const postCompactionMonitor = createPostCompactionDegradationMonitor({ client: ctx.client, directory: ctx.directory, pluginConfig, tokenCache, compactionInProgress, }) const toolExecuteAfter = async ( input: { tool: string; sessionID: string; callID: string }, _output: { title: string; output: string; metadata: unknown } ) => { const { sessionID } = input if (compactedSessions.has(sessionID) || compactionInProgress.has(sessionID)) return const lastTime = lastCompactionTime.get(sessionID) if (lastTime && Date.now() - lastTime < PREEMPTIVE_COMPACTION_COOLDOWN_MS) return const cached = tokenCache.get(sessionID) if (!cached) return const actualLimit = resolveActualContextLimit( cached.providerID, cached.modelID, modelCacheState, ) if (actualLimit === null) { log("[preemptive-compaction] Skipping preemptive compaction: unknown context limit for model", { providerID: cached.providerID, modelID: cached.modelID, }) return } const totalInputTokens = (cached.tokens.input ?? 0) + (cached.tokens.cache?.read ?? 0) const usageRatio = totalInputTokens / actualLimit if (usageRatio < PREEMPTIVE_COMPACTION_THRESHOLD || !cached.modelID) return compactionInProgress.add(sessionID) lastCompactionTime.set(sessionID, Date.now()) try { const { providerID: targetProviderID, modelID: targetModelID } = resolveCompactionModel( pluginConfig, sessionID, cached.providerID, cached.modelID, ) await withTimeout( ctx.client.session.summarize({ path: { id: sessionID }, body: { providerID: targetProviderID, modelID: targetModelID, auto: true } as never, query: { directory: ctx.directory }, }), PREEMPTIVE_COMPACTION_TIMEOUT_MS, `Compaction summarize timed out after ${PREEMPTIVE_COMPACTION_TIMEOUT_MS}ms`, ) compactedSessions.add(sessionID) } catch (error) { log("[preemptive-compaction] Compaction failed", { sessionID, error: String(error) }) } finally { compactionInProgress.delete(sessionID) } } const eventHandler = async ({ event }: { event: { type: string; properties?: unknown } }) => { const props = event.properties as Record | undefined if (event.type === "session.deleted") { const sessionID = (props?.info as { id?: string } | undefined)?.id if (sessionID) { compactionInProgress.delete(sessionID) compactedSessions.delete(sessionID) lastCompactionTime.delete(sessionID) tokenCache.delete(sessionID) postCompactionMonitor.clear(sessionID) } return } if (event.type === "session.compacted") { const sessionID = (props?.sessionID as string | undefined) ?? (props?.info as { id?: string } | undefined)?.id if (sessionID) { postCompactionMonitor.onSessionCompacted(sessionID) } return } if (event.type === "message.updated") { const info = props?.info as { id?: string role?: string sessionID?: string providerID?: string modelID?: string finish?: boolean tokens?: TokenInfo } | undefined if (!info || info.role !== "assistant" || !info.finish || !info.sessionID) return if (info.providerID && info.tokens) { tokenCache.set(info.sessionID, { providerID: info.providerID, modelID: info.modelID ?? "", tokens: info.tokens, }) } compactedSessions.delete(info.sessionID) await postCompactionMonitor.onAssistantMessageUpdated({ sessionID: info.sessionID, id: info.id, }) } } return { "tool.execute.after": toolExecuteAfter, event: eventHandler, } }