UNPKG

@phren/cli

Version:

Knowledge layer for AI agents - CLI, MCP server, and data layer

168 lines (167 loc) • 6.04 kB
import * as fs from "fs"; import * as crypto from "crypto"; import { runtimeFile, debugLog } from "../shared.js"; import { withFileLock } from "./governance.js"; import { errorMessage } from "../utils.js"; function statSourceMarker(filePath) { try { const stat = fs.statSync(filePath); return { mtimeMs: stat.mtimeMs, size: stat.size }; } catch { return null; } } function isEmbeddingEntry(value) { if (!value || typeof value !== "object" || Array.isArray(value)) return false; const candidate = value; return typeof candidate.model === "string" && Array.isArray(candidate.vec) && candidate.vec.every((n) => typeof n === "number" && Number.isFinite(n)) && typeof candidate.at === "string"; } function readEmbeddingMapFromDisk(filePath) { const raw = fs.readFileSync(filePath, "utf-8"); const parsed = JSON.parse(raw); if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) { throw new Error("Embedding cache must be a JSON object"); } const data = {}; for (const [k, v] of Object.entries(parsed)) { if (isEmbeddingEntry(v)) data[k] = v; } return data; } export class EmbeddingCache { phrenPath; cache = new Map(); dirty = false; dirtyUpserts = new Set(); dirtyDeletes = new Set(); source = null; constructor(phrenPath) { this.phrenPath = phrenPath; } async load() { if (this.dirty) return; const filePath = runtimeFile(this.phrenPath, "embeddings.json"); // Stat before the read: if the file is rewritten mid-read, the recorded // marker is the older one, so derived caches report stale rather than fresh. const marker = statSourceMarker(filePath); try { const data = readEmbeddingMapFromDisk(filePath); this.cache = new Map(Object.entries(data)); this.source = marker; debugLog(`EmbeddingCache loaded: ${this.cache.size} entries`); } catch (err) { const code = err instanceof Error && "code" in err ? String(err.code ?? "") : ""; if (code === "ENOENT") { this.cache.clear(); this.source = null; return; } debugLog(`EmbeddingCache load failed for ${filePath}: ${errorMessage(err)}`); } } /** * Marker for the embeddings.json revision this in-memory map reflects, or * null when it was never loaded from a file. Pass it to anything that * persists a derived view of `getAllEntries()`. */ sourceMarker() { return this.source; } get(docPath, model) { const entry = this.cache.get(docPath); if (!entry || entry.model !== model) return null; return entry.vec; } set(docPath, model, vec) { this.cache.set(docPath, { model, vec, at: new Date().toISOString().slice(0, 10) }); this.dirty = true; this.dirtyUpserts.add(docPath); this.dirtyDeletes.delete(docPath); } delete(docPath) { this.cache.delete(docPath); this.dirty = true; this.dirtyDeletes.add(docPath); this.dirtyUpserts.delete(docPath); } async flush() { if (!this.dirty) return; const filePath = runtimeFile(this.phrenPath, "embeddings.json"); try { withFileLock(filePath, () => { let data = {}; try { if (fs.existsSync(filePath)) data = readEmbeddingMapFromDisk(filePath); } catch (err) { debugLog(`EmbeddingCache flush merge read failed for ${filePath}: ${errorMessage(err)}`); } for (const key of this.dirtyDeletes) delete data[key]; for (const key of this.dirtyUpserts) { const entry = this.cache.get(key); if (entry) data[key] = entry; } const tmp = filePath + `.tmp-${crypto.randomUUID()}`; fs.writeFileSync(tmp, JSON.stringify(data)); fs.renameSync(tmp, filePath); this.cache = new Map(Object.entries(data)); this.source = statSourceMarker(filePath); }); this.dirty = false; this.dirtyUpserts.clear(); this.dirtyDeletes.clear(); } catch (err) { debugLog(`EmbeddingCache flush error: ${errorMessage(err)}`); } } getAllEntries() { return [...this.cache.entries()].map(([p, e]) => ({ path: p, vec: e.vec, model: e.model })); } size() { return this.cache.size; } coverage(allPaths) { const total = allPaths.length; const embedded = allPaths.filter(p => this.cache.has(p)).length; const missing = Math.max(0, total - embedded); const pct = total === 0 ? 0 : Math.round((embedded / total) * 100); const missingPct = total === 0 ? 0 : Math.max(0, 100 - pct); const state = total === 0 ? "empty" : embedded === 0 ? "cold" : embedded === total ? "warm" : "warming"; return { total, embedded, missing, pct, missingPct, state }; } } export function formatEmbeddingCoverage(coverage) { if (coverage.total === 0) return "0 indexed docs"; return `${coverage.embedded}/${coverage.total} docs embedded (${coverage.pct}% warm, ${coverage.missingPct}% cold)`; } // Module-level singleton per phrenPath const cacheInstances = new Map(); export function getEmbeddingCache(phrenPath) { const existing = cacheInstances.get(phrenPath); if (existing) return existing; const instance = new EmbeddingCache(phrenPath); cacheInstances.set(phrenPath, instance); return instance; }