UNPKG

@phren/cli

Version:

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

339 lines (338 loc) • 12.6 kB
import * as fs from "fs"; import * as path from "path"; import * as crypto from "crypto"; import { debugLog, runtimeDir, } from "./shared.js"; import { withFileLock } from "./shared/governance.js"; import { errorMessage } from "./utils.js"; import { bootstrapSqlJs } from "./shared/sqljs.js"; import { logger } from "./logger.js"; let sqlJsLoader = bootstrapSqlJs; const EMBED_CACHE_DB = "embed-cache.db"; function getCacheDbPath(phrenPath) { const dir = runtimeDir(phrenPath); fs.mkdirSync(dir, { recursive: true }); return path.join(dir, EMBED_CACHE_DB); } function sha256(text) { return crypto.createHash("sha256").update(text).digest("hex"); } /** Encode a number[] embedding into a compact binary blob (Float32Array). */ function encodeEmbedding(embedding) { const f32 = new Float32Array(embedding); return Buffer.from(f32.buffer); } /** Decode a binary blob back to number[]. */ function decodeEmbedding(blob) { const f32 = new Float32Array(blob.buffer, blob.byteOffset, blob.byteLength / 4); return Array.from(f32); } let sqlPromise = null; // Q14: Synchronously-accessible resolved SQL static, set once sqlPromise settles. let sqlResolved = null; function getSql() { if (!sqlPromise) { sqlPromise = sqlJsLoader() .then(sql => { sqlResolved = sql; return sql; }) .catch((err) => { sqlPromise = null; sqlResolved = null; debugLog(`embedding: sql.js init failed: ${errorMessage(err)}`); throw err; }); } return sqlPromise; } export function setSqlJsLoaderForTests(loader) { sqlJsLoader = loader; sqlPromise = null; sqlResolved = null; } export function resetSqlJsStateForTests() { sqlJsLoader = bootstrapSqlJs; sqlPromise = null; sqlResolved = null; } async function openCacheDb(phrenPath) { const dbPath = getCacheDbPath(phrenPath); const SQL = await getSql(); let db; try { if (fs.existsSync(dbPath)) { const data = fs.readFileSync(dbPath); db = new SQL.Database(data); } else { db = new SQL.Database(); } db.run(`CREATE TABLE IF NOT EXISTS embeddings ( model TEXT NOT NULL, hash TEXT NOT NULL, embedding BLOB NOT NULL, PRIMARY KEY (model, hash) )`); return db; } catch (err) { try { db?.close(); } catch (e2) { logger.debug("embedding", `embedding openCacheDb dbClose: ${e2 instanceof Error ? e2.message : String(e2)}`); } throw err; } } /** * Q14: Persist the in-memory DB to disk under a file lock. * Reads the current on-disk snapshot inside the lock, merges any entries that * are in `db` but missing from disk, then writes atomically via temp-file rename. * This prevents the "last writer wins" race where two concurrent processes each * open the same on-disk snapshot, insert different entries, and overwrite each * other's work. */ function persistDb(phrenPath, db) { const dbPath = getCacheDbPath(phrenPath); try { withFileLock(dbPath, () => { // Read the freshest on-disk snapshot (may have entries from another process) let onDisk = null; // We cannot call the async openCacheDb here; use a raw sync open instead. // If that fails we fall back to writing `db` as-is (best-effort). try { if (fs.existsSync(dbPath)) { // sql.js-fts5 is already initialised by the time we persist; reuse the // cached promise result synchronously if available (it is a resolved // Promise at this point because getCachedEmbedding awaited it first). // We extract the resolved value by creating a fulfilled-only thenable. if (sqlResolved) { onDisk = new sqlResolved.Database(fs.readFileSync(dbPath)); onDisk.run(`CREATE TABLE IF NOT EXISTS embeddings ( model TEXT NOT NULL, hash TEXT NOT NULL, embedding BLOB NOT NULL, PRIMARY KEY (model, hash) )`); // Merge entries from `db` into onDisk const rows = db.exec("SELECT model, hash, embedding FROM embeddings")[0]?.values ?? []; if (rows.length > 0) { onDisk.run("BEGIN TRANSACTION"); for (const [model, hash, embedding] of rows) { onDisk.run("INSERT OR IGNORE INTO embeddings (model, hash, embedding) VALUES (?, ?, ?)", [model, hash, embedding]); } onDisk.run("COMMIT"); } } } } catch (err) { logger.debug("embedding", `embedding persistDb onDiskLoad: ${errorMessage(err)}`); try { onDisk?.close(); } catch (e2) { logger.debug("embedding", `embedding persistDb onDiskClose: ${e2 instanceof Error ? e2.message : String(e2)}`); } onDisk = null; } const target = onDisk ?? db; const tmp = dbPath + `.tmp-${crypto.randomUUID()}`; try { fs.writeFileSync(tmp, Buffer.from(target.export())); fs.renameSync(tmp, dbPath); } finally { if (onDisk) try { onDisk.close(); } catch (e2) { logger.debug("embedding", `embedding persistDb onDiskCloseFinally: ${e2 instanceof Error ? e2.message : String(e2)}`); } } }); } catch (err) { debugLog(`embedding: failed to persist cache db: ${errorMessage(err)}`); } } function lookupCache(db, model, hash) { const results = db.exec("SELECT embedding FROM embeddings WHERE model = ? AND hash = ?", [model, hash]); if (results.length > 0 && results[0].values.length > 0) { const blob = results[0].values[0][0]; return decodeEmbedding(blob); } return null; } function insertCache(db, model, hash, embedding) { db.run("INSERT OR REPLACE INTO embeddings (model, hash, embedding) VALUES (?, ?, ?)", [model, hash, encodeEmbedding(embedding)]); } // --------------------------------------------------------------------------- // API embedding (unchanged) // --------------------------------------------------------------------------- /** * Get embedding from OpenAI-compatible API. * Calls POST https://api.openai.com/v1/embeddings (or compatible endpoint). */ async function getApiEmbedding(text, apiKey, model = "text-embedding-3-small") { const response = await fetch("https://api.openai.com/v1/embeddings", { method: "POST", headers: { "Content-Type": "application/json", "Authorization": `Bearer ${apiKey}`, }, body: JSON.stringify({ input: text, model, }), }); if (!response.ok) { const body = await response.text(); throw new Error(`Embedding API error ${response.status}: ${body}`); } const data = await response.json(); if (!data.data?.[0]?.embedding) { throw new Error("Embedding API returned unexpected format"); } return data.data[0].embedding; } /** * Get embeddings for multiple texts in a single API call. * The OpenAI embeddings API supports array input natively. */ async function getApiEmbeddings(texts, apiKey, model = "text-embedding-3-small") { if (texts.length === 0) return []; if (texts.length === 1) return [await getApiEmbedding(texts[0], apiKey, model)]; const response = await fetch("https://api.openai.com/v1/embeddings", { method: "POST", headers: { "Content-Type": "application/json", "Authorization": `Bearer ${apiKey}`, }, body: JSON.stringify({ input: texts, model, }), }); if (!response.ok) { const body = await response.text(); throw new Error(`Embedding API error ${response.status}: ${body}`); } const data = await response.json(); if (!data.data?.length) { throw new Error("Embedding API returned unexpected format"); } // Sort by index to ensure order matches input const sorted = data.data.sort((a, b) => a.index - b.index); return sorted.map(d => d.embedding); } export const embeddingOps = { openCacheDb, persistDb, lookupCache, insertCache, getApiEmbedding, getApiEmbeddings, }; // --------------------------------------------------------------------------- // Cached embedding (uses SQLite cache) // --------------------------------------------------------------------------- /** * Get embedding with caching. Uses the configured provider. */ export async function getCachedEmbedding(phrenPath, text, apiKey, model) { let db; try { const hash = sha256(`${model}:${text}`); db = await embeddingOps.openCacheDb(phrenPath); const cached = embeddingOps.lookupCache(db, model, hash); if (cached) return cached; const embedding = await embeddingOps.getApiEmbedding(text, apiKey, model); embeddingOps.insertCache(db, model, hash, embedding); // Q14: persistDb now holds a file lock and merges with the on-disk snapshot // before writing, so concurrent callers don't overwrite each other's entries. embeddingOps.persistDb(phrenPath, db); return embedding; } catch (err) { debugLog(`embedding: getCachedEmbedding failed: ${errorMessage(err)}`); return []; } finally { try { db?.close(); } catch (e2) { logger.debug("embedding", `embedding getCachedEmbedding dbClose: ${e2 instanceof Error ? e2.message : String(e2)}`); } } } /** * Get embeddings for multiple texts with caching. Batches uncached texts into single API calls. */ export async function getCachedEmbeddings(phrenPath, texts, apiKey, model) { if (texts.length === 0) return []; let db; try { db = await embeddingOps.openCacheDb(phrenPath); const results = texts.map(text => { const hash = sha256(`${model}:${text}`); return embeddingOps.lookupCache(db, model, hash); }); const uncachedIndices = []; const uncachedTexts = []; for (let i = 0; i < results.length; i++) { if (results[i] === null) { uncachedIndices.push(i); uncachedTexts.push(texts[i]); } } if (uncachedTexts.length > 0) { const BATCH_SIZE = 20; for (let start = 0; start < uncachedTexts.length; start += BATCH_SIZE) { const batch = uncachedTexts.slice(start, start + BATCH_SIZE); const batchEmbeddings = await embeddingOps.getApiEmbeddings(batch, apiKey, model); for (let j = 0; j < batch.length; j++) { const idx = uncachedIndices[start + j]; results[idx] = batchEmbeddings[j]; const hash = sha256(`${model}:${batch[j]}`); embeddingOps.insertCache(db, model, hash, batchEmbeddings[j]); } } embeddingOps.persistDb(phrenPath, db); } return results.map(result => result ?? []); } catch (err) { debugLog(`embedding: getCachedEmbeddings failed: ${errorMessage(err)}`); return texts.map(() => []); } finally { try { db?.close(); } catch (e2) { logger.debug("embedding", `embedding getCachedEmbeddings dbClose: ${e2 instanceof Error ? e2.message : String(e2)}`); } } } /** * Compute cosine similarity between two vectors. */ export function cosineSimilarity(a, b) { if (a.length !== b.length || a.length === 0) return 0; let dot = 0, normA = 0, normB = 0; for (let i = 0; i < a.length; i++) { dot += a[i] * b[i]; normA += a[i] * a[i]; normB += b[i] * b[i]; } const denom = Math.sqrt(normA) * Math.sqrt(normB); return denom === 0 ? 0 : dot / denom; } export { encodeEmbedding, decodeEmbedding, openCacheDb };