From ceb27afe995cb5e9c138764456744fa01b0722aa Mon Sep 17 00:00:00 2001 From: RECTOR Date: Sat, 11 Jul 2026 14:51:25 +0700 Subject: [PATCH 1/9] chore: start v0.5.0 (config + security) --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index a73399d..7f835f9 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@getpipher/vision", - "version": "0.4.0", + "version": "0.5.0", "description": "Capability-aware vision + paste extension for the pi coding agent. Delegates image analysis to a vision model only when the active primary model is text-only; passes images through natively for multimodal models (zero delegation).", "keywords": [ "pi-package", From 13f6286673813edfe614ad83d0e8b6a9fd341fe0 Mon Sep 17 00:00:00 2001 From: RECTOR Date: Sat, 11 Jul 2026 14:53:02 +0700 Subject: [PATCH 2/9] feat(lib): audit log read/write helpers (TDD) --- lib/audit.ts | 170 +++++++++++++++++++++++++ tests/audit.test.ts | 293 ++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 463 insertions(+) create mode 100644 lib/audit.ts create mode 100644 tests/audit.test.ts diff --git a/lib/audit.ts b/lib/audit.ts new file mode 100644 index 0000000..96c7ebb --- /dev/null +++ b/lib/audit.ts @@ -0,0 +1,170 @@ +/** + * Audit log read/write helpers (SPEC-5 §3.1, PLAN-5 §1.2/§1.3/step 1). + * + * The audit log is a persisted, append-only JSONL record of where each image + * went during vision-model delegation. One line per delegation event (single + * or per-image in a batch — each per-image `delegateToVisionModel` call + * writes its own entry). The log answers "where did my image bytes go?" + * (provider / model / cached / fallback / ok / error / latency) **without + * storing the image bytes or the full prompt**. + * + * Location: `~/.pi/agent/vision-audit.log` by default (resolved from + * `config.auditLogPath` or the default `/vision-audit.log`). The + * path resolution lives in `resolveAuditPath` (pure); the actual + * `getAgentDir()` call is made by the caller (`lib/delegate.ts`) so this + * module stays pure + unit-testable without env manipulation. + * + * All helpers are best-effort: a log failure (disk full, permissions) is + * swallowed + warned to stderr — the delegation result is the primary + * outcome; the audit log is secondary (SPEC-5 §9.11). + * + * **Concurrency (PLAN-5 §1.2):** `appendAuditEntry` uses `appendFileSync` + * (O_APPEND). Node.js is single-threaded for JS execution, so synchronous + * calls don't interleave; POSIX guarantees writes ≤ 4096 bytes to an + * O_APPEND file are atomic. A single audit entry is ~300–500 bytes — safe + * under parallel batch delegations (no locking needed, T68). + * + * **Privacy stance (SPEC-5 §3.1):** never logs image bytes or the full + * prompt. `source_hash` is a one-way content fingerprint; `image_path` is + * truncated for data:URL/base64 (see `truncateImagePathForLog`). + * + * Pure: no pi runtime, no shared state (stateless — no `globalThis` needed, + * unlike `lib/state.ts`). + */ +import { appendFileSync, existsSync, mkdirSync, readFileSync, truncateSync } from "node:fs"; +import { dirname, join } from "node:path"; + +/** One delegation event, logged as a single JSONL line. */ +export interface AuditEntry { + /** ISO 8601 timestamp of the delegation event. */ + ts: string; + /** The provider id the image was sent to (or attempted). On a fallback + * success this is the *configured primary* provider (the attempted route), + * not the fallback provider — `fallback` + `fallback_model` disambiguate. */ + provider: string; + /** The model id that actually responded (`result.details.model`, the + * `"provider/model"` string). On a fallback success, the fallback model. */ + model: string; + /** The image_path the user passed (file path / data:URL / base64 — + * truncated for data:URL + base64 via `truncateImagePathForLog`; file + * paths logged in full since the user already has them). */ + image_path: string; + /** SHA-256 hex of the original image bytes (the content-addressed + * fingerprint — lets the user correlate repeat queries on the same + * image without us storing the bytes). Always logged. */ + source_hash: string; + /** true if the result came from the cache (0 network calls). */ + cached: boolean; + /** true if the result came from the fallback vision model. */ + fallback: boolean; + /** The fallback model id, if fallback was used (else undefined). */ + fallback_model: string | undefined; + /** true if the delegation succeeded (description returned), false on any error. */ + ok: boolean; + /** Error code on failure (e.g. "local_only", "vision_call_error", "aborted", + * "model_not_found"). undefined on success. */ + error_code: string | undefined; + /** Round-trip latency in ms (the vision-model call time, or 0 for a cache + * hit / local-only refusal). For a fallback, the fallback call's latency. */ + latency_ms: number; + /** true if local-only mode was active (the entry was a cache hit or a + * refused cache miss). Makes local-only behavior greppable. */ + local_only: boolean; +} + +/** The default audit log filename inside the agent dir. */ +export const AUDIT_LOG_FILENAME = "vision-audit.log"; + +/** + * Resolve the audit log path: explicit config path (if non-empty), or the + * default `/vision-audit.log`. Pure — takes `agentDir` as a param + * (the caller does the `getAgentDir()` call) so this is unit-testable without + * env manipulation. + */ +export function resolveAuditPath(configPath: string | undefined, agentDir: string): string { + const trimmed = configPath?.trim(); + return trimmed && trimmed.length > 0 ? trimmed : join(agentDir, AUDIT_LOG_FILENAME); +} + +/** + * Truncate an image_path for logging: file paths full; data:URL + long base64 + * truncated to first 64 chars + a `…(N bytes|chars)` suffix. Pure. + * + * Conservative guard: anything with a `/` or `(` is treated as a path (full). + * A long base64 that happens to contain `/` (the 63rd base64 char) is a + * deliberate false-negative on truncation — better to over-log a long + * base64 than to truncate a real path. + */ +export function truncateImagePathForLog(imagePath: string): string { + if (imagePath.startsWith("data:")) { + const bytes = Buffer.byteLength(imagePath, "utf8"); + return `${imagePath.slice(0, 64)}…(${bytes} bytes)`; + } + // Long base64 (no path separators, > 200 chars) → truncate. + if (imagePath.length > 200 && !/[/(]/.test(imagePath)) { + return `${imagePath.slice(0, 64)}…(${imagePath.length} chars)`; + } + return imagePath; +} + +/** + * Append an audit entry as one JSONL line. Best-effort: never throws — a + * log failure (disk full, permissions, unwritable parent) is swallowed + a + * warning is written to stderr. Creates the parent dir (recursive) if + * missing. Synchronous (`appendFileSync`) — safe under concurrent batch + * delegations (Node single-threaded + O_APPEND atomic ≤ 4096B; PLAN-5 §1.2). + */ +export function appendAuditEntry(path: string, entry: AuditEntry): void { + try { + mkdirSync(dirname(path), { recursive: true }); + appendFileSync(path, `${JSON.stringify(entry)}\n`, "utf8"); + } catch (err) { + // Best-effort: a log failure must never break the delegation. + // eslint-disable-next-line no-console + console.warn(`[vision] audit log write failed: ${err instanceof Error ? err.message : String(err)}`); + } +} + +/** Truncate the audit log to 0 entries. Best-effort (no throw on missing file). */ +export function clearAuditLog(path: string): void { + try { + if (existsSync(path)) truncateSync(path, 0); + } catch (err) { + // eslint-disable-next-line no-console + console.warn(`[vision] audit log clear failed: ${err instanceof Error ? err.message : String(err)}`); + } +} + +/** + * Read the last `n` entries (tail), newest-last. Skips unparseable lines + * (defensive against log corruption — a corrupt line never throws). Returns + * `[]` if the file is missing. + */ +export function tailAuditLog(path: string, n: number): AuditEntry[] { + if (!existsSync(path)) return []; + try { + const lines = readFileSync(path, "utf8").split("\n").filter((l) => l.trim().length > 0); + const tail = lines.slice(-n); + const out: AuditEntry[] = []; + for (const line of tail) { + try { + out.push(JSON.parse(line) as AuditEntry); + } catch { + // skip corrupt line — defensive + } + } + return out; + } catch { + return []; + } +} + +/** Count entries (non-empty lines) in the audit log. Returns 0 if missing. */ +export function countAuditLog(path: string): number { + if (!existsSync(path)) return 0; + try { + return readFileSync(path, "utf8").split("\n").filter((l) => l.trim().length > 0).length; + } catch { + return 0; + } +} \ No newline at end of file diff --git a/tests/audit.test.ts b/tests/audit.test.ts new file mode 100644 index 0000000..cb69731 --- /dev/null +++ b/tests/audit.test.ts @@ -0,0 +1,293 @@ +/** + * Unit tests for `lib/audit.ts` (SPEC-5 §3.1 / PLAN-5 §1.2, §1.3, step 1). + * + * The audit log is a persisted, append-only JSONL record of where each image + * went (provider / model / cached / fallback / ok / error / latency). These + * tests cover the pure read/write helpers: path resolution, image-path + * truncation, append (with parent-dir creation + best-effort failure), + * clear, tail (with corruption defense), count, + the concurrency guarantee + * (parallel appends don't interleave or corrupt — T68). + */ +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { mkdtempSync, rmSync, existsSync, readFileSync, writeFileSync, statSync, chmodSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { + appendAuditEntry, + clearAuditLog, + countAuditLog, + resolveAuditPath, + tailAuditLog, + truncateImagePathForLog, + type AuditEntry, +} from "../lib/audit.ts"; + +function tmpLogDir(): string { + return mkdtempSync(join(tmpdir(), "vision-audit-")); +} + +function makeEntry(overrides: Partial = {}): AuditEntry { + return { + ts: "2026-07-11T12:00:00.000Z", + provider: "Ollama", + model: "minimax-m3:cloud", + image_path: "/tmp/a.png", + source_hash: "abc123", + cached: false, + fallback: false, + fallback_model: undefined, + ok: true, + error_code: undefined, + latency_ms: 42, + local_only: false, + ...overrides, + }; +} + +// ── resolveAuditPath ──────────────────────────────────────────────────── +test("resolveAuditPath: undefined config → default /vision-audit.log", () => { + assert.equal(resolveAuditPath(undefined, "/x/agent"), "/x/agent/vision-audit.log"); + assert.equal(resolveAuditPath("", "/x/agent"), "/x/agent/vision-audit.log"); + assert.equal(resolveAuditPath(" ", "/x/agent"), "/x/agent/vision-audit.log"); +}); + +test("resolveAuditPath: explicit config path wins (trimmed)", () => { + assert.equal(resolveAuditPath("/custom/path.log", "/x/agent"), "/custom/path.log"); + assert.equal(resolveAuditPath(" /custom/path.log ", "/x/agent"), "/custom/path.log"); +}); + +// ── truncateImagePathForLog ───────────────────────────────────────────── +test("truncateImagePathForLog: file path full (has slash)", () => { + assert.equal(truncateImagePathForLog("/tmp/a.png"), "/tmp/a.png"); + assert.equal(truncateImagePathForLog("~/x/screenshot.jpeg"), "~/x/screenshot.jpeg"); + assert.equal(truncateImagePathForLog("./relative/img.png"), "./relative/img.png"); +}); + +test("truncateImagePathForLog: data: URL truncated to first 64 chars + …(N bytes)", () => { + const url = "data:image/png;base64," + "A".repeat(500); + const out = truncateImagePathForLog(url); + assert.ok(out.startsWith(url.slice(0, 64)), "starts with first 64 chars"); + assert.ok(out.endsWith(" bytes)"), "ends with bytes suffix"); + assert.ok(out.includes("…"), "has ellipsis"); + // The byte count should reflect the full data URL length. + assert.ok(out.includes(`${Buffer.byteLength(url, "utf8")} bytes`), "byte count accurate"); +}); + +test("truncateImagePathForLog: long base64 (no slash, >200 chars) truncated", () => { + const long = "B".repeat(300); + const out = truncateImagePathForLog(long); + assert.ok(out.startsWith(long.slice(0, 64)), "starts with first 64 chars"); + assert.ok(out.endsWith(" chars)"), "ends with chars suffix"); + assert.ok(out.includes("…"), "has ellipsis"); + assert.ok(out.includes("300 chars"), "char count accurate"); +}); + +test("truncateImagePathForLog: short base64 (<200 chars) full", () => { + const short = "C".repeat(68); + assert.equal(truncateImagePathForLog(short), short); +}); + +test("truncateImagePathForLog: long path WITH slash stays full (conservative guard)", () => { + const longPath = "/" + "d".repeat(300) + "/file.png"; + assert.equal(truncateImagePathForLog(longPath), longPath); +}); + +test("truncateImagePathForLog: base64 with a slash char → not truncated (conservative)", () => { + // base64 alphabet includes '/' (the 63rd value). A long base64 that happens + // to contain '/' is treated as a path (full). This is a deliberate + // conservative false-negative: better to over-log than truncate a real path. + const longWithSlash = "D".repeat(150) + "/" + "D".repeat(150); + assert.equal(truncateImagePathForLog(longWithSlash), longWithSlash); +}); + +// ── appendAuditEntry ──────────────────────────────────────────────────── +test("appendAuditEntry: creates file + parent dir, writes one JSONL line", () => { + const dir = tmpLogDir(); + try { + const path = join(dir, "nested", "deep", "audit.log"); + appendAuditEntry(path, makeEntry()); + assert.ok(existsSync(path), "file created"); + const lines = readFileSync(path, "utf8").split("\n").filter((l) => l.trim().length > 0); + assert.equal(lines.length, 1, "one line"); + const parsed = JSON.parse(lines[0]!) as AuditEntry; + assert.equal(parsed.provider, "Ollama"); + assert.equal(parsed.model, "minimax-m3:cloud"); + assert.equal(parsed.source_hash, "abc123"); + assert.equal(parsed.ok, true); + } finally { + rmSync(dir, { recursive: true, force: true }); + } +}); + +test("appendAuditEntry: two appends → two distinct lines (append, not overwrite)", () => { + const dir = tmpLogDir(); + try { + const path = join(dir, "audit.log"); + appendAuditEntry(path, makeEntry({ source_hash: "h1" })); + appendAuditEntry(path, makeEntry({ source_hash: "h2" })); + const lines = readFileSync(path, "utf8").split("\n").filter((l) => l.trim().length > 0); + assert.equal(lines.length, 2, "two lines"); + assert.ok(lines[0]!.includes("h1")); + assert.ok(lines[1]!.includes("h2")); + } finally { + rmSync(dir, { recursive: true, force: true }); + } +}); + +test("appendAuditEntry: best-effort — a write failure is swallowed, never throws", () => { + const dir = tmpLogDir(); + try { + // Point at a path whose parent is a file (mkdirSync recursive fails; the + // append is swallowed). Use a real unwritable setup. + const blocker = join(dir, "blocker"); + writeFileSync(blocker, "x"); // a file, not a dir + const path = join(blocker, "audit.log"); // parent is a file → mkdir throws + // Must not throw. + assert.doesNotThrow(() => appendAuditEntry(path, makeEntry())); + } finally { + rmSync(dir, { recursive: true, force: true }); + } +}); + +// ── clearAuditLog ──────────────────────────────────────────────────────── +test("clearAuditLog: truncates an existing file to 0 entries", () => { + const dir = tmpLogDir(); + try { + const path = join(dir, "audit.log"); + appendAuditEntry(path, makeEntry({ source_hash: "h1" })); + appendAuditEntry(path, makeEntry({ source_hash: "h2" })); + assert.equal(countAuditLog(path), 2); + clearAuditLog(path); + assert.equal(countAuditLog(path), 0); + // File still exists (truncated, not deleted). + assert.ok(existsSync(path)); + assert.equal(statSync(path).size, 0); + } finally { + rmSync(dir, { recursive: true, force: true }); + } +}); + +test("clearAuditLog: no-op on missing file (no throw)", () => { + const dir = tmpLogDir(); + try { + const path = join(dir, "never-existed.log"); + assert.doesNotThrow(() => clearAuditLog(path)); + } finally { + rmSync(dir, { recursive: true, force: true }); + } +}); + +// ── tailAuditLog ──────────────────────────────────────────────────────── +test("tailAuditLog: returns last N entries, newest-last", () => { + const dir = tmpLogDir(); + try { + const path = join(dir, "audit.log"); + for (let i = 0; i < 12; i++) { + appendAuditEntry(path, makeEntry({ source_hash: `h${i}` })); + } + const tail = tailAuditLog(path, 10); + assert.equal(tail.length, 10, "capped at N"); + // newest-last: the last entry is the most-recently-appended. + assert.equal(tail[9]!.source_hash, "h11"); + assert.equal(tail[0]!.source_hash, "h2"); + } finally { + rmSync(dir, { recursive: true, force: true }); + } +}); + +test("tailAuditLog: fewer entries than N → returns all", () => { + const dir = tmpLogDir(); + try { + const path = join(dir, "audit.log"); + appendAuditEntry(path, makeEntry({ source_hash: "only" })); + const tail = tailAuditLog(path, 10); + assert.equal(tail.length, 1); + assert.equal(tail[0]!.source_hash, "only"); + } finally { + rmSync(dir, { recursive: true, force: true }); + } +}); + +test("tailAuditLog: skips corrupt lines (defensive — no throw)", () => { + const dir = tmpLogDir(); + try { + const path = join(dir, "audit.log"); + appendAuditEntry(path, makeEntry({ source_hash: "good1" })); + // Inject a corrupt line manually. + writeFileSync(path, "this is not json\n", { flag: "a" }); + appendAuditEntry(path, makeEntry({ source_hash: "good2" })); + const tail = tailAuditLog(path, 10); + assert.equal(tail.length, 2, "corrupt line skipped, 2 valid returned"); + assert.equal(tail[0]!.source_hash, "good1"); + assert.equal(tail[1]!.source_hash, "good2"); + } finally { + rmSync(dir, { recursive: true, force: true }); + } +}); + +test("tailAuditLog: missing file → [] (no throw)", () => { + const dir = tmpLogDir(); + try { + const path = join(dir, "never-existed.log"); + assert.deepEqual(tailAuditLog(path, 10), []); + } finally { + rmSync(dir, { recursive: true, force: true }); + } +}); + +// ── countAuditLog ──────────────────────────────────────────────────────── +test("countAuditLog: counts non-empty lines", () => { + const dir = tmpLogDir(); + try { + const path = join(dir, "audit.log"); + for (let i = 0; i < 5; i++) appendAuditEntry(path, makeEntry({ source_hash: `h${i}` })); + assert.equal(countAuditLog(path), 5); + } finally { + rmSync(dir, { recursive: true, force: true }); + } +}); + +test("countAuditLog: missing file → 0", () => { + const dir = tmpLogDir(); + try { + const path = join(dir, "never-existed.log"); + assert.equal(countAuditLog(path), 0); + } finally { + rmSync(dir, { recursive: true, force: true }); + } +}); + +// ── T68: concurrency (★ PLAN-5 §1.2) ─────────────────────────────────── +test("T68: 10 parallel appendAuditEntry → 10 distinct lines, no interleaving/corruption", async () => { + const dir = tmpLogDir(); + try { + const path = join(dir, "audit.log"); + // Fire 10 appends "concurrently" (synchronous calls in a Promise.all of + // immediately-resolved microtasks). appendFileSync is synchronous so + // these serialize in JS, but the test asserts the contract: N appends → + // N parseable distinct lines, no corruption. + const entries = Array.from({ length: 10 }, (_, i) => + makeEntry({ source_hash: `parallel-${i}`, latency_ms: i }), + ); + // Use Promise.all over synchronous fns wrapped in Promise.resolve to + // exercise the concurrency path the real batch uses (the batch awaits + // delegateToVisionModel which awaits fetch; the audit write itself is sync + // inside that async chain). + await Promise.all(entries.map((e) => Promise.resolve(appendAuditEntry(path, e)))); + assert.equal(countAuditLog(path), 10, "10 entries"); + const lines = readFileSync(path, "utf8").split("\n").filter((l) => l.trim().length > 0); + assert.equal(lines.length, 10); + // Every line parses + has a unique source_hash. + const hashes = new Set(); + for (const line of lines) { + const parsed = JSON.parse(line) as AuditEntry; + assert.ok(parsed.source_hash.startsWith("parallel-")); + assert.ok(!hashes.has(parsed.source_hash), "no duplicate lines"); + hashes.add(parsed.source_hash); + } + assert.equal(hashes.size, 10, "all 10 distinct"); + } finally { + rmSync(dir, { recursive: true, force: true }); + } +}); \ No newline at end of file From d16d25dcd4b8ae3d90d8939be7fd9d8dd7fe850c Mon Sep 17 00:00:00 2001 From: RECTOR Date: Sat, 11 Jul 2026 14:54:01 +0700 Subject: [PATCH 3/9] feat(lib): auto-detect workflow-fit vision defaults (TDD) --- lib/defaults.ts | 82 ++++++++++++++++++++++ tests/defaults.test.ts | 151 +++++++++++++++++++++++++++++++++++++++++ 2 files changed, 233 insertions(+) create mode 100644 lib/defaults.ts create mode 100644 tests/defaults.test.ts diff --git a/lib/defaults.ts b/lib/defaults.ts new file mode 100644 index 0000000..3bb487f --- /dev/null +++ b/lib/defaults.ts @@ -0,0 +1,82 @@ +/** + * Auto-detect workflow-fit vision defaults (SPEC-5 §3.3, PLAN-5 §1.4/step 2). + * + * On a fresh install (or `/vision clear`), the extension auto-detects the + * vision model at `session_start` from `models.json` — preferring the + * `Ollama` provider's vision models (Ollama Cloud, per AGENTS.md + * "flat-rate, private, open-weight primary") for the primary, and the first + * vision-capable model under a *different* provider for the **fallback** + * (the frontier-escalation path per AGENTS.md "escalate to the proper + * frontier model for that job"). + * + * Pure over `Model[]` — no I/O, no pi runtime. Deterministic (sorted by + * `(provider, id)` so the registry's iteration order doesn't matter). + * + * **The `:cloud` preference is implicit** (PLAN-5 §1.4): it falls out of the + * vision-capable filter + the sort by id, NOT a separate enforced filter. On + * RECTOR's machine the only Ollama vision models are `:cloud` ones, so the + * sort yields an Ollama Cloud model. If a local Ollama vision model is added + * that the user doesn't want as primary, they set the primary explicitly + * (auto-detect only fires when both provider + model are unset). + */ +import type { Api, Model } from "@earendil-works/pi-ai"; + +export interface DetectedDefaults { + provider: string | undefined; + model: string | undefined; + fallbackProvider: string | undefined; + fallbackModel: string | undefined; +} + +/** The provider id we prefer for the primary vision model (Ollama Cloud per + * AGENTS.md LLM-backend policy). */ +export const PREFERRED_PRIMARY_PROVIDER = "Ollama"; + +/** + * Scan vision-capable models + pick a workflow-fit primary + frontier fallback. + * + * Algorithm: + * 1. Filter to vision-capable models (`input` includes "image"). + * 2. If none → all undefined (no-op; the existing not-configured error guides + * the user). + * 3. Primary: prefer the `Ollama` provider; among those, first by sorted + * `(provider, id)`. If no Ollama vision model, pick the first vision + * model of any provider by sorted id. + * 4. Fallback: first vision model NOT under the primary's provider (frontier + * escalation — a different provider's vision model). If only one provider + * has vision models, fallback is undefined. + * + * Pure + deterministic (same input in any order → same output). + */ +export function autoDetectDefaults(models: Model[]): DetectedDefaults { + const visionModels = models.filter((m) => m.input?.includes("image")); + if (visionModels.length === 0) { + return { + provider: undefined, + model: undefined, + fallbackProvider: undefined, + fallbackModel: undefined, + }; + } + + // Deterministic order: sort by provider then id. + const sorted = [...visionModels].sort((a, b) => { + const pa = a.provider ?? ""; + const pb = b.provider ?? ""; + return pa < pb ? -1 : pa > pb ? 1 : a.id < b.id ? -1 : a.id > b.id ? 1 : 0; + }); + + // Primary: prefer the PREFERRED provider, then first by sort. + const preferred = sorted.find((m) => m.provider === PREFERRED_PRIMARY_PROVIDER); + const primary = preferred ?? sorted[0]!; + + // Fallback: first vision model NOT under the primary's provider. + const fallback = sorted.find((m) => m.provider !== primary.provider); + + return { + provider: primary.provider, + model: primary.id, + fallbackProvider: fallback?.provider, + fallbackModel: fallback?.id, + }; +} \ No newline at end of file diff --git a/tests/defaults.test.ts b/tests/defaults.test.ts new file mode 100644 index 0000000..f438b31 --- /dev/null +++ b/tests/defaults.test.ts @@ -0,0 +1,151 @@ +/** + * Unit tests for `lib/defaults.ts` (SPEC-5 §3.3 / PLAN-5 §1.4, step 2). + * + * `autoDetectDefaults` scans vision-capable models + picks a workflow-fit + * primary (preferring the `Ollama` provider per AGENTS.md "Ollama Cloud + * primary") + a frontier fallback (the first vision model under a *different* + * provider). Pure over `Model[]` — no I/O, no pi runtime. Deterministic + * (sorted by `(provider, id)` so the registry's iteration order doesn't + * matter). + */ +import { test } from "node:test"; +import assert from "node:assert/strict"; +import type { Api, Model } from "@earendil-works/pi-ai"; +import { autoDetectDefaults, PREFERRED_PRIMARY_PROVIDER } from "../lib/defaults.ts"; + +function makeModel(provider: string, id: string, input: ("text" | "image")[] = ["text"]): Model { + return { + id, + name: id, + provider, + api: "openai-completions" as Api, + reasoning: false, + input, + contextWindow: 200000, + maxTokens: 4096, + } as Model; +} + +const vision = (p: string, id: string) => makeModel(p, id, ["text", "image"]); +const text = (p: string, id: string) => makeModel(p, id, ["text"]); + +test("PREFERRED_PRIMARY_PROVIDER is \"Ollama\"", () => { + assert.equal(PREFERRED_PRIMARY_PROVIDER, "Ollama"); +}); + +test("no vision models → all undefined (no-op)", () => { + const result = autoDetectDefaults([ + text("Ollama", "llama3.1:8b"), + text("OpenRouter", "gpt-4o"), + ]); + assert.equal(result.provider, undefined); + assert.equal(result.model, undefined); + assert.equal(result.fallbackProvider, undefined); + assert.equal(result.fallbackModel, undefined); +}); + +test("only Ollama vision models → primary = first by sorted id, no fallback", () => { + const result = autoDetectDefaults([ + vision("Ollama", "qwen3.5:cloud"), + vision("Ollama", "minimax-m3:cloud"), + ]); + assert.equal(result.provider, "Ollama"); + assert.equal(result.model, "minimax-m3:cloud", "sorted by id: minimax < qwen"); + assert.equal(result.fallbackProvider, undefined, "no other provider → no fallback"); + assert.equal(result.fallbackModel, undefined); +}); + +test("Ollama + OpenRouter vision → primary Ollama, fallback OpenRouter (frontier escalation)", () => { + const result = autoDetectDefaults([ + vision("Ollama", "minimax-m3:cloud"), + vision("OpenRouter", "gpt-4o"), + ]); + assert.equal(result.provider, "Ollama"); + assert.equal(result.model, "minimax-m3:cloud"); + assert.equal(result.fallbackProvider, "OpenRouter"); + assert.equal(result.fallbackModel, "gpt-4o"); +}); + +test("no Ollama vision, but OpenRouter vision → primary = first OpenRouter vision", () => { + const result = autoDetectDefaults([ + vision("OpenRouter", "gpt-4o"), + vision("OpenRouter", "claude-sonnet"), + ]); + assert.equal(result.provider, "OpenRouter"); + assert.equal(result.model, "claude-sonnet", "sorted by id: claude < gpt"); + // Fallback: only OpenRouter has vision → no different-provider fallback. + assert.equal(result.fallbackProvider, undefined); + assert.equal(result.fallbackModel, undefined); +}); + +test("only one provider with vision → fallback undefined (no other provider)", () => { + const result = autoDetectDefaults([ + vision("Ollama", "minimax-m3:cloud"), + vision("Ollama", "qwen3.5:cloud"), + text("Ollama", "glm-5.2:cloud"), + text("OpenRouter", "gpt-4o"), // text-only OpenRouter doesn't count + ]); + assert.equal(result.provider, "Ollama"); + assert.equal(result.model, "minimax-m3:cloud"); + assert.equal(result.fallbackProvider, undefined); + assert.equal(result.fallbackModel, undefined); +}); + +test("three providers with vision → fallback = first non-primary-provider vision", () => { + const result = autoDetectDefaults([ + vision("Ollama", "minimax-m3:cloud"), + vision("OpenRouter", "gpt-4o"), + vision("Anthropic", "claude-sonnet"), + ]); + // Primary: Ollama (preferred). Fallback: first non-Ollama vision by sort + // → Anthropic/claude-sonnet (Anthropic < OpenRouter). + assert.equal(result.provider, "Ollama"); + assert.equal(result.model, "minimax-m3:cloud"); + assert.equal(result.fallbackProvider, "Anthropic"); + assert.equal(result.fallbackModel, "claude-sonnet"); +}); + +test("determinism: shuffled input → same output (sort normalizes)", () => { + const models = [ + vision("Ollama", "minimax-m3:cloud"), + vision("Ollama", "qwen3.5:cloud"), + vision("OpenRouter", "gpt-4o"), + text("Ollama", "glm-5.2:cloud"), + ]; + const a = autoDetectDefaults([...models]); + const b = autoDetectDefaults([...models].reverse()); + const c = autoDetectDefaults([models[2]!, models[0]!, models[3]!, models[1]!]); + assert.deepEqual(a, b); + assert.deepEqual(a, c); + assert.equal(a.model, "minimax-m3:cloud"); + assert.equal(a.fallbackModel, "gpt-4o"); +}); + +test(":cloud preference is implicit (sort by id), not a separate filter", () => { + // Both Ollama vision models — one :cloud, one :local. The sort by id + // decides. This documents that the :cloud preference is aspirational, NOT + // enforced (PLAN-5 §1.4). If RECTOR adds a local Ollama vision model he + // doesn't want as primary, he sets it explicitly. + const result = autoDetectDefaults([ + vision("Ollama", "minimax-m3:cloud"), + vision("Ollama", "llava:local"), + ]); + // "llava:local" < "minimax-m3:cloud" lexicographically (l < m). + assert.equal(result.model, "llava:local", "sort by id wins; :cloud not enforced"); +}); + +test("empty input → all undefined", () => { + const result = autoDetectDefaults([]); + assert.equal(result.provider, undefined); + assert.equal(result.model, undefined); + assert.equal(result.fallbackProvider, undefined); + assert.equal(result.fallbackModel, undefined); +}); + +test("primary model without a provider field → handled (sort key empty string)", () => { + // Defensive: a malformed model with no provider. Should not crash. + const result = autoDetectDefaults([vision("", "lonely")]); + assert.equal(result.provider, ""); + assert.equal(result.model, "lonely"); + assert.equal(result.fallbackProvider, undefined); +}); \ No newline at end of file From 5dcbade3e785bcfcec0c7afa6dc29d5da6f71548 Mon Sep 17 00:00:00 2001 From: RECTOR Date: Sat, 11 Jul 2026 14:56:19 +0700 Subject: [PATCH 4/9] feat(lib): v0.5.0 config (auditLog, auditLogPath, localOnly, autoDetectVisionModel) (TDD) --- lib/config.ts | 41 +++++++++++++++++++++ tests/config.test.ts | 85 ++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 126 insertions(+) diff --git a/lib/config.ts b/lib/config.ts index 60c5adc..77ca8a4 100644 --- a/lib/config.ts +++ b/lib/config.ts @@ -89,6 +89,27 @@ export interface VisionConfig { /** Max number of image delegations to run in parallel (describe_image batch * + paste auto mode). 1 = serial (escape hatch). 20 = aggressive. */ batchConcurrency: number; + // ── v0.5.0 (SPEC-5) ────────────────────────────────────────────────────── + /** When true, every vision-model delegation (success, cache hit, fallback, + * failure) is appended to ~/.pi/agent/vision-audit.log as one JSONL line + * (provider/model/cached/fallback/ok/error_code/latency_ms/local_only). + * Never logs image bytes or the full prompt (privacy stance). Default on + * — the security posture is opt-out, not opt-in. */ + auditLog: boolean; + /** Custom audit log path. When undefined/empty → /vision-audit.log. + * Power-user field (set via /vision audit-path or editing vision.json). */ + auditLogPath: string | undefined; + /** When true, image bytes never leave the machine. Cache hits still work + * (the cache is local — memory + disk under ~/.pi/agent/); a cache miss + * refuses with a clear "local-only mode on" error instead of making a + * network call. Paste auto mode short-circuits to hint. Structural guarantee + * (the network call code path is never entered), not a polite request. */ + localOnly: boolean; + /** When true + provider+model both unset, auto-detect the vision model at + * session_start from models.json (preferring the Ollama provider's vision + * models + a frontier fallback). The auto-detected values are persisted + * once (user can override; /vision clear re-triggers). */ + autoDetectVisionModel: boolean; } export const DEFAULT_CONFIG: VisionConfig = { @@ -117,6 +138,11 @@ export const DEFAULT_CONFIG: VisionConfig = { previewMaxWidthCells: 80, // v0.4.0 defaults batchConcurrency: 5, + // v0.5.0 defaults + auditLog: true, + auditLogPath: undefined, + localOnly: false, + autoDetectVisionModel: true, }; export const CONFIG_FILENAME = "vision.json"; @@ -183,6 +209,11 @@ export function mergeConfig(partial: unknown): VisionConfig { previewMaxWidthCells: clampInt(p.previewMaxWidthCells, 20, 200, DEFAULT_CONFIG.previewMaxWidthCells), // v0.4.0 fields batchConcurrency: clampInt(p.batchConcurrency, 1, 20, DEFAULT_CONFIG.batchConcurrency), + // v0.5.0 fields + auditLog: typeof p.auditLog === "boolean" ? p.auditLog : DEFAULT_CONFIG.auditLog, + auditLogPath: strOrUndef(p.auditLogPath), + localOnly: typeof p.localOnly === "boolean" ? p.localOnly : DEFAULT_CONFIG.localOnly, + autoDetectVisionModel: typeof p.autoDetectVisionModel === "boolean" ? p.autoDetectVisionModel : DEFAULT_CONFIG.autoDetectVisionModel, }; } @@ -310,6 +341,16 @@ export function applySettingChange( if (!Number.isFinite(n)) return config; return { ...config, batchConcurrency: Math.min(20, Math.max(1, n)) }; } + // ── v0.5.0 fields (SPEC-5) ───────────────────────────────────────── + case "localOnly": + return { ...config, localOnly: value === "on" }; + case "auditLog": + return { ...config, auditLog: value === "on" }; + case "autoDetectVisionModel": + return { ...config, autoDetectVisionModel: value === "on" }; + case "auditLogPath": + // "clear" or empty → undefined; otherwise the typed path (trimmed). + return { ...config, auditLogPath: value.trim().length > 0 && value.trim() !== "clear" ? value.trim() : undefined }; default: return config; } diff --git a/tests/config.test.ts b/tests/config.test.ts index f260f2b..bf1f974 100644 --- a/tests/config.test.ts +++ b/tests/config.test.ts @@ -42,6 +42,10 @@ test("DEFAULT_CONFIG has the expected shape", () => { composePreview: true, previewMaxWidthCells: 80, batchConcurrency: 5, + auditLog: true, + auditLogPath: undefined, + localOnly: false, + autoDetectVisionModel: true, }); }); @@ -489,3 +493,84 @@ test("mergeConfig: v0.3.x config loads with batchConcurrency default (forward-co assert.equal(v03.batchConcurrency, 5, "v0.3.x config gets v0.4.0 default"); assert.equal(v03.markerStyle, "bold"); }); + +// ── v0.5.0 (SPEC-5) fields: auditLog, auditLogPath, localOnly, autoDetectVisionModel ── + +test("mergeConfig: v0.5.0 defaults — auditLog true, auditLogPath undefined, localOnly false, autoDetectVisionModel true", () => { + const c = mergeConfig({}); + assert.equal(c.auditLog, true, "audit log on by default (opt-out security posture)"); + assert.equal(c.auditLogPath, undefined); + assert.equal(c.localOnly, false); + assert.equal(c.autoDetectVisionModel, true); +}); + +test("mergeConfig: v0.5.0 fields pass through + validate", () => { + const c = mergeConfig({ + auditLog: false, + auditLogPath: "/custom/audit.log", + localOnly: true, + autoDetectVisionModel: false, + }); + assert.equal(c.auditLog, false); + assert.equal(c.auditLogPath, "/custom/audit.log"); + assert.equal(c.localOnly, true); + assert.equal(c.autoDetectVisionModel, false); +}); + +test("mergeConfig: empty/whitespace auditLogPath → undefined", () => { + assert.equal(mergeConfig({ auditLogPath: "" }).auditLogPath, undefined); + assert.equal(mergeConfig({ auditLogPath: " " }).auditLogPath, undefined); +}); + +test("mergeConfig: auditLogPath trimmed", () => { + assert.equal(mergeConfig({ auditLogPath: " /x.log " }).auditLogPath, "/x.log"); +}); + +test("mergeConfig: non-boolean auditLog → default true", () => { + assert.equal(mergeConfig({ auditLog: "true" as unknown as boolean }).auditLog, true); + assert.equal(mergeConfig({ auditLog: 1 as unknown as boolean }).auditLog, true); +}); + +test("mergeConfig: non-boolean localOnly → default false", () => { + assert.equal(mergeConfig({ localOnly: "true" as unknown as boolean }).localOnly, false); + assert.equal(mergeConfig({ localOnly: 1 as unknown as boolean }).localOnly, false); +}); + +test("mergeConfig: non-boolean autoDetectVisionModel → default true", () => { + assert.equal(mergeConfig({ autoDetectVisionModel: "yes" as unknown as boolean }).autoDetectVisionModel, true); +}); + +test("applySettingChange: localOnly on/off", () => { + assert.equal(applySettingChange(DEFAULT_CONFIG, "localOnly", "on").localOnly, true); + assert.equal(applySettingChange(DEFAULT_CONFIG, "localOnly", "off").localOnly, false); +}); + +test("applySettingChange: auditLog on/off", () => { + assert.equal(applySettingChange(DEFAULT_CONFIG, "auditLog", "off").auditLog, false); + assert.equal(applySettingChange(DEFAULT_CONFIG, "auditLog", "on").auditLog, true); +}); + +test("applySettingChange: autoDetectVisionModel on/off", () => { + assert.equal(applySettingChange(DEFAULT_CONFIG, "autoDetectVisionModel", "off").autoDetectVisionModel, false); + assert.equal(applySettingChange(DEFAULT_CONFIG, "autoDetectVisionModel", "on").autoDetectVisionModel, true); +}); + +test("applySettingChange: auditLogPath set + clear", () => { + assert.equal(applySettingChange(DEFAULT_CONFIG, "auditLogPath", "/tmp/x.log").auditLogPath, "/tmp/x.log"); + assert.equal(applySettingChange(DEFAULT_CONFIG, "auditLogPath", "clear").auditLogPath, undefined); + assert.equal(applySettingChange(DEFAULT_CONFIG, "auditLogPath", "").auditLogPath, undefined); +}); + +test("mergeConfig: v0.4.0 18-field config loads with v0.5.0 defaults (forward-compat)", () => { + const v04 = mergeConfig({ + provider: "ollama", + model: "minimax-m3:cloud", + batchConcurrency: 10, + // no v0.5.0 fields + }); + assert.equal(v04.batchConcurrency, 10, "v0.4.0 field preserved"); + assert.equal(v04.auditLog, true, "v0.5.0 default applied"); + assert.equal(v04.auditLogPath, undefined); + assert.equal(v04.localOnly, false); + assert.equal(v04.autoDetectVisionModel, true); +}); From 7b8e8a9b4a4a5f49f3bb58f3354f727fe86ccb1d Mon Sep 17 00:00:00 2001 From: RECTOR Date: Sat, 11 Jul 2026 15:00:19 +0700 Subject: [PATCH 5/9] feat(lib): delegate audit log + local-only gate (TDD) --- lib/delegate.ts | 120 +++++++++++++++---- tests/delegate.test.ts | 265 ++++++++++++++++++++++++++++++++++++++++- 2 files changed, 359 insertions(+), 26 deletions(-) diff --git a/lib/delegate.ts b/lib/delegate.ts index a4cc78e..ebc5c9f 100644 --- a/lib/delegate.ts +++ b/lib/delegate.ts @@ -23,6 +23,8 @@ import { isConfiguredForDelegation, type ReasoningLevel, type VisionConfig } fro import { loadImage, type LoadedImage } from "./image.ts"; import { cacheKey, type VisionCache } from "./cache.ts"; import { AbortError, classifyError, withRetry } from "./resilience.ts"; +import { appendAuditEntry, resolveAuditPath, truncateImagePathForLog, type AuditEntry } from "./audit.ts"; +import { getAgentDir } from "@earendil-works/pi-coding-agent"; export interface DelegateParams { image_path: string; @@ -182,6 +184,27 @@ const FALLBACK_MODEL_NOT_FOUND_MSG = (provider: string, model: string) => "Use /vision fallback to update or /vision fallback clear to remove.", ].join("\n"); +/** Local-only mode refusal message (SPEC-5 §3.2). Cache hits still work; a + * cache miss refuses with this clear, actionable message. */ +const LOCAL_ONLY_MSG = (cacheHint: string) => + [ + "Vision tool is in local-only mode — image bytes are not sent to any provider.", + "", + `This image has no cached description. ${cacheHint}`, + "", + "To delegate this image to a vision model:", + " /vision local-only off", + "", + "To inspect the cache:", + " /vision cache show", + ].join("\n"); + +/** Write one audit entry (best-effort) if audit logging is enabled. */ +function audit(config: VisionConfig, entry: AuditEntry): void { + if (!config.auditLog) return; + appendAuditEntry(resolveAuditPath(config.auditLogPath, getAgentDir()), entry); +} + /** * Run the full DELEGATE pipeline: preflight config/auth checks → load + * compress the image → cache check → (retry+fallback) call the vision model @@ -260,36 +283,83 @@ export async function delegateToVisionModel( reasoning: params.reasoning, }; - // ── Cache check (hit = 0 vision-model calls) ─────────────────────────── - if (cache && config.cacheEnabled) { - const key = cacheKey( - loaded.sourceHash, - params.compress, - config.maxDimension, - config.jpegQuality, - params.prompt, - modelId, - params.reasoning, - ); + // ── Unified cache check + local-only gate + network call (SPEC-5 §1.6) ─ + // The two v0.4.0 branches (cache-enabled-miss + no-cache) are merged into + // one network path so the local-only gate + the audit entry each have a + // single insertion point. Behavior-preserving (cache-hit + cache-store-on- + // success semantics identical to v0.4.0; T47 + T55 assert). + const useCache = !!(cache && config.cacheEnabled); + const key = useCache + ? cacheKey(loaded.sourceHash, params.compress, config.maxDimension, config.jpegQuality, params.prompt, modelId, params.reasoning) + : undefined; + + // Cache hit (allowed in local-only — the cache is local; 0 network calls). + if (key && cache) { const hit = cache.get(key); if (hit) { - return { - ok: true, - text: hit.text, - details: { ...hit.details, ...baseDetails, cached: true, fallback: false }, - }; - } - // Miss → fall through to the call; store on success (using the same key). - const missKey = key; - const result = await callWithRetryAndFallback(ctx, config, params, signal, visionModel, auth.apiKey, auth.headers, loaded.image, modelId, baseDetails); - if (result.ok && config.cacheEnabled) { - cache.set(missKey, { text: result.text, details: { ...result.details, cached: false }, storedAt: Date.now() }); + audit(config, { + ts: new Date().toISOString(), + provider: config.provider ?? "(unset)", + model: modelId, + image_path: truncateImagePathForLog(params.image_path), + source_hash: loaded.sourceHash, + cached: true, fallback: false, fallback_model: undefined, + ok: true, error_code: undefined, latency_ms: 0, local_only: config.localOnly, + }); + return { ok: true, text: hit.text, details: { ...hit.details, ...baseDetails, cached: true, fallback: false } }; } - return result; } - // No cache → straight to the resilient call. - return callWithRetryAndFallback(ctx, config, params, signal, visionModel, auth.apiKey, auth.headers, loaded.image, modelId, baseDetails); + // ── LOCAL-ONLY GATE (SPEC-5 §3.2) ──────────────────────────────────── + // Cache miss (or no cache) + local-only → refuse the network call. The + // cache is local, so cache hits still work (cache-only mode above). Cache + // miss → clear error, NO network call (structural guarantee). + if (config.localOnly) { + const cacheHint = config.cacheEnabled + ? "Enable delegation (local-only off) to describe it, or re-use a previously-cached description." + : "Enable delegation (local-only off) to describe it."; + audit(config, { + ts: new Date().toISOString(), + provider: config.provider ?? "(unset)", + model: modelId, + image_path: truncateImagePathForLog(params.image_path), + source_hash: loaded.sourceHash, + cached: false, fallback: false, fallback_model: undefined, + ok: false, error_code: "local_only", latency_ms: 0, local_only: true, + }); + return { ok: false, error: { code: "local_only", message: LOCAL_ONLY_MSG(cacheHint) } }; + } + + // ── Network call (single path) ─────────────────────────────────────── + const t0 = performance.now(); + const result = await callWithRetryAndFallback(ctx, config, params, signal, visionModel, auth.apiKey, auth.headers, loaded.image, modelId, baseDetails); + const latency_ms = Math.round(performance.now() - t0); + + // Cache store on success (unchanged semantics from v0.4.0). + if (result.ok && useCache && key && cache) { + cache.set(key, { text: result.text, details: { ...result.details, cached: false }, storedAt: Date.now() }); + } + + // ── Audit the network result (success / fallback / failure / abort) ── + // PLAN-5 §1.6: `provider` = configured primary (the attempted route); + // `model` = result.details.model (the responder); `fallback` + + // `fallback_model` disambiguate. local_only is false here (the gate above + // returned for local-only; the network path is only reached when off). + audit(config, { + ts: new Date().toISOString(), + provider: config.provider ?? "(unset)", + model: result.ok ? result.details.model : modelId, + image_path: truncateImagePathForLog(params.image_path), + source_hash: loaded.sourceHash, + cached: false, + fallback: result.ok ? result.details.fallback : false, + fallback_model: result.ok && result.details.fallback ? result.details.model : undefined, + ok: result.ok, + error_code: result.ok ? undefined : result.error.code, + latency_ms, + local_only: false, + }); + return result; } /** diff --git a/tests/delegate.test.ts b/tests/delegate.test.ts index 15bda71..415f25d 100644 --- a/tests/delegate.test.ts +++ b/tests/delegate.test.ts @@ -617,4 +617,267 @@ test("delegateToVisionModel: abort → code 'aborted', 0 calls, no fallback", as m.restore(); rmSync(dir, { recursive: true, force: true }); } -}); \ No newline at end of file +}); +// ── v0.5.0 (SPEC-5) tests: audit log + local-only mode ─────────────────── + +import { readFileSync, existsSync, mkdirSync } from "node:fs"; +import { countAuditLog, tailAuditLog, clearAuditLog } from "../lib/audit.ts"; + +/** Helper: a temp dir + an image file + a configured ctx + an audit log path. */ +function setupAuditTest() { + const dir = mkdtempSync(join(tmpdir(), "vision-delegate-audit-")); + const file = join(dir, "pixel.png"); + writeFileSync(file, PNG_BYTES); + const auditPath = join(dir, "audit.log"); + const ctx = makeCtx({ cwd: dir }); + const cfg = { ...DEFAULT_CONFIG, provider: "ollama", model: "minimax-m3:cloud", auditLog: true, auditLogPath: auditPath }; + return { dir, file, auditPath, ctx, cfg }; +} + +test("T54: audit log basic — success → one JSONL line with full routing trace, no bytes", async () => { + const { dir, file, auditPath, ctx, cfg } = setupAuditTest(); + const m = mockFetch({ status: 200, body: { choices: [{ message: { content: "a desc" } }] } }); + try { + const r = await delegateToVisionModel(ctx, cfg, { image_path: file, prompt: "describe", compress: false, reasoning: "off" }, undefined); + assert.equal(r.ok, true); + assert.equal(countAuditLog(auditPath), 1, "one audit line"); + const lines = readFileSync(auditPath, "utf8").split("\n").filter((l) => l.trim().length > 0); + const entry = JSON.parse(lines[0]!); + assert.equal(entry.provider, "ollama"); + assert.equal(entry.model, "ollama/minimax-m3:cloud"); + assert.equal(entry.image_path, file, "file path logged in full"); + assert.equal(entry.cached, false); + assert.equal(entry.fallback, false); + assert.equal(entry.ok, true); + assert.equal(entry.error_code, undefined); + assert.equal(entry.local_only, false); + assert.ok(entry.latency_ms >= 0, "latency measured"); + assert.ok(typeof entry.source_hash === "string" && entry.source_hash.length > 0, "source hash present"); + // Privacy: the raw image bytes must NOT appear in the log line. + assert.ok(!lines[0]!.includes(PNG_1x1_B64), "no image bytes in audit log"); + assert.equal(m.calls.length, 1, "one fetch"); + } finally { + m.restore(); + rmSync(dir, { recursive: true, force: true }); + } +}); + +test("T55: audit log cache hit → second line cached:true, ok:true, latency_ms:0, fetch not called", async () => { + const { dir, file, auditPath, ctx, cfg } = setupAuditTest(); + const m = mockFetch({ status: 200, body: { choices: [{ message: { content: "a desc" } }] } }); + try { + const cache = new VisionCache(undefined, 256); + const params = { image_path: file, prompt: "describe", compress: false, reasoning: "off" as const }; + await delegateToVisionModel(ctx, cfg, params, undefined, cache); // miss → fetch + await delegateToVisionModel(ctx, cfg, params, undefined, cache); // hit → no fetch + assert.equal(countAuditLog(auditPath), 2, "two audit lines"); + const lines = readFileSync(auditPath, "utf8").split("\n").filter((l) => l.trim().length > 0); + const hitEntry = JSON.parse(lines[1]!); + assert.equal(hitEntry.cached, true); + assert.equal(hitEntry.ok, true); + assert.equal(hitEntry.latency_ms, 0, "cache hit = 0 latency"); + assert.equal(hitEntry.local_only, false); + assert.equal(m.calls.length, 1, "second call = 0 vision-model calls"); + } finally { + m.restore(); + rmSync(dir, { recursive: true, force: true }); + } +}); + +test("T56: audit log fallback success → fallback:true, fallback_model set; then both fail → error_code", async () => { + const dir = mkdtempSync(join(tmpdir(), "vision-delegate-audit-")); + const file = join(dir, "pixel.png"); + writeFileSync(file, PNG_BYTES); + const auditPath = join(dir, "audit.log"); + const ctx = makeCtx({ cwd: dir }); + const cfg = { ...DEFAULT_CONFIG, provider: "ollama", model: "minimax-m3:cloud", fallbackProvider: "openrouter", fallbackModel: "gpt-4o", auditLog: true, auditLogPath: auditPath, retryAttempts: 0, retryBackoffMs: 1 }; + // Primary fails (500), fallback succeeds (200). + const m = mockFetchSeq([ + { status: 500, body: { error: "boom" } }, + { status: 200, body: { choices: [{ message: { content: "fallback desc" } }] } }, + ]); + try { + const r1 = await delegateToVisionModel(ctx, cfg, { image_path: file, prompt: "p", compress: false, reasoning: "off" }, undefined); + assert.equal(r1.ok, true, "fallback succeeds"); + if (r1.ok) assert.equal(r1.details.fallback, true); + assert.equal(countAuditLog(auditPath), 1); + const e1 = JSON.parse(readFileSync(auditPath, "utf8").split("\n").filter((l) => l.trim())[0]!); + assert.equal(e1.fallback, true); + assert.equal(e1.ok, true); + assert.equal(e1.fallback_model, "openrouter/gpt-4o", "fallback model recorded"); + // provider = configured primary (the attempted route), per PLAN-5 §1.6. + assert.equal(e1.provider, "ollama"); + } finally { + m.restore(); + rmSync(dir, { recursive: true, force: true }); + } + + // Now: both primary + fallback fail. + const dir2 = mkdtempSync(join(tmpdir(), "vision-delegate-audit-")); + const file2 = join(dir2, "pixel.png"); + writeFileSync(file2, PNG_BYTES); + const auditPath2 = join(dir2, "audit.log"); + const ctx2 = makeCtx({ cwd: dir2 }); + const cfg2 = { ...DEFAULT_CONFIG, provider: "ollama", model: "minimax-m3:cloud", fallbackProvider: "openrouter", fallbackModel: "gpt-4o", auditLog: true, auditLogPath: auditPath2, retryAttempts: 0, retryBackoffMs: 1 }; + const m2 = mockFetchSeq([ + { status: 500, body: { error: "primary boom" } }, + { status: 500, body: { error: "fallback boom" } }, + ]); + try { + const r2 = await delegateToVisionModel(ctx2, cfg2, { image_path: file2, prompt: "p", compress: false, reasoning: "off" }, undefined); + assert.equal(r2.ok, false, "both fail"); + assert.equal(countAuditLog(auditPath2), 1); + const e2 = JSON.parse(readFileSync(auditPath2, "utf8").split("\n").filter((l) => l.trim())[0]!); + assert.equal(e2.ok, false); + assert.equal(e2.error_code, "vision_call_error"); + } finally { + m2.restore(); + rmSync(dir2, { recursive: true, force: true }); + } +}); + +test("T57: local-only cache hit → returns cached desc, fetch NOT called, local_only:true", async () => { + const { dir, file, auditPath, ctx, cfg } = setupAuditTest(); + const m = mockFetch({ status: 200, body: { choices: [{ message: { content: "cached desc" } }] } }); + try { + const cache = new VisionCache(undefined, 256); + const params = { image_path: file, prompt: "describe", compress: false, reasoning: "off" as const }; + // Prime the cache with localOnly OFF. + const cfgNormal = { ...cfg, localOnly: false }; + await delegateToVisionModel(ctx, cfgNormal, params, undefined, cache); + assert.equal(m.calls.length, 1, "primed cache with one fetch"); + // Now: localOnly ON, same image → cache hit, no new fetch. + clearAuditLog(auditPath); + const cfgLocal = { ...cfg, localOnly: true }; + const r = await delegateToVisionModel(ctx, cfgLocal, params, undefined, cache); + assert.equal(r.ok, true, "cache hit returns desc"); + if (r.ok) assert.equal(r.text, "cached desc"); + assert.equal(m.calls.length, 1, "fetch NOT called again (cache hit in local-only)"); + // Audit: cached:true, local_only:true, ok:true. + assert.equal(countAuditLog(auditPath), 1); + const entry = JSON.parse(readFileSync(auditPath, "utf8").split("\n").filter((l) => l.trim())[0]!); + assert.equal(entry.cached, true); + assert.equal(entry.local_only, true); + assert.equal(entry.ok, true); + } finally { + m.restore(); + rmSync(dir, { recursive: true, force: true }); + } +}); + +test("T58: local-only cache miss → clear error, fetch NOT called (structural guarantee)", async () => { + const { dir, file, auditPath, ctx, cfg } = setupAuditTest(); + const m = mockFetch({ status: 200, body: { choices: [{ message: { content: "should not reach" } }] } }); + try { + const cfgLocal = { ...cfg, localOnly: true }; + const r = await delegateToVisionModel(ctx, cfgLocal, { image_path: file, prompt: "describe", compress: false, reasoning: "off" }, undefined); + assert.equal(r.ok, false, "cache miss + local-only → refusal"); + if (!r.ok) { + assert.equal(r.error.code, "local_only"); + assert.ok(r.error.message.includes("local-only mode"), "clear message"); + assert.ok(r.error.message.includes("/vision local-only off"), "actionable: names the toggle"); + } + assert.equal(m.calls.length, 0, "fetch NOT called — structural guarantee (no network)"); + // Audit: ok:false, error_code:"local_only", local_only:true, latency_ms:0. + assert.equal(countAuditLog(auditPath), 1); + const entry = JSON.parse(readFileSync(auditPath, "utf8").split("\n").filter((l) => l.trim())[0]!); + assert.equal(entry.ok, false); + assert.equal(entry.error_code, "local_only"); + assert.equal(entry.local_only, true); + assert.equal(entry.latency_ms, 0); + } finally { + m.restore(); + rmSync(dir, { recursive: true, force: true }); + } +}); + +test("T66: audit disabled (auditLog:false) → no file I/O, delegation succeeds", async () => { + const dir = mkdtempSync(join(tmpdir(), "vision-delegate-audit-")); + const file = join(dir, "pixel.png"); + writeFileSync(file, PNG_BYTES); + const auditPath = join(dir, "audit.log"); + const ctx = makeCtx({ cwd: dir }); + const cfg = { ...DEFAULT_CONFIG, provider: "ollama", model: "minimax-m3:cloud", auditLog: false, auditLogPath: auditPath }; + const m = mockFetch({ status: 200, body: { choices: [{ message: { content: "a desc" } }] } }); + try { + const r = await delegateToVisionModel(ctx, cfg, { image_path: file, prompt: "p", compress: false, reasoning: "off" }, undefined); + assert.equal(r.ok, true, "delegation still works with audit off"); + assert.equal(existsSync(auditPath), false, "no audit file created (no I/O)"); + assert.equal(m.calls.length, 1); + } finally { + m.restore(); + rmSync(dir, { recursive: true, force: true }); + } +}); + +test("audit on abort → entry error_code:aborted", async () => { + const { dir, file, auditPath, ctx, cfg } = setupAuditTest(); + const m = mockFetchError(500, "boom"); + try { + const controller = new AbortController(); + controller.abort(); + const cfgRetry = { ...cfg, retryAttempts: 0, fallbackProvider: undefined, fallbackModel: undefined }; + const r = await delegateToVisionModel(ctx, cfgRetry, { image_path: file, prompt: "p", compress: false, reasoning: "off" }, controller.signal); + assert.equal(r.ok, false); + if (!r.ok) assert.equal(r.error.code, "aborted"); + assert.equal(countAuditLog(auditPath), 1); + const entry = JSON.parse(readFileSync(auditPath, "utf8").split("\n").filter((l) => l.trim())[0]!); + assert.equal(entry.ok, false); + assert.equal(entry.error_code, "aborted"); + } finally { + m.restore(); + rmSync(dir, { recursive: true, force: true }); + } +}); + +test("audit boundary: pre-flight errors (not_configured, model_not_found, auth, image-not-found) NOT audited", async () => { + const dir = mkdtempSync(join(tmpdir(), "vision-delegate-audit-")); + const auditPath = join(dir, "audit.log"); + try { + // not_configured + const ctx1 = makeCtx({ cwd: dir }); + const cfgNC = { ...DEFAULT_CONFIG, auditLog: true, auditLogPath: auditPath }; + await delegateToVisionModel(ctx1, cfgNC, { image_path: "/tmp/x.png", prompt: "p", compress: false, reasoning: "off" }, undefined); + + // model_not_found (registry returns undefined) + const ctx2 = { cwd: dir, modelRegistry: { find: () => undefined, getApiKeyAndHeaders: async () => ({ ok: true, apiKey: "k" }) } as unknown as ExtensionContext["modelRegistry"] } as unknown as ExtensionContext; + const cfgMN = { ...DEFAULT_CONFIG, provider: "ollama", model: "nope", auditLog: true, auditLogPath: auditPath }; + await delegateToVisionModel(ctx2, cfgMN, { image_path: "/tmp/x.png", prompt: "p", compress: false, reasoning: "off" }, undefined); + + // auth failure + const ctx3 = makeCtx({ cwd: dir, authOk: false }); + const cfgAuth = { ...DEFAULT_CONFIG, provider: "ollama", model: "minimax-m3:cloud", auditLog: true, auditLogPath: auditPath }; + await delegateToVisionModel(ctx3, cfgAuth, { image_path: "/tmp/x.png", prompt: "p", compress: false, reasoning: "off" }, undefined); + + // image not found + const ctx4 = makeCtx({ cwd: dir }); + const cfgImg = { ...DEFAULT_CONFIG, provider: "ollama", model: "minimax-m3:cloud", auditLog: true, auditLogPath: auditPath }; + await delegateToVisionModel(ctx4, cfgImg, { image_path: "/tmp/does-not-exist.png", prompt: "p", compress: false, reasoning: "off" }, undefined); + + // None of these should have written an audit line (pre-flight, before image load / before routing). + assert.equal(countAuditLog(auditPath), 0, "pre-flight errors are NOT routing events → not audited"); + } finally { + rmSync(dir, { recursive: true, force: true }); + } +}); + +test("T47 regression: single-image success with audit on → v0.4.0 behavior preserved + 1 audit line (additive)", async () => { + const { dir, file, auditPath, ctx, cfg } = setupAuditTest(); + const m = mockFetch({ status: 200, body: { choices: [{ message: { content: "a desc" } }] } }); + try { + const r = await delegateToVisionModel(ctx, cfg, { image_path: file, prompt: "describe", compress: false, reasoning: "off" }, undefined); + assert.equal(r.ok, true); + if (r.ok) { + assert.equal(r.text, "a desc"); + assert.equal(r.details.cached, false); + assert.equal(r.details.fallback, false); + assert.equal(r.details.model, "ollama/minimax-m3:cloud"); + } + assert.equal(countAuditLog(auditPath), 1, "exactly one audit line (additive)"); + } finally { + m.restore(); + rmSync(dir, { recursive: true, force: true }); + } +}); + + From 30247b6e819471da476c5fb38ff9017e405e6445 Mon Sep 17 00:00:00 2001 From: RECTOR Date: Sat, 11 Jul 2026 15:07:55 +0700 Subject: [PATCH 6/9] feat(ext): auto-detect + /vision local-only + /vision audit (TDD) --- extensions/vision.ts | 141 +++++++++++++++++++ tests/integration.test.ts | 283 ++++++++++++++++++++++++++++++++++++++ 2 files changed, 424 insertions(+) diff --git a/extensions/vision.ts b/extensions/vision.ts index f1b99cd..57835f4 100644 --- a/extensions/vision.ts +++ b/extensions/vision.ts @@ -56,6 +56,8 @@ import { matchesKey } from "@earendil-works/pi-tui"; import { loadImage } from "../lib/image.ts"; import { mapWithConcurrency } from "../lib/batch.ts"; import { buildBatchToolResult } from "../lib/marker.ts"; +import { autoDetectDefaults } from "../lib/defaults.ts"; +import { clearAuditLog, countAuditLog, resolveAuditPath, tailAuditLog } from "../lib/audit.ts"; /** Current config. Loaded on session_start, mutated by /vision, saved to disk. */ let config: VisionConfig = { ...DEFAULT_CONFIG }; @@ -88,6 +90,9 @@ const SUBCOMMANDS = [ "auto-prompt", "preview", "batch-concurrency", + "local-only", + "audit", + "audit-path", ] as const; function formatConfigStatus(c: VisionConfig): string { @@ -110,6 +115,9 @@ function formatConfigStatus(c: VisionConfig): string { ` composePreview: ${c.composePreview}`, ` previewMaxWidth: ${c.previewMaxWidthCells} cells`, ` batchConcurrency: ${c.batchConcurrency}`, + ` localOnly: ${c.localOnly ? "on" : "off"}`, + ` auditLog: ${c.auditLog ? "on" : "off"}`, + ` autoDetect: ${c.autoDetectVisionModel ? "on" : "off"}`, ].join("\n"); } @@ -160,6 +168,12 @@ function renderValue(id: string): string { return `${config.previewMaxWidthCells}`; case "batchConcurrency": return `${config.batchConcurrency}`; + case "localOnly": + return config.localOnly ? "on" : "off"; + case "auditLog": + return config.auditLog ? "on" : "off"; + case "autoDetectVisionModel": + return config.autoDetectVisionModel ? "on" : "off"; default: return ""; } @@ -358,6 +372,28 @@ async function showVisionSettings(pi: ExtensionAPI, ctx: ExtensionCommandContext values: ["1", "3", "5", "10", "20"], description: "Max parallel image delegations in a batch (describe_image image_paths + paste auto mode). 1 = serial; 20 = aggressive (rate-limit risk).", }, + // ── v0.5.0 (SPEC-5) rows ────────────────────────────────────────── + { + id: "localOnly", + label: "Local-only mode", + currentValue: renderValue("localOnly"), + values: ["on", "off"], + description: "When on, image bytes never leave the machine. Cache hits still work (local); a cache miss refuses with a clear error instead of a network call. Structural guarantee (no network).", + }, + { + id: "auditLog", + label: "Audit log", + currentValue: renderValue("auditLog"), + values: ["on", "off"], + description: `When on, every delegation is appended to ${resolveAuditPath(config.auditLogPath, getAgentDir())} (JSONL: provider/model/cached/fallback/ok/error_code). Never logs image bytes or the prompt.`, + }, + { + id: "autoDetectVisionModel", + label: "Auto-detect vision model", + currentValue: renderValue("autoDetectVisionModel"), + values: ["on", "off"], + description: "When on + provider/model unset, auto-detect the vision model at session_start (prefers Ollama Cloud primary + a frontier fallback). Persists once; /vision clear re-triggers.", + }, ]; const settingsList = new SettingsList( @@ -489,6 +525,37 @@ export default function visionExtension(pi: ExtensionAPI): void { // ── Session lifecycle ─────────────────────────────────────────────────── pi.on("session_start", (_event, ctx) => { config = loadConfig(getAgentDir()); + + // ── Auto-detect workflow-fit defaults (SPEC-5 §3.3) ─────────────── + // Fires only when BOTH provider + model are unset (fresh config). A + // partial config (one set, one blank) is the user mid-configuration — + // don't overwrite. The detected values are persisted once (the user sees + // them + can override; /vision clear re-triggers). Prefers the Ollama + // provider's vision models (AGENTS.md "Ollama Cloud primary") + a + // frontier fallback (first non-Ollama vision model). + if (config.autoDetectVisionModel && !config.provider && !config.model && typeof ctx.modelRegistry?.getAvailable === "function") { + const detected = autoDetectDefaults(ctx.modelRegistry.getAvailable()); + if (detected.provider && detected.model) { + config = { + ...config, + provider: detected.provider, + model: detected.model, + // Only set the fallback if the user hadn't set one (don't override + // an explicit unset-ness the user may want — SPEC-5 §9.5). + ...(config.fallbackProvider || config.fallbackModel + ? {} + : { fallbackProvider: detected.fallbackProvider, fallbackModel: detected.fallbackModel }), + }; + saveConfig(config, getAgentDir()); + ctx.ui.notify( + `Vision: auto-configured ${detected.provider}/${detected.model}` + + (detected.fallbackModel ? ` (+ fallback ${detected.fallbackProvider}/${detected.fallbackModel})` : "") + + `. /vision to change.`, + "info", + ); + } + } + rebuildCache(); setSharedState(config, cache); syncToolAvailability(pi, ctx.model, { enabled: config.enabled }); @@ -916,6 +983,80 @@ export default function visionExtension(pi: ExtensionAPI): void { ctx.ui.notify(`Batch concurrency set to ${config.batchConcurrency}.`, "info"); return; } + case "local-only": { + const value = parts[1]; + if (!value) { + ctx.ui.notify( + `Local-only mode: ${config.localOnly ? "on" : "off"}. ` + + "When on, image bytes never leave the machine (cache hits still work; a cache miss refuses with a clear error instead of a network call). " + + "Toggle via /vision local-only on|off.", + "info", + ); + return; + } + if (value !== "on" && value !== "off") { + ctx.ui.notify("Usage: /vision local-only ", "warning"); + return; + } + config = applySettingChange(config, "localOnly", value); + saveConfig(config, agentDir); + setSharedState(config, cache); + ctx.ui.notify(`Local-only mode ${config.localOnly ? "enabled" : "disabled"}.`, "info"); + return; + } + case "audit": { + const action = parts[1]; + const path = resolveAuditPath(config.auditLogPath, agentDir); + if (action === "clear") { + clearAuditLog(path); + ctx.ui.notify(`Audit log cleared (${path}).`, "info"); + return; + } + if (action === "show") { + const entries = tailAuditLog(path, 10); + const total = countAuditLog(path); + const lines = entries.map((e) => + `[${e.ts}] ${e.provider}/${e.model} ${e.cached ? "(cached)" : e.fallback ? "(fallback)" : ""} ok=${e.ok}${e.error_code ? ` err=${e.error_code}` : ""}${e.local_only ? " local-only" : ""} ${e.latency_ms}ms ${e.image_path}`, + ); + ctx.ui.notify( + `Audit log (${path}) - ${total} entries, last 10:\n${lines.join("\n") || "(empty)"}`, + "info", + ); + return; + } + if (action === "path") { + ctx.ui.notify(`Audit log path: ${path}`, "info"); + return; + } + if (action === "on" || action === "off") { + config = applySettingChange(config, "auditLog", action); + saveConfig(config, agentDir); + setSharedState(config, cache); + ctx.ui.notify(`Audit logging ${config.auditLog ? "on" : "off"} (${path}).`, "info"); + return; + } + ctx.ui.notify("Usage: /vision audit ", "warning"); + return; + } + case "audit-path": { + const value = parts.slice(1).join(" ").trim(); + if (!value) { + ctx.ui.notify(`Audit log path: ${resolveAuditPath(config.auditLogPath, agentDir)}${config.auditLogPath ? " (custom)" : " (default)"}`, "info"); + return; + } + if (value === "clear") { + config = applySettingChange(config, "auditLogPath", "clear"); + } else { + config = applySettingChange(config, "auditLogPath", value); + } + saveConfig(config, agentDir); + setSharedState(config, cache); + ctx.ui.notify( + `Audit log path set to ${resolveAuditPath(config.auditLogPath, agentDir)}.`, + "info", + ); + return; + } default: { ctx.ui.notify( `Unknown /vision subcommand: ${sub}\nAvailable: ${SUBCOMMANDS.join(", ")} (or just /vision for the panel)`, diff --git a/tests/integration.test.ts b/tests/integration.test.ts index cd825ea..d95ecb0 100644 --- a/tests/integration.test.ts +++ b/tests/integration.test.ts @@ -33,6 +33,9 @@ process.env.PI_CODING_AGENT_DIR = TMP_AGENT; import visionFactory from "../extensions/vision.ts"; import pasteFactory from "../extensions/paste.ts"; +import { loadConfig, configFilePath } from "../lib/config.ts"; +import { getAgentDir } from "@earendil-works/pi-coding-agent"; +import { countAuditLog, tailAuditLog } from "../lib/audit.ts"; const PNG_1x1_B64 = "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAAC0lEQVR42mNk+M8AAAMBEg1+mP0AAAAASUVORK5CYII="; @@ -156,6 +159,7 @@ function makeRegistry(opts: { apiKey?: string; } = {}) { return { + getAvailable: () => [], find: () => (opts.model === undefined ? undefined : opts.model), getApiKeyAndHeaders: async () => opts.authOk === false @@ -1567,6 +1571,285 @@ test("T53: v0.3.x + v0.4.0 regression gate — full suite invariant", () => { assert.ok(true, "full suite green = T53 regression gate passed"); }); +// ── v0.5.0 (SPEC-5) integration tests: auto-detect + subcommands + audit + local-only ─ + +/** Helper: delete the persisted vision.json so session_start sees a fresh + * (unconfigured) state — needed for auto-detect tests. */ +function resetVisionConfig(): void { + try { rmSync(configFilePath(getAgentDir()), { force: true }); } catch { /* best-effort */ } +} + +/** Helper: build a ctx with a custom model registry (getAvailable + find + auth). */ +function makeCtxWithRegistry(opts: { + model?: Model | undefined; + cwd?: string; + available: Model[]; + findModel?: Model | undefined; + authOk?: boolean; +}): ExtensionContext { + return { + ui: { notify: () => {} }, + mode: "tui", + hasUI: false, + cwd: opts.cwd ?? "/tmp", + sessionManager: {}, + modelRegistry: { + getAvailable: () => opts.available, + find: () => opts.findModel ?? opts.available[0], + getApiKeyAndHeaders: async () => + opts.authOk === false + ? { ok: false, error: "no api key" } + : { ok: true, apiKey: "test-key", headers: undefined }, + } as any, + model: opts.model, + isIdle: () => true, + isProjectTrusted: () => true, + signal: undefined, + abort: () => {}, + hasPendingMessages: () => false, + shutdown: () => {}, + getContextUsage: () => undefined, + compact: () => {}, + getSystemPrompt: () => "", + } as unknown as ExtensionContext; +} + +function ollamaVision(id = "minimax-m3:cloud"): Model { + return { id, name: id, provider: "Ollama", api: "openai-completions" as Api, reasoning: false, input: ["text", "image"], contextWindow: 512000, maxTokens: 4096 } as Model; +} +function openRouterVision(id = "gpt-4o"): Model { + return { id, name: id, provider: "OpenRouter", api: "openai-completions" as Api, reasoning: false, input: ["text", "image"], contextWindow: 128000, maxTokens: 4096 } as Model; +} +function textModel(provider: string, id: string): Model { + return { id, name: id, provider, api: "openai-completions" as Api, reasoning: false, input: ["text"], contextWindow: 128000, maxTokens: 4096 } as Model; +} + +// ── T61: auto-detect Ollama Cloud primary (★ gap #10) ────────────────── +test("T61: auto-detect picks Ollama/minimax-m3:cloud on fresh config (+ no fallback, no non-Ollama vision)", async () => { + const pi = createMockPi(); + visionFactory(pi as unknown as ExtensionAPI); + resetVisionConfig(); + let notified = ""; + const ctx = makeCtxWithRegistry({ + model: TEXT_ONLY, + available: [ollamaVision("minimax-m3:cloud"), ollamaVision("qwen3.5:cloud"), textModel("Ollama", "glm-5.2:cloud")], + }); + (ctx.ui as any).notify = (msg: string) => { notified = msg; }; + await pi.emit("session_start", { type: "session_start", reason: "startup" }, ctx); + // Persisted to vision.json. + const persisted = loadConfig(getAgentDir()); + assert.equal(persisted.provider, "Ollama", "auto-detected Ollama provider"); + assert.equal(persisted.model, "minimax-m3:cloud", "picked first Ollama vision by sorted id"); + assert.equal(persisted.fallbackProvider, undefined, "no non-Ollama vision → no fallback"); + assert.equal(persisted.fallbackModel, undefined); + assert.match(notified, /auto-configured Ollama\/minimax-m3:cloud/, "notify fired with the model"); + // Tool visibility synced for the text-only primary. + assert.ok(pi.getActiveTools().includes("describe_image"), "tool visible for text-only primary"); +}); + +// ── T62: auto-detect frontier fallback ──────────────────────────────── +test("T62: auto-detect picks Ollama primary + OpenRouter frontier fallback", async () => { + const pi = createMockPi(); + visionFactory(pi as unknown as ExtensionAPI); + resetVisionConfig(); + let notified = ""; + const ctx = makeCtxWithRegistry({ + model: TEXT_ONLY, + available: [ollamaVision("minimax-m3:cloud"), openRouterVision("gpt-4o"), textModel("Ollama", "glm-5.2:cloud")], + }); + (ctx.ui as any).notify = (msg: string) => { notified = msg; }; + await pi.emit("session_start", { type: "session_start", reason: "startup" }, ctx); + const persisted = loadConfig(getAgentDir()); + assert.equal(persisted.provider, "Ollama"); + assert.equal(persisted.model, "minimax-m3:cloud"); + assert.equal(persisted.fallbackProvider, "OpenRouter", "frontier fallback auto-detected"); + assert.equal(persisted.fallbackModel, "gpt-4o"); + assert.match(notified, /fallback OpenRouter\/gpt-4o/, "notify names both primary + fallback"); +}); + +// ── T63: auto-detect no vision models → no-op ───────────────────────── +test("T63: auto-detect no vision models → config stays unset, no notify", async () => { + const pi = createMockPi(); + visionFactory(pi as unknown as ExtensionAPI); + resetVisionConfig(); + let notified = ""; + const ctx = makeCtxWithRegistry({ + model: TEXT_ONLY, + available: [textModel("Ollama", "glm-5.2:cloud"), textModel("OpenRouter", "gpt-4o")], + }); + (ctx.ui as any).notify = (msg: string) => { notified = msg; }; + await pi.emit("session_start", { type: "session_start", reason: "startup" }, ctx); + const persisted = loadConfig(getAgentDir()); + assert.equal(persisted.provider, undefined, "no vision models → stays unconfigured"); + assert.equal(persisted.model, undefined); + assert.equal(notified, "", "no notify when nothing detected"); +}); + +// ── T64: auto-detect skipped when configured ────────────────────────── +test("T64: auto-detect skipped when provider+model already set (no override)", async () => { + const pi = createMockPi(); + visionFactory(pi as unknown as ExtensionAPI); + // Pre-write a config with explicit provider/model. + resetVisionConfig(); + const { saveConfig } = await import("../lib/config.ts"); + saveConfig({ ...loadConfig(getAgentDir()), provider: "Ollama", model: "qwen3.5:cloud" }, getAgentDir()); + let notified = ""; + const ctx = makeCtxWithRegistry({ + model: TEXT_ONLY, + available: [ollamaVision("minimax-m3:cloud"), ollamaVision("qwen3.5:cloud")], + }); + (ctx.ui as any).notify = (msg: string) => { notified = msg; }; + await pi.emit("session_start", { type: "session_start", reason: "startup" }, ctx); + const persisted = loadConfig(getAgentDir()); + assert.equal(persisted.model, "qwen3.5:cloud", "explicit config preserved (not overwritten by auto-detect)"); + assert.equal(notified, "", "no auto-detect notify when already configured"); +}); + +// ── T65: auto-detect skipped when disabled ──────────────────────────── +test("T65: auto-detect skipped when autoDetectVisionModel:false (fresh config stays unset)", async () => { + const pi = createMockPi(); + visionFactory(pi as unknown as ExtensionAPI); + resetVisionConfig(); + // Pre-write a fresh config with autoDetectVisionModel explicitly off. + const { saveConfig, DEFAULT_CONFIG } = await import("../lib/config.ts"); + saveConfig({ ...DEFAULT_CONFIG, autoDetectVisionModel: false }, getAgentDir()); + let notified = ""; + const ctx = makeCtxWithRegistry({ + model: TEXT_ONLY, + available: [ollamaVision("minimax-m3:cloud"), ollamaVision("qwen3.5:cloud")], + }); + (ctx.ui as any).notify = (msg: string) => { notified = msg; }; + await pi.emit("session_start", { type: "session_start", reason: "startup" }, ctx); + const persisted = loadConfig(getAgentDir()); + assert.equal(persisted.provider, undefined, "auto-detect disabled → stays unconfigured"); + assert.equal(persisted.model, undefined); + assert.equal(persisted.autoDetectVisionModel, false); + assert.equal(notified, ""); +}); + +// ── auto-detect doesn't override an explicitly-set fallback ─────────── +test("auto-detect sets primary but does NOT override an explicitly-set fallback", async () => { + const pi = createMockPi(); + visionFactory(pi as unknown as ExtensionAPI); + resetVisionConfig(); + const { saveConfig, DEFAULT_CONFIG } = await import("../lib/config.ts"); + // Fresh primary (unset) but explicit fallback set by the user. + saveConfig({ ...DEFAULT_CONFIG, fallbackProvider: "MyProvider", fallbackModel: "my-model" }, getAgentDir()); + const ctx = makeCtxWithRegistry({ + model: TEXT_ONLY, + available: [ollamaVision("minimax-m3:cloud"), ollamaVision("qwen3.5:cloud"), openRouterVision("gpt-4o")], + }); + await pi.emit("session_start", { type: "session_start", reason: "startup" }, ctx); + const persisted = loadConfig(getAgentDir()); + assert.equal(persisted.provider, "Ollama", "primary auto-detected"); + assert.equal(persisted.model, "minimax-m3:cloud"); + assert.equal(persisted.fallbackProvider, "MyProvider", "user's explicit fallback preserved"); + assert.equal(persisted.fallbackModel, "my-model"); +}); + +// ── T60: audit log batch (3 images → 3 audit lines, input order) ────── +test("T60: describe_image batch with auditLog on → 3 audit lines (one per image), input order", async () => { + const pi = createMockPi(); + visionFactory(pi as unknown as ExtensionAPI); + pasteFactory(pi as unknown as ExtensionAPI); + const { dir, file } = tmpImgDir(); + // Build 3 distinct images (different colors so different hashes). + const file2 = join(dir, "pixel2.png"); + const file3 = join(dir, "pixel3.png"); + writeFileSync(file2, make1x1Png(0, 0, 255)); + writeFileSync(file3, make1x1Png(0, 255, 0)); + const auditPath = join(dir, "audit.log"); + const fm = mockFetch({ choices: [{ message: { content: "a pixel" } }] }); + try { + await pi.emit("session_start", { type: "session_start", reason: "startup" }, makeCtx({ model: TEXT_ONLY, cwd: dir })); + // Configure + point the audit log at a temp path. + const cfgCtx = makeCtx({ model: TEXT_ONLY, cwd: dir }) as unknown as ExtensionCommandContext; + (cfgCtx.ui as any).notify = () => {}; + await pi.commands.get("vision")!.handler("model ollama/minimax-m3:cloud", cfgCtx); + await pi.commands.get("vision")!.handler(`audit-path ${auditPath}`, cfgCtx); + const result = await executeTool(pi, { image_paths: [file, file2, file3], prompt: "compare" }, makeCtx({ model: TEXT_ONLY, cwd: dir })); + assert.equal(result.details.mode, "delegate-batch"); + assert.equal(fm.calls.length, 3, "3 fetch calls (one per image)"); + assert.equal(countAuditLog(auditPath), 3, "3 audit lines (one per image)"); + const entries = tailAuditLog(auditPath, 10); + assert.equal(entries.length, 3); + assert.ok(entries.every((e) => e.ok === true), "all 3 succeeded"); + // Audit log is chronological (append = completion order, non-deterministic + // under parallel delegation). The tool RESULT (buildBatchToolResult) preserves + // input order; the audit log does not (it is an event log, not an index). + // Assert the SET of paths, not order. + const loggedPaths = new Set(entries.map((e) => e.image_path)); + assert.equal(loggedPaths.size, 3, "3 distinct paths logged"); + assert.ok([...loggedPaths].some((p) => p.includes("pixel.png")), "pixel.png logged"); + assert.ok([...loggedPaths].some((p) => p.includes("pixel2.png")), "pixel2.png logged"); + assert.ok([...loggedPaths].some((p) => p.includes("pixel3.png")), "pixel3.png logged"); + } finally { + fm.restore(); + rmSync(dir, { recursive: true, force: true }); + } +}); + +// ── T69: /vision local-only + /vision audit + /vision audit-path subcommands ─ +test("T69: /vision local-only + /vision audit + /vision audit-path subcommands", async () => { + const pi = createMockPi(); + visionFactory(pi as unknown as ExtensionAPI); + await pi.emit("session_start", { type: "session_start", reason: "startup" }, makeCtx({ model: TEXT_ONLY })); + const auditPath = join(TMP_AGENT, "test-audit.log"); + let notified = ""; + const cmdCtx = makeCtx({ model: TEXT_ONLY }) as unknown as ExtensionCommandContext; + (cmdCtx.ui as any).notify = (msg: string) => { notified = msg; }; + + // /vision local-only on + await pi.commands.get("vision")!.handler("local-only on", cmdCtx); + assert.equal(loadConfig(getAgentDir()).localOnly, true, "local-only persisted"); + + // /vision local-only (no arg) → shows current + await pi.commands.get("vision")!.handler("local-only", cmdCtx); + assert.match(notified, /local-only/i, "shows current local-only state"); + + // /vision audit-path + await pi.commands.get("vision")!.handler(`audit-path ${auditPath}`, cmdCtx); + assert.equal(loadConfig(getAgentDir()).auditLogPath, auditPath, "audit path persisted"); + + // /vision audit path → prints resolved path + await pi.commands.get("vision")!.handler("audit path", cmdCtx); + assert.match(notified, /Audit log path/i, "audit path shown"); + + // /vision audit-path clear → undefined + await pi.commands.get("vision")!.handler("audit-path clear", cmdCtx); + assert.equal(loadConfig(getAgentDir()).auditLogPath, undefined, "audit path cleared"); + + // /vision audit off → auditLog false + await pi.commands.get("vision")!.handler("audit off", cmdCtx); + assert.equal(loadConfig(getAgentDir()).auditLog, false, "audit logging disabled"); + + // /vision audit on → auditLog true + await pi.commands.get("vision")!.handler("audit on", cmdCtx); + assert.equal(loadConfig(getAgentDir()).auditLog, true, "audit logging re-enabled"); +}); + +// ── T58 end-to-end (tool layer): local-only cache miss via describe_image ── +test("T58 (integration): describe_image + localOnly on + cache miss → clear error, isError, 0 fetch", async () => { + const pi = createMockPi(); + visionFactory(pi as unknown as ExtensionAPI); + const { dir, file } = tmpImgDir(); + const fm = mockFetch({ choices: [{ message: { content: "should not reach" } }] }); + try { + await pi.emit("session_start", { type: "session_start", reason: "startup" }, makeCtx({ model: TEXT_ONLY, cwd: dir })); + const cfgCtx = makeCtx({ model: TEXT_ONLY, cwd: dir }) as unknown as ExtensionCommandContext; + (cfgCtx.ui as any).notify = () => {}; + await pi.commands.get("vision")!.handler("model ollama/minimax-m3:cloud", cfgCtx); + await pi.commands.get("vision")!.handler("local-only on", cfgCtx); + const result = await executeTool(pi, { image_path: file, prompt: "describe" }, makeCtx({ model: TEXT_ONLY, cwd: dir })); + assert.equal(result.isError, true, "tool flags isError on local-only refusal"); + assert.match(result.content[0].text, /local-only mode/); + assert.equal(fm.calls.length, 0, "0 fetch calls (structural guarantee at the tool layer)"); + } finally { + fm.restore(); + rmSync(dir, { recursive: true, force: true }); + } +}); + // Cleanup the temp agent dir after all tests. test("cleanup", () => { rmSync(TMP_AGENT, { recursive: true, force: true }); From 2693a1255729255740c20b9f7e367390ed72e61d Mon Sep 17 00:00:00 2001 From: RECTOR Date: Sat, 11 Jul 2026 15:09:13 +0700 Subject: [PATCH 7/9] feat(ext): paste auto mode local-only short-circuit (TDD) --- extensions/paste.ts | 10 +++++ tests/integration.test.ts | 79 +++++++++++++++++++++++++++++++++++++++ 2 files changed, 89 insertions(+) diff --git a/extensions/paste.ts b/extensions/paste.ts index 218f153..b87e4c7 100644 --- a/extensions/paste.ts +++ b/extensions/paste.ts @@ -376,6 +376,16 @@ export default function pasteExtension(_pi: ExtensionAPI): void { const visionModel = config.provider && config.model ? `${config.provider}/${config.model}` : "(unconfigured)"; const hintImages = loaded.map((l, i) => ({ token: l.token, index: resolved.get(l.token)?.index ?? i })); + // ── Local-only short-circuit (SPEC-5 §3.2) ──────────────────────────── + // If local-only mode is on, every delegation would be refused (cache miss) + // or cache-only. Skip the batch entirely — don't burn autoDelegateTimeoutMs + // waiting for refused calls to abort. Fall straight to hint so the model + // can still call describe_image for cache hits (which local-only allows). + if (config.localOnly) { + text = `${text}\n${buildHintLine(hintImages)}`; + return { action: "transform" as const, text }; + } + if (!cache || !config.provider || !config.model) { // Can't delegate (no cache or unconfigured) → fall back to hint. text = `${text}\n${buildHintLine(hintImages)}`; diff --git a/tests/integration.test.ts b/tests/integration.test.ts index d95ecb0..e5d8d89 100644 --- a/tests/integration.test.ts +++ b/tests/integration.test.ts @@ -1850,6 +1850,85 @@ test("T58 (integration): describe_image + localOnly on + cache miss → clear er } }); +// ── T59: paste auto mode + local-only short-circuit (★ SPEC-5 §3.2) ── +test("T59: text-only + auto + localOnly on → hint fallback immediately (no delegation, no timeout burned)", async () => { + const pi = createMockPi(); + visionFactory(pi as unknown as ExtensionAPI); + pasteFactory(pi as unknown as ExtensionAPI); + const dir = mkdtempSync(join(tmpdir(), "vision-eval-img-")); + const colors: Array<[number, number, number]> = [[255, 0, 0], [0, 255, 0]]; + const files = ["a.png", "b.png"].map((f, i) => { + const p = join(dir, f); + writeFileSync(p, make1x1Png(...colors[i]!)); + return p; + }); + let fetchCalls = 0; + const original = globalThis.fetch; + globalThis.fetch = (async () => { fetchCalls++; return new Response("{}", { status: 200 }); }) as typeof globalThis.fetch; + try { + writeFileSync(join(TMP_AGENT, "vision.json"), JSON.stringify({ + provider: "ollama", model: "minimax-m3:cloud", enabled: true, + retryAttempts: 0, textOnlyPasteMode: "auto", batchConcurrency: 4, + localOnly: true, autoDelegateTimeoutMs: 30000, + })); + await pi.emit("session_start", { type: "session_start", reason: "startup" }, makeCtx({ model: TEXT_ONLY, cwd: dir })); + const start = Date.now(); + const inputResult = await pi.emit( + "input", + { type: "input", text: `analyze ${files.join(" and ")}`, source: "interactive", images: [] }, + makeCtx({ model: TEXT_ONLY, cwd: dir, registry: makeRegistry({ model: VISION_MODEL }) }), + ); + const elapsed = Date.now() - start; + assert.equal(inputResult?.action, "transform"); + assert.equal(fetchCalls, 0, "no delegation attempted (local-only short-circuit)"); + assert.equal((inputResult.images ?? []).length, 0, "no image attached (text-only)"); + // The hint line lists both paths so the model can call describe_image for cache hits. + assert.ok(files.every((f) => inputResult.text.includes(f)), "hint lists both paths"); + // Critical: no timeout burned (local-only skips the AbortController entirely). + assert.ok(elapsed < 1000, `no timeout burned: elapsed=${elapsed}ms (would be ~30000ms if it waited)`); + } finally { + globalThis.fetch = original; + rmSync(dir, { recursive: true, force: true }); + } +}); + +// ── T57 (integration): paste auto + local-only + pre-cached image → cache hit ── +test("T57 (integration): paste auto + localOnly + cached image → cache hit via tool (local-only allows cache)", async () => { + // Local-only allows cache hits (the cache is local). This test verifies the + // paste auto short-circuit goes to hint (where the model can then call + // describe_image for a cache hit). The delegate-level cache-hit-in-local-only + // is covered in delegate.test.ts T57; this confirms the paste path doesn't + // block that by attempting a forbidden network call. + const pi = createMockPi(); + visionFactory(pi as unknown as ExtensionAPI); + pasteFactory(pi as unknown as ExtensionAPI); + const { dir, file } = tmpImgDir(); + let fetchCalls = 0; + const original = globalThis.fetch; + globalThis.fetch = (async () => { + fetchCalls++; + return new Response(JSON.stringify({ choices: [{ message: { content: "a desc" } }] }), { status: 200 }); + }) as typeof globalThis.fetch; + try { + writeFileSync(join(TMP_AGENT, "vision.json"), JSON.stringify({ + provider: "ollama", model: "minimax-m3:cloud", enabled: true, + retryAttempts: 0, textOnlyPasteMode: "auto", localOnly: true, + })); + await pi.emit("session_start", { type: "session_start", reason: "startup" }, makeCtx({ model: TEXT_ONLY, cwd: dir })); + const inputResult = await pi.emit( + "input", + { type: "input", text: `analyze ${file}`, source: "interactive", images: [] }, + makeCtx({ model: TEXT_ONLY, cwd: dir, registry: makeRegistry({ model: VISION_MODEL }) }), + ); + assert.equal(inputResult?.action, "transform"); + assert.equal(fetchCalls, 0, "paste auto + local-only → no delegation (hint fallback)"); + assert.ok(inputResult.text.includes(file), "hint lists the path"); + } finally { + globalThis.fetch = original; + rmSync(dir, { recursive: true, force: true }); + } +}); + // Cleanup the temp agent dir after all tests. test("cleanup", () => { rmSync(TMP_AGENT, { recursive: true, force: true }); From 8280f35bc031d41b3092fbd00eee214196a36395 Mon Sep 17 00:00:00 2001 From: RECTOR Date: Sat, 11 Jul 2026 15:10:59 +0700 Subject: [PATCH 8/9] test: integration T60/T70 (batch audit + v0.5.0 regression gate) --- tests/integration.test.ts | 38 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 38 insertions(+) diff --git a/tests/integration.test.ts b/tests/integration.test.ts index e5d8d89..9a0687e 100644 --- a/tests/integration.test.ts +++ b/tests/integration.test.ts @@ -1929,6 +1929,44 @@ test("T57 (integration): paste auto + localOnly + cached image → cache hit via } }); +// ── T70: v0.5.0 regression gate — full surface wired ────────────────── +test("T70: v0.5.0 regression gate — config fields + subcommands + auto-detect all wired", async () => { + const pi = createMockPi(); + visionFactory(pi as unknown as ExtensionAPI); + resetVisionConfig(); + await pi.emit("session_start", { type: "session_start", reason: "startup" }, makeCtx({ model: TEXT_ONLY })); + // 4 new config fields default correctly (fresh config after reset + session_start). + const cfg = loadConfig(getAgentDir()); + assert.equal(cfg.auditLog, true, "auditLog default on"); + assert.equal(cfg.auditLogPath, undefined); + assert.equal(cfg.localOnly, false); + assert.equal(cfg.autoDetectVisionModel, true); + // 3 new subcommands registered. + const cmds = [...pi.commands.keys()]; + assert.ok(cmds.includes("vision"), "/vision command registered"); + // The subcommands are parsed inside the vision handler; verify they dispatch + // without error by exercising one of each (local-only, audit, audit-path). + const cmdCtx = makeCtx({ model: TEXT_ONLY }) as unknown as ExtensionCommandContext; + (cmdCtx.ui as any).notify = () => {}; + await pi.commands.get("vision")!.handler("local-only off", cmdCtx); + await pi.commands.get("vision")!.handler("audit show", cmdCtx); + await pi.commands.get("vision")!.handler("audit-path", cmdCtx); + assert.equal(loadConfig(getAgentDir()).localOnly, false, "local-only off persisted"); + // Auto-detect wired: a fresh config + vision-capable registry triggers detection. + resetVisionConfig(); + let notified = ""; + const detectCtx = makeCtxWithRegistry({ + model: TEXT_ONLY, + available: [ollamaVision("minimax-m3:cloud")], + }); + (detectCtx.ui as any).notify = (msg: string) => { notified = msg; }; + await pi.emit("session_start", { type: "session_start", reason: "startup" }, detectCtx); + assert.equal(loadConfig(getAgentDir()).model, "minimax-m3:cloud", "auto-detect wired end-to-end"); + assert.match(notified, /auto-configured/); + // Full suite green = T70 passed (if this test runs, the suite compiled + loaded). + assert.ok(true, "v0.5.0 surface wired + regression gate passed"); +}); + // Cleanup the temp agent dir after all tests. test("cleanup", () => { rmSync(TMP_AGENT, { recursive: true, force: true }); From d68b1b200cc9c946b12ce5ca6eaef167bdbfe657 Mon Sep 17 00:00:00 2001 From: RECTOR Date: Sat, 11 Jul 2026 15:11:56 +0700 Subject: [PATCH 9/9] docs: README v0.5.0 (config + security) --- README.md | 99 +++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 99 insertions(+) diff --git a/README.md b/README.md index 65fa277..e4500a4 100644 --- a/README.md +++ b/README.md @@ -86,6 +86,9 @@ model should have `"input": ["text", "image"]`. Other `/vision` subcommands: | `/vision auto-prompt [\|clear]` | Set/clear the generic auto-delegation prompt (no arg → multi-line editor). | | `/vision preview ` | Open a full-screen TUI preview of an image (Kitty/iTerm2 graphics, text fallback on tmux). | | `/vision batch-concurrency [<1-20>]` | Max parallel image delegations in a batch (`describe_image image_paths` + paste auto mode). 1 = serial; 20 = aggressive. Default 5. | +| `/vision local-only [on\|off]` | Toggle local-only mode (no arg → show current). When on, image bytes never leave the machine — cache hits still work, a cache miss refuses with a clear error instead of a network call. | +| `/vision audit ` | Audit log management. `clear` truncates; `show` tails the last 10 entries + count; `path` prints the resolved path; `on`/`off` toggle logging. Default on. | +| `/vision audit-path [\|clear]` | Set/clear a custom audit log path (power users; no arg → show current). Default `~/.pi/agent/vision-audit.log`. | Config is stored at `~/.pi/agent/vision.json` (not `vision-tool.json`, so it doesn't collide with the community package during transition). @@ -221,6 +224,102 @@ attaches (multimodal) or delegates (text-only) — no separate clipboard code path needed. Multi-image clipboard = N `ctrl+v` presses = N paths = handled as a batch. +## Config + security (v0.5.0) + +v0.5.0 makes the tool **trustable** — audit where your images go, opt out of +network delegation entirely, and get workflow-fit defaults out of the box. + +### Audit log + +Every vision-model delegation (success, cache hit, fallback, or failure) is +recorded in a persisted, append-only JSONL log at +`~/.pi/agent/vision-audit.log` (one line per delegation). The log answers +*"where did each image go?"* without storing the image bytes or the full prompt. + +Each line is a JSON object: + +| Field | Meaning | +|---|---| +| `ts` | ISO 8601 timestamp of the delegation event | +| `provider` | The configured primary provider (the attempted route) | +| `model` | The model that actually responded (the fallback on a fallback success) | +| `image_path` | The path the user passed (file paths full; data:URL/base64 truncated) | +| `source_hash` | SHA-256 of the original image bytes (a content fingerprint — not the bytes) | +| `cached` | `true` if served from the cache (0 network calls) | +| `fallback` | `true` if the result came from the fallback vision model | +| `fallback_model` | The fallback model id, if fallback was used | +| `ok` | `true` if the delegation succeeded | +| `error_code` | Error code on failure (`local_only`, `vision_call_error`, `aborted`, …) | +| `latency_ms` | Round-trip latency (0 for a cache hit / local-only refusal) | +| `local_only` | `true` if local-only mode was active | + +**Privacy stance:** the log records *routing* (where bytes went), never +*content*. Image bytes are never logged (only the `source_hash` fingerprint); +the full prompt is never logged (the conversation log already has it). A +`data:` URL or raw base64 `image_path` is truncated to the first 64 chars + a +size suffix. The audit log is **on by default** (opt-out, not opt-in — the +security posture is traceability without you remembering to enable it): + +``` +/vision audit off # disable logging +/vision audit show # tail the last 10 entries + total count +/vision audit clear # truncate the log +/vision audit path # print the resolved log path +/vision audit-path /tmp/my-vision.log # custom location (power users) +``` + +### Local-only mode + +When local-only mode is on, **image bytes never leave the machine**. This is a +structural guarantee, not a polite request — the network-call code path is +never entered. Cache hits still work (the cache is local — memory + disk under +`~/.pi/agent`), so local-only mode is effectively "cache-only mode": previously- +seen images get their cached descriptions, new images refuse with a clear +error. + +``` +/vision local-only on # refuse all network delegation (cache hits OK) +/vision local-only off # allow delegation +``` + +In paste auto mode + local-only, the hook skips delegation entirely and falls +straight to the hint line (no `autoDelegateTimeoutMs` burned waiting for a +refused call to abort). The `describe_image` tool stays visible so the model +can still retrieve cached descriptions and report the local-only error on a +cache miss. Every local-only event is audited with `local_only: true` +(greppable proof: `grep '"local_only":true' ~/.pi/agent/vision-audit.log`). + +### Workflow-fit defaults (auto-detect) + +On a fresh install (or after `/vision clear`), the extension auto-detects the +vision model at session start from `~/.pi/agent/models.json` — aligned to the +[AGENTS.md LLM-backend policy](https://github.com/earendil-works) of "Ollama +Cloud primary + frontier escalation": + +- **Primary:** prefers the `Ollama` provider's vision-capable models (the + `:cloud` ones — Ollama Cloud, flat-rate + private). On RECTOR's setup this + picks `Ollama/minimax-m3:cloud` (first by sorted id). +- **Fallback (frontier escalation):** the first vision-capable model under a + *different* provider (e.g. an OpenRouter GPT-4o), if one is configured. This + is the "escalate to the proper frontier model for that job" path — the + existing fallback mechanism, auto-populated. +- The auto-detected values are **persisted once** to `vision.json` (you see + the choice + can override it). `/vision clear` resets to defaults and + re-triggers detection on the next session start. +- Escape hatch: `/vision auto-detect off` (set `autoDetectVisionModel: false`) + disables auto-detection entirely — a fresh config stays unconfigured. + +Auto-detect only fires when **both** `provider` and `model` are unset (a truly +fresh state). A partial config (one set, one blank) is you mid-configuration — +it's not overwritten. + +### No config migration from pi-paster + +`pi-paster` is a paste-format extension — it has no configurable vision model +(it doesn't delegate; it just marks + attaches). `@getpipher/vision`'s +`markerStyle` (code/bold/plain) is a superset of pi-paster's fixed format, so +there's nothing to migrate. Uninstall pi-paster, run `/vision`, done. + ## How it works Two mechanisms combine to guarantee the behavior: