agentfootprint
Version:
The explainable agent framework — backtrack a wrong answer to the exact context that caused it (evidence, not guesses). Built on footprintjs.
272 lines • 14.2 kB
JavaScript
;
Object.defineProperty(exports, "__esModule", { value: true });
exports.staticVectorStore = exports.bundleEntryToMemoryEntry = exports.assertCorpusBundle = exports.CORPUS_BUNDLE_FORMAT = void 0;
/**
* staticVectorStore — serve a corpus that was built somewhere else.
*
* Pattern: read-only `MemoryStore` adapter over a plain-JSON bundle.
* Role: memory/store layer, beside `InMemoryStore` — the runtime half of
* the corpus-as-build-artifact story (`exportCorpus` in
* `agentfootprint/rag` is the build half).
* Emits: N/A.
*
* ── Why a corpus would be a build artifact ──────────────────────────────────
* An immutable or serverless runtime has no durable disk and often no
* credentials to an embedding API — while the BUILD machine has both. The
* field-reported shape: index the documents where the credentials live, ship
* the result with the deploy, serve it read-only where the process runs. The
* bundle is deliberately plain JSON — `JSON.parse(readFileSync(...))`, a
* bundler import, a KV fetch — because the runtime that needs this is exactly
* the runtime that cannot open a database file.
*
* build time: indexCorpus → exportCorpus(store) → corpus.json
* run time: staticVectorStore(bundle) → defineRAG({ store })
*
* ── What it refuses, and why loudly ─────────────────────────────────────────
* Writes. A static corpus is not a conversation log, and accepting a write
* that evaporates with the process would be the quiet version of losing data.
* Every write method throws a refusal naming the fix (re-export at build
* time, or use a writable store).
*
* The wrong embedder. The bundle records the embedder id and dimensions it
* was built with. Pass your runtime embedder as the second argument and a
* mismatch is refused AT LOAD — the same rule `sqliteVectorStore` enforces:
* dimensions always decide; ids decide only when both sides named themselves.
* At search time, a query vector of the wrong length, or an `embedderId`
* naming a different space, is refused rather than silently ranking to
* nothing — the field report's own embedder-id format change was caught by
* exactly this class of check.
*
* Entries are seeded in the exact shape the retrieval formatter reads —
* `value.content` for the passage (with provenance under `value.metadata`) —
* because a corpus that indexes perfectly and renders an empty passage was
* this door's worst shipped bug (8.19.0).
*/
const requestLimit_js_1 = require("./requestLimit.js");
const index_js_1 = require("../identity/index.js");
const cosine_js_1 = require("../embedding/cosine.js");
/** The bundle format marker. Version the FORMAT, not the library. */
exports.CORPUS_BUNDLE_FORMAT = 'agentfootprint-corpus-v1';
/**
* Validate a value as a `CorpusBundle`, teachingly. Shared by
* `staticVectorStore` and `importCorpus`, so a truncated file or a
* hand-edited bundle fails the same way at every door.
*/
function assertCorpusBundle(bundle, caller) {
const b = bundle;
if (b === null || typeof b !== 'object') {
throw new Error(`${caller}: expected a corpus bundle object, received ${b === null ? 'null' : typeof b}. ` +
`A bundle comes from exportCorpus() — typically JSON.parse of the file it was saved to.`);
}
if (b.format !== exports.CORPUS_BUNDLE_FORMAT) {
throw new Error(`${caller}: this is not a corpus bundle — \`format\` is ${JSON.stringify(b.format)}, expected '${exports.CORPUS_BUNDLE_FORMAT}'. Bundles are produced by exportCorpus(); a raw ` +
`entries array or a different artifact cannot be served as one.`);
}
const dims = b.embedder?.dimensions;
if (typeof b.embedder?.id !== 'string' || typeof dims !== 'number' || dims < 1) {
throw new Error(`${caller}: the bundle does not name its embedding space — \`embedder\` must carry a ` +
`string \`id\` and a positive \`dimensions\`. Without them nothing can refuse a wrong ` +
`embedder at load, which is the silent failure this format exists to prevent.`);
}
if (typeof b.namespace !== 'string' || b.namespace.length === 0) {
throw new Error(`${caller}: the bundle carries no \`namespace\`. Re-export with exportCorpus().`);
}
if (!Array.isArray(b.entries)) {
throw new Error(`${caller}: \`entries\` must be an array. Re-export with exportCorpus().`);
}
b.entries.forEach((entry, i) => {
const where = `entries[${i}]${entry?.id ? ` ('${entry.id}')` : ''}`;
if (!entry || typeof entry.id !== 'string' || entry.id.length === 0) {
throw new Error(`${caller}: ${where} has no \`id\`.`);
}
if (typeof entry.text !== 'string' || entry.text.trim().length === 0) {
throw new Error(`${caller}: ${where} has no passage on \`text\`. A citable id with nothing to cite is ` +
`the blank-citation bug; a bundle never carries one.`);
}
if (!Array.isArray(entry.vector) || entry.vector.length !== dims) {
throw new Error(`${caller}: ${where} carries a vector of length ` +
`${Array.isArray(entry.vector) ? entry.vector.length : 'none'}, but the bundle's ` +
`embedder declares ${dims} dimensions. The bundle is corrupt or hand-edited — ` +
`re-export it.`);
}
});
}
exports.assertCorpusBundle = assertCorpusBundle;
/**
* The `MemoryEntry` a bundle entry seeds — in the exact shape the retrieval
* formatter reads: passage on `value.content`, provenance under
* `value.metadata`. Shared with `importCorpus` so a static corpus and an
* imported one render identically.
*/
function bundleEntryToMemoryEntry(entry, bundle) {
return {
id: entry.id,
value: {
id: entry.id,
content: entry.text,
...(entry.metadata !== undefined && { metadata: { ...entry.metadata } }),
},
version: 1,
createdAt: bundle.exportedAt,
updatedAt: bundle.exportedAt,
lastAccessedAt: bundle.exportedAt,
accessCount: 0,
embedding: [...entry.vector],
embeddingModel: bundle.embedder.id,
};
}
exports.bundleEntryToMemoryEntry = bundleEntryToMemoryEntry;
const READ_ONLY_FIX = `A static corpus is a build artifact: change it by re-indexing at build time and ` +
`re-exporting (exportCorpus), or load the bundle into a writable store with ` +
`importCorpus(store, bundle). For conversation memory alongside a static corpus, ` +
`register a SEPARATE defineMemory with its own writable store.`;
function refuseWrite(method) {
throw new Error(`staticVectorStore.${method}: this store is read-only — accepting the write and losing ` +
`it with the process would be the quiet version of data loss. ${READ_ONLY_FIX}`);
}
/**
* Serve an exported corpus bundle as a read-only, vector-capable
* `MemoryStore`.
*
* @param bundle a `CorpusBundle` from `exportCorpus` (usually
* `JSON.parse` of the shipped file).
* @param embedder optional — the embedder the RUNTIME will query with (or
* just `{ id, dimensions }`). When given, a fingerprint
* mismatch is refused HERE, at load, instead of surfacing as
* an empty retrieval at the first question. Recommended.
*
* @example
* ```ts
* import { staticVectorStore } from 'agentfootprint/memory';
* import corpus from './corpus.json';
*
* const store = staticVectorStore(corpus, embedder);
* const docs = defineRAG({ id: 'docs', store, embedder });
* ```
*/
function staticVectorStore(bundle, embedder) {
assertCorpusBundle(bundle, 'staticVectorStore');
if (embedder !== undefined) {
// The sqliteVectorStore rule, applied at load: dimensions ALWAYS decide;
// ids decide only when both sides named themselves.
if (embedder.dimensions !== bundle.embedder.dimensions) {
throw new Error(`staticVectorStore: this bundle was built in '${bundle.embedder.id}@` +
`${bundle.embedder.dimensions}' and the configured embedder produces ` +
`${embedder.dimensions}-dimensional vectors. Vectors of different lengths cannot ` +
`be compared at all. Re-export the corpus with the runtime's embedder, or ` +
`configure the embedder the corpus was built with.`);
}
if (embedder.id !== undefined && embedder.id !== bundle.embedder.id) {
throw new Error(`staticVectorStore: this bundle was built in '${bundle.embedder.id}@` +
`${bundle.embedder.dimensions}' and the configured embedder is '${embedder.id}'. ` +
`Cosine similarity between two embedding spaces is not a weak signal — it is not ` +
`a signal, and it comes back as a confident number in the same range as a real ` +
`one. Re-export the corpus with '${embedder.id}', or configure ` +
`'${bundle.embedder.id}'.`);
}
}
const entries = new Map();
for (const entry of bundle.entries) {
entries.set(entry.id, bundleEntryToMemoryEntry(entry, bundle));
}
const namespace = bundle.namespace;
const inNamespace = (identity) => (0, index_js_1.identityNamespace)(identity) === namespace;
return {
// Vectors in, ranked vectors out — the cosine scan below ranks the
// embeddings the bundle carries. Declared, so corpus builders can tell
// this apart from a server-side store (and refuse to INDEX into it —
// which they will anyway, at the first write).
supportsVectorSearch: true,
async get(identity, id) {
if (!inNamespace(identity))
return null;
// No access-count bump: the store is read-only, decay signals frozen
// at export.
return entries.get(id) ?? null;
},
async put() {
refuseWrite('put');
},
async putMany() {
refuseWrite('putMany');
},
async putIfVersion() {
refuseWrite('putIfVersion');
},
async delete() {
refuseWrite('delete');
},
async forget() {
refuseWrite('forget');
},
async recordSignature() {
refuseWrite('recordSignature');
},
async feedback() {
refuseWrite('feedback');
},
async list(identity, options) {
const limit = Math.min((0, requestLimit_js_1.storeRequestLimit)(options?.limit, 'limit') ?? 100, 1000);
if (!inNamespace(identity))
return { entries: [] };
const all = [...entries.values()];
const offset = options?.cursor ? parseInt(options.cursor, 10) : 0;
const safeOffset = Number.isFinite(offset) && offset >= 0 ? offset : 0;
const page = all.slice(safeOffset, safeOffset + limit);
const next = safeOffset + page.length;
return {
entries: page,
...(next < all.length && { cursor: String(next) }),
};
},
async seen() {
return false; // no signature set was exported; recognition is a write-side concern
},
async getFeedback() {
return null;
},
async search(identity, query, options) {
const k = (0, requestLimit_js_1.storeRequestLimit)(options?.k, 'k') ?? 10;
// A wrong-space query is refused BY NAME rather than ranked to an empty
// page — the loud version of the mismatch machinery, because on a
// static corpus "no results" reads as "the corpus has nothing to say".
if (query.length !== bundle.embedder.dimensions) {
throw new Error(`staticVectorStore.search: the query vector has ${query.length} dimensions, but ` +
`this bundle was built in '${bundle.embedder.id}@${bundle.embedder.dimensions}'. ` +
`The query was embedded with a different embedder than the corpus. Configure the ` +
`embedder the corpus was built with, or re-export the corpus.`);
}
if (options?.embedderId !== undefined && options.embedderId !== bundle.embedder.id) {
throw new Error(`staticVectorStore.search: the retriever declares embedderId '${options.embedderId}' ` +
`but this bundle was built in '${bundle.embedder.id}@` +
`${bundle.embedder.dimensions}'. Silently skipping every entry would look like an ` +
`empty corpus. Align the retriever's \`embedderId\` with the bundle, or re-export ` +
`the corpus with the new embedder.`);
}
if (!inNamespace(identity))
return [];
const tierFilter = options?.tiers ? new Set(options.tiers) : undefined;
const minScore = options?.minScore;
const scored = [];
for (const entry of entries.values()) {
if (tierFilter && (!entry.tier || !tierFilter.has(entry.tier)))
continue;
const emb = entry.embedding;
if (!emb || emb.length !== query.length)
continue;
const score = (0, cosine_js_1.cosineSimilarity)(emb, query);
if (minScore !== undefined && score < minScore)
continue;
scored.push({ entry: entry, score });
}
scored.sort((a, b) => {
if (b.score !== a.score)
return b.score - a.score;
return a.entry.id < b.entry.id ? -1 : a.entry.id > b.entry.id ? 1 : 0;
});
return scored.slice(0, k);
},
};
}
exports.staticVectorStore = staticVectorStore;
//# sourceMappingURL=staticVectorStore.js.map