diff --git a/.gitignore b/.gitignore index 212fbbe008..06aa8147b2 100644 --- a/.gitignore +++ b/.gitignore @@ -9,6 +9,7 @@ dist/ # Platform binaries (built, not committed) packages/*/bin/oh-my-opencode packages/*/bin/oh-my-opencode.exe +packages/*/bin/*.map # IDE .idea/ diff --git a/bun.lock b/bun.lock index 1c5d84e94a..e5b6e7050e 100644 --- a/bun.lock +++ b/bun.lock @@ -29,13 +29,17 @@ "typescript": "^5.7.3", }, "optionalDependencies": { - "oh-my-opencode-darwin-arm64": "3.8.5", - "oh-my-opencode-darwin-x64": "3.8.5", - "oh-my-opencode-linux-arm64": "3.8.5", - "oh-my-opencode-linux-arm64-musl": "3.8.5", - "oh-my-opencode-linux-x64": "3.8.5", - "oh-my-opencode-linux-x64-musl": "3.8.5", - "oh-my-opencode-windows-x64": "3.8.5", + "oh-my-opencode-darwin-arm64": "3.9.0", + "oh-my-opencode-darwin-x64": "3.9.0", + "oh-my-opencode-darwin-x64-baseline": "3.9.0", + "oh-my-opencode-linux-arm64": "3.9.0", + "oh-my-opencode-linux-arm64-musl": "3.9.0", + "oh-my-opencode-linux-x64": "3.9.0", + "oh-my-opencode-linux-x64-baseline": "3.9.0", + "oh-my-opencode-linux-x64-musl": "3.9.0", + "oh-my-opencode-linux-x64-musl-baseline": "3.9.0", + "oh-my-opencode-windows-x64": "3.9.0", + "oh-my-opencode-windows-x64-baseline": "3.9.0", }, }, }, @@ -231,19 +235,27 @@ "object-inspect": ["object-inspect@1.13.4", "", {}, "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew=="], - "oh-my-opencode-darwin-arm64": ["oh-my-opencode-darwin-arm64@3.8.5", "", { "os": "darwin", "cpu": "arm64", "bin": { "oh-my-opencode": "bin/oh-my-opencode" } }, "sha512-bbLu1We9NNhYAVp9Q/FK8dYFlYLp2PKfvdBCr+O6QjNRixdjp8Ru4RK7i9mKg0ybYBUzzCcbbC2Cc1o8orkhBA=="], + "oh-my-opencode-darwin-arm64": ["oh-my-opencode-darwin-arm64@3.9.0", "", { "os": "darwin", "cpu": "arm64", "bin": { "oh-my-opencode": "bin/oh-my-opencode" } }, "sha512-S+x8/rgWHTxjjnN/IMo1tmZCm9Ex5ODDU+Dnas51WR4ztdV2keYCtC97lQxz04iOeNaoV7flkV2/pA8516ah8Q=="], - "oh-my-opencode-darwin-x64": ["oh-my-opencode-darwin-x64@3.8.5", "", { "os": "darwin", "cpu": "x64", "bin": { "oh-my-opencode": "bin/oh-my-opencode" } }, "sha512-N9GcmzYgL87UybSaMGiHc5lwT5Mxg1tyB502el5syouN39wfeUYoj37SonENrMUTiEfn75Lwv/5cSLCesSubpA=="], + "oh-my-opencode-darwin-x64": ["oh-my-opencode-darwin-x64@3.9.0", "", { "os": "darwin", "cpu": "x64", "bin": { "oh-my-opencode": "bin/oh-my-opencode" } }, "sha512-A3cv89/CgSdFJRgz2+/EvhgvK11i8BOOJgTJmG03ya0rcKltN3K7j02wXbH2sqwrr0+TqAQ5LLuRqURZO6GqBQ=="], - "oh-my-opencode-linux-arm64": ["oh-my-opencode-linux-arm64@3.8.5", "", { "os": "linux", "cpu": "arm64", "bin": { "oh-my-opencode": "bin/oh-my-opencode" } }, "sha512-ki4a7s1DD5z5wEKmzcchqAKOIpw0LsBvyF8ieqNLS5Xl8PWE0gAZ7rqjlXC54NTubpexVH6lO2yenFJsk2Zk9A=="], + "oh-my-opencode-darwin-x64-baseline": ["oh-my-opencode-darwin-x64-baseline@3.9.0", "", { "os": "darwin", "cpu": "x64", "bin": { "oh-my-opencode": "bin/oh-my-opencode" } }, "sha512-/8T7CSIMKu89yfAaHg/K/Me7+FkLg47TfK8ozf5SK8p7KBvjHE8iU4kCe9h0ck6F6g9kXcnuAHj4urIXzxsS6A=="], - "oh-my-opencode-linux-arm64-musl": ["oh-my-opencode-linux-arm64-musl@3.8.5", "", { "os": "linux", "cpu": "arm64", "bin": { "oh-my-opencode": "bin/oh-my-opencode" } }, "sha512-9+6hU3z503fBzuV0VjxIkTKFElbKacHijFcdKAussG6gPFLWmCRWtdowzEDwUfAoIsoHHH7FBwvh5waGp/ZksA=="], + "oh-my-opencode-linux-arm64": ["oh-my-opencode-linux-arm64@3.9.0", "", { "os": "linux", "cpu": "arm64", "bin": { "oh-my-opencode": "bin/oh-my-opencode" } }, "sha512-XEC96voUOGErbjFJ2CEz7u/xz3mP2q3feNjHWXbFPVEMY4Xma1nayYt06bBzSSy1Q7cd21Ga/xbWEtNYsCofsA=="], - "oh-my-opencode-linux-x64": ["oh-my-opencode-linux-x64@3.8.5", "", { "os": "linux", "cpu": "x64", "bin": { "oh-my-opencode": "bin/oh-my-opencode" } }, "sha512-DmnMK/PgvdcCYL+OQE5iZWgi/vmjm0sIPQVQgSUbWn3izcUF7C5DtlxqaU2cKxNZwrhDTlJdLWxmJqgLmLqd9A=="], + "oh-my-opencode-linux-arm64-musl": ["oh-my-opencode-linux-arm64-musl@3.9.0", "", { "os": "linux", "cpu": "arm64", "bin": { "oh-my-opencode": "bin/oh-my-opencode" } }, "sha512-Ln9mEvVb4A2jaIvxNQYk2I2hh6qCWLoXhODKyqD81tBmWAFpZ55d9zJkV499V3GsRbEV47C9GVpUtuYi/XXSYg=="], - "oh-my-opencode-linux-x64-musl": ["oh-my-opencode-linux-x64-musl@3.8.5", "", { "os": "linux", "cpu": "x64", "bin": { "oh-my-opencode": "bin/oh-my-opencode" } }, "sha512-jhCNStljsyapVq9X7PaHSOcWxxEA4BUcIibvoPs/xc7fVP8D47p651LzIRsM6STn6Bx684mlYbxxX1P/0QPKNg=="], + "oh-my-opencode-linux-x64": ["oh-my-opencode-linux-x64@3.9.0", "", { "os": "linux", "cpu": "x64", "bin": { "oh-my-opencode": "bin/oh-my-opencode" } }, "sha512-1w0jnwuj9Db/QTEBtZxUgPx31S88zgS8AQaNl6GAAa3i+OVfLByKTmqf4LDtEDOfWcbl601RQBJ9yYI4ZkRvxQ=="], - "oh-my-opencode-windows-x64": ["oh-my-opencode-windows-x64@3.8.5", "", { "os": "win32", "cpu": "x64", "bin": { "oh-my-opencode": "bin/oh-my-opencode.exe" } }, "sha512-lcPBp9NCNQ6TnqzsN9p/K+xKwOzBoIPw7HncxmrXSberZ3uHy0K9uNraQ7fqnXIKWqQiK4kSwWfSHpmhbaHiNg=="], + "oh-my-opencode-linux-x64-baseline": ["oh-my-opencode-linux-x64-baseline@3.9.0", "", { "os": "linux", "cpu": "x64", "bin": { "oh-my-opencode": "bin/oh-my-opencode" } }, "sha512-DDMfV/l9iz3tFJqesg2XxP2rDR+yzfTOTqXOlVOmyFgO43tAMOrNl50pq5WS3y8mfLgLsDSPCf22PImWx1GM2A=="], + + "oh-my-opencode-linux-x64-musl": ["oh-my-opencode-linux-x64-musl@3.9.0", "", { "os": "linux", "cpu": "x64", "bin": { "oh-my-opencode": "bin/oh-my-opencode" } }, "sha512-gZvSUQXmhrsP3yF7U0PSZF6SixSLLRTKJfNPrOJdUhX/DspULQxnBpzc2AUhDv+ncmqT1gHdqE3Mg8A4T/qhlw=="], + + "oh-my-opencode-linux-x64-musl-baseline": ["oh-my-opencode-linux-x64-musl-baseline@3.9.0", "", { "os": "linux", "cpu": "x64", "bin": { "oh-my-opencode": "bin/oh-my-opencode" } }, "sha512-EuX2k2JudJsALvo2KV6nwd7iTD0QJgh27jt7joIlm5awAssyd9VKFW79ri/2jVcxMHfLlCxDz/0nAa2tsrc9Ag=="], + + "oh-my-opencode-windows-x64": ["oh-my-opencode-windows-x64@3.9.0", "", { "os": "win32", "cpu": "x64", "bin": { "oh-my-opencode": "bin/oh-my-opencode.exe" } }, "sha512-xohpzwmIo2x675nL8KLGdrANGsHSdXLmj1QpMfnWJE8gTxROBjS85uWCsqdLtjNapJ+q1oESStW3y+SNgPjDOA=="], + + "oh-my-opencode-windows-x64-baseline": ["oh-my-opencode-windows-x64-baseline@3.9.0", "", { "os": "win32", "cpu": "x64", "bin": { "oh-my-opencode": "bin/oh-my-opencode.exe" } }, "sha512-JTkBkXV9m2/HVcOoean5Eun6Rvma/n5Eu2/EaIKbqEtkXVB9Bf9KXMMI8tf51V9CxlG2QEw7+HmPDWAeFN3LWA=="], "on-finished": ["on-finished@2.4.1", "", { "dependencies": { "ee-first": "1.1.1" } }, "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg=="], diff --git a/package.json b/package.json index 012e6dcc49..2b40d90848 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "oh-my-opencode", - "version": "3.9.0", + "version": "3.10.0", "description": "The Best AI Agent Harness - Batteries-Included OpenCode Plugin with Multi-Model Orchestration, Parallel Background Agents, and Crafted LSP/AST Tools", "main": "dist/index.js", "types": "dist/index.d.ts", diff --git a/src/config/schema/hooks.ts b/src/config/schema/hooks.ts index 28ab58851b..076630e1e4 100644 --- a/src/config/schema/hooks.ts +++ b/src/config/schema/hooks.ts @@ -50,6 +50,10 @@ export const HookNameSchema = z.enum([ "anthropic-effort", "hashline-read-enhancer", "read-image-resizer", + "learning-bus-injector", + "execution-gate", + "resource-gate", + "auto-checkpoint", ]) export type HookName = z.infer diff --git a/src/hooks/auto-checkpoint/constants.ts b/src/hooks/auto-checkpoint/constants.ts new file mode 100644 index 0000000000..acd8e672f9 --- /dev/null +++ b/src/hooks/auto-checkpoint/constants.ts @@ -0,0 +1,5 @@ +export const HOOK_NAME = "auto-checkpoint" +export const CHECKPOINT_MESSAGE_THRESHOLD = 20 +export const CHECKPOINT_TIME_THRESHOLD_MS = 15 * 60 * 1000 // 15 minutes +export const CHECKPOINT_NAME = "auto-idle" +export const DEFAULT_SKIP_AGENTS = ["explore", "librarian", "multimodal-looker", "oracle", "metis", "momus"] diff --git a/src/hooks/auto-checkpoint/hook.ts b/src/hooks/auto-checkpoint/hook.ts new file mode 100644 index 0000000000..616ea04f22 --- /dev/null +++ b/src/hooks/auto-checkpoint/hook.ts @@ -0,0 +1,157 @@ +import type { PluginInput } from "@opencode-ai/plugin" + +import { createInternalAgentTextPart, normalizeSDKResponse } from "../../shared" +import { getAgentConfigKey } from "../../shared/agent-display-names" +import { log } from "../../shared/logger" + +import { + CHECKPOINT_MESSAGE_THRESHOLD, + CHECKPOINT_TIME_THRESHOLD_MS, + DEFAULT_SKIP_AGENTS, + HOOK_NAME, +} from "./constants" +import { buildCheckpointPrompt, buildRestorePrompt } from "./prompt-templates" + + const COOLDOWN_MS = 5 * 60 * 1000 + const MAX_INJECTION_CHARS = 2000 + + type SessionState = { + idleCount: number + lastInjectionAt: number + restoreInjected: boolean + isSubagent: boolean | null +} + + export type AutoCheckpointHook = ReturnType + + export function createAutoCheckpointHook( + ctx: PluginInput, + options: { skipAgents?: string[] } = {}, + ) { + const { skipAgents = DEFAULT_SKIP_AGENTS } = options + const sessions = new Map() + + async function resolveAgent(sessionID: string): Promise { + try { + const messagesResp = await ctx.client.session.messages({ + path: { id: sessionID }, + }) + const messages = normalizeSDKResponse(messagesResp, [] as Array<{ info?: { agent?: string } }>) + for (const msg of messages) { + if (msg.info?.agent) return msg.info.agent + } + } catch (error) { + log(`[${HOOK_NAME}] Failed to resolve agent`, { sessionID, error: String(error) }) + } + return undefined + } + + function getOrCreateSession(sessionID: string): SessionState { + let state = sessions.get(sessionID) + if (!state) { + state = { idleCount: 0, lastInjectionAt: 0, restoreInjected: false, isSubagent: null } + sessions.set(sessionID, state) + } + return state + } + + async function isSkippedAgent(sessionID: string, state: SessionState): Promise { + if (state.isSubagent !== null) return state.isSubagent + const agentName = await resolveAgent(sessionID) + if (agentName && skipAgents.some((s) => getAgentConfigKey(s) === getAgentConfigKey(agentName))) { + state.isSubagent = true + log(`[${HOOK_NAME}] Skipped: sub-agent session`, { sessionID, agent: agentName }) + return true + } + state.isSubagent = false + return false + } + + const event = async (input: { event: { type: string; properties?: unknown } }): Promise => { + if (input.event.type === "session.deleted") { + const props = input.event.properties as Record | undefined + const sessionInfo = props?.info as { id?: string } | undefined + if (sessionInfo?.id) { + sessions.delete(sessionInfo.id) + } + return + } + + if (input.event.type !== "session.idle") return + + const props = input.event.properties as Record | undefined + const sessionID = props?.sessionID as string | undefined + if (!sessionID) return + + const state = getOrCreateSession(sessionID) + state.idleCount++ + const now = Date.now() + + // First idle of a fresh session: inject restore prompt + if (state.idleCount === 1 && !state.restoreInjected) { + if (await isSkippedAgent(sessionID, state)) return + + state.restoreInjected = true + try { + const prompt = buildRestorePrompt() + await ctx.client.session.promptAsync({ + path: { id: sessionID }, + body: { parts: [createInternalAgentTextPart(prompt)] }, + query: { directory: ctx.directory }, + }) + state.lastInjectionAt = now + log(`[${HOOK_NAME}] Injected restore prompt`, { sessionID }) + } catch (error) { + log(`[${HOOK_NAME}] Failed to inject restore prompt`, { sessionID, error: String(error) }) + state.restoreInjected = false + } + return + } + + // Cooldown: skip if last injection was less than 5min ago + if (now - state.lastInjectionAt < COOLDOWN_MS) return + + // Check thresholds: idle count (proxy for messages) or time since last injection + const idlesSinceLastInjection = state.idleCount + const timeSinceLastInjection = state.lastInjectionAt === 0 ? now : now - state.lastInjectionAt + + const messageThresholdMet = idlesSinceLastInjection >= CHECKPOINT_MESSAGE_THRESHOLD + const timeThresholdMet = timeSinceLastInjection >= CHECKPOINT_TIME_THRESHOLD_MS + + if (!messageThresholdMet && !timeThresholdMet) return + + if (await isSkippedAgent(sessionID, state)) return + + try { + const minutesSince = Math.round(timeSinceLastInjection / 60_000) + let prompt = buildCheckpointPrompt({ + messagesSinceCheckpoint: idlesSinceLastInjection, + minutesSinceCheckpoint: minutesSince, + }) + + if (prompt.length > MAX_INJECTION_CHARS) { + prompt = prompt.slice(0, MAX_INJECTION_CHARS) + "\n\n(Truncated)" + } + + await ctx.client.session.promptAsync({ + path: { id: sessionID }, + body: { parts: [createInternalAgentTextPart(prompt)] }, + query: { directory: ctx.directory }, + }) + + state.lastInjectionAt = now + state.idleCount = 0 + + log(`[${HOOK_NAME}] Injected checkpoint prompt`, { + sessionID, + idleCount: idlesSinceLastInjection, + minutesSinceCheckpoint: minutesSince, + trigger: messageThresholdMet ? "message-threshold" : "time-threshold", + }) + } catch (error) { + log(`[${HOOK_NAME}] Failed to inject checkpoint prompt`, { sessionID, error: String(error) }) + } + } + + return { event } +} diff --git a/src/hooks/auto-checkpoint/index.test.ts b/src/hooks/auto-checkpoint/index.test.ts new file mode 100644 index 0000000000..b763897971 --- /dev/null +++ b/src/hooks/auto-checkpoint/index.test.ts @@ -0,0 +1,153 @@ +/// + +import { beforeEach, describe, expect, mock, test } from "bun:test" + +import { CHECKPOINT_MESSAGE_THRESHOLD } from "./constants" + +const logMock = mock(() => {}) + +mock.module("../../shared", () => ({ + createInternalAgentTextPart: (text: string) => ({ type: "text", text }), + normalizeSDKResponse: (resp: unknown, fallback: unknown) => (Array.isArray(resp) ? resp : fallback), + log: () => {}, +})) +mock.module("../../shared/logger", () => ({ log: logMock })) +mock.module("../../shared/agent-display-names", () => ({ + getAgentConfigKey: (name: string) => name.toLowerCase(), +})) + +type Hook = { event: (input: { event: { type: string; properties?: unknown } }) => Promise } +type Message = { info?: { agent?: string } } + +describe("createAutoCheckpointHook", () => { + const realNow = Date.now + let now = 1_000 + let createAutoCheckpointHook: typeof import("./hook").createAutoCheckpointHook + let promptAsync: ReturnType Promise>> + let messages: ReturnType Promise>> + let ctx: { client: { session: { messages: typeof messages; promptAsync: typeof promptAsync } }; directory: string } + let hook: Hook + + const emitIdle = async (sessionID: string): Promise => { + await hook.event({ event: { type: "session.idle", properties: { sessionID } } }) + } + + const emitDeleted = async (sessionID: string): Promise => { + await hook.event({ event: { type: "session.deleted", properties: { info: { id: sessionID } } } }) + } + + beforeEach(async () => { + ;({ createAutoCheckpointHook } = await import("./hook")) + now = 1_000 + Date.now = () => now + logMock.mockClear() + messages = mock(async () => [{ info: { agent: "sisyphus" } }]) + promptAsync = mock(async () => {}) + ctx = { client: { session: { messages, promptAsync } }, directory: "/tmp/test" } + hook = createAutoCheckpointHook(ctx as never) + }) + + test("#given fresh session #when first idle #then injects restore prompt", async () => { + await emitIdle("ses-1") + expect(promptAsync).toHaveBeenCalledTimes(1) + Date.now = realNow + }) + + test("#given restored session #when second idle below threshold #then does not inject", async () => { + await emitIdle("ses-1") + now += 6 * 60 * 1000 + await emitIdle("ses-1") + expect(promptAsync).toHaveBeenCalledTimes(1) + Date.now = realNow + }) + + test("#given session with many idles #when message threshold reached #then injects checkpoint", async () => { + await emitIdle("ses-1") + now += 6 * 60 * 1000 + for (let i = 0; i < CHECKPOINT_MESSAGE_THRESHOLD - 1; i++) await emitIdle("ses-1") + expect(promptAsync).toHaveBeenCalledTimes(2) + Date.now = realNow + }) + + test("#given elapsed 15min #when idle occurs #then injects checkpoint by time threshold", async () => { + await emitIdle("ses-1") + now += 15 * 60 * 1000 + 1000 + await emitIdle("ses-1") + expect(promptAsync).toHaveBeenCalledTimes(2) + Date.now = realNow + }) + + test("#given recent checkpoint #when next idle within cooldown #then does not inject", async () => { + await emitIdle("ses-1") + now += 6 * 60 * 1000 + for (let i = 0; i < CHECKPOINT_MESSAGE_THRESHOLD - 1; i++) await emitIdle("ses-1") + now += 60 * 1000 + await emitIdle("ses-1") + expect(promptAsync).toHaveBeenCalledTimes(2) + Date.now = realNow + }) + + test("#given subagent session #when idle event arrives #then skips injection", async () => { + messages = mock(async () => [{ info: { agent: "explore" } }]) + ctx = { client: { session: { messages, promptAsync } }, directory: "/tmp/test" } + hook = createAutoCheckpointHook(ctx as never) + await emitIdle("ses-sub") + expect(promptAsync).toHaveBeenCalledTimes(0) + Date.now = realNow + }) + + test("#given deleted session #when idle after deletion #then treats as fresh and restores", async () => { + await emitIdle("ses-1") + await emitDeleted("ses-1") + await emitIdle("ses-1") + expect(promptAsync).toHaveBeenCalledTimes(2) + Date.now = realNow + }) + + test("#given non-idle event #when hook handles event #then does nothing", async () => { + await hook.event({ event: { type: "session.created", properties: { sessionID: "ses-1" } } }) + expect(promptAsync).toHaveBeenCalledTimes(0) + Date.now = realNow + }) + + test("#given idle event without sessionID #when hook handles event #then does nothing", async () => { + await hook.event({ event: { type: "session.idle", properties: {} } }) + expect(promptAsync).toHaveBeenCalledTimes(0) + Date.now = realNow + }) + + test("#given promptAsync failure #when first idle then next idle after cleanup #then logs and retries restore", async () => { + let shouldFail = true + promptAsync = mock(async () => { + if (shouldFail) { + shouldFail = false + throw new Error("boom") + } + }) + ctx = { client: { session: { messages, promptAsync } }, directory: "/tmp/test" } + hook = createAutoCheckpointHook(ctx as never) + + await emitIdle("ses-1") + await emitDeleted("ses-1") + now += 6 * 60 * 1000 + await emitIdle("ses-1") + + expect(promptAsync).toHaveBeenCalledTimes(2) + expect(logMock).toHaveBeenCalled() + Date.now = realNow + }) + + test("#given checkpoint just injected #when 19 idles then 20th idle #then only second threshold injects", async () => { + await emitIdle("ses-1") + now += 6 * 60 * 1000 + for (let i = 0; i < CHECKPOINT_MESSAGE_THRESHOLD - 1; i++) await emitIdle("ses-1") + + now += 6 * 60 * 1000 + for (let i = 0; i < CHECKPOINT_MESSAGE_THRESHOLD - 1; i++) await emitIdle("ses-1") + expect(promptAsync).toHaveBeenCalledTimes(2) + + await emitIdle("ses-1") + expect(promptAsync).toHaveBeenCalledTimes(3) + Date.now = realNow + }) +}) diff --git a/src/hooks/auto-checkpoint/index.ts b/src/hooks/auto-checkpoint/index.ts new file mode 100644 index 0000000000..372119cffc --- /dev/null +++ b/src/hooks/auto-checkpoint/index.ts @@ -0,0 +1 @@ +export { createAutoCheckpointHook } from "./hook" diff --git a/src/hooks/auto-checkpoint/prompt-templates.ts b/src/hooks/auto-checkpoint/prompt-templates.ts new file mode 100644 index 0000000000..5db9575b21 --- /dev/null +++ b/src/hooks/auto-checkpoint/prompt-templates.ts @@ -0,0 +1,22 @@ +export function buildCheckpointPrompt(stats: { + messagesSinceCheckpoint: number + minutesSinceCheckpoint: number +}): string { + return [ + ``, + `[AUTO-CHECKPOINT] Session has ${stats.messagesSinceCheckpoint} messages since last checkpoint (${stats.minutesSinceCheckpoint}min ago).`, + `Run session_checkpoint now to preserve context in case of crash.`, + `Include: current todos, active files, key decisions, and a brief summary of work in progress.`, + ``, + ].join("\n") +} + +export function buildRestorePrompt(): string { + return [ + ``, + `[SESSION RECOVERY] This appears to be a fresh session. A previous checkpoint may exist.`, + `Run session_restore to recover context from your last session.`, + `If no checkpoint exists, this will be a no-op.`, + ``, + ].join("\n") +} diff --git a/src/hooks/correction-detector/constants.ts b/src/hooks/correction-detector/constants.ts new file mode 100644 index 0000000000..d9d143cca0 --- /dev/null +++ b/src/hooks/correction-detector/constants.ts @@ -0,0 +1,105 @@ +/** + * Correction detection patterns. + * + * CK's pushback signals (from his own description): + * - "this is wrong" + * - impatience/annoyance in tone + * - "this is not what I want" + * - "you didn't understand my request" + * + * Each pattern has a severity: + * - "hard": Explicit rejection ("this is wrong", "no", "redo") + * - "soft": Frustration signal ("not what I want", "you didn't understand") + * + * Hard corrections dock trust more than soft ones. + */ + +export const HOOK_NAME = "correction-detector" + +export interface CorrectionPattern { + pattern: RegExp + severity: "hard" | "soft" + label: string +} + +export const CORRECTION_PATTERNS: CorrectionPattern[] = [ + // Hard corrections — explicit rejection + { + pattern: /\b(this is wrong|that's wrong|that is wrong|you('re| are) wrong)\b/i, + severity: "hard", + label: "explicit_wrong", + }, + { + pattern: /\b(no[,.]?\s+(that's not|that isn't|this isn't|this is not)\s+(right|correct|what))/i, + severity: "hard", + label: "explicit_no", + }, + { + pattern: /\b(redo (this|that|it)|do (this|that|it) again|start over|try again)\b/i, + severity: "hard", + label: "explicit_redo", + }, + { + pattern: /\b(completely wrong|totally wrong|way off|miss(ed|ing) the (point|mark))\b/i, + severity: "hard", + label: "strong_rejection", + }, + { + pattern: /\b(revert|undo|roll\s?back|back out)\b/i, + severity: "hard", + label: "revert_request", + }, + + // Soft corrections — frustration / misunderstanding signals + { + pattern: /\b((this|that) is not what I (want|asked|meant|need))/i, + severity: "soft", + label: "not_what_wanted", + }, + { + pattern: /\b(you didn't understand|you misunderstood|you('re| are) not (listening|understanding|getting))/i, + severity: "soft", + label: "misunderstanding", + }, + { + pattern: /\b(I (already|just) (said|told|explained|asked))\b/i, + severity: "soft", + label: "impatience_repeat", + }, + { + pattern: /\b(raise your standards|think harder|don't bring me garbage)\b/i, + severity: "soft", + label: "standards_trigger", + }, + { + pattern: /\b(not useful|useless|waste of time|doesn't help|didn't help)\b/i, + severity: "soft", + label: "low_value", + }, + { + pattern: /\b(I('ll| will) (just )?do it myself)\b/i, + severity: "soft", + label: "giving_up_on_agent", + }, +] + +/** + * Minimum message length to scan. + * Very short messages (< 5 chars) are likely "ok", "yes", "no" — not corrections. + * But "no" itself could be a correction, so we set this low. + */ +export const MIN_MESSAGE_LENGTH = 2 + +/** + * Trust score dock per severity level. + * These values are written into the correction event for the trust scorer to read. + */ +export const SEVERITY_WEIGHTS: Record = { + hard: 1.0, + soft: 0.5, +} + +/** + * Path to system events file where correction events are emitted. + */ +export const SYSTEM_EVENTS_PATH = "AI/_state/system-events.jsonl" diff --git a/src/hooks/correction-detector/detector.ts b/src/hooks/correction-detector/detector.ts new file mode 100644 index 0000000000..8618a6c7b5 --- /dev/null +++ b/src/hooks/correction-detector/detector.ts @@ -0,0 +1,52 @@ +import { CORRECTION_PATTERNS, MIN_MESSAGE_LENGTH, type CorrectionPattern } from "./constants" + +export interface DetectedCorrection { + pattern: CorrectionPattern + matchedText: string +} + +/** + * Strip code blocks and system reminders from text before scanning. + * We only want to detect corrections in CK's natural language, not in code or system content. + */ +function cleanText(text: string): string { + return text + .replace(/```[\s\S]*?```/g, "") + .replace(/`[^`]+`/g, "") + .replace(/[\s\S]*?<\/system-reminder>/g, "") + .replace(//g, "") + .trim() +} + +/** + * Detect correction patterns in user message text. + * Returns all matched patterns with severity and matched text. + */ +export function detectCorrections(text: string): DetectedCorrection[] { + if (text.length < MIN_MESSAGE_LENGTH) return [] + + const cleaned = cleanText(text) + if (cleaned.length < MIN_MESSAGE_LENGTH) return [] + + const matches: DetectedCorrection[] = [] + + for (const pattern of CORRECTION_PATTERNS) { + const match = cleaned.match(pattern.pattern) + if (match) { + matches.push({ + pattern, + matchedText: match[0], + }) + } + } + + return matches +} + +/** + * Get the highest severity from a set of detected corrections. + */ +export function highestSeverity(corrections: DetectedCorrection[]): "hard" | "soft" | null { + if (corrections.length === 0) return null + return corrections.some((c) => c.pattern.severity === "hard") ? "hard" : "soft" +} diff --git a/src/hooks/correction-detector/emitter.ts b/src/hooks/correction-detector/emitter.ts new file mode 100644 index 0000000000..29018f5214 --- /dev/null +++ b/src/hooks/correction-detector/emitter.ts @@ -0,0 +1,98 @@ +import * as fs from "node:fs" +import * as path from "node:path" +import * as os from "node:os" + +import { SYSTEM_EVENTS_PATH, SEVERITY_WEIGHTS, HOOK_NAME } from "./constants" +import type { DetectedCorrection } from "./detector" +import { highestSeverity } from "./detector" +import { log } from "../../shared/logger" + +/** + * Find the Mind Palace vault root. + * Checks the standard iCloud path on macOS. + */ +function findVaultRoot(): string | null { + const candidates = [ + path.join( + os.homedir(), + "Library/Mobile Documents/iCloud~md~obsidian/Documents/Mind Palace", + ), + ] + for (const candidate of candidates) { + if (fs.existsSync(candidate)) return candidate + } + return null +} + +/** + * Emit a correction event to system-events.jsonl. + * + * The trust scorer reads these events and docks the relevant agent's trust score. + * Event format matches the system-event-schema.md specification. + */ +export function emitCorrectionEvent( + corrections: DetectedCorrection[], + sessionID: string, + agentName?: string, +): boolean { + const vaultRoot = findVaultRoot() + if (!vaultRoot) { + log(`[${HOOK_NAME}] Cannot emit: Mind Palace not found`) + return false + } + + const eventsPath = path.join(vaultRoot, SYSTEM_EVENTS_PATH) + const eventsDir = path.dirname(eventsPath) + + if (!fs.existsSync(eventsDir)) { + log(`[${HOOK_NAME}] Cannot emit: events directory does not exist: ${eventsDir}`) + return false + } + + const severity = highestSeverity(corrections) + if (!severity) return false + + const labels = corrections.map((c) => c.pattern.label) + const matchedTexts = corrections.map((c) => c.matchedText) + const weight = SEVERITY_WEIGHTS[severity] ?? 0.5 + + const now = new Date() + const dateStr = now.toISOString().slice(0, 10).replace(/-/g, "") + const seq = String(Math.floor(Math.random() * 999) + 1).padStart(3, "0") + const eventId = `evt_${dateStr}_${seq}` + + const event = { + id: eventId, + event_type: "correction", + timestamp: now.toISOString(), + content: `CK correction (${severity}): ${matchedTexts.join("; ")}`, + domain_tags: ["governance", "trust"], + agent_type: agentName ?? "unknown", + session_id: sessionID, + confidence: severity === "hard" ? 0.95 : 0.75, + source_component: `${HOOK_NAME}:${severity}`, + correction_meta: { + severity, + weight, + labels, + matched_texts: matchedTexts, + agent: agentName ?? "unknown", + session_id: sessionID, + }, + } + + try { + const line = JSON.stringify(event) + "\n" + fs.appendFileSync(eventsPath, line, "utf-8") + log(`[${HOOK_NAME}] Emitted correction event`, { + severity, + labels, + agent: agentName, + sessionID, + }) + return true + } catch (error) { + log(`[${HOOK_NAME}] Failed to write correction event`, { error: String(error) }) + return false + } +} diff --git a/src/hooks/execution-gate/constants.ts b/src/hooks/execution-gate/constants.ts new file mode 100644 index 0000000000..117823ddb0 --- /dev/null +++ b/src/hooks/execution-gate/constants.ts @@ -0,0 +1,21 @@ +import * as os from "node:os" +import * as path from "node:path" + +export const HOOK_NAME = "execution-gate" + +export const VAULT_ROOT = path.join( + os.homedir(), + "Library/Mobile Documents/iCloud~md~obsidian/Documents/Mind Palace", +) + +export const DECISION_JOURNAL_PATH = path.join(VAULT_ROOT, "AI/_state/decision-journal.jsonl") +export const FLIGHT_PLAN_PATH = path.join(VAULT_ROOT, "AI/_state/flight-plan.json") +export const CORRECTION_INDEX_PATH = path.join(VAULT_ROOT, "AI/_state/correction-index.json") + +export const MAX_INJECTION_CHARS = 4000 + +export const DEFAULT_SKIP_AGENTS = [ + "explore", + "librarian", + "multimodal-looker", +] diff --git a/src/hooks/execution-gate/formatter.ts b/src/hooks/execution-gate/formatter.ts new file mode 100644 index 0000000000..936246f01a --- /dev/null +++ b/src/hooks/execution-gate/formatter.ts @@ -0,0 +1,117 @@ +import type { DecisionEntry, FlightPlan, CorrectionEntry } from "./readers" + +function formatFlightPlanSection(plan: FlightPlan): string { + const expectations = plan.expectations + const completed = expectations.filter((e) => e.status === "completed").length + const failed = expectations.filter((e) => e.status === "failed").length + const unverified = expectations.filter((e) => + e.status === "planned" || e.status === "in_progress", + ).length + const total = expectations.length + + const lines: string[] = ["### Flight Plan"] + + if (completed === total) { + lines.push(`✅ All ${total} expectations met.`) + } else if (failed > 0) { + lines.push(`⚠️ ${completed}/${total} met, ${failed} failed, ${unverified} unverified.`) + } else if (unverified > 0) { + lines.push(`${completed}/${total} met, ${unverified} unverified.`) + } + + const mustFails = expectations.filter( + (e) => e.priority === "must" && e.status === "failed", + ) + for (const exp of mustFails) { + lines.push(`❌ MUST: ${exp.description}`) + if (exp.result_notes) lines.push(` Result: ${exp.result_notes}`) + } + + const mustUnverified = expectations.filter( + (e) => e.priority === "must" && (e.status === "planned" || e.status === "in_progress"), + ) + for (const exp of mustUnverified) { + lines.push(`⚠️ MUST (unverified): ${exp.description}`) + lines.push(` Verify: ${exp.verification}`) + } + + if (plan.metadata.notes) { + lines.push(`Context: ${plan.metadata.notes}`) + } + + return lines.join("\n") +} + +function formatDecisionsSection(decisions: DecisionEntry[]): string { + if (decisions.length === 0) return "" + + const l2 = decisions.filter((d) => d.standing_order_level === 2) + const l3 = decisions.filter((d) => d.standing_order_level === 3) + const promoteCandidates = decisions.filter((d) => d.promote_candidate) + + const lines: string[] = ["### Autonomous Decisions (last 24h)"] + + if (l2.length > 0) { + lines.push(`L2 (did then reported): ${l2.length}`) + for (const d of l2.slice(0, 5)) { + const emoji = d.outcome === "success" ? "✅" : d.outcome === "failure" ? "❌" : "⏳" + lines.push(` ${emoji} ${d.action}`) + } + if (l2.length > 5) lines.push(` (+${l2.length - 5} more)`) + } + + if (l3.length > 0) { + lines.push(`L3 (proposed): ${l3.length}`) + for (const d of l3.slice(0, 3)) { + lines.push(` ⏳ ${d.action}`) + } + } + + if (promoteCandidates.length > 0) { + lines.push(`🔼 ${promoteCandidates.length} promotion candidate(s)`) + } + + return lines.join("\n") +} + +function formatCorrectionsSection(corrections: CorrectionEntry[]): string { + if (corrections.length === 0) return "" + + const lines: string[] = ["### Session-Start Corrections"] + for (const c of corrections) { + lines.push(`🔴 ${c.title}: ${c.rule}`) + } + + return lines.join("\n") +} + +export function formatExecutionGateBriefing( + plan: FlightPlan | null, + decisions: DecisionEntry[], + corrections: CorrectionEntry[], +): string { + const sections: string[] = ["[SYSTEM REMINDER - EXECUTION GATE]", ""] + + if (plan) { + sections.push(formatFlightPlanSection(plan)) + sections.push("") + } + + const decisionsSection = formatDecisionsSection(decisions) + if (decisionsSection) { + sections.push(decisionsSection) + sections.push("") + } + + const correctionsSection = formatCorrectionsSection(corrections) + if (correctionsSection) { + sections.push(correctionsSection) + sections.push("") + } + + if (sections.length <= 2) return "" + + sections.push("Verify flight plan expectations. Review autonomous decisions. Apply corrections.") + + return sections.join("\n") +} diff --git a/src/hooks/execution-gate/index.ts b/src/hooks/execution-gate/index.ts new file mode 100644 index 0000000000..011a5de1e5 --- /dev/null +++ b/src/hooks/execution-gate/index.ts @@ -0,0 +1,97 @@ +import type { PluginInput } from "@opencode-ai/plugin" + +import { createInternalAgentTextPart, normalizeSDKResponse } from "../../shared" +import { log } from "../../shared/logger" +import { getAgentConfigKey } from "../../shared/agent-display-names" + +import { HOOK_NAME, MAX_INJECTION_CHARS, DEFAULT_SKIP_AGENTS } from "./constants" +import { loadRecentDecisions, loadFlightPlan, loadSessionStartCorrections } from "./readers" +import { formatExecutionGateBriefing } from "./formatter" + +export type ExecutionGateHook = ReturnType + +export function createExecutionGateHook( + ctx: PluginInput, + options: { skipAgents?: string[] } = {}, +) { + const { skipAgents = DEFAULT_SKIP_AGENTS } = options + const injectedSessions = new Set() + + async function resolveAgent(sessionID: string): Promise { + try { + const messagesResp = await ctx.client.session.messages({ + path: { id: sessionID }, + }) + const messages = normalizeSDKResponse(messagesResp, [] as Array<{ info?: { agent?: string } }>) + for (const msg of messages) { + if (msg.info?.agent) return msg.info.agent + } + } catch (error) { + log(`[${HOOK_NAME}] Failed to resolve agent`, { sessionID, error: String(error) }) + } + return undefined + } + + const event = async (input: { event: { type: string; properties?: unknown } }): Promise => { + if (input.event.type === "session.deleted") { + const props = input.event.properties as Record | undefined + const sessionInfo = props?.info as { id?: string } | undefined + if (sessionInfo?.id) { + injectedSessions.delete(sessionInfo.id) + } + return + } + + if (input.event.type !== "session.idle") return + + const props = input.event.properties as Record | undefined + const sessionID = props?.sessionID as string | undefined + if (!sessionID) return + + if (injectedSessions.has(sessionID)) return + injectedSessions.add(sessionID) + + const agentName = await resolveAgent(sessionID) + if (agentName && skipAgents.some((s) => getAgentConfigKey(s) === getAgentConfigKey(agentName))) { + log(`[${HOOK_NAME}] Skipped: sub-agent session`, { sessionID, agent: agentName }) + return + } + + try { + const plan = loadFlightPlan() + const decisions = loadRecentDecisions() + const corrections = loadSessionStartCorrections() + + let briefing = formatExecutionGateBriefing(plan, decisions, corrections) + if (!briefing) { + log(`[${HOOK_NAME}] No briefing to inject`, { sessionID }) + return + } + + if (briefing.length > MAX_INJECTION_CHARS) { + briefing = briefing.slice(0, MAX_INJECTION_CHARS) + "\n\n(Truncated)" + } + + await ctx.client.session.promptAsync({ + path: { id: sessionID }, + body: { + parts: [createInternalAgentTextPart(briefing)], + }, + query: { directory: ctx.directory }, + }) + + log(`[${HOOK_NAME}] Injected briefing`, { + sessionID, + flightPlan: plan !== null, + decisions: decisions.length, + corrections: corrections.length, + chars: briefing.length, + }) + } catch (error) { + log(`[${HOOK_NAME}] Failed to inject`, { sessionID, error: String(error) }) + injectedSessions.delete(sessionID) + } + } + + return { event } +} diff --git a/src/hooks/execution-gate/readers.ts b/src/hooks/execution-gate/readers.ts new file mode 100644 index 0000000000..be29bc8b57 --- /dev/null +++ b/src/hooks/execution-gate/readers.ts @@ -0,0 +1,112 @@ +import * as fs from "node:fs" + +import { log } from "../../shared/logger" + +import { + HOOK_NAME, + DECISION_JOURNAL_PATH, + FLIGHT_PLAN_PATH, + CORRECTION_INDEX_PATH, +} from "./constants" + +export interface DecisionEntry { + id: string + timestamp: string + standing_order_level: number + action: string + rationale: string + outcome: string + promote_candidate?: boolean + alternatives_considered?: string[] + related_task_id?: string +} + +export interface FlightExpectation { + id: string + description: string + verification: string + priority: "must" | "should" + status: string + result_notes?: string +} + +export interface FlightPlan { + metadata: { + created_at: string + notes: string + } + expectations: FlightExpectation[] +} + +export interface CorrectionEntry { + id: string + title: string + rule: string + severity: "high" | "medium" | "low" + triggers: { + contexts: string[] + } +} + +export function loadRecentDecisions(maxAge24h: boolean = true): DecisionEntry[] { + if (!fs.existsSync(DECISION_JOURNAL_PATH)) return [] + + try { + const content = fs.readFileSync(DECISION_JOURNAL_PATH, "utf-8").trim() + if (!content) return [] + + const cutoff = maxAge24h + ? new Date(Date.now() - 24 * 60 * 60 * 1000).toISOString() + : "" + + const entries: DecisionEntry[] = [] + for (const line of content.split("\n")) { + if (!line.trim()) continue + try { + const entry = JSON.parse(line) as DecisionEntry + if (!cutoff || entry.timestamp >= cutoff) { + entries.push(entry) + } + } catch { + continue + } + } + + return entries + } catch (error) { + log(`[${HOOK_NAME}] Failed to read decision journal`, { error: String(error) }) + return [] + } +} + +export function loadFlightPlan(): FlightPlan | null { + if (!fs.existsSync(FLIGHT_PLAN_PATH)) return null + + try { + const content = fs.readFileSync(FLIGHT_PLAN_PATH, "utf-8") + const plan = JSON.parse(content) as FlightPlan + if (!plan.expectations || plan.expectations.length === 0) return null + return plan + } catch (error) { + log(`[${HOOK_NAME}] Failed to read flight plan`, { error: String(error) }) + return null + } +} + +export function loadSessionStartCorrections(): CorrectionEntry[] { + if (!fs.existsSync(CORRECTION_INDEX_PATH)) return [] + + try { + const content = fs.readFileSync(CORRECTION_INDEX_PATH, "utf-8") + const index = JSON.parse(content) as { corrections: CorrectionEntry[] } + const corrections = index.corrections ?? [] + + return corrections.filter((c) => { + const contexts = c.triggers?.contexts ?? [] + return contexts.includes("session_start") && c.severity === "high" + }) + } catch (error) { + log(`[${HOOK_NAME}] Failed to read correction index`, { error: String(error) }) + return [] + } +} diff --git a/src/hooks/index.ts b/src/hooks/index.ts index 171f5dd128..a4b6570137 100644 --- a/src/hooks/index.ts +++ b/src/hooks/index.ts @@ -51,3 +51,7 @@ export { createWriteExistingFileGuardHook } from "./write-existing-file-guard"; export { createHashlineReadEnhancerHook } from "./hashline-read-enhancer"; export { createJsonErrorRecoveryHook, JSON_ERROR_TOOL_EXCLUDE_LIST, JSON_ERROR_PATTERNS, JSON_ERROR_REMINDER } from "./json-error-recovery"; export { createReadImageResizerHook } from "./read-image-resizer" +export { createLearningBusInjectorHook, type LearningBusInjectorHook } from "./learning-bus-injector" +export { createExecutionGateHook, type ExecutionGateHook } from "./execution-gate" +export { createResourceGateHook, type MemoryStatus } from "./resource-gate" +export { createAutoCheckpointHook } from "./auto-checkpoint" diff --git a/src/hooks/learning-bus-injector/constants.ts b/src/hooks/learning-bus-injector/constants.ts new file mode 100644 index 0000000000..fcec3c486f --- /dev/null +++ b/src/hooks/learning-bus-injector/constants.ts @@ -0,0 +1,31 @@ +export const HOOK_NAME = "learning-bus-injector" + +export const DEFAULT_SKIP_AGENTS = [ + "prometheus", + "compaction", + "oracle", + "librarian", + "explore", + "atlas", + "metis", + "momus", + "sisyphus-junior", + "multimodal-looker", +] + +export const RECENCY_DAYS = 7 +export const MIN_CONFIDENCE = 0.5 +export const MAX_EVENTS_TO_INJECT = 10 +export const MAX_INJECTION_CHARS = 2000 + +export const EVENT_TYPE_WEIGHTS: Record = { + correction: 1.0, + discovery: 0.8, + pattern: 0.8, + insight: 0.7, + calibration: 0.7, + decision: 0.4, + result: 0.3, + task_dispatched: 0.1, + task_completed: 0.2, +} diff --git a/src/hooks/learning-bus-injector/event-reader.ts b/src/hooks/learning-bus-injector/event-reader.ts new file mode 100644 index 0000000000..12c8d1f085 --- /dev/null +++ b/src/hooks/learning-bus-injector/event-reader.ts @@ -0,0 +1,84 @@ +import { readFileSync, existsSync } from "fs" +import { homedir } from "os" +import { join } from "path" + +import { log } from "../../shared/logger" + +import { + HOOK_NAME, + RECENCY_DAYS, + MIN_CONFIDENCE, + MAX_EVENTS_TO_INJECT, + EVENT_TYPE_WEIGHTS, +} from "./constants" + +export interface SystemEvent { + id: string + timestamp: string + session_id: string + agent_type: string + event_type: string + domain_tags: string[] + content: string + confidence?: number + source_component?: string + related_events?: string[] + propagation_targets?: string[] +} + +const SYSTEM_EVENTS_PATH = join( + homedir(), + "Library/Mobile Documents/iCloud~md~obsidian/Documents/Mind Palace/AI/_state/system-events.jsonl", +) + +function recencyScore(timestamp: string): number { + const eventDate = new Date(timestamp) + const now = new Date() + const daysDiff = Math.floor((now.getTime() - eventDate.getTime()) / (1000 * 60 * 60 * 24)) + if (daysDiff >= RECENCY_DAYS) return 0 + return 1.0 - (daysDiff * 0.1) +} + +function computeScore(event: SystemEvent): number { + const typeWeight = EVENT_TYPE_WEIGHTS[event.event_type] ?? 0.3 + const confidence = event.confidence ?? 0.8 + const recency = recencyScore(event.timestamp) + return typeWeight * confidence * recency +} + +export function loadAndRankEvents(): SystemEvent[] { + if (!existsSync(SYSTEM_EVENTS_PATH)) return [] + + try { + const content = readFileSync(SYSTEM_EVENTS_PATH, "utf-8").trim() + if (!content) return [] + + const cutoff = new Date() + cutoff.setDate(cutoff.getDate() - RECENCY_DAYS) + const cutoffStr = cutoff.toISOString() + + const events: SystemEvent[] = [] + for (const line of content.split("\n")) { + if (!line.trim()) continue + try { + const event = JSON.parse(line) as SystemEvent + if (event.timestamp >= cutoffStr) { + const confidence = event.confidence ?? 0.8 + if (confidence >= MIN_CONFIDENCE) { + events.push(event) + } + } + } catch { + continue + } + } + + if (events.length === 0) return [] + + const ranked = events.sort((a, b) => computeScore(b) - computeScore(a)) + return ranked.slice(0, MAX_EVENTS_TO_INJECT) + } catch (error) { + log(`[${HOOK_NAME}] Failed to read event store`, { error: String(error) }) + return [] + } +} diff --git a/src/hooks/learning-bus-injector/index.ts b/src/hooks/learning-bus-injector/index.ts new file mode 100644 index 0000000000..c9b6319a60 --- /dev/null +++ b/src/hooks/learning-bus-injector/index.ts @@ -0,0 +1,45 @@ +import type { PluginInput } from "@opencode-ai/plugin" + +import { log } from "../../shared/logger" + +import { HOOK_NAME, DEFAULT_SKIP_AGENTS } from "./constants" +import { handleSessionIdle } from "./session-handler" + +export type LearningBusInjectorHook = ReturnType + +export function createLearningBusInjectorHook( + ctx: PluginInput, + options: { skipAgents?: string[] } = {}, +) { + const { skipAgents = DEFAULT_SKIP_AGENTS } = options + const injectedSessions = new Set() + + const event = async (input: { event: { type: string; properties?: unknown } }): Promise => { + if (input.event.type === "session.deleted") { + const props = input.event.properties as Record | undefined + const sessionInfo = props?.info as { id?: string } | undefined + if (sessionInfo?.id) { + injectedSessions.delete(sessionInfo.id) + } + return + } + + if (input.event.type !== "session.idle") return + + const props = input.event.properties as Record | undefined + const sessionID = props?.sessionID as string | undefined + if (!sessionID) return + + if (injectedSessions.has(sessionID)) return + injectedSessions.add(sessionID) + + try { + await handleSessionIdle(ctx, sessionID, skipAgents) + } catch (error) { + log(`[${HOOK_NAME}] Failed to inject`, { sessionID, error: String(error) }) + injectedSessions.delete(sessionID) + } + } + + return { event } +} diff --git a/src/hooks/learning-bus-injector/session-handler.ts b/src/hooks/learning-bus-injector/session-handler.ts new file mode 100644 index 0000000000..ade72b3222 --- /dev/null +++ b/src/hooks/learning-bus-injector/session-handler.ts @@ -0,0 +1,84 @@ +import type { PluginInput } from "@opencode-ai/plugin" + +import { createInternalAgentTextPart, normalizeSDKResponse } from "../../shared" +import { log } from "../../shared/logger" +import { getAgentConfigKey } from "../../shared/agent-display-names" + +import { HOOK_NAME, MAX_INJECTION_CHARS } from "./constants" +import { loadAndRankEvents, type SystemEvent } from "./event-reader" + +function formatDate(timestamp: string): string { + return new Date(timestamp).toLocaleDateString("en-US", { month: "short", day: "numeric" }) +} + +function formatInjection(events: SystemEvent[]): string { + const lines = events.map((event, i) => { + const date = formatDate(event.timestamp) + const confidence = event.confidence ?? 0.8 + const tags = event.domain_tags.length > 0 + ? `\n Tags: ${event.domain_tags.join(", ")}` + : "" + return `${i + 1}. [${event.event_type}] (${date}, confidence: ${confidence}) \u2014 ${event.content}${tags}` + }) + + return `[SYSTEM REMINDER - LEARNING BUS] + +Recent system intelligence (from previous sessions): + +${lines.join("\n\n")} + +Apply corrections as constraints. Consider discoveries and patterns when making decisions. Flag if any learning conflicts with your current task.` +} + +async function resolveAgent( + ctx: PluginInput, + sessionID: string, +): Promise { + try { + const messagesResp = await ctx.client.session.messages({ + path: { id: sessionID }, + }) + const messages = normalizeSDKResponse(messagesResp, [] as Array<{ info?: { agent?: string } }>) + for (const msg of messages) { + if (msg.info?.agent) return msg.info.agent + } + } catch (error) { + log(`[${HOOK_NAME}] Failed to resolve agent`, { sessionID, error: String(error) }) + } + return undefined +} + +export async function handleSessionIdle( + ctx: PluginInput, + sessionID: string, + skipAgents: string[], +): Promise { + const agentName = await resolveAgent(ctx, sessionID) + if (agentName && skipAgents.some(s => getAgentConfigKey(s) === getAgentConfigKey(agentName))) { + log(`[${HOOK_NAME}] Skipped: sub-agent session`, { sessionID, agent: agentName }) + return + } + + const events = loadAndRankEvents() + if (events.length === 0) { + log(`[${HOOK_NAME}] No events to inject`, { sessionID }) + return + } + + let eventsToInject = events + let prompt = formatInjection(eventsToInject) + while (prompt.length > MAX_INJECTION_CHARS && eventsToInject.length > 1) { + eventsToInject = eventsToInject.slice(0, eventsToInject.length - 1) + prompt = formatInjection(eventsToInject) + } + + await ctx.client.session.promptAsync({ + path: { id: sessionID }, + body: { + parts: [createInternalAgentTextPart(prompt)], + }, + query: { directory: ctx.directory }, + }) + + log(`[${HOOK_NAME}] Injected ${eventsToInject.length} events`, { sessionID }) +} diff --git a/src/hooks/resource-gate/constants.ts b/src/hooks/resource-gate/constants.ts new file mode 100644 index 0000000000..de9be5b0aa --- /dev/null +++ b/src/hooks/resource-gate/constants.ts @@ -0,0 +1,7 @@ +export const HOOK_NAME = "resource-gate" +export const ESTIMATED_AGENT_COST_MB = 750 +export const CRITICAL_THRESHOLD_GB = 1.0 +export const WARNING_THRESHOLD_GB = 2.0 +export const WARNING_PERCENT = 88 +export const SAFE_THRESHOLD_GB = 4.0 +export const SWAP_WARNING_PERCENT = 50 diff --git a/src/hooks/resource-gate/hook.ts b/src/hooks/resource-gate/hook.ts new file mode 100644 index 0000000000..05d738ade6 --- /dev/null +++ b/src/hooks/resource-gate/hook.ts @@ -0,0 +1,62 @@ +import type { PluginInput } from "@opencode-ai/plugin" + +import { log } from "../../shared" +import { + CRITICAL_THRESHOLD_GB, + HOOK_NAME, + WARNING_PERCENT, + WARNING_THRESHOLD_GB, +} from "./constants" +import { checkMacOSMemory } from "./memory-check" + +export function createResourceGateHook(ctx: PluginInput) { + void ctx + + return { + "tool.execute.before": async ( + input: { tool: string; sessionID: string; callID: string }, + output: { args: Record }, + ): Promise => { + if (input.tool !== "task") { + return + } + + const args = output.args + if (args.run_in_background !== true) { + return + } + + const memory = checkMacOSMemory() + + if (memory.availableGB < CRITICAL_THRESHOLD_GB) { + log(`[${HOOK_NAME}] BLOCKED: ${memory.availableGB.toFixed(1)}GB available`, { + sessionID: input.sessionID, + ...memory, + }) + + throw new Error( + `[Resource Gate] Cannot spawn background agent: only ${memory.availableGB.toFixed(1)}GB memory available (need >${CRITICAL_THRESHOLD_GB}GB). ` + + `System is at ${memory.usedPercent.toFixed(0)}% memory usage. ` + + "Use run_in_background=false to run synchronously instead.", + ) + } + + if (memory.availableGB < WARNING_THRESHOLD_GB || memory.usedPercent > WARNING_PERCENT) { + const warningTag = + ` [MEMORY WARNING: ${memory.availableGB.toFixed(1)}GB free, ${memory.usedPercent.toFixed(0)}% used - ` + + `serialize instead of parallel spawns, recommendation: ${memory.recommendation}]` + + if (typeof args.description === "string") { + args.description = args.description + warningTag + } else { + args.description = warningTag.trim() + } + + log(`[${HOOK_NAME}] WARNING injected: ${memory.availableGB.toFixed(1)}GB available`, { + sessionID: input.sessionID, + ...memory, + }) + } + }, + } +} diff --git a/src/hooks/resource-gate/index.test.ts b/src/hooks/resource-gate/index.test.ts new file mode 100644 index 0000000000..6a4d7009ad --- /dev/null +++ b/src/hooks/resource-gate/index.test.ts @@ -0,0 +1,142 @@ + +import { beforeEach, describe, expect, mock, test } from "bun:test" +import type { MemoryStatus } from "./index" + +const mockCheckMacOSMemory = mock(() => ({ + availableGB: 8, + usedPercent: 50, + swapUsedPercent: 10, + recommendation: "parallelize_5" as const, +})) + +mock.module("./memory-check", () => ({ + checkMacOSMemory: mockCheckMacOSMemory, +})) + +const { createResourceGateHook } = await import("./index") + +type Hook = ReturnType + +describe("createResourceGateHook", () => { + let hook: Hook + let callCounter = 0 + + const invoke = async (input: { + tool: string + runInBackground?: boolean + description?: string + }): Promise<{ args: Record }> => { + callCounter += 1 + const output: { args: Record } = { + args: { run_in_background: input.runInBackground }, + } + if (typeof input.description === "string") { + output.args.description = input.description + } + + await hook["tool.execute.before"]( + { + tool: input.tool, + sessionID: "ses_resource_gate", + callID: `call_${callCounter}`, + }, + output, + ) + + return output + } + + const setMemory = (overrides: Partial): void => { + mockCheckMacOSMemory.mockReturnValue({ + availableGB: 8, + usedPercent: 50, + swapUsedPercent: 10, + recommendation: "parallelize_5", + ...overrides, + }) + } + + beforeEach(() => { + hook = createResourceGateHook({ directory: "/tmp" } as never) + callCounter = 0 + mockCheckMacOSMemory.mockReset() + setMemory({}) + }) + + test("#given non-task tool #when tool executes #then passes through", async () => { + const output = await invoke({ tool: "bash", runInBackground: true, description: "keep me" }) + + expect(output.args.description).toBe("keep me") + expect(mockCheckMacOSMemory).not.toHaveBeenCalled() + }) + + test("#given task tool with run_in_background=false #when tool executes #then passes through", async () => { + const output = await invoke({ tool: "task", runInBackground: false, description: "stay" }) + + expect(output.args.description).toBe("stay") + expect(mockCheckMacOSMemory).not.toHaveBeenCalled() + }) + + test("#given task background run with plenty memory #when tool executes #then passes through", async () => { + setMemory({ availableGB: 5.2, usedPercent: 70, recommendation: "parallelize_5" }) + + const output = await invoke({ tool: "task", runInBackground: true, description: "parallel" }) + + expect(output.args.description).toBe("parallel") + expect(mockCheckMacOSMemory).toHaveBeenCalledTimes(1) + }) + + test("#given task background run with warning memory #when tool executes #then injects warning into description", async () => { + setMemory({ availableGB: 1.5, usedPercent: 80, recommendation: "serialize" }) + + const output = await invoke({ tool: "task", runInBackground: true, description: "start" }) + + expect(String(output.args.description)).toContain("start [MEMORY WARNING: 1.5GB free, 80% used") + expect(String(output.args.description)).toContain("recommendation: serialize") + }) + + test("#given task background run with high percent used #when tool executes #then injects warning into description", async () => { + setMemory({ availableGB: 3.4, usedPercent: 89, recommendation: "parallelize_2" }) + + const output = await invoke({ tool: "task", runInBackground: true, description: "already set" }) + + expect(String(output.args.description)).toContain("3.4GB free, 89% used") + expect(String(output.args.description)).toContain("recommendation: parallelize_2") + }) + + test("#given task background run with critical memory #when tool executes #then throws Resource Gate error", async () => { + setMemory({ availableGB: 0.8, usedPercent: 92, recommendation: "wait" }) + + expect(invoke({ tool: "task", runInBackground: true, description: "blocked" })).rejects.toThrow( + "Resource Gate", + ) + }) + + test("#given vm_stat failure fallback status #when task runs in background #then does not throw", async () => { + setMemory({ availableGB: 4.3, usedPercent: 40, recommendation: "parallelize_5" }) + + expect(invoke({ tool: "task", runInBackground: true, description: "fallback" })).resolves.toBeDefined() + }) + + test("#given task background warning with no existing description #when tool executes #then sets warning text", async () => { + setMemory({ availableGB: 1.7, usedPercent: 75, recommendation: "serialize" }) + + const output = await invoke({ tool: "task", runInBackground: true }) + + expect(String(output.args.description)).toMatch(/^\[MEMORY WARNING:/) + expect(String(output.args.description)).toContain("recommendation: serialize") + }) + + test("#given task background warning with existing description #when tool executes #then appends warning", async () => { + setMemory({ availableGB: 1.9, usedPercent: 70, recommendation: "serialize" }) + + const output = await invoke({ + tool: "task", + runInBackground: true, + description: "Investigate flake", + }) + + expect(String(output.args.description)).toContain("Investigate flake [MEMORY WARNING:") + expect(String(output.args.description)).toContain("1.9GB free, 70% used") + }) +}) diff --git a/src/hooks/resource-gate/index.ts b/src/hooks/resource-gate/index.ts new file mode 100644 index 0000000000..f7150a42d2 --- /dev/null +++ b/src/hooks/resource-gate/index.ts @@ -0,0 +1,2 @@ +export { createResourceGateHook } from "./hook" +export type { MemoryStatus } from "./memory-check" diff --git a/src/hooks/resource-gate/memory-check.ts b/src/hooks/resource-gate/memory-check.ts new file mode 100644 index 0000000000..f16c54c312 --- /dev/null +++ b/src/hooks/resource-gate/memory-check.ts @@ -0,0 +1,136 @@ +import { execSync } from "child_process" +import os from "os" + +export type MemoryStatus = { + availableGB: number + usedPercent: number + swapUsedPercent: number + recommendation: "parallelize_5" | "parallelize_2" | "serialize" | "wait" +} + +type VmStatMemory = { + pageSizeBytes: number + freePages: number + inactivePages: number + activePages: number + wiredPages: number + speculativePages: number +} + +const BYTES_PER_GB = 1024 ** 3 + +function getRecommendation(availableGB: number): MemoryStatus["recommendation"] { + if (availableGB < 1) { + return "wait" + } + + if (availableGB < 2) { + return "serialize" + } + + if (availableGB < 4) { + return "parallelize_2" + } + + return "parallelize_5" +} + +function parseVmStat(output: string): VmStatMemory { + const pageSizeMatch = output.match(/page size of\s+(\d+)\s+bytes/i) + if (!pageSizeMatch) { + throw new Error("Unable to parse page size from vm_stat output") + } + + const pageSizeBytes = Number.parseInt(pageSizeMatch[1], 10) + const pageCounts = new Map() + + for (const line of output.split("\n")) { + const match = line.match(/^Pages\s+([^:]+):\s+(\d+)\./) + if (!match) { + continue + } + + pageCounts.set(match[1].trim().toLowerCase(), Number.parseInt(match[2], 10)) + } + + return { + pageSizeBytes, + freePages: pageCounts.get("free") ?? 0, + inactivePages: pageCounts.get("inactive") ?? 0, + activePages: pageCounts.get("active") ?? 0, + wiredPages: pageCounts.get("wired down") ?? 0, + speculativePages: pageCounts.get("speculative") ?? 0, + } +} + +function parseSizeToBytes(value: string): number { + const match = value.trim().match(/^(\d+(?:\.\d+)?)([KMGTP])$/i) + if (!match) { + throw new Error(`Unable to parse size: ${value}`) + } + + const amount = Number.parseFloat(match[1]) + const unit = match[2].toUpperCase() + const multiplierByUnit: Record = { + K: 1024, + M: 1024 ** 2, + G: 1024 ** 3, + T: 1024 ** 4, + P: 1024 ** 5, + } + + return amount * multiplierByUnit[unit] +} + +function parseSwapPercent(output: string): number { + const totalMatch = output.match(/total\s*=\s*([0-9.]+[KMGTP])/i) + const usedMatch = output.match(/used\s*=\s*([0-9.]+[KMGTP])/i) + if (!totalMatch || !usedMatch) { + throw new Error("Unable to parse swap usage from sysctl output") + } + + const totalBytes = parseSizeToBytes(totalMatch[1]) + const usedBytes = parseSizeToBytes(usedMatch[1]) + if (totalBytes <= 0) { + return 0 + } + + return (usedBytes / totalBytes) * 100 +} + +function getFallbackStatus(): MemoryStatus { + const totalBytes = os.totalmem() + const availableBytes = os.freemem() + const availableGB = availableBytes / BYTES_PER_GB + const usedPercent = ((totalBytes - availableBytes) / totalBytes) * 100 + + return { + availableGB, + usedPercent, + swapUsedPercent: 0, + recommendation: getRecommendation(availableGB), + } +} + +export function checkMacOSMemory(): MemoryStatus { + try { + const vmStatOutput = execSync("vm_stat", { encoding: "utf8" }) + const swapOutput = execSync("sysctl vm.swapusage", { encoding: "utf8" }) + + const vm = parseVmStat(vmStatOutput) + const availableBytes = (vm.freePages + vm.inactivePages) * vm.pageSizeBytes + const totalBytes = os.totalmem() + const availableGB = availableBytes / BYTES_PER_GB + const usedPercent = ((totalBytes - availableBytes) / totalBytes) * 100 + const swapUsedPercent = parseSwapPercent(swapOutput) + + return { + availableGB, + usedPercent, + swapUsedPercent, + recommendation: getRecommendation(availableGB), + } + } catch (error) { + return getFallbackStatus() + } +} diff --git a/src/plugin/event.ts b/src/plugin/event.ts index b8f7910a8d..003223d887 100644 --- a/src/plugin/event.ts +++ b/src/plugin/event.ts @@ -156,6 +156,9 @@ export function createEventHandler(args: { await Promise.resolve(hooks.compactionTodoPreserver?.event?.(input)); await Promise.resolve(hooks.writeExistingFileGuard?.event?.(input)); await Promise.resolve(hooks.atlasHook?.handler?.(input)); + await Promise.resolve(hooks.learningBusInjector?.event?.(input)); + await Promise.resolve(hooks.executionGate?.event?.(input)); + await Promise.resolve(hooks.autoCheckpoint?.event?.(input)); }; const recentSyntheticIdles = new Map(); diff --git a/src/plugin/hooks/create-session-hooks.ts b/src/plugin/hooks/create-session-hooks.ts index daa5e4ff54..059fe64fe1 100644 --- a/src/plugin/hooks/create-session-hooks.ts +++ b/src/plugin/hooks/create-session-hooks.ts @@ -25,7 +25,10 @@ import { createQuestionLabelTruncatorHook, createPreemptiveCompactionHook, createRuntimeFallbackHook, + createLearningBusInjectorHook, } from "../../hooks" +import { createExecutionGateHook } from "../../hooks" +import { createAutoCheckpointHook } from "../../hooks" import { createAnthropicEffortHook } from "../../hooks/anthropic-effort" import { detectExternalNotificationPlugin, @@ -60,6 +63,9 @@ export type SessionHooks = { taskResumeInfo: ReturnType | null anthropicEffort: ReturnType | null runtimeFallback: ReturnType | null + learningBusInjector: ReturnType | null + executionGate: ReturnType | null + autoCheckpoint: ReturnType | null } export function createSessionHooks(args: { @@ -261,6 +267,19 @@ export function createSessionHooks(args: { pluginConfig, })) : null + + const learningBusInjector = isHookEnabled("learning-bus-injector") + ? safeHook("learning-bus-injector", () => createLearningBusInjectorHook(ctx)) + : null + + const executionGate = isHookEnabled("execution-gate") + ? safeHook("execution-gate", () => createExecutionGateHook(ctx)) + : null + + const autoCheckpoint = isHookEnabled("auto-checkpoint") + ? safeHook("auto-checkpoint", () => createAutoCheckpointHook(ctx)) + : null + return { contextWindowMonitor, preemptiveCompaction, @@ -285,5 +304,8 @@ export function createSessionHooks(args: { taskResumeInfo, anthropicEffort, runtimeFallback, - } + learningBusInjector, + executionGate, + autoCheckpoint, +} } diff --git a/src/plugin/hooks/create-tool-guard-hooks.ts b/src/plugin/hooks/create-tool-guard-hooks.ts index 758b78c59e..23ffca2960 100644 --- a/src/plugin/hooks/create-tool-guard-hooks.ts +++ b/src/plugin/hooks/create-tool-guard-hooks.ts @@ -14,6 +14,7 @@ import { createHashlineReadEnhancerHook, createReadImageResizerHook, createJsonErrorRecoveryHook, + createResourceGateHook, } from "../../hooks" import { getOpenCodeVersion, @@ -35,6 +36,7 @@ export type ToolGuardHooks = { hashlineReadEnhancer: ReturnType | null jsonErrorRecovery: ReturnType | null readImageResizer: ReturnType | null + resourceGate: ReturnType | null } export function createToolGuardHooks(args: { @@ -111,6 +113,10 @@ export function createToolGuardHooks(args: { ? safeHook("read-image-resizer", () => createReadImageResizerHook(ctx)) : null + const resourceGate = isHookEnabled("resource-gate") + ? safeHook("resource-gate", () => createResourceGateHook(ctx)) + : null + return { commentChecker, toolOutputTruncator, @@ -123,5 +129,6 @@ export function createToolGuardHooks(args: { hashlineReadEnhancer, jsonErrorRecovery, readImageResizer, + resourceGate, } } diff --git a/src/plugin/tool-execute-before.ts b/src/plugin/tool-execute-before.ts index 65ebaed637..44345c7b96 100644 --- a/src/plugin/tool-execute-before.ts +++ b/src/plugin/tool-execute-before.ts @@ -18,6 +18,7 @@ export function createToolExecuteBeforeHandler(args: { const { ctx, hooks } = args return async (input, output): Promise => { + await hooks.resourceGate?.["tool.execute.before"]?.(input, output) await hooks.writeExistingFileGuard?.["tool.execute.before"]?.(input, output) await hooks.questionLabelTruncator?.["tool.execute.before"]?.(input, output) await hooks.claudeCodeHooks?.["tool.execute.before"]?.(input, output)