Files
oh-my-opencode/src/hooks/context-window-monitor.ts
T
YeonGyu-Kim 4da48555ee fix(plugin): normalize event session ids
Handle OpenCode session events that carry the session ID under properties.info.id or properties.info.sessionID so background tasks and continuation hooks do not miss idle/error/delete events.

Add regression coverage for nested session.idle events completing background tasks and waking continuation hooks.
2026-05-12 12:32:26 +09:00

126 lines
4.0 KiB
TypeScript

import type { PluginInput } from "@opencode-ai/plugin"
import {
resolveActualContextLimit,
type ContextLimitModelCacheState,
} from "../shared/context-limit-resolver"
import { isCompactionAgent } from "../shared/compaction-marker"
import { resolveMessageEventSessionID, resolveSessionEventID } from "../shared/event-session-id"
import { createSystemDirective, SystemDirectiveTypes } from "../shared/system-directive"
const CONTEXT_WARNING_THRESHOLD = 0.70
function createContextReminder(actualLimit: number): string {
const limitTokens = actualLimit.toLocaleString()
return `${createSystemDirective(SystemDirectiveTypes.CONTEXT_WINDOW_MONITOR)}
You are using a ${limitTokens}-token context window.
You still have context remaining - do NOT rush or skip tasks.
Complete your work thoroughly and methodically.`
}
interface TokenInfo {
input: number
output: number
reasoning: number
cache: { read: number; write: number }
}
interface CachedTokenState {
providerID: string
modelID: string
tokens: TokenInfo
}
export function createContextWindowMonitorHook(
_ctx: PluginInput,
modelCacheState?: ContextLimitModelCacheState,
) {
const remindedSessions = new Set<string>()
const tokenCache = new Map<string, CachedTokenState>()
const toolExecuteAfter = async (
input: { tool: string; sessionID: string; callID: string },
output: { title: string; output: string; metadata: unknown }
) => {
const { sessionID } = input
if (remindedSessions.has(sessionID)) return
const cached = tokenCache.get(sessionID)
if (!cached) return
const actualLimit = resolveActualContextLimit(
cached.providerID,
cached.modelID,
modelCacheState,
)
if (!actualLimit) return
const lastTokens = cached.tokens
const totalInputTokens = (lastTokens?.input ?? 0) + (lastTokens?.cache?.read ?? 0)
const actualUsagePercentage = totalInputTokens / actualLimit
if (actualUsagePercentage < CONTEXT_WARNING_THRESHOLD) return
remindedSessions.add(sessionID)
// Clamp the displayed percentages so the block stays trustworthy when the
// resolved actualLimit underestimates the model's real context window
// (e.g. a 1M-context Anthropic model that falls back to the 200K default).
// Without clamping, the block would advertise >100% used and a negative
// "remaining" - safety-tuned models flag exactly that pattern as a prompt
// injection and refuse to follow the directive (issue #3655).
const clampedPercentage = Math.min(Math.max(actualUsagePercentage, 0), 1)
const usedPct = (clampedPercentage * 100).toFixed(1)
const remainingPct = ((1 - clampedPercentage) * 100).toFixed(1)
const usedTokens = totalInputTokens.toLocaleString()
const limitTokens = actualLimit.toLocaleString()
output.output += `\n\n${createContextReminder(actualLimit)}
[Context Status: ${usedPct}% used (${usedTokens}/${limitTokens} tokens), ${remainingPct}% remaining]`
}
const eventHandler = async ({ event }: { event: { type: string; properties?: unknown } }) => {
const props = event.properties as Record<string, unknown> | undefined
if (event.type === "session.deleted") {
const sessionID = resolveSessionEventID(props)
if (sessionID) {
remindedSessions.delete(sessionID)
tokenCache.delete(sessionID)
}
}
if (event.type === "message.updated") {
const info = props?.info as {
agent?: unknown
role?: string
sessionID?: string
providerID?: string
modelID?: string
finish?: boolean
tokens?: TokenInfo
} | undefined
if (!info || info.role !== "assistant" || !info.finish) return
if (isCompactionAgent(info.agent)) return
const sessionID = resolveMessageEventSessionID(props)
if (!sessionID || !info.providerID || !info.tokens) return
tokenCache.set(sessionID, {
providerID: info.providerID,
modelID: info.modelID ?? "",
tokens: info.tokens,
})
}
}
return {
"tool.execute.after": toolExecuteAfter,
event: eventHandler,
}
}