UNPKG

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
"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