agentfootprint
Version:
The explainable agent framework — backtrack a wrong answer to the exact context that caused it (evidence, not guesses). Built on footprintjs.
155 lines • 5.68 kB
JavaScript
"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
exports.embeddingCache = exports.EmbeddingCache = exports.contentHash = void 0;
/**
* Fast, deterministic, browser-safe content hash (FNV-1a, two 32-bit
* lanes + length qualifier → "len-xxxxxxxxyyyyyyyy"). Non-cryptographic
* — cache keying only.
*/
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}`;
}
exports.contentHash = contentHash;
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.
*/
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;
}
}
exports.EmbeddingCache = EmbeddingCache;
/** Factory sugar — `embeddingCache(embedder)` reads like the built-ins. */
function embeddingCache(inner, options = {}) {
return new EmbeddingCache(inner, options);
}
exports.embeddingCache = embeddingCache;
//# sourceMappingURL=cache.js.map