agentfootprint
Version:
The explainable agent framework — backtrack a wrong answer to the exact context that caused it (evidence, not guesses). Built on footprintjs.
170 lines (169 loc) • 8.17 kB
TypeScript
/**
* 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.
*/
import type { ListOptions, ListResult, MemoryStore, PutIfVersionResult } from '../../memory/store/types.js';
import type { MemoryEntry } from '../../memory/entry/index.js';
import type { MemoryIdentity } from '../../memory/identity/index.js';
import { type MemoryWriteOptions } from '../../memory/store/writeOptions.js';
/**
* Minimal `ioredis` client surface this adapter needs. Defined locally so
* we don't take a hard import on `ioredis` (lazy peer-dep) and tests can
* inject a mock implementation via `_client`.
*/
export interface RedisLikeClient {
get(key: string): Promise<string | null>;
set(key: string, value: string, ...args: ReadonlyArray<string | number>): Promise<unknown>;
del(...keys: ReadonlyArray<string>): Promise<number>;
sadd(key: string, ...members: ReadonlyArray<string>): Promise<number>;
srem(key: string, ...members: ReadonlyArray<string>): Promise<number>;
sismember(key: string, member: string): Promise<number>;
smembers(key: string): Promise<readonly string[]>;
hgetall(key: string): Promise<Record<string, string>>;
hset(key: string, ...args: ReadonlyArray<string | number>): Promise<number>;
scan(cursor: string, match: 'MATCH', pattern: string, count: 'COUNT', n: number): Promise<readonly [string, readonly string[]]>;
eval(script: string, numKeys: number, ...args: ReadonlyArray<string | number>): Promise<unknown>;
pipeline(): RedisLikePipeline;
quit(): Promise<unknown>;
}
export interface RedisLikePipeline {
set(key: string, value: string, ...args: ReadonlyArray<string | number>): RedisLikePipeline;
sadd(key: string, ...members: ReadonlyArray<string>): RedisLikePipeline;
exec(): Promise<unknown>;
}
export interface RedisStoreOptions {
/**
* Connection URL (e.g. `redis://default:password@host:6379/0`). Required
* unless `_client` is supplied.
*/
readonly url?: string;
/**
* Pre-built `ioredis` client. Use this when the host app already manages
* a Redis connection pool. Adapter does NOT call `quit()` on a borrowed
* client — caller owns the lifecycle.
*/
readonly client?: RedisLikeClient;
/** Key prefix for namespace isolation across apps sharing one Redis. Default `'agentfootprint'`. */
readonly prefix?: string;
/**
* SCAN page size when iterating keys. Default 100. Larger = fewer
* round-trips but more memory per response. Adapter never uses `KEYS *`
* (which blocks Redis).
*/
readonly scanCount?: number;
/**
* @internal Test injection point. When provided, skips the SDK require.
*/
readonly _client?: RedisLikeClient;
}
/**
* Redis-backed `MemoryStore`. Implements every method except `search()`.
*
* @throws when `ioredis` is not installed and no `_client` is supplied.
*/
export declare class RedisStore implements MemoryStore {
/**
* **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.
*/
readonly supportsVectorSearch = false;
private readonly client;
private readonly prefix;
private readonly scanCount;
private readonly ownsClient;
private closed;
constructor(options?: RedisStoreOptions);
/**
* 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 `*`.
*/
private static globLiteral;
private nsKey;
private entryKey;
private indexKey;
private sigKey;
private feedbackKey;
get<T = unknown>(identity: MemoryIdentity, id: string): Promise<MemoryEntry<T> | null>;
/**
* Write options: none honoured — Redis keeps the entry and derives nothing
* from it, so `extraction` and `namespaceScope` are refused by name.
*/
put<T = unknown>(identity: MemoryIdentity, entry: MemoryEntry<T>, options?: MemoryWriteOptions): Promise<void>;
putMany<T = unknown>(identity: MemoryIdentity, entries: readonly MemoryEntry<T>[], options?: MemoryWriteOptions): Promise<void>;
/**
* Optimistic concurrency via a small Lua script — atomic
* compare-and-swap on the JSON-encoded `version` field.
*/
putIfVersion<T = unknown>(identity: MemoryIdentity, entry: MemoryEntry<T>, expectedVersion: number, options?: MemoryWriteOptions): Promise<PutIfVersionResult>;
list<T = unknown>(identity: MemoryIdentity, options?: ListOptions): Promise<ListResult<T>>;
delete(identity: MemoryIdentity, id: string): Promise<void>;
seen(identity: MemoryIdentity, signature: string): Promise<boolean>;
recordSignature(identity: MemoryIdentity, signature: string): Promise<void>;
feedback(identity: MemoryIdentity, id: string, usefulness: number): Promise<void>;
getFeedback(identity: MemoryIdentity, id: string): Promise<{
average: number;
count: number;
} | null>;
/**
* 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.
*/
forget(identity: MemoryIdentity): Promise<void>;
/**
* Close the underlying Redis connection — only when this adapter
* owns it. Borrowed clients (passed via `client` option) are left to
* the caller. Idempotent.
*/
close(): Promise<void>;
private ensureOpen;
}