diff --git a/dot_config/opencode/plugins/safety-watch/index.js b/dot_config/opencode/plugins/safety-watch/index.js index dda99a0..ef8af51 100644 --- a/dot_config/opencode/plugins/safety-watch/index.js +++ b/dot_config/opencode/plugins/safety-watch/index.js @@ -1,11 +1,16 @@ -import { layerEnabled, readState, statePath, writeState } from "./state.js" +import { layerEnabled, readState, statePath, writeState } from "./state.js"; -const REVIEWER_AGENT = "safety-watch-reviewer" -const DEFAULT_TIMEOUT_MS = 60_000 -const DEFAULT_TEXT_LIMIT = 600 +const REVIEWER_AGENT = "safety-watch-reviewer"; +const DEFAULT_TIMEOUT_MS = 60_000; +const DEFAULT_TEXT_LIMIT = 600; +const IMMEDIATE_COMPACTION_RATIO = 0.85; +const SHORT_IDLE_COMPACTION_RATIO = 0.7; +const LONG_IDLE_COMPACTION_RATIO = 0.3; +const SHORT_IDLE_MS = 10_000; +const LONG_IDLE_MS = 60_000; const RESPONSE_SHAPE = `{"allow":true,"reason":"short reason"} or -{"allow":false,"reason":"specific risk and safer alternative"}` +{"allow":false,"reason":"specific risk and safer alternative"}`; const REVIEWER_PROMPT = `You are a command safety classifier. Decide whether the proposed tool call is safe in light of the prior calls and their outcomes. @@ -17,81 +22,93 @@ Each user message has exactly this format: # Determine if this is safe to run: -${RESPONSE_SHAPE}` +${RESPONSE_SHAPE}`; function compact(value, limit) { - const text = typeof value === "string" ? value : JSON.stringify(value) - if (!text) return "" - return text.length <= limit ? text : `${text.slice(0, limit)}... [truncated]` + const text = typeof value === "string" ? value : JSON.stringify(value); + if (!text) return ""; + return text.length <= limit ? text : `${text.slice(0, limit)}... [truncated]`; } function commandText(tool, args, limit) { if (tool === "bash" && typeof args?.command === "string") { - return compact(args.command, limit) + return compact(args.command, limit); } - return compact(args, limit) + return compact(args, limit); } function unwrap(response, operation) { if (response?.error) { - throw new Error(`${operation} failed: ${compact(response.error, 500)}`) + throw new Error(`${operation} failed: ${compact(response.error, 500)}`); } - if (!response?.data) throw new Error(`${operation} returned no data`) - return response.data + if (!response?.data) throw new Error(`${operation} returned no data`); + return response.data; } function parseDecision(text) { - const match = text.match(/\{[\s\S]*\}/) - if (!match) throw new Error("reviewer returned no JSON decision") - const decision = JSON.parse(match[0]) + const match = text.match(/\{[\s\S]*\}/); + if (!match) throw new Error("reviewer returned no JSON decision"); + const decision = JSON.parse(match[0]); if ( typeof decision.allow !== "boolean" || typeof decision.reason !== "string" || !decision.reason.trim() ) { - throw new Error("reviewer returned an invalid decision") + throw new Error("reviewer returned an invalid decision"); } - return decision + return decision; } function matchesTool(toolName, patterns) { return patterns.some((pattern) => { - if (pattern === "*") return true - if (pattern.startsWith("*.") && toolName.endsWith(pattern.slice(1))) return true - return toolName === pattern - }) + if (pattern === "*") return true; + if (pattern.startsWith("*.") && toolName.endsWith(pattern.slice(1))) + return true; + return toolName === pattern; + }); } /** @type {import('@opencode-ai/plugin').Plugin} */ export async function SafetyWatch({ client, directory }, options = {}) { - const dcgEnabled = options.dcg === true - const aiReviewEnabled = options["ai-review"] !== false - if (!dcgEnabled && !aiReviewEnabled) return {} - - const { checkDcg } = await import("../dcg-guard/index.js") - let toggleStatePath - let reviewerModel = typeof options.model === "string" ? options.model : undefined - const timeoutMs = Number.isFinite(options.timeoutMs) ? options.timeoutMs : DEFAULT_TIMEOUT_MS - const textLimit = Number.isFinite(options.textLimit) ? options.textLimit : DEFAULT_TEXT_LIMIT - const guardedTools = Array.isArray(options.tools) ? options.tools : ["bash", "*.bash"] - const reviewerSessions = new Map() - const reviewerSessionOwners = new Map() - const reviewQueues = new Map() - const reviewGenerations = new Map() + const dcgEnabled = options.dcg === true; + const aiReviewEnabled = options["ai-review"] !== false; + if (!dcgEnabled && !aiReviewEnabled) return {}; + + const { checkDcg } = await import("../dcg-guard/index.js"); + let toggleStatePath; + let reviewerModel = + typeof options.model === "string" ? options.model : undefined; + let reviewerContextTokens; + const timeoutMs = Number.isFinite(options.timeoutMs) + ? options.timeoutMs + : DEFAULT_TIMEOUT_MS; + const textLimit = Number.isFinite(options.textLimit) + ? options.textLimit + : DEFAULT_TEXT_LIMIT; + const guardedTools = Array.isArray(options.tools) + ? options.tools + : ["bash", "*.bash"]; + const reviewerSessions = new Map(); + const reviewerSessionOwners = new Map(); + const reviewQueues = new Map(); + const reviewGenerations = new Map(); + const reviewerCompactions = new Map(); + const reviewerUsage = new Map(); + const idleCompactionTimers = new Map(); async function activeLayers(sessionID) { if (!toggleStatePath) { const paths = unwrap( await client.path.get({ query: { directory } }), "resolving Safety Watch state path", - ) - toggleStatePath = statePath(paths.state) + ); + toggleStatePath = statePath(paths.state); } - const state = await readState(toggleStatePath) + const state = await readState(toggleStatePath); return { dcg: layerEnabled(state, sessionID, "dcg", dcgEnabled), aiReview: layerEnabled(state, sessionID, "aiReview", aiReviewEnabled), - } + }; } async function applyPendingSettings(sessionID) { @@ -99,41 +116,47 @@ export async function SafetyWatch({ client, directory }, options = {}) { const paths = unwrap( await client.path.get({ query: { directory } }), "resolving Safety Watch state path", - ) - toggleStatePath = statePath(paths.state) + ); + toggleStatePath = statePath(paths.state); } - const state = await readState(toggleStatePath) - if (!Object.values(state.pending).some((value) => typeof value === "boolean")) return - state.sessions[sessionID] = { ...state.pending, ...state.sessions[sessionID] } - state.pending = {} - await Bun.write(toggleStatePath, JSON.stringify(state)) + const state = await readState(toggleStatePath); + if ( + !Object.values(state.pending).some((value) => typeof value === "boolean") + ) + return; + state.sessions[sessionID] = { + ...state.pending, + ...state.sessions[sessionID], + }; + state.pending = {}; + await Bun.write(toggleStatePath, JSON.stringify(state)); } async function reviewerSession(parentID) { - const existing = reviewerSessions.get(parentID) - if (existing) return existing + const existing = reviewerSessions.get(parentID); + if (existing) return existing; if (!toggleStatePath) { const paths = unwrap( await client.path.get({ query: { directory } }), "resolving Safety Watch state path", - ) - toggleStatePath = statePath(paths.state) + ); + toggleStatePath = statePath(paths.state); } - const state = await readState(toggleStatePath) - const persisted = state.reviewers[parentID] + const state = await readState(toggleStatePath); + const persisted = state.reviewers[parentID]; if (typeof persisted === "string") { const response = await client.session.get({ path: { id: persisted }, query: { directory }, - }) + }); if (response?.data) { - reviewerSessions.set(parentID, persisted) - reviewerSessionOwners.set(persisted, parentID) - return persisted + reviewerSessions.set(parentID, persisted); + reviewerSessionOwners.set(persisted, parentID); + return persisted; } - delete state.reviewers[parentID] - await writeState(toggleStatePath, state) + delete state.reviewers[parentID]; + await writeState(toggleStatePath, state); } const created = unwrap( @@ -142,19 +165,19 @@ export async function SafetyWatch({ client, directory }, options = {}) { query: { directory }, }), "creating reviewer session", - ) - reviewerSessions.set(parentID, created.id) - reviewerSessionOwners.set(created.id, parentID) - state.reviewers[parentID] = created.id - await writeState(toggleStatePath, state) - return created.id + ); + reviewerSessions.set(parentID, created.id); + reviewerSessionOwners.set(created.id, parentID); + state.reviewers[parentID] = created.id; + await writeState(toggleStatePath, state); + return created.id; } async function forgetReviewer(parentID) { - if (!toggleStatePath) return - const state = await readState(toggleStatePath) - delete state.reviewers[parentID] - await writeState(toggleStatePath, state) + if (!toggleStatePath) return; + const state = await readState(toggleStatePath); + delete state.reviewers[parentID]; + await writeState(toggleStatePath, state); } async function setReviewing(sessionID, reviewing) { @@ -162,66 +185,127 @@ export async function SafetyWatch({ client, directory }, options = {}) { const paths = unwrap( await client.path.get({ query: { directory } }), "resolving Safety Watch state path", - ) - toggleStatePath = statePath(paths.state) + ); + toggleStatePath = statePath(paths.state); } - const state = await readState(toggleStatePath) - state.reviewing[sessionID] = reviewing - await writeState(toggleStatePath, state) + const state = await readState(toggleStatePath); + state.reviewing[sessionID] = reviewing; + await writeState(toggleStatePath, state); } async function queueReview(sessionID, task) { - const generation = reviewGenerations.get(sessionID) ?? 0 - const previous = reviewQueues.get(sessionID) ?? Promise.resolve() - let release + const generation = reviewGenerations.get(sessionID) ?? 0; + const previous = reviewQueues.get(sessionID) ?? Promise.resolve(); + let release; const current = new Promise((resolve) => { - release = resolve - }) - reviewQueues.set(sessionID, current) + release = resolve; + }); + reviewQueues.set(sessionID, current); - await previous + await previous; + await reviewerCompactions.get(sessionID); if ((reviewGenerations.get(sessionID) ?? 0) !== generation) { - throw new Error("Safety Watch review was canceled") + throw new Error("Safety Watch review was canceled"); } - await setReviewing(sessionID, true).catch(() => {}) + await setReviewing(sessionID, true).catch(() => {}); try { - return await task() + return await task(); } finally { - release() + release(); if (reviewQueues.get(sessionID) === current) { - reviewQueues.delete(sessionID) - await setReviewing(sessionID, false).catch(() => {}) + reviewQueues.delete(sessionID); + await setReviewing(sessionID, false).catch(() => {}); } } } + function clearIdleCompaction(sessionID) { + const timers = idleCompactionTimers.get(sessionID); + if (!timers) return; + clearTimeout(timers.short); + clearTimeout(timers.long); + idleCompactionTimers.delete(sessionID); + } + + function scheduleCompaction(sessionID, threshold) { + const usage = reviewerUsage.get(sessionID); + if ( + !usage || + !Number.isFinite(reviewerContextTokens) || + usage.inputTokens < reviewerContextTokens * threshold || + reviewerCompactions.has(sessionID) + ) { + return; + } + // One compaction supersedes both deferred thresholds for this reviewer. + clearIdleCompaction(sessionID); + const compacting = client.session + .summarize({ + path: { id: usage.reviewerID }, + query: { directory }, + body: { ...usage.model, auto: true }, + }) + .catch(() => {}) + .finally(() => { + reviewerCompactions.delete(sessionID); + reviewerUsage.delete(sessionID); + clearIdleCompaction(sessionID); + }); + reviewerCompactions.set(sessionID, compacting); + } + + function scheduleIdleCompaction(sessionID) { + clearIdleCompaction(sessionID); + const timers = { + short: setTimeout( + () => scheduleCompaction(sessionID, SHORT_IDLE_COMPACTION_RATIO), + SHORT_IDLE_MS, + ), + long: setTimeout( + () => scheduleCompaction(sessionID, LONG_IDLE_COMPACTION_RATIO), + LONG_IDLE_MS, + ), + }; + idleCompactionTimers.set(sessionID, timers); + } + async function cancelReview(sessionID) { - if (!reviewQueues.has(sessionID)) return - reviewGenerations.set(sessionID, (reviewGenerations.get(sessionID) ?? 0) + 1) - reviewQueues.delete(sessionID) - await setReviewing(sessionID, false).catch(() => {}) - const reviewerID = reviewerSessions.get(sessionID) + if (!reviewQueues.has(sessionID)) return; + clearIdleCompaction(sessionID); + reviewerUsage.delete(sessionID); + reviewGenerations.set( + sessionID, + (reviewGenerations.get(sessionID) ?? 0) + 1, + ); + reviewQueues.delete(sessionID); + await setReviewing(sessionID, false).catch(() => {}); + const reviewerID = reviewerSessions.get(sessionID); if (reviewerID) { - await client.session.abort({ path: { id: reviewerID }, query: { directory } }).catch(() => {}) + await client.session + .abort({ path: { id: reviewerID }, query: { directory } }) + .catch(() => {}); } } async function review(input, args) { - const reviewerID = await reviewerSession(input.sessionID) + const reviewerID = await reviewerSession(input.sessionID); const toolIDs = unwrap( await client.tool.ids({ query: { directory } }), "listing reviewer tools", - ) - const disabledTools = Object.fromEntries(toolIDs.map((id) => [id, false])) + ); + const disabledTools = Object.fromEntries(toolIDs.map((id) => [id, false])); const promptText = `# Determine if this is safe to run: -${args}` +${args}`; const model = reviewerModel - ? { providerID: reviewerModel.split("/")[0], modelID: reviewerModel.split("/").slice(1).join("/") } - : undefined - const deadline = Date.now() + timeoutMs + ? { + providerID: reviewerModel.split("/")[0], + modelID: reviewerModel.split("/").slice(1).join("/"), + } + : undefined; + const deadline = Date.now() + timeoutMs; async function promptReviewer(text) { - const remaining = deadline - Date.now() - if (remaining <= 0) return undefined + const remaining = deadline - Date.now(); + if (remaining <= 0) return undefined; const prompt = client.session.prompt({ path: { id: reviewerID }, query: { directory }, @@ -232,119 +316,151 @@ ${args}` system: REVIEWER_PROMPT, parts: [{ type: "text", text }], }, - }) - let timeout + }); + let timeout; return Promise.race([ prompt, new Promise((resolve) => { - timeout = setTimeout(() => resolve(undefined), remaining) + timeout = setTimeout(() => resolve(undefined), remaining); }), - ]).finally(() => clearTimeout(timeout)) + ]).finally(() => clearTimeout(timeout)); } - async function decisionFrom(text) { - const response = await promptReviewer(text) + async function decisionFrom(text, scheduleCompactionAfterResponse = true) { + const response = await promptReviewer(text); if (!response) { - await client.session.abort({ path: { id: reviewerID }, query: { directory } }).catch(() => {}) - return undefined + await client.session + .abort({ path: { id: reviewerID }, query: { directory } }) + .catch(() => {}); + return undefined; } - const message = unwrap(response, "reviewing tool call") + const message = unwrap(response, "reviewing tool call"); const answer = message.parts .filter((part) => part.type === "text") .map((part) => part.text) - .join("\n") - return parseDecision(answer) + .join("\n"); + const decision = parseDecision(answer); + if (scheduleCompactionAfterResponse) { + reviewerUsage.set(input.sessionID, { + reviewerID, + model, + inputTokens: message.info?.tokens?.input ?? 0, + }); + scheduleCompaction(input.sessionID, IMMEDIATE_COMPACTION_RATIO); + if (!reviewerCompactions.has(input.sessionID)) { + scheduleIdleCompaction(input.sessionID); + } + } + return decision; } try { - const decision = await decisionFrom(promptText) - if (decision) return decision + const decision = await decisionFrom(promptText); + if (decision) return decision; } catch { const correction = `Answer with this shape only and no other text: -${RESPONSE_SHAPE}` - const decision = await decisionFrom(correction) - if (decision) return decision +${RESPONSE_SHAPE}`; + const decision = await decisionFrom(correction, false); + if (decision) return decision; } - return { allow: true, reason: "AI review timed out; allowed by fallback." } + return { allow: true, reason: "AI review timed out; allowed by fallback." }; } return { config: async (config) => { - reviewerModel ??= config.small_model - config.agent ??= {} + reviewerModel ??= config.small_model; + const [providerID, modelID] = reviewerModel.split("/"); + const configuredContext = config.provider?.[providerID]?.models?.[modelID]?.limit?.context; + if (Number.isFinite(configuredContext)) reviewerContextTokens = configuredContext; + config.agent ??= {}; config.agent[REVIEWER_AGENT] = { - description: "Internal text-only classifier for Safety Watch.", - mode: "subagent", - model: reviewerModel, - hidden: true, - maxSteps: 1, - tools: { "*": false }, - permission: { - "*": "deny", - edit: "deny", - bash: "deny", - webfetch: "deny", - external_directory: "deny", - }, - prompt: REVIEWER_PROMPT, - } + description: "Internal text-only classifier for Safety Watch.", + mode: "subagent", + model: reviewerModel, + hidden: true, + maxSteps: 1, + tools: { "*": false }, + permission: { + "*": "deny", + edit: "deny", + bash: "deny", + webfetch: "deny", + external_directory: "deny", + }, + prompt: REVIEWER_PROMPT, + }; }, event: async ({ event }) => { if (event?.type === "session.created") { - await applyPendingSettings(event.properties.info.id) - return + await applyPendingSettings(event.properties.info.id); + return; } if ( event?.type === "session.idle" || - (event?.type === "session.status" && event.properties.status.type === "idle") + (event?.type === "session.status" && + event.properties.status.type === "idle") ) { - await cancelReview(event.properties.sessionID) - return + await cancelReview(event.properties.sessionID); + return; } - if (event?.type !== "session.deleted") return + if (event?.type !== "session.deleted") return; - const sessionID = event.properties.sessionID - const parentID = reviewerSessionOwners.get(sessionID) + const sessionID = event.properties.sessionID; + const parentID = reviewerSessionOwners.get(sessionID); if (parentID) { - reviewerSessionOwners.delete(sessionID) - reviewerSessions.delete(parentID) - await forgetReviewer(parentID).catch(() => {}) - return + reviewerSessionOwners.delete(sessionID); + reviewerSessions.delete(parentID); + await forgetReviewer(parentID).catch(() => {}); + return; } - const reviewerID = reviewerSessions.get(sessionID) - if (!reviewerID) return - reviewerSessions.delete(sessionID) - reviewQueues.delete(sessionID) - await setReviewing(sessionID, false).catch(() => {}) - reviewerSessionOwners.delete(reviewerID) - await forgetReviewer(sessionID).catch(() => {}) - await client.session.abort({ path: { id: reviewerID }, query: { directory } }).catch(() => {}) - await client.session.delete({ path: { id: reviewerID }, query: { directory } }).catch(() => {}) + const reviewerID = reviewerSessions.get(sessionID); + if (!reviewerID) return; + reviewerSessions.delete(sessionID); + reviewQueues.delete(sessionID); + clearIdleCompaction(sessionID); + reviewerUsage.delete(sessionID); + await setReviewing(sessionID, false).catch(() => {}); + reviewerSessionOwners.delete(reviewerID); + await forgetReviewer(sessionID).catch(() => {}); + await client.session + .abort({ path: { id: reviewerID }, query: { directory } }) + .catch(() => {}); + await client.session + .delete({ path: { id: reviewerID }, query: { directory } }) + .catch(() => {}); }, "tool.execute.before": async (input, output) => { - if (reviewerSessionOwners.has(input.sessionID)) return - if (!matchesTool(input.tool, guardedTools)) return + if (reviewerSessionOwners.has(input.sessionID)) return; + clearIdleCompaction(input.sessionID); + if (!matchesTool(input.tool, guardedTools)) return; - const args = commandText(input.tool, output.args, textLimit) - const layers = await activeLayers(input.sessionID) - if (layers.dcg) await checkDcg(output.args?.command, { required: true }) + const args = commandText(input.tool, output.args, textLimit); + const layers = await activeLayers(input.sessionID); + if (layers.dcg) await checkDcg(output.args?.command, { required: true }); if (layers.aiReview) { - let decision - try { - decision = await queueReview(input.sessionID, () => review(input, args)) - } catch (error) { - const reason = `Safety Watch failed closed: ${error?.message ?? String(error)}` - throw new Error(reason) - } - if (!decision.allow) { - throw new Error( - `Safety Watch blocked this tool call. It was not run. Reason: ${decision.reason.trim()} Revise the approach instead of retrying the same call.`, - ) - } + let decision; + try { + decision = await queueReview(input.sessionID, () => + review(input, args), + ); + } catch (error) { + const reason = `Safety Watch failed closed: ${error?.message ?? String(error)}`; + throw new Error(reason); + } + if (!decision.allow) { + throw new Error( + `Safety Watch blocked this tool call. It was not run. Reason: ${decision.reason.trim()} Revise the approach instead of retrying the same call.`, + ); + } } }, - } + + "tool.execute.after": async (input) => { + if (reviewerSessionOwners.has(input.sessionID)) return; + scheduleIdleCompaction(input.sessionID); + }, + }; } -export default SafetyWatch +export default SafetyWatch;