diff --git a/dot_config/opencode/plugins/safety-watch/index.js b/dot_config/opencode/plugins/safety-watch/index.js index 93c6595..00f446f 100644 --- a/dot_config/opencode/plugins/safety-watch/index.js +++ b/dot_config/opencode/plugins/safety-watch/index.js @@ -1,4 +1,9 @@ -import { DEFAULT_TEXT_LIMIT, DEFAULT_TIMEOUT_MS, REVIEWER_AGENT, REVIEWER_PROMPT } from "./constants.js"; +import { + DEFAULT_TEXT_LIMIT, + DEFAULT_TIMEOUT_MS, + REVIEWER_AGENT, + REVIEWER_PROMPT, +} from "./constants.js"; import { createReviewer } from "./reviewer.js"; import { createStateController } from "./state-controller.js"; import { commandText, matchesTool } from "./utils.js"; @@ -10,17 +15,34 @@ export async function SafetyWatch({ client, directory }, options = {}) { if (!dcgEnabled && !aiReviewEnabled) return {}; const { checkDcg } = await import("../dcg-guard/index.js"); - let reviewerModel = typeof options.model === "string" ? options.model : undefined; + 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 state = createStateController({ client, directory, dcgEnabled, aiReviewEnabled }); + 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 state = createStateController({ + client, + directory, + dcgEnabled, + aiReviewEnabled, + }); let reviewer; function getReviewer() { reviewer ??= createReviewer({ - client, directory, state, model: () => reviewerModel, contextTokens: () => reviewerContextTokens, timeoutMs, + client, + directory, + state, + model: () => reviewerModel, + contextTokens: () => reviewerContextTokens, + timeoutMs, }); return reviewer; } @@ -29,8 +51,10 @@ export async function SafetyWatch({ client, directory }, options = {}) { config: async (config) => { reviewerModel ??= config.small_model; const [providerID, modelID] = reviewerModel.split("/"); - const configuredContext = config.provider?.[providerID]?.models?.[modelID]?.limit?.context; - if (Number.isFinite(configuredContext)) reviewerContextTokens = configuredContext; + 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.", @@ -39,7 +63,13 @@ export async function SafetyWatch({ client, directory }, options = {}) { hidden: true, maxSteps: 1, tools: { "*": false }, - permission: { "*": "deny", edit: "deny", bash: "deny", webfetch: "deny", external_directory: "deny" }, + permission: { + "*": "deny", + edit: "deny", + bash: "deny", + webfetch: "deny", + external_directory: "deny", + }, prompt: REVIEWER_PROMPT, }; }, @@ -49,11 +79,16 @@ export async function SafetyWatch({ client, directory }, options = {}) { await state.applyPendingSettings(event.properties.info.id); return; } - if (event?.type === "session.idle" || (event?.type === "session.status" && event.properties.status.type === "idle")) { + if ( + event?.type === "session.idle" || + (event?.type === "session.status" && + event.properties.status.type === "idle") + ) { await getReviewer().cancelReview(event.properties.sessionID); return; } - if (event?.type === "session.deleted") await getReviewer().handleDeleted(event.properties.sessionID); + if (event?.type === "session.deleted") + await getReviewer().handleDeleted(event.properties.sessionID); }, "tool.execute.before": async (input, output) => { @@ -66,18 +101,28 @@ export async function SafetyWatch({ client, directory }, options = {}) { if (!layers.aiReview) return; let decision; try { - decision = await reviews.queueReview(input.sessionID, () => reviews.review(input.sessionID, commandText(input.tool, output.args, textLimit))); + decision = await reviews.queueReview(input.sessionID, () => + reviews.review( + input.sessionID, + commandText(input.tool, output.args, textLimit), + ), + ); } catch (error) { - throw new Error(`Safety Watch failed closed: ${error?.message ?? String(error)}`); + throw new Error( + `Safety Watch failed closed: ${error?.message ?? String(error)}`, + ); } 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.`); + 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) => { const reviews = getReviewer(); - if (!reviews.isReviewer(input.sessionID)) reviews.scheduleIdleCompaction(input.sessionID); + if (!reviews.isReviewer(input.sessionID)) + reviews.scheduleIdleCompaction(input.sessionID); }, }; } diff --git a/dot_config/opencode/plugins/safety-watch/reviewer.js b/dot_config/opencode/plugins/safety-watch/reviewer.js index d074d6a..d0ea134 100644 --- a/dot_config/opencode/plugins/safety-watch/reviewer.js +++ b/dot_config/opencode/plugins/safety-watch/reviewer.js @@ -11,7 +11,14 @@ import { } from "./constants.js"; import { parseDecision, unwrap } from "./utils.js"; -export function createReviewer({ client, directory, state, model, contextTokens, timeoutMs = DEFAULT_TIMEOUT_MS }) { +export function createReviewer({ + client, + directory, + state, + model, + contextTokens, + timeoutMs = DEFAULT_TIMEOUT_MS, +}) { const sessions = new Map(); const owners = new Map(); const queues = new Map(); @@ -31,23 +38,40 @@ export function createReviewer({ client, directory, state, model, contextTokens, function scheduleCompaction(sessionID, threshold) { const current = usage.get(sessionID); const limit = contextTokens(); - if (!current || !Number.isFinite(limit) || current.inputTokens < limit * threshold || compactions.has(sessionID)) return; + if ( + !current || + !Number.isFinite(limit) || + current.inputTokens < limit * threshold || + compactions.has(sessionID) + ) + return; clearIdleCompaction(sessionID); - const compacting = client.session.summarize({ - path: { id: current.reviewerID }, query: { directory }, body: { ...current.model, auto: true }, - }).catch(() => {}).finally(() => { - compactions.delete(sessionID); - usage.delete(sessionID); - clearIdleCompaction(sessionID); - }); + const compacting = client.session + .summarize({ + path: { id: current.reviewerID }, + query: { directory }, + body: { ...current.model, auto: true }, + }) + .catch(() => {}) + .finally(() => { + compactions.delete(sessionID); + usage.delete(sessionID); + clearIdleCompaction(sessionID); + }); compactions.set(sessionID, compacting); } function scheduleIdleCompaction(sessionID) { clearIdleCompaction(sessionID); timers.set(sessionID, { - short: setTimeout(() => scheduleCompaction(sessionID, SHORT_IDLE_COMPACTION_RATIO), SHORT_IDLE_MS), - long: setTimeout(() => scheduleCompaction(sessionID, LONG_IDLE_COMPACTION_RATIO), LONG_IDLE_MS), + short: setTimeout( + () => scheduleCompaction(sessionID, SHORT_IDLE_COMPACTION_RATIO), + SHORT_IDLE_MS, + ), + long: setTimeout( + () => scheduleCompaction(sessionID, LONG_IDLE_COMPACTION_RATIO), + LONG_IDLE_MS, + ), }); } @@ -56,7 +80,10 @@ export function createReviewer({ client, directory, state, model, contextTokens, if (existing) return existing; const persisted = await state.reviewerID(parentID); if (typeof persisted === "string") { - const response = await client.session.get({ path: { id: persisted }, query: { directory } }); + const response = await client.session.get({ + path: { id: persisted }, + query: { directory }, + }); if (response?.data) { sessions.set(parentID, persisted); owners.set(persisted, parentID); @@ -64,9 +91,13 @@ export function createReviewer({ client, directory, state, model, contextTokens, } await state.saveReviewer(parentID); } - const created = unwrap(await client.session.create({ - body: { parentID, title: "[internal] Safety Watch reviewer" }, query: { directory }, - }), "creating reviewer session"); + const created = unwrap( + await client.session.create({ + body: { parentID, title: "[internal] Safety Watch reviewer" }, + query: { directory }, + }), + "creating reviewer session", + ); sessions.set(parentID, created.id); owners.set(created.id, parentID); await state.saveReviewer(parentID, created.id); @@ -77,11 +108,14 @@ export function createReviewer({ client, directory, state, model, contextTokens, const generation = generations.get(sessionID) ?? 0; const previous = queues.get(sessionID) ?? Promise.resolve(); let release; - const current = new Promise((resolve) => { release = resolve; }); + const current = new Promise((resolve) => { + release = resolve; + }); queues.set(sessionID, current); await previous; await compactions.get(sessionID); - if ((generations.get(sessionID) ?? 0) !== generation) throw new Error("Safety Watch review was canceled"); + if ((generations.get(sessionID) ?? 0) !== generation) + throw new Error("Safety Watch review was canceled"); await state.setReviewing(sessionID, true).catch(() => {}); try { return await task(); @@ -96,42 +130,76 @@ export function createReviewer({ client, directory, state, model, contextTokens, async function review(sessionID, args) { const reviewerID = await reviewerSession(sessionID); - const toolIDs = unwrap(await client.tool.ids({ query: { directory } }), "listing reviewer tools"); + const toolIDs = unwrap( + await client.tool.ids({ query: { directory } }), + "listing reviewer tools", + ); const tools = Object.fromEntries(toolIDs.map((id) => [id, false])); const selectedModel = model(); - const reviewerModel = selectedModel ? { providerID: selectedModel.split("/")[0], modelID: selectedModel.split("/").slice(1).join("/") } : undefined; + const reviewerModel = selectedModel + ? { + providerID: selectedModel.split("/")[0], + modelID: selectedModel.split("/").slice(1).join("/"), + } + : undefined; const deadline = Date.now() + timeoutMs; async function prompt(text) { const remaining = deadline - Date.now(); if (remaining <= 0) return; let timeout; return Promise.race([ - client.session.prompt({ path: { id: reviewerID }, query: { directory }, body: { - agent: REVIEWER_AGENT, model: reviewerModel, tools, system: REVIEWER_PROMPT, parts: [{ type: "text", text }], - } }), - new Promise((resolve) => { timeout = setTimeout(() => resolve(), remaining); }), + client.session.prompt({ + path: { id: reviewerID }, + query: { directory }, + body: { + agent: REVIEWER_AGENT, + model: reviewerModel, + tools, + system: REVIEWER_PROMPT, + parts: [{ type: "text", text }], + }, + }), + new Promise((resolve) => { + timeout = setTimeout(() => resolve(), remaining); + }), ]).finally(() => clearTimeout(timeout)); } async function decide(text, schedule = true) { const response = await prompt(text); if (!response) { - await client.session.abort({ path: { id: reviewerID }, query: { directory } }).catch(() => {}); + await client.session + .abort({ path: { id: reviewerID }, query: { directory } }) + .catch(() => {}); return; } const message = unwrap(response, "reviewing tool call"); - const decision = parseDecision(message.parts.filter((part) => part.type === "text").map((part) => part.text).join("\n")); + const decision = parseDecision( + message.parts + .filter((part) => part.type === "text") + .map((part) => part.text) + .join("\n"), + ); if (schedule) { - usage.set(sessionID, { reviewerID, model: reviewerModel, inputTokens: message.info?.tokens?.input ?? 0 }); + usage.set(sessionID, { + reviewerID, + model: reviewerModel, + inputTokens: message.info?.tokens?.input ?? 0, + }); scheduleCompaction(sessionID, IMMEDIATE_COMPACTION_RATIO); if (!compactions.has(sessionID)) scheduleIdleCompaction(sessionID); } return decision; } try { - const decision = await decide(`# Determine if this is safe to run:\n${args}`); + const decision = await decide( + `# Determine if this is safe to run:\n${args}`, + ); if (decision) return decision; } catch { - const decision = await decide(`Answer with this shape only and no other text:\n${RESPONSE_SHAPE}`, false); + const decision = await decide( + `Answer with this shape only and no other text:\n${RESPONSE_SHAPE}`, + false, + ); if (decision) return decision; } return { allow: true, reason: "AI review timed out; allowed by fallback." }; @@ -145,7 +213,10 @@ export function createReviewer({ client, directory, state, model, contextTokens, queues.delete(sessionID); await state.setReviewing(sessionID, false).catch(() => {}); const reviewerID = sessions.get(sessionID); - if (reviewerID) await client.session.abort({ path: { id: reviewerID }, query: { directory } }).catch(() => {}); + if (reviewerID) + await client.session + .abort({ path: { id: reviewerID }, query: { directory } }) + .catch(() => {}); } async function handleDeleted(sessionID) { @@ -165,9 +236,21 @@ export function createReviewer({ client, directory, state, model, contextTokens, await state.setReviewing(sessionID, false).catch(() => {}); owners.delete(reviewerID); await state.saveReviewer(sessionID).catch(() => {}); - await client.session.abort({ path: { id: reviewerID }, query: { directory } }).catch(() => {}); - await client.session.delete({ path: { id: reviewerID }, query: { directory } }).catch(() => {}); + await client.session + .abort({ path: { id: reviewerID }, query: { directory } }) + .catch(() => {}); + await client.session + .delete({ path: { id: reviewerID }, query: { directory } }) + .catch(() => {}); } - return { isReviewer: (sessionID) => owners.has(sessionID), clearIdleCompaction, scheduleIdleCompaction, queueReview, review, cancelReview, handleDeleted }; + return { + isReviewer: (sessionID) => owners.has(sessionID), + clearIdleCompaction, + scheduleIdleCompaction, + queueReview, + review, + cancelReview, + handleDeleted, + }; } diff --git a/dot_config/opencode/plugins/safety-watch/state-controller.js b/dot_config/opencode/plugins/safety-watch/state-controller.js index 1ad56b5..76932e1 100644 --- a/dot_config/opencode/plugins/safety-watch/state-controller.js +++ b/dot_config/opencode/plugins/safety-watch/state-controller.js @@ -1,12 +1,20 @@ import { layerEnabled, readState, statePath, writeState } from "./state.js"; import { unwrap } from "./utils.js"; -export function createStateController({ client, directory, dcgEnabled, aiReviewEnabled }) { +export function createStateController({ + client, + directory, + dcgEnabled, + aiReviewEnabled, +}) { let path; async function getPath() { if (!path) { - const paths = unwrap(await client.path.get({ query: { directory } }), "resolving Safety Watch state path"); + const paths = unwrap( + await client.path.get({ query: { directory } }), + "resolving Safety Watch state path", + ); path = statePath(paths.state); } return path; @@ -26,8 +34,16 @@ export function createStateController({ client, directory, dcgEnabled, aiReviewE }, async applyPendingSettings(sessionID) { const state = await load(); - if (!Object.values(state.pending).some((value) => typeof value === "boolean")) return; - state.sessions[sessionID] = { ...state.pending, ...state.sessions[sessionID] }; + if ( + !Object.values(state.pending).some( + (value) => typeof value === "boolean", + ) + ) + return; + state.sessions[sessionID] = { + ...state.pending, + ...state.sessions[sessionID], + }; state.pending = {}; await writeState(await getPath(), state); }, diff --git a/dot_config/opencode/plugins/safety-watch/state.js b/dot_config/opencode/plugins/safety-watch/state.js index 2b11a0b..8414d69 100644 --- a/dot_config/opencode/plugins/safety-watch/state.js +++ b/dot_config/opencode/plugins/safety-watch/state.js @@ -1,29 +1,40 @@ -const FILE_NAME = "safety-watch.json" +const FILE_NAME = "safety-watch.json"; export function statePath(directory) { - return `${directory}/${FILE_NAME}` + return `${directory}/${FILE_NAME}`; } export async function readState(path) { try { - const value = await Bun.file(path).json() + const value = await Bun.file(path).json(); return { - sessions: value.sessions && typeof value.sessions === "object" ? value.sessions : {}, - pending: value.pending && typeof value.pending === "object" ? value.pending : {}, - reviewing: value.reviewing && typeof value.reviewing === "object" ? value.reviewing : {}, - reviewers: value.reviewers && typeof value.reviewers === "object" ? value.reviewers : {}, - } + sessions: + value.sessions && typeof value.sessions === "object" + ? value.sessions + : {}, + pending: + value.pending && typeof value.pending === "object" ? value.pending : {}, + reviewing: + value.reviewing && typeof value.reviewing === "object" + ? value.reviewing + : {}, + reviewers: + value.reviewers && typeof value.reviewers === "object" + ? value.reviewers + : {}, + }; } catch (error) { - if (error?.code === "ENOENT") return { sessions: {}, pending: {}, reviewing: {}, reviewers: {} } - throw error + if (error?.code === "ENOENT") + return { sessions: {}, pending: {}, reviewing: {}, reviewers: {} }; + throw error; } } export async function writeState(path, value) { - await Bun.write(path, JSON.stringify(value)) + await Bun.write(path, JSON.stringify(value)); } export function layerEnabled(state, sessionID, layer, fallback) { - const session = state.sessions[sessionID] ?? state.pending - return typeof session?.[layer] === "boolean" ? session[layer] : fallback + const session = state.sessions[sessionID] ?? state.pending; + return typeof session?.[layer] === "boolean" ? session[layer] : fallback; } diff --git a/dot_config/opencode/plugins/safety-watch/tui.tsx b/dot_config/opencode/plugins/safety-watch/tui.tsx index 898468d..433fdcd 100644 --- a/dot_config/opencode/plugins/safety-watch/tui.tsx +++ b/dot_config/opencode/plugins/safety-watch/tui.tsx @@ -40,8 +40,8 @@ const tui: TuiPlugin = async (api, options) => { } setStatus((current) => current.dcg === next.dcg && - current.aiReview === next.aiReview && - current.showStatus === next.showStatus + current.aiReview === next.aiReview && + current.showStatus === next.showStatus ? current : next, ) @@ -136,7 +136,7 @@ const tui: TuiPlugin = async (api, options) => { await loadDefaults() await refresh() - const refreshTimer = setInterval(() => void refresh().catch(() => {}), 200) + const refreshTimer = setInterval(() => void refresh().catch(() => { }), 200) const spinnerTimer = setInterval(() => { if (!reviewing()) return const frames = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"] diff --git a/dot_config/opencode/plugins/safety-watch/utils.js b/dot_config/opencode/plugins/safety-watch/utils.js index 40e5878..fba4c6b 100644 --- a/dot_config/opencode/plugins/safety-watch/utils.js +++ b/dot_config/opencode/plugins/safety-watch/utils.js @@ -11,7 +11,8 @@ export function commandText(tool, args, limit) { } export function unwrap(response, operation) { - if (response?.error) throw new Error(`${operation} failed: ${compact(response.error, 500)}`); + if (response?.error) + throw new Error(`${operation} failed: ${compact(response.error, 500)}`); if (!response?.data) throw new Error(`${operation} returned no data`); return response.data; } @@ -20,13 +21,21 @@ export 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]); - if (typeof decision.allow !== "boolean" || typeof decision.reason !== "string" || !decision.reason.trim()) { + if ( + typeof decision.allow !== "boolean" || + typeof decision.reason !== "string" || + !decision.reason.trim() + ) { throw new Error("reviewer returned an invalid decision"); } return decision; } export function matchesTool(toolName, patterns) { - return patterns.some((pattern) => pattern === "*" || - (pattern.startsWith("*.") && toolName.endsWith(pattern.slice(1))) || toolName === pattern); + return patterns.some( + (pattern) => + pattern === "*" || + (pattern.startsWith("*.") && toolName.endsWith(pattern.slice(1))) || + toolName === pattern, + ); }