import { execFileSync } from "node:child_process"; import { existsSync, statSync } from "node:fs"; import { validateHarnessModelPair } from "./harness.ts"; import { isRouteAdmitted } from "./routes.ts"; /** Deterministic vision gate: judge a UI screenshot through an admitted * vision route with a bounded prompt and a strict structured verdict. The * gate never free-form chats and never fabricates — an unreadable image, an * unadmitted route, or an unparsable verdict returns `verdict: null` with a * `divergence` reason. Transport is injectable so tests run without * network. */ export const VISION_GATE_DEFAULT_ROUTE = "openrouter/z-ai/glm-5.3-flash"; const QUESTION_MAX_CHARS = 500; const RAW_MAX_CHARS = 2000; export interface VisionGateOptions { projectRoot?: string | undefined; /** Absolute or project-relative path to the screenshot. */ imagePath: string; /** What to assert about the screenshot, phrased as a yes/no question. */ question: string; /** Producer id as admitted in routes.yaml. Defaults to the verified * vision route. */ route?: string | undefined; timeoutMs?: number | undefined; /** Injectable transport: receives the composed prompt, returns CLI stdout. */ transport?: ((prompt: string) => Promise) | undefined; } export interface VisionGateResult { /** true/false when the verdict parsed; null = fail closed. */ verdict: boolean | null; route: string; /** Bounded raw output for audit. */ raw: string; divergence?: string | undefined; } export function visionGatePrompt(question: string, imagePath?: string | undefined): string { const bounded = question.trim().slice(0, QUESTION_MAX_CHARS); const reference = imagePath ? `Look at the image @${imagePath}.` : "Look at the image attached to this prompt."; return [ "You are judging a UI screenshot for an automated verification gate.", reference, bounded, 'Reply with exactly one JSON object and nothing else: {"verdict": true} or {"verdict": false}', ].join(" "); } export function parseVisionVerdict(raw: string): boolean | null { const match = /"verdict"\s*:\s*(true|false)/i.exec(raw); return match ? match[1] === "true" : null; } /** Pi argv for an admitted vision route, without the prompt. * Every id, including the default, is `pi --model ` after the * native-vendor brake, so `--route` selects the model. */ export function visionPiArgs(route: string): { ok: true; args: readonly string[] } | { ok: false; divergence: string } { const validation = validateHarnessModelPair("pi", route); if (!validation.valid) { return { ok: false, divergence: validation.message ?? "route_not_pi" }; } const trimmed = route.trim(); if (!trimmed.includes("/")) { return { ok: false, divergence: `route_not_pi: ${trimmed} is not a provider-qualified Pi model id` }; } return { ok: true, args: ["--model", trimmed, "-p", "--no-session"] }; } function defaultTransport(args: readonly string[], timeoutMs: number): string { return execFileSync("pi", [...args], { encoding: "utf8", timeout: timeoutMs, windowsHide: true }); } export async function runVisionGate(options: VisionGateOptions): Promise { const projectRoot = options.projectRoot ?? process.cwd(); const route = options.route ?? VISION_GATE_DEFAULT_ROUTE; const timeoutMs = options.timeoutMs ?? 90_000; const base: VisionGateResult = { verdict: null, route, raw: "" }; if (!Number.isInteger(timeoutMs) || timeoutMs < 5_000 || timeoutMs > 300_000) { return { ...base, divergence: "timeout_out_of_bounds" }; } if (!options.imagePath || !existsSync(options.imagePath) || !statSync(options.imagePath).isFile()) { return { ...base, divergence: "image unreadable" }; } if (!isRouteAdmitted(projectRoot, route)) { return { ...base, divergence: `route_not_admitted: ${route} is not admitted; run kxm routes admit --model ${route} or pass --route `, }; } const piArgs = visionPiArgs(route); if (!piArgs.ok) return { ...base, divergence: piArgs.divergence }; const prompt = visionGatePrompt(options.question, options.imagePath); let raw: string; try { raw = options.transport ? await options.transport(prompt) : defaultTransport([...piArgs.args, prompt], timeoutMs); } catch (error) { return { ...base, divergence: `transport failed: ${String((error as Error)?.message ?? error).slice(0, 200)}` }; } const boundedRaw = raw.slice(0, RAW_MAX_CHARS); const verdict = parseVisionVerdict(boundedRaw); if (verdict === null) { return { ...base, raw: boundedRaw, divergence: "verdict unreadable" }; } return { ...base, verdict, raw: boundedRaw }; }