UNPKG

agentfootprint

Version:

The explainable agent framework — backtrack a wrong answer to the exact context that caused it (evidence, not guesses). Built on footprintjs.

149 lines • 5.42 kB
/** * Fast, deterministic, browser-safe content hash (FNV-1a, two 32-bit * lanes + length qualifier → "len-xxxxxxxxyyyyyyyy"). Non-cryptographic * — cache keying only. */ export function contentHash(text) { let h1 = 0x811c9dc5; // FNV offset basis let h2 = 0xcbf29ce4; // second lane, different seed for (let i = 0; i < text.length; i++) { const c = text.charCodeAt(i); h1 = Math.imul(h1 ^ c, 0x01000193); // FNV prime h2 = Math.imul(h2 ^ c, 0x01000197); // distinct odd prime } const lane1 = (h1 >>> 0).toString(16).padStart(8, '0'); const lane2 = (h2 >>> 0).toString(16).padStart(8, '0'); return `${text.length.toString(36)}-${lane1}${lane2}`; } const DEFAULT_MAX_ENTRIES = 1024; /** * Wrap an embedder with a bounded, content-hash-keyed LRU cache. * See module docs for keying, bounds, and concurrency semantics. */ export class EmbeddingCache { dimensions; inner; maxEntries; /** LRU store — Map iteration order is recency (refreshed on hit). */ vectors = new Map(); /** Single-flight joins — promises live here until settled. */ inflight = new Map(); hits = 0; misses = 0; evictions = 0; constructor(inner, options = {}) { const maxEntries = options.maxEntries ?? DEFAULT_MAX_ENTRIES; if (!Number.isInteger(maxEntries) || maxEntries <= 0) { throw new Error(`EmbeddingCache: maxEntries must be a positive integer (got ${maxEntries})`); } this.inner = inner; this.maxEntries = maxEntries; this.dimensions = inner.dimensions; } async embed(args) { const key = contentHash(args.text); const cached = this.vectors.get(key); if (cached !== undefined) { this.hits += 1; this.refresh(key, cached); return cached.slice(); } const joined = this.inflight.get(key); if (joined !== undefined) { this.hits += 1; // coalesced — no extra inner call return (await joined).slice(); } this.misses += 1; const promise = this.inner.embed(args); this.inflight.set(key, promise); try { const vector = await promise; this.store(key, vector); return vector.slice(); } finally { // Settled either way; rejections are never cached. this.inflight.delete(key); } } async embedBatch(args) { const { texts, signal } = args; const out = new Array(texts.length); // Partition into cache hits and misses (deduplicating within the // batch — the same text twice embeds once). const missTexts = []; const missSlots = new Map(); // key → output indices for (let i = 0; i < texts.length; i++) { const key = contentHash(texts[i]); const cached = this.vectors.get(key); if (cached !== undefined) { this.hits += 1; this.refresh(key, cached); out[i] = cached.slice(); continue; } const slots = missSlots.get(key); if (slots !== undefined) { this.hits += 1; // in-batch duplicate — one inner embed serves both slots.push(i); continue; } this.misses += 1; missSlots.set(key, [i]); missTexts.push(texts[i]); } if (missTexts.length > 0) { const vectors = this.inner.embedBatch ? await this.inner.embedBatch({ texts: missTexts, ...(signal ? { signal } : {}) }) : await this.embedSequential(missTexts, signal); let v = 0; for (const [key, slots] of missSlots) { const vector = vectors[v++]; this.store(key, vector); for (const slot of slots) out[slot] = vector.slice(); } } return out; } /** Visible cache health (bounded honesty — see module docs). */ stats() { return { size: this.vectors.size, maxEntries: this.maxEntries, hits: this.hits, misses: this.misses, evictions: this.evictions, }; } /** Drop all cached vectors. Stats counters are preserved. */ clear() { this.vectors.clear(); } refresh(key, vector) { // Map insertion order doubles as the LRU order. this.vectors.delete(key); this.vectors.set(key, vector); } store(key, vector) { // Defensive copy in — callers can't mutate the cached vector. this.vectors.set(key, vector.slice()); while (this.vectors.size > this.maxEntries) { const oldest = this.vectors.keys().next().value; this.vectors.delete(oldest); this.evictions += 1; } } async embedSequential(texts, signal) { const vectors = []; for (const text of texts) { vectors.push(await this.inner.embed({ text, ...(signal ? { signal } : {}) })); } return vectors; } } /** Factory sugar — `embeddingCache(embedder)` reads like the built-ins. */ export function embeddingCache(inner, options = {}) { return new EmbeddingCache(inner, options); } //# sourceMappingURL=cache.js.map