2026-02-16 14:58:59 +09:00
import { log } from "../shared/logger"
2026-02-22 17:19:39 +09:00
import type { OhMyOpenCodeConfig } from "../config"
2026-03-13 12:36:07 +09:00
import {
resolveActualContextLimit ,
type ContextLimitModelCacheState ,
} from "../shared/context-limit-resolver"
2026-02-16 14:58:59 +09:00
2026-02-22 17:19:39 +09:00
import { resolveCompactionModel } from "./shared/compaction-model-resolver"
2026-03-18 16:13:23 +09:00
import { createPostCompactionDegradationMonitor } from "./preemptive-compaction-degradation-monitor"
2026-02-06 17:49:26 +09:00
2026-04-08 20:10:25 +09:00
const PREEMPTIVE_COMPACTION_TIMEOUT_MS = 60 _000
2026-02-02 22:40:59 +09:00
const PREEMPTIVE_COMPACTION_THRESHOLD = 0.78
2026-03-27 13:59:06 +09:00
const PREEMPTIVE_COMPACTION_COOLDOWN_MS = 60 _000
2026-02-02 22:40:59 +09:00
2026-03-18 17:42:17 +09:00
declare function setTimeout ( handler : ( ) = > void , timeout? : number ) : unknown
declare function clearTimeout ( timeoutID : unknown ) : void
2026-02-12 11:38:11 +09:00
interface TokenInfo {
input : number
output : number
reasoning : number
cache : { read : number ; write : number }
2026-02-02 22:40:59 +09:00
}
2026-02-12 11:38:11 +09:00
interface CachedCompactionState {
providerID : string
modelID : string
tokens : TokenInfo
2026-02-02 22:40:59 +09:00
}
2026-03-13 12:36:07 +09:00
async function withTimeout < TValue > (
2026-02-26 20:58:01 +09:00
promise : Promise < TValue > ,
timeoutMs : number ,
errorMessage : string ,
) : Promise < TValue > {
2026-03-18 17:42:17 +09:00
let timeoutID : unknown
2026-02-26 20:58:01 +09:00
const timeoutPromise = new Promise < never > ( ( _ , reject ) = > {
timeoutID = setTimeout ( ( ) = > {
reject ( new Error ( errorMessage ) )
} , timeoutMs )
} )
2026-03-13 12:36:07 +09:00
return await Promise . race ( [ promise , timeoutPromise ] ) . finally ( ( ) = > {
2026-03-18 17:42:17 +09:00
clearTimeout ( timeoutID )
2026-02-26 20:58:01 +09:00
} )
}
2026-02-02 22:40:59 +09:00
type PluginInput = {
client : {
session : {
2026-02-12 11:38:11 +09:00
// eslint-disable-next-line @typescript-eslint/no-explicit-any
2026-02-02 22:40:59 +09:00
messages : ( . . . args : any [ ] ) = > any
2026-02-12 11:38:11 +09:00
// eslint-disable-next-line @typescript-eslint/no-explicit-any
2026-02-02 22:40:59 +09:00
summarize : ( . . . args : any [ ] ) = > any
}
tui : {
2026-02-12 11:38:11 +09:00
// eslint-disable-next-line @typescript-eslint/no-explicit-any
2026-02-02 22:40:59 +09:00
showToast : ( . . . args : any [ ] ) = > any
}
}
directory : string
}
2026-02-17 10:33:17 +09:00
export function createPreemptiveCompactionHook (
ctx : PluginInput ,
2026-02-22 17:19:39 +09:00
pluginConfig : OhMyOpenCodeConfig ,
2026-03-13 12:36:07 +09:00
modelCacheState? : ContextLimitModelCacheState ,
2026-02-17 10:33:17 +09:00
) {
2026-02-02 22:40:59 +09:00
const compactionInProgress = new Set < string > ( )
const compactedSessions = new Set < string > ( )
2026-03-27 13:59:06 +09:00
const lastCompactionTime = new Map < string , number > ( )
2026-02-12 11:38:11 +09:00
const tokenCache = new Map < string , CachedCompactionState > ( )
2026-02-02 22:40:59 +09:00
2026-03-18 16:13:23 +09:00
const postCompactionMonitor = createPostCompactionDegradationMonitor ( {
client : ctx.client ,
directory : ctx.directory ,
pluginConfig ,
tokenCache ,
compactionInProgress ,
} )
2026-02-02 22:40:59 +09:00
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
2026-03-27 13:59:06 +09:00
const lastTime = lastCompactionTime . get ( sessionID )
if ( lastTime && Date . now ( ) - lastTime < PREEMPTIVE_COMPACTION_COOLDOWN_MS ) return
2026-02-12 11:38:11 +09:00
const cached = tokenCache . get ( sessionID )
if ( ! cached ) return
2026-02-02 22:40:59 +09:00
2026-03-13 12:36:07 +09:00
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
2026-03-12 01:24:16 +09:00
}
2026-02-02 22:40:59 +09:00
2026-03-18 16:13:23 +09:00
const totalInputTokens = ( cached . tokens . input ? ? 0 ) + ( cached . tokens . cache ? . read ? ? 0 )
2026-02-12 11:38:11 +09:00
const usageRatio = totalInputTokens / actualLimit
2026-03-18 16:13:23 +09:00
if ( usageRatio < PREEMPTIVE_COMPACTION_THRESHOLD || ! cached . modelID ) return
2026-02-02 22:40:59 +09:00
2026-02-12 11:38:11 +09:00
compactionInProgress . add ( sessionID )
2026-03-27 14:25:38 +09:00
lastCompactionTime . set ( sessionID , Date . now ( ) )
2026-02-02 22:40:59 +09:00
2026-02-12 11:38:11 +09:00
try {
2026-02-22 17:19:39 +09:00
const { providerID : targetProviderID , modelID : targetModelID } = resolveCompactionModel (
pluginConfig ,
sessionID ,
cached . providerID ,
2026-03-18 16:13:23 +09:00
cached . modelID ,
2026-02-22 17:19:39 +09:00
)
2026-02-26 20:58:01 +09:00
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 ` ,
)
2026-02-02 22:40:59 +09:00
compactedSessions . add ( sessionID )
2026-02-16 14:58:59 +09:00
} catch ( error ) {
2026-04-08 20:10:25 +09:00
log ( "[preemptive-compaction] Compaction failed" , {
sessionID ,
providerID : cached.providerID ,
modelID : cached.modelID ,
error : String ( error ) ,
} )
ctx . client . tui . showToast ( {
body : {
title : "Preemptive compaction failed" ,
message : ` Context window is above ${ Math . round ( PREEMPTIVE_COMPACTION_THRESHOLD * 100 ) } % and auto-compaction could not run. The session may grow large. Error: ${ String ( error ) } ` ,
variant : "warning" ,
duration : 10000 ,
} ,
} ) . catch ( ( toastError : unknown ) = > {
log ( "[preemptive-compaction] Failed to show toast" , {
sessionID ,
toastError : String ( toastError ) ,
} )
} )
2026-02-02 22:40:59 +09:00
} finally {
compactionInProgress . delete ( sessionID )
}
}
const eventHandler = async ( { event } : { event : { type : string ; properties? : unknown } } ) = > {
const props = event . properties as Record < string , unknown > | undefined
2026-02-12 11:38:11 +09:00
if ( event . type === "session.deleted" ) {
2026-03-18 16:13:23 +09:00
const sessionID = ( props ? . info as { id? : string } | undefined ) ? . id
if ( sessionID ) {
compactionInProgress . delete ( sessionID )
compactedSessions . delete ( sessionID )
2026-03-27 13:59:06 +09:00
lastCompactionTime . delete ( sessionID )
2026-03-18 16:13:23 +09:00
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 )
2026-02-12 11:38:11 +09:00
}
return
}
if ( event . type === "message.updated" ) {
const info = props ? . info as {
2026-03-18 16:13:23 +09:00
id? : string
2026-02-12 11:38:11 +09:00
role? : string
sessionID? : string
providerID? : string
modelID? : string
finish? : boolean
tokens? : TokenInfo
} | undefined
2026-03-18 16:13:23 +09:00
if ( ! info || info . role !== "assistant" || ! info . finish || ! info . sessionID ) return
2026-02-12 11:38:11 +09:00
2026-03-18 16:13:23 +09:00
if ( info . providerID && info . tokens ) {
tokenCache . set ( info . sessionID , {
providerID : info.providerID ,
modelID : info.modelID ? ? "" ,
tokens : info.tokens ,
} )
}
2026-03-02 23:07:39 +09:00
compactedSessions . delete ( info . sessionID )
2026-03-18 16:13:23 +09:00
await postCompactionMonitor . onAssistantMessageUpdated ( {
sessionID : info.sessionID ,
id : info.id ,
} )
2026-02-02 22:40:59 +09:00
}
}
return {
"tool.execute.after" : toolExecuteAfter ,
event : eventHandler ,
}
}