agentfootprint
Version:
The explainable agent framework — backtrack a wrong answer to the exact context that caused it (evidence, not guesses). Built on footprintjs.
338 lines • 13.2 kB
JavaScript
"use strict";
/**
* RedisStore — Redis-backed `MemoryStore` adapter (peer-dep `ioredis`).
*
* Import from the canonical subpath (the `agentfootprint/memory-redis` alias
* was removed in 4.0.0):
*
* import { RedisStore } from 'agentfootprint/memory';
*
* const store = new RedisStore({ url: 'redis://localhost:6379' });
*
* Pattern: Adapter (GoF) — translates the `MemoryStore` interface onto
* Redis primitives (key/value for entries, set for signatures,
* hash for feedback aggregates).
* Role: Outer ring. Lazy-requires `ioredis`; no runtime cost when
* another adapter is in use.
* Emits: N/A (storage adapters don't emit; recorders observe the
* memory pipeline that calls them).
*
* Vector search (`search()`) is NOT implemented in this adapter — RedisSearch
* is a separate Redis module with its own API surface. A `RedisSearchStore`
* may ship in a future release. RAG users with v2.3 should use
* `InMemoryStore` until the search-capable adapter lands.
*
* Concurrency model:
* - `put` / `putMany` use simple SET / pipelined SET (last-write-wins).
* - `putIfVersion` uses a small Lua script for atomic version compare-and-swap.
* - Multi-writer correctness ⇒ prefer `putIfVersion` in stage code.
*/
Object.defineProperty(exports, "__esModule", { value: true });
exports.RedisStore = void 0;
const requestLimit_js_1 = require("../../memory/store/requestLimit.js");
const index_js_1 = require("../../memory/identity/index.js");
const lazyRequire_js_1 = require("../../lib/lazyRequire.js");
const writeOptions_js_1 = require("../../memory/store/writeOptions.js");
/**
* Redis-backed `MemoryStore`. Implements every method except `search()`.
*
* @throws when `ioredis` is not installed and no `_client` is supplied.
*/
class RedisStore {
/**
* **No.** This adapter implements no `search()` at all — vector search over
* Redis is RedisSearch, a separate module with its own API, and a
* `RedisSearchStore` may ship later.
*
* Declared (8.19.0) rather than left to the absent method, so the corpus
* builders refuse it at the CALL, before a corpus is embedded and paid for.
* Without the declaration the same mistake still failed — one layer later,
* when `defineRAG` refused the store for having no `search()`, after the
* whole index had been built and billed.
*/
supportsVectorSearch = false;
client;
prefix;
scanCount;
ownsClient;
closed = false;
constructor(options = {}) {
this.prefix = options.prefix ?? 'agentfootprint';
this.scanCount = options.scanCount ?? 100;
if (options._client) {
this.client = options._client;
this.ownsClient = false;
}
else if (options.client) {
this.client = options.client;
this.ownsClient = false;
}
else if (options.url) {
this.client = createIoredis(options.url);
this.ownsClient = true;
}
else {
throw new Error('RedisStore requires `url` or `client` (or `_client` for tests).');
}
}
// Key helpers
/**
* A literal string → a `SCAN MATCH` pattern that matches only itself.
*
* A key is stored bytes; a MATCH pattern is a GLOB, and Redis reads it with
* `stringmatchlen`, whose metacharacters are `*`, `?`, `[` and `\` (a `]`
* outside a class is an ordinary character, so it needs no escape). The
* namespace deliberately does NOT escape `*` — it is a legal character in a
* name and escaping it in the KEY would re-key every store that has one —
* so it has to be escaped HERE, at the one place a name is used as a
* pattern instead of as data.
*
* Without this, `forget({ conversationId: '*' })` scanned `af:_/_/*:*`,
* which glob-matches every other scope's keys — and `forget` DELETES what
* it scans. That is cross-tenant destruction from one ordinary-looking id.
*
* The backslash is escaped FIRST, for the same reason the namespace encoder
* escapes `%` first: it introduces every escape that follows, so a name
* containing `\*` must not come out as an escaped `*`.
*/
static globLiteral(raw) {
return raw.replace(/\\/g, '\\\\').replace(/[*?[]/g, (ch) => `\\${ch}`);
}
nsKey(identity, suffix) {
return `${this.prefix}:${(0, index_js_1.identityNamespace)(identity)}:${suffix}`;
}
entryKey(identity, id) {
return this.nsKey(identity, `e:${id}`);
}
indexKey(identity) {
return this.nsKey(identity, 'idx');
}
sigKey(identity) {
return this.nsKey(identity, 'sig');
}
feedbackKey(identity, id) {
return this.nsKey(identity, `fb:${id}`);
}
// MemoryStore implementation
async get(identity, id) {
this.ensureOpen('get');
const raw = await this.client.get(this.entryKey(identity, id));
if (raw === null)
return null;
const entry = parseEntry(raw);
if (!entry)
return null;
if (entry.ttl !== undefined && entry.ttl <= Date.now())
return null;
return entry;
}
/**
* Write options: none honoured — Redis keeps the entry and derives nothing
* from it, so `extraction` and `namespaceScope` are refused by name.
*/
async put(identity, entry, options) {
this.ensureOpen('put');
(0, writeOptions_js_1.refuseUnhonouredWriteOptions)('RedisStore', undefined, options, writeOptions_js_1.NO_SERVER_EXTRACTION);
const payload = JSON.stringify(entry);
const eKey = this.entryKey(identity, entry.id);
if (entry.ttl !== undefined) {
const pxMs = Math.max(0, entry.ttl - Date.now());
if (pxMs <= 0)
return;
await this.client.set(eKey, payload, 'PX', pxMs);
}
else {
await this.client.set(eKey, payload);
}
await this.client.sadd(this.indexKey(identity), entry.id);
}
async putMany(identity, entries, options) {
this.ensureOpen('putMany');
if (entries.length === 0)
return;
(0, writeOptions_js_1.refuseUnhonouredWriteOptions)('RedisStore', undefined, options, writeOptions_js_1.NO_SERVER_EXTRACTION);
const pipeline = this.client.pipeline();
const ids = [];
const now = Date.now();
for (const entry of entries) {
const payload = JSON.stringify(entry);
const eKey = this.entryKey(identity, entry.id);
if (entry.ttl !== undefined) {
const pxMs = Math.max(0, entry.ttl - now);
if (pxMs <= 0)
continue;
pipeline.set(eKey, payload, 'PX', pxMs);
}
else {
pipeline.set(eKey, payload);
}
ids.push(entry.id);
}
if (ids.length > 0)
pipeline.sadd(this.indexKey(identity), ...ids);
await pipeline.exec();
}
/**
* Optimistic concurrency via a small Lua script — atomic
* compare-and-swap on the JSON-encoded `version` field.
*/
async putIfVersion(identity, entry, expectedVersion, options) {
this.ensureOpen('putIfVersion');
(0, writeOptions_js_1.refuseUnhonouredWriteOptions)('RedisStore', undefined, options, writeOptions_js_1.NO_SERVER_EXTRACTION);
const eKey = this.entryKey(identity, entry.id);
const idxKey = this.indexKey(identity);
const payload = JSON.stringify(entry);
const px = entry.ttl !== undefined ? Math.max(0, entry.ttl - Date.now()).toString() : '';
const result = (await this.client.eval(PUT_IF_VERSION_LUA, 2, eKey, idxKey, String(expectedVersion), payload, entry.id, px));
const applied = result[0] === 1;
if (applied)
return { applied: true };
return result[1] !== undefined
? { applied: false, currentVersion: result[1] }
: { applied: false };
}
async list(identity, options = {}) {
this.ensureOpen('list');
const requestedLimit = (0, requestLimit_js_1.storeRequestLimit)(options.limit, 'limit');
const idxKey = this.indexKey(identity);
const ids = await this.client.smembers(idxKey);
if (ids.length === 0)
return { entries: [] };
const entries = [];
for (const id of ids) {
const raw = await this.client.get(this.entryKey(identity, id));
if (raw === null)
continue;
const entry = parseEntry(raw);
if (!entry)
continue;
if (entry.ttl !== undefined && entry.ttl <= Date.now())
continue;
if (options.tiers && (!entry.tier || !options.tiers.includes(entry.tier)))
continue;
entries.push(entry);
}
const start = options.cursor ? parseInt(options.cursor, 10) : 0;
const limit = requestedLimit ?? entries.length;
const page = entries.slice(start, start + limit);
const next = start + limit;
return next < entries.length ? { entries: page, cursor: String(next) } : { entries: page };
}
async delete(identity, id) {
this.ensureOpen('delete');
await this.client.del(this.entryKey(identity, id), this.feedbackKey(identity, id));
await this.client.srem(this.indexKey(identity), id);
}
async seen(identity, signature) {
this.ensureOpen('seen');
const result = await this.client.sismember(this.sigKey(identity), signature);
return result === 1;
}
async recordSignature(identity, signature) {
this.ensureOpen('recordSignature');
await this.client.sadd(this.sigKey(identity), signature);
}
async feedback(identity, id, usefulness) {
this.ensureOpen('feedback');
if (!Number.isFinite(usefulness))
return;
const clamped = Math.max(-1, Math.min(1, usefulness));
const fbKey = this.feedbackKey(identity, id);
await this.client.eval(FEEDBACK_LUA, 1, fbKey, String(clamped));
}
async getFeedback(identity, id) {
this.ensureOpen('getFeedback');
const fb = await this.client.hgetall(this.feedbackKey(identity, id));
if (!fb || !fb.count || parseInt(fb.count, 10) === 0)
return null;
const count = parseInt(fb.count, 10);
const sum = parseFloat(fb.sum ?? '0');
return { average: sum / count, count };
}
/**
* GDPR — drop every key under this identity's namespace.
*
* Only the trailing `*` is a wildcard: the prefix and the namespace are
* matched LITERALLY (see {@link RedisStore.globLiteral}), so an id that
* happens to contain a glob character erases its own scope and nobody
* else's.
*/
async forget(identity) {
this.ensureOpen('forget');
const pattern = `${RedisStore.globLiteral(`${this.prefix}:${(0, index_js_1.identityNamespace)(identity)}:`)}*`;
let cursor = '0';
do {
const [next, keys] = await this.client.scan(cursor, 'MATCH', pattern, 'COUNT', this.scanCount);
cursor = next;
if (keys.length > 0)
await this.client.del(...keys);
} while (cursor !== '0');
}
/**
* Close the underlying Redis connection — only when this adapter
* owns it. Borrowed clients (passed via `client` option) are left to
* the caller. Idempotent.
*/
async close() {
if (this.closed)
return;
this.closed = true;
if (this.ownsClient)
await this.client.quit();
}
ensureOpen(op) {
if (this.closed) {
throw new Error(`RedisStore.${op}() called after close().`);
}
}
}
exports.RedisStore = RedisStore;
// Helpers
function parseEntry(raw) {
try {
return JSON.parse(raw);
}
catch {
return null;
}
}
const PUT_IF_VERSION_LUA = `
local current = redis.call('GET', KEYS[1])
local expected = tonumber(ARGV[1])
if current == false then
if expected ~= 0 then
return {0}
end
else
local cur_version = tonumber(string.match(current, '"version":(%d+)'))
if cur_version ~= expected then
return {0, cur_version}
end
end
if ARGV[4] ~= '' then
redis.call('SET', KEYS[1], ARGV[2], 'PX', tonumber(ARGV[4]))
else
redis.call('SET', KEYS[1], ARGV[2])
end
redis.call('SADD', KEYS[2], ARGV[3])
return {1}
`;
const FEEDBACK_LUA = `
redis.call('HINCRBYFLOAT', KEYS[1], 'sum', ARGV[1])
redis.call('HINCRBY', KEYS[1], 'count', 1)
return 1
`;
function createIoredis(url) {
let mod;
try {
mod = (0, lazyRequire_js_1.lazyRequire)('ioredis');
}
catch {
throw new Error('RedisStore requires the `ioredis` peer dependency.\n' +
' Install: npm install ioredis\n' +
' Or pass `_client` for test injection.');
}
const Ctor = mod.default ?? mod;
return new Ctor(url);
}
//# sourceMappingURL=redis.js.map