/**
* Content-addressed cache for vision-model delegation results.
*
* A second `describe_image` call on the same image (same prompt, same vision
* model, same compression params, same reasoning) returns the cached
* description WITHOUT calling the vision model — zero tokens, zero latency
* (SPEC-2 gap #2).
*
* Cache key = sha256(sourceHash + compress + maxDimension + jpegQuality +
* prompt + modelId + reasoning). Keying on the ORIGINAL-byte hash (not the
* compressed bytes) makes hits stable regardless of compression
* nondeterminism (worker vs in-process fallback — see PLAN-2 §1.1).
*
* Two layers:
* 1. In-memory `Map` (session-scoped, always active when `cacheEnabled`).
* 2. Optional persisted disk cache (`
/.json`, LRU-evicted by file
* mtime) — active when a `dir` is provided (`cachePersist: true`).
*
* Only successful results are cached (failures are never cached — a transient
* error must not poison the cache). Writes are atomic (tmp + rename). No
* cross-session lockfile (benign races only — see PLAN-2 §1.2).
*/
import { createHash } from "node:crypto";
import { existsSync, readdirSync, readFileSync, renameSync, rmSync, statSync, writeFileSync } from "node:fs";
import { join } from "node:path";
import type { DelegateSuccess } from "./delegate.ts";
/** A cached delegation result. `storedAt` is informational (LRU uses file
* mtime, which is robust to clock skew across sessions). */
export interface CacheEntry {
text: string;
details: DelegateSuccess["details"];
storedAt: number;
}
export interface CacheStats {
memoryEntries: number;
diskEntries: number;
maxEntries: number;
persisted: boolean;
}
/**
* Compute the content-addressed cache key for a delegation call. Deterministic
* + collision-safe (sha256 over the full tuple with `\0` separators so no two
* distinct tuples can collide via concatenation ambiguity).
*/
export function cacheKey(
sourceHash: string,
compress: boolean,
maxDimension: number,
jpegQuality: number,
prompt: string,
modelId: string,
reasoning: string,
): string {
const tuple = [sourceHash, compress, maxDimension, jpegQuality, prompt, modelId, reasoning].join("\0");
return createHash("sha256").update(tuple).digest("hex");
}
/**
* Vision description cache. Memory-first, disk-optional. Pure I/O — no pi
* runtime dependency — so it unit-tests with a tmp dir or memory-only.
*/
export class VisionCache {
private readonly memory = new Map();
private readonly dir?: string;
private readonly maxEntries: number;
constructor(dir?: string, maxEntries = 256) {
this.dir = dir;
this.maxEntries = Math.max(1, Math.round(maxEntries));
}
get persisted(): boolean {
return this.dir !== undefined;
}
/** Look up a cached entry. Memory first; on miss, disk (promoting a hit
* into memory). A corrupt disk file is treated as a miss + removed. */
get(key: string): CacheEntry | undefined {
const mem = this.memory.get(key);
if (mem) return mem;
if (this.dir) {
const file = this.fileFor(key);
if (existsSync(file)) {
try {
const entry = JSON.parse(readFileSync(file, "utf8")) as CacheEntry;
if (typeof entry.text === "string" && entry.details) {
this.memory.set(key, entry); // promote disk hit → memory
return entry;
}
} catch {
// corrupt JSON → remove + miss
}
try {
rmSync(file, { force: true });
} catch {
// best-effort cleanup
}
}
}
return undefined;
}
/** Store a successful result. Memory always; disk (atomic tmp+rename) when
* persisted. A disk write failure never fails the call (memory still has
* the entry for the session). */
set(key: string, entry: CacheEntry): void {
this.memory.set(key, entry);
if (this.dir) {
const file = this.fileFor(key);
const tmp = `${file}.tmp`;
try {
writeFileSync(tmp, JSON.stringify(entry), "utf8");
renameSync(tmp, file);
} catch {
// disk failure → memory-only degradation; don't throw
}
this.evictIfNeeded();
}
}
/** Wipe both layers. */
clear(): void {
this.memory.clear();
if (this.dir && existsSync(this.dir)) {
try {
for (const f of readdirSync(this.dir)) {
if (f.endsWith(".json")) rmSync(join(this.dir, f), { force: true });
}
} catch {
// best-effort
}
}
}
stats(): CacheStats {
let diskEntries = 0;
if (this.dir && existsSync(this.dir)) {
try {
diskEntries = readdirSync(this.dir).filter((f) => f.endsWith(".json")).length;
} catch {
// best-effort
}
}
return {
memoryEntries: this.memory.size,
diskEntries,
maxEntries: this.maxEntries,
persisted: this.dir !== undefined,
};
}
private fileFor(key: string): string {
return join(this.dir!, `${key}.json`);
}
/** LRU eviction by file mtime: if disk entries exceed `maxEntries`, delete
* the oldest until under cap. Benign across concurrent sessions (worst
* case: a redundant eviction). */
private evictIfNeeded(): void {
if (!this.dir || !existsSync(this.dir)) return;
let files: { name: string; mtime: number }[] = [];
try {
files = readdirSync(this.dir)
.filter((f) => f.endsWith(".json"))
.map((name) => {
let mtime = 0;
try {
mtime = statSync(join(this.dir!, name)).mtimeMs;
} catch {
// unreadable file → mtime 0 (evicted first)
}
return { name, mtime };
});
} catch {
return;
}
if (files.length <= this.maxEntries) return;
files.sort((a, b) => a.mtime - b.mtime); // oldest first
const toEvict = files.length - this.maxEntries;
for (let i = 0; i < toEvict; i++) {
const victim = files[i];
if (!victim) continue;
try {
rmSync(join(this.dir!, victim.name), { force: true });
} catch {
// best-effort
}
}
}
}