277 lines
7.9 KiB
TypeScript
277 lines
7.9 KiB
TypeScript
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 { PreemptiveCompactionState, TokenInfo } from "./types"
|
|
import {
|
|
DEFAULT_THRESHOLD,
|
|
MIN_TOKENS_FOR_COMPACTION,
|
|
COMPACTION_COOLDOWN_MS,
|
|
} from "./constants"
|
|
import {
|
|
findNearestMessageWithFields,
|
|
MESSAGE_STORAGE,
|
|
} from "../../features/hook-message-injector"
|
|
import { log } from "../../shared/logger"
|
|
|
|
export interface SummarizeContext {
|
|
sessionID: string
|
|
providerID: string
|
|
modelID: string
|
|
usageRatio: number
|
|
directory: string
|
|
}
|
|
|
|
export type BeforeSummarizeCallback = (ctx: SummarizeContext) => Promise<void> | void
|
|
|
|
export type GetModelLimitCallback = (providerID: string, modelID: string) => number | undefined
|
|
|
|
export interface PreemptiveCompactionOptions {
|
|
experimental?: ExperimentalConfig
|
|
onBeforeSummarize?: BeforeSummarizeCallback
|
|
getModelLimit?: GetModelLimitCallback
|
|
}
|
|
|
|
interface MessageInfo {
|
|
id: string
|
|
role: string
|
|
sessionID: string
|
|
providerID?: string
|
|
modelID?: string
|
|
tokens?: TokenInfo
|
|
summary?: boolean
|
|
finish?: boolean
|
|
}
|
|
|
|
interface MessageWrapper {
|
|
info: MessageInfo
|
|
}
|
|
|
|
const CLAUDE_MODEL_PATTERN = /claude-(opus|sonnet|haiku)/i
|
|
const CLAUDE_DEFAULT_CONTEXT_LIMIT = 200_000
|
|
|
|
function isSupportedModel(modelID: string): boolean {
|
|
return CLAUDE_MODEL_PATTERN.test(modelID)
|
|
}
|
|
|
|
function getMessageDir(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
|
|
}
|
|
|
|
function createState(): PreemptiveCompactionState {
|
|
return {
|
|
lastCompactionTime: new Map(),
|
|
compactionInProgress: new Set(),
|
|
}
|
|
}
|
|
|
|
export function createPreemptiveCompactionHook(
|
|
ctx: PluginInput,
|
|
options?: PreemptiveCompactionOptions
|
|
) {
|
|
const experimental = options?.experimental
|
|
const onBeforeSummarize = options?.onBeforeSummarize
|
|
const getModelLimit = options?.getModelLimit
|
|
// Preemptive compaction is now enabled by default.
|
|
// Backward compatibility: explicit false in experimental config disables the hook.
|
|
const explicitlyDisabled = experimental?.preemptive_compaction === false
|
|
const threshold = experimental?.preemptive_compaction_threshold ?? DEFAULT_THRESHOLD
|
|
|
|
if (explicitlyDisabled) {
|
|
return { event: async () => {} }
|
|
}
|
|
|
|
const state = createState()
|
|
|
|
const checkAndTriggerCompaction = async (
|
|
sessionID: string,
|
|
lastAssistant: MessageInfo
|
|
): Promise<void> => {
|
|
if (state.compactionInProgress.has(sessionID)) return
|
|
|
|
const lastCompaction = state.lastCompactionTime.get(sessionID) ?? 0
|
|
if (Date.now() - lastCompaction < COMPACTION_COOLDOWN_MS) return
|
|
|
|
if (lastAssistant.summary === true) return
|
|
|
|
const tokens = lastAssistant.tokens
|
|
if (!tokens) return
|
|
|
|
const modelID = lastAssistant.modelID ?? ""
|
|
const providerID = lastAssistant.providerID ?? ""
|
|
|
|
if (!isSupportedModel(modelID)) {
|
|
log("[preemptive-compaction] skipping unsupported model", { modelID })
|
|
return
|
|
}
|
|
|
|
const configLimit = getModelLimit?.(providerID, modelID)
|
|
const contextLimit = configLimit ?? CLAUDE_DEFAULT_CONTEXT_LIMIT
|
|
const totalUsed = tokens.input + tokens.cache.read + tokens.output
|
|
|
|
if (totalUsed < MIN_TOKENS_FOR_COMPACTION) return
|
|
|
|
const usageRatio = totalUsed / contextLimit
|
|
|
|
log("[preemptive-compaction] checking", {
|
|
sessionID,
|
|
totalUsed,
|
|
contextLimit,
|
|
usageRatio: usageRatio.toFixed(2),
|
|
threshold,
|
|
})
|
|
|
|
if (usageRatio < threshold) return
|
|
|
|
state.compactionInProgress.add(sessionID)
|
|
state.lastCompactionTime.set(sessionID, Date.now())
|
|
|
|
if (!providerID || !modelID) {
|
|
state.compactionInProgress.delete(sessionID)
|
|
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,
|
|
},
|
|
})
|
|
.catch(() => {})
|
|
|
|
log("[preemptive-compaction] triggering compaction", { sessionID, usageRatio })
|
|
|
|
try {
|
|
if (onBeforeSummarize) {
|
|
await onBeforeSummarize({
|
|
sessionID,
|
|
providerID,
|
|
modelID,
|
|
usageRatio,
|
|
directory: ctx.directory,
|
|
})
|
|
}
|
|
|
|
await ctx.client.session.summarize({
|
|
path: { id: sessionID },
|
|
body: { providerID, modelID },
|
|
query: { directory: ctx.directory },
|
|
})
|
|
|
|
await ctx.client.tui
|
|
.showToast({
|
|
body: {
|
|
title: "Compaction Complete",
|
|
message: "Session compacted successfully. Resuming...",
|
|
variant: "success",
|
|
duration: 2000,
|
|
},
|
|
})
|
|
.catch(() => {})
|
|
|
|
state.compactionInProgress.delete(sessionID)
|
|
|
|
setTimeout(async () => {
|
|
try {
|
|
const messageDir = getMessageDir(sessionID)
|
|
const storedMessage = messageDir ? findNearestMessageWithFields(messageDir) : null
|
|
|
|
await ctx.client.session.promptAsync({
|
|
path: { id: sessionID },
|
|
body: {
|
|
agent: storedMessage?.agent,
|
|
parts: [{ type: "text", text: "Continue" }],
|
|
},
|
|
query: { directory: ctx.directory },
|
|
})
|
|
} catch {}
|
|
}, 500)
|
|
return
|
|
} catch (err) {
|
|
log("[preemptive-compaction] compaction failed", { sessionID, error: err })
|
|
} finally {
|
|
state.compactionInProgress.delete(sessionID)
|
|
}
|
|
}
|
|
|
|
const eventHandler = async ({ event }: { event: { type: string; properties?: unknown } }) => {
|
|
const props = event.properties as Record<string, unknown> | undefined
|
|
|
|
if (event.type === "session.deleted") {
|
|
const sessionInfo = props?.info as { id?: string } | undefined
|
|
if (sessionInfo?.id) {
|
|
state.lastCompactionTime.delete(sessionInfo.id)
|
|
state.compactionInProgress.delete(sessionInfo.id)
|
|
}
|
|
return
|
|
}
|
|
|
|
if (event.type === "message.updated") {
|
|
const info = props?.info as MessageInfo | undefined
|
|
if (!info) return
|
|
|
|
if (info.role !== "assistant" || !info.finish) return
|
|
|
|
const sessionID = info.sessionID
|
|
if (!sessionID) return
|
|
|
|
await checkAndTriggerCompaction(sessionID, info)
|
|
return
|
|
}
|
|
|
|
if (event.type === "session.idle") {
|
|
const sessionID = props?.sessionID as string | undefined
|
|
if (!sessionID) return
|
|
|
|
try {
|
|
const resp = await ctx.client.session.messages({
|
|
path: { id: sessionID },
|
|
query: { directory: ctx.directory },
|
|
})
|
|
|
|
const messages = (resp.data ?? resp) as MessageWrapper[]
|
|
const assistants = messages
|
|
.filter((m) => m.info.role === "assistant")
|
|
.map((m) => m.info)
|
|
|
|
if (assistants.length === 0) return
|
|
|
|
const lastAssistant = assistants[assistants.length - 1]
|
|
|
|
if (!lastAssistant.providerID || !lastAssistant.modelID) {
|
|
const messageDir = getMessageDir(sessionID)
|
|
const storedMessage = messageDir ? findNearestMessageWithFields(messageDir) : null
|
|
if (storedMessage?.model?.providerID && storedMessage?.model?.modelID) {
|
|
lastAssistant.providerID = storedMessage.model.providerID
|
|
lastAssistant.modelID = storedMessage.model.modelID
|
|
log("[preemptive-compaction] using stored message model info", {
|
|
sessionID,
|
|
providerID: lastAssistant.providerID,
|
|
modelID: lastAssistant.modelID,
|
|
})
|
|
}
|
|
}
|
|
|
|
await checkAndTriggerCompaction(sessionID, lastAssistant)
|
|
} catch {}
|
|
}
|
|
}
|
|
|
|
return {
|
|
event: eventHandler,
|
|
}
|
|
}
|