diff --git a/.changeset/memory-middleware.md b/.changeset/memory-middleware.md new file mode 100644 index 000000000..6598bfa11 --- /dev/null +++ b/.changeset/memory-middleware.md @@ -0,0 +1,25 @@ +--- +'@tanstack/ai': minor +'@tanstack/ai-event-client': minor +'@tanstack/ai-memory': minor +--- + +**Add server-side memory support via `memoryMiddleware`.** + +A new `memoryMiddleware` from `@tanstack/ai/memory` retrieves relevant memories at chat init and persists user/assistant turns + tool results at finish. The middleware injects a rendered system prompt before the model call and runs persistence via `ctx.defer` so streaming is never blocked. + +`@tanstack/ai`: + +- New subpath `@tanstack/ai/memory` exporting `memoryMiddleware`, the `MemoryAdapter` / `MemoryRecord` / `MemoryScope` types, the `MemoryOp` union, helpers (`scopeMatches`, `cosine`, `lexicalOverlap`, `recencyScore`, `defaultRenderMemory`, `defaultScoreHit`, `isExpired`). +- Middleware extension hooks: `shouldRetrieve`, `rerank`, `shouldRemember`, `extractMemories`, `onToolResult`, `afterPersist`, plus app-level `events.*` callbacks and a `strict` mode. + +`@tanstack/ai-event-client`: + +- Five new events on `AIDevtoolsEventMap`: `memory:retrieve:started`, `memory:retrieve:completed`, `memory:persist:started`, `memory:persist:completed`, `memory:error`. + +`@tanstack/ai-memory` (new package): + +- `inMemoryMemoryAdapter()` — zero-dep adapter for dev/tests. +- `redisMemoryAdapter({ redis, prefix? })` — production adapter for plain Redis. `ioredis` and `redis` (node-redis v4+) are both supported as optional peer dependencies. +- `nodeRedisAsRedisLike(client)` — helper for users wiring `redis` (node-redis v4+) without `legacyMode`; translates the camelCase API into the lowercase `RedisLike` shape the adapter expects. `ioredis` clients wire in directly without a wrapper. +- Both adapters pass a shared contract suite covering scope isolation, expiry, cursor pagination, kinds filtering, lexical-only ranking, semantic ranking with embeddings, and serialization round-trip (Redis). diff --git a/docs/config.json b/docs/config.json index 89d4f5abc..ba48ad3ea 100644 --- a/docs/config.json +++ b/docs/config.json @@ -164,6 +164,23 @@ } ] }, + { + "label": "Memory", + "children": [ + { + "label": "Overview", + "to": "memory/overview" + }, + { + "label": "Quickstart", + "to": "memory/quickstart" + }, + { + "label": "Custom Adapter", + "to": "memory/custom-adapter" + } + ] + }, { "label": "Advanced", "children": [ diff --git a/docs/memory/custom-adapter.md b/docs/memory/custom-adapter.md new file mode 100644 index 000000000..2648a2e51 --- /dev/null +++ b/docs/memory/custom-adapter.md @@ -0,0 +1,315 @@ +--- +title: Custom Adapter +id: memory-custom-adapter +order: 3 +description: "Write a MemoryAdapter for a backend that isn't shipped — pgvector, MongoDB, DynamoDB, Pinecone, Supabase. Walks through the eight contract members, the three isolation invariants, the shared contract test suite, and publishing as a package." +keywords: + - tanstack ai + - memory + - custom adapter + - MemoryAdapter + - pgvector + - mongodb + - dynamodb + - pinecone + - supabase + - contract suite +--- + +You have a backend in mind — pgvector, MongoDB, DynamoDB, Pinecone, Supabase, a hand-rolled SQL table — and the built-in `inMemoryMemoryAdapter` and `redisMemoryAdapter` don't fit. By the end of this guide, you'll have a working adapter that passes the shared contract suite, plugs into `memoryMiddleware`, and is ready to publish if you want. + +> **Already comfortable with the contract?** Jump to [Step 4 — Run the contract suite](#step-4--run-the-contract-suite). **First time looking at memory?** Start with the [Overview](./overview) for what `MemoryAdapter` is and what it does. + +## When to write a custom adapter + +| Situation | Use this | +|-----------|----------| +| You already use Postgres + pgvector / Supabase / Neon for app data | Custom adapter (one fewer system to operate) | +| You need ANN search through a hosted vector DB (Pinecone, Weaviate, Qdrant) | Custom adapter | +| You need DynamoDB / Cosmos / Spanner for compliance or existing infra | Custom adapter | +| You want to layer caching, encryption, or tenant routing in front of an existing adapter | Custom adapter that wraps `inMemoryMemoryAdapter` or `redisMemoryAdapter` | +| Local dev or single-process demo | `inMemoryMemoryAdapter` from `@tanstack/ai-memory` | +| Production with Redis already in your stack | `redisMemoryAdapter` from `@tanstack/ai-memory` | + +If a built-in fits, use it. The contract is documented precisely so a custom adapter is always an option — not a requirement. + +## The contract at a glance + +A `MemoryAdapter` has one identifier and seven methods. The [Overview](./overview#adapter-contract) page covers each method's semantics in detail; this guide focuses on the implementation journey. + +```ts +import type { + MemoryAdapter, + MemoryRecord, + MemoryRecordPatch, + MemoryScope, + MemoryQuery, + MemorySearchResult, + MemoryListOptions, + MemoryListResult, +} from '@tanstack/ai/memory' + +interface MemoryAdapter { + name: string + add(records: MemoryRecord | MemoryRecord[]): Promise + get(id: string, scope: MemoryScope): Promise + update(id: string, scope: MemoryScope, patch: MemoryRecordPatch): Promise + search(query: MemoryQuery): Promise + list(scope: MemoryScope, options?: MemoryListOptions): Promise + delete(ids: string[], scope: MemoryScope): Promise + clear(scope: MemoryScope): Promise +} +``` + +Three invariants every adapter MUST uphold — these are non-negotiable: + +1. **Scope isolation.** Reads and writes never cross scopes. A query for `{tenantId: 't1'}` MUST NOT return records belonging to `{tenantId: 't2'}`. +2. **Expiry filtering.** Records whose `expiresAt` is in the past MUST be excluded from `get`, `search`, and `list`. Adapters SHOULD opportunistically sweep them on `add`. +3. **Id uniqueness across all scopes.** Two records with the same `id` MUST NOT coexist, even if their scopes differ. + +The shared contract suite in `@tanstack/ai-memory/tests/contract.ts` verifies all three across every method. If your adapter passes it, the middleware works. + +## Step 1 — Scaffold the adapter shape + +Pick a backend and stub the eight members. Here's a pgvector skeleton you can copy as a starting point: + +```ts +import type { + MemoryAdapter, + MemoryListOptions, + MemoryListResult, + MemoryQuery, + MemoryRecord, + MemoryRecordPatch, + MemoryScope, + MemorySearchResult, +} from '@tanstack/ai/memory' +import type { Pool } from 'pg' + +export interface PgvectorMemoryAdapterOptions { + pool: Pool + /** Table name. Defaults to "tanstack_ai_memory". */ + table?: string +} + +export function pgvectorMemoryAdapter( + options: PgvectorMemoryAdapterOptions, +): MemoryAdapter { + const table = options.table ?? 'tanstack_ai_memory' + const pool = options.pool + + return { + name: 'pgvector', + async add(records) { /* … */ }, + async get(id, scope) { /* … */ }, + async update(id, scope, patch) { /* … */ }, + async search(query) { /* … */ }, + async list(scope, options) { /* … */ }, + async delete(ids, scope) { /* … */ }, + async clear(scope) { /* … */ }, + } +} +``` + +Pick a `name` your operators will see in logs and devtools — usually the backend's name. + +## Step 2 — Reuse the shared helpers + +`@tanstack/ai/memory` exports helpers that handle the parts of the contract that don't depend on your storage choice. Use them instead of reimplementing: + +```ts +import { + scopeMatches, + isExpired, + defaultScoreHit, + cosine, + lexicalOverlap, + recencyScore, +} from '@tanstack/ai/memory' +``` + +- `scopeMatches(recordScope, queryScope)` — the canonical "does this record match this query scope?" check. Treats empty-string values and empty objects as no-match. Use everywhere you'd filter by scope. +- `isExpired(record, now?)` — returns `true` for records past their `expiresAt`. Inject `now` for deterministic tests. +- `defaultScoreHit({ record, query, now? })` — weighted score: semantic 0.55, lexical 0.20, recency 0.15, importance 0.10. Use as your default ranker, or roll your own and reuse `cosine` / `lexicalOverlap` / `recencyScore` à la carte. + +If your backend has native vector or full-text search (pgvector's `<->`, Postgres `ts_rank`, Pinecone's score), prefer it — the helpers are for adapters with no native ranking. + +## Step 3 — Implement each method + +Implementation specifics are backend-dependent, but the shape is the same everywhere. A pgvector example for `add` and `search` makes the pattern concrete: + +```ts +async add(input) { + const batch = Array.isArray(input) ? input : [input] + const now = Date.now() + + for (const r of batch) { + await pool.query( + `INSERT INTO ${table} (id, tenant_id, user_id, session_id, thread_id, namespace, + text, kind, role, created_at, updated_at, expires_at, + importance, embedding, metadata) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15) + ON CONFLICT (id) DO UPDATE SET + tenant_id = EXCLUDED.tenant_id, + user_id = EXCLUDED.user_id, + session_id = EXCLUDED.session_id, + thread_id = EXCLUDED.thread_id, + namespace = EXCLUDED.namespace, + text = EXCLUDED.text, + kind = EXCLUDED.kind, + role = EXCLUDED.role, + updated_at = EXCLUDED.updated_at, + expires_at = EXCLUDED.expires_at, + importance = EXCLUDED.importance, + embedding = EXCLUDED.embedding, + metadata = EXCLUDED.metadata`, + [ + r.id, r.scope.tenantId ?? null, r.scope.userId ?? null, + r.scope.sessionId ?? null, r.scope.threadId ?? null, r.scope.namespace ?? null, + r.text, r.kind, r.role ?? null, r.createdAt ?? now, now, + r.expiresAt ?? null, r.importance ?? null, + r.embedding ? JSON.stringify(r.embedding) : null, + r.metadata ? JSON.stringify(r.metadata) : null, + ], + ) + } +}, + +async search(query: MemoryQuery): Promise { + const topK = query.topK ?? 6 + const minScore = query.minScore ?? 0 + const offset = query.cursor ? Number.parseInt(query.cursor, 10) || 0 : 0 + + const { rows } = await pool.query( + `SELECT *, + CASE WHEN $1::vector IS NOT NULL AND embedding IS NOT NULL + THEN 1 - (embedding <=> $1::vector) + ELSE 0 + END AS score + FROM ${table} + WHERE ($2::text IS NULL OR tenant_id = $2) + AND ($3::text IS NULL OR user_id = $3) + AND ($4::text IS NULL OR session_id = $4) + AND ($5::text IS NULL OR thread_id = $5) + AND ($6::text IS NULL OR namespace = $6) + AND (expires_at IS NULL OR expires_at > $7) + AND ($8::text[] IS NULL OR kind = ANY($8)) + ORDER BY score DESC + OFFSET $9 LIMIT $10`, + [ + query.embedding ? JSON.stringify(query.embedding) : null, + query.scope.tenantId ?? null, query.scope.userId ?? null, + query.scope.sessionId ?? null, query.scope.threadId ?? null, + query.scope.namespace ?? null, + Date.now(), + query.kinds ?? null, + offset, topK + 1, + ], + ) + + const hits = rows.slice(0, topK).map((row) => ({ + record: rowToRecord(row), + score: Number(row.score), + })).filter((h) => h.score >= minScore) + + return { + hits, + nextCursor: rows.length > topK ? String(offset + topK) : undefined, + } +} +``` + +The shape generalizes: every method takes a `scope`, does its backend-specific work, and respects the three invariants. For backends without native search, fall back to "load scope-matched records, score via `defaultScoreHit`, sort, slice" — that's exactly what `inMemoryMemoryAdapter` does. + +## Step 4 — Run the contract suite + +The shared test suite in `@tanstack/ai-memory/tests/contract.ts` is the canonical verification for any adapter. Import `runMemoryAdapterContract` and point it at a factory that returns a fresh adapter: + +```ts +// tests/pgvector.test.ts +import { Pool } from 'pg' +import { runMemoryAdapterContract } from '@tanstack/ai-memory/tests/contract' +import { pgvectorMemoryAdapter } from '../src/pgvector' + +runMemoryAdapterContract('pgvectorMemoryAdapter', async () => { + const pool = new Pool({ connectionString: process.env.TEST_DATABASE_URL }) + // Truncate the table between tests so each test gets a clean adapter. + await pool.query('TRUNCATE tanstack_ai_memory') + return pgvectorMemoryAdapter({ pool }) +}) +``` + +The suite covers `add` (single, batch, upsert), `get`, `update`, `search` (topK, minScore, kinds filter, cursor pagination, lexical-vs-semantic ranking), `list`, `delete`, `clear`, scope isolation across every method, expiry filtering, partial-scope cascades, glob metacharacter safety, and colon and underscore safety. If your adapter passes, every adapter-level contract guarantee is met. + +The contract module isn't re-exported from `@tanstack/ai-memory`'s public entry yet — import directly from `@tanstack/ai-memory/tests/contract` until that lands. + +## Step 5 — Wire it into `memoryMiddleware` + +Once the contract suite is green, the adapter is interchangeable with the built-ins: + +```ts +import { chat } from '@tanstack/ai' +import { openaiText } from '@tanstack/ai-openai' +import { memoryMiddleware } from '@tanstack/ai/memory' +import { Pool } from 'pg' +import { pgvectorMemoryAdapter } from './pgvector-adapter' + +const pool = new Pool({ connectionString: process.env.DATABASE_URL }) +const memory = pgvectorMemoryAdapter({ pool }) + +const stream = chat({ + adapter: openaiText('gpt-4o'), + messages, + middleware: [memoryMiddleware({ adapter: memory, scope })], +}) +``` + +Everything the middleware does — retrieval, deferred persistence, `extractMemories`, `onToolResult`, `afterPersist`, devtools events — works exactly the same. The middleware never inspects the adapter's internals; the contract is the entire interface. + +## Step 6 — Publish (optional) + +If you want others to use your adapter, ship it as its own package. The conventions: + +- Name it `@your-org/ai-memory-` (e.g. `@acme/ai-memory-pgvector`). +- List `@tanstack/ai` as a peer dependency with a workspace-friendly range — `">=0.16.0 <1"` is typical. +- List your backend client (`pg`, `mongodb`, `@pinecone-database/pinecone`, …) as a peer dependency, marked optional via `peerDependenciesMeta` if your adapter accepts any compatible shape (BYO-client pattern, like `redisMemoryAdapter`). +- Include the contract suite as a `devDependency` so consumers can run the same tests against forks. +- Re-export the relevant types from `@tanstack/ai/memory` for ergonomics. + +A minimal `package.json` for a published adapter: + +```json +{ + "name": "@acme/ai-memory-pgvector", + "version": "0.1.0", + "type": "module", + "exports": { ".": { "types": "./dist/index.d.ts", "import": "./dist/index.js" } }, + "peerDependencies": { + "@tanstack/ai": ">=0.16.0 <1", + "pg": ">=8" + }, + "peerDependenciesMeta": { "pg": { "optional": false } }, + "devDependencies": { + "@tanstack/ai": "^0.16.0", + "@tanstack/ai-memory": "^0.1.0", + "pg": "^8", + "vitest": "^1" + } +} +``` + +## Pitfalls + +A few things that catch first-time adapter authors: + +- **Don't trust the caller's `record.scope`.** The middleware overrides it before calling `add`, so adapter implementations should not silently rewrite scope based on caller intent. If your storage encodes scope into keys, take it from the record you were handed — and treat empty values defensively. +- **Escape your delimiters.** If your storage serializes scope into a composite key, escape any character your delimiter uses (`:`, `_`, `/`, …) when it appears inside a user-supplied scope value. Otherwise a tenant whose id legitimately contains the delimiter will collide with sub-scope buckets. The Redis adapter handles this with an `escapeScopeValue` helper. +- **Make `clear` cascade correctly.** `clear({tenantId: 't1'})` MUST wipe every record whose scope is `t1`-prefixed (e.g. `{tenantId: 't1', userId: 'u1'}`), not only records whose scope is exactly `{tenantId: 't1'}`. This is the partial-scope contract — the in-memory adapter gets it for free via `scopeMatches`; the Redis adapter implements it via SCAN over a glob pattern. +- **Multi-step writes are not atomic by default.** If your backend supports transactions (Postgres, MongoDB sessions, DynamoDB transact-write), use them for `add` on scope changes and for `clear`. Document the consistency guarantee you provide. +- **Refuse `clear({})`.** Empty scope is documented as misuse. `scopeMatches` returns `false` for it, so adapters using the helper get the guard for free. Adapters that bypass `scopeMatches` (Redis with its SCAN path) need an explicit `hasAnyScopeKey` check. + +## Where to go next + +- [Overview](./overview) — adapter contract, hooks reference, devtools events, failure modes +- [Quickstart](./quickstart) — wire `memoryMiddleware` into a real `chat()` call +- [Middleware](../advanced/middleware) — the underlying `chat()` middleware lifecycle, useful when your adapter needs to coordinate with other middlewares diff --git a/docs/memory/overview.md b/docs/memory/overview.md new file mode 100644 index 000000000..2e49c35e7 --- /dev/null +++ b/docs/memory/overview.md @@ -0,0 +1,171 @@ +--- +title: Overview +id: memory-overview +order: 1 +description: "Persist and recall context across turns and sessions in TanStack AI — the memoryMiddleware retrieves relevant records into the prompt, then deferred-persists user, assistant, and tool turns through a pluggable adapter." +keywords: + - tanstack ai + - memory + - long-term memory + - retrieval + - persistence + - middleware + - rag + - personalization +--- + +`memoryMiddleware` plugs server-side memory into a `chat()` run. It retrieves relevant records from a pluggable adapter into the system prompt before the model runs, then asynchronously persists what should be remembered after the run finishes. It is the right tool when you need recall **across turns or across sessions** — not for keeping recent messages in the same request. + +> **Want a copy-paste setup before reading the contract?** See the [Memory Quickstart](./quickstart) guide. **Building an adapter for a backend that isn't shipped?** See the [Custom Adapter](./custom-adapter) guide. + +## When to reach for it + +| Need | Use this | +|------|----------| +| "Remember what the user told me last week" | Memory middleware + persistent adapter | +| "Each tenant or user has its own context" | Memory middleware with scoped adapter calls | +| "Cache expensive tool results across requests" | Memory middleware with `onToolResult` + `kind: 'tool-result'` | +| Keep the last N turns in the same request | Just pass them in `messages` — memory is overkill | + +Memory is for cross-turn / cross-session recall. The `messages` array on `chat()` already covers within-turn history. + +## Adapter contract + +Adapters are thin storage. They persist, fetch, search, and isolate by scope — they do not decide what to remember or how to render hits. Every backend implements the same seven methods: + +| Method | Purpose | +|--------|---------| +| `name` | Stable identifier used in logs and devtools. | +| `add(records)` | Upsert one or many records by `id`. Same id replaces. | +| `get(id, scope)` | Fetch a single record. Returns `undefined` for missing, out-of-scope, or expired records. | +| `update(id, scope, patch)` | Patch a record in place. Preserves `id`/`scope`/`createdAt`, bumps `updatedAt`. | +| `search(query)` | Relevance-ranked search within a scope. Strategy (lexical / semantic / hybrid) is adapter-defined. | +| `list(scope, options)` | Non-relevance browsing — for inspectors, admin tools, exports. | +| `delete(ids, scope)` | Remove ids within a scope. Out-of-scope ids are silently skipped. | +| `clear(scope)` | Wipe everything matching a scope. Empty scope (`{}`) is treated as misuse. | + +Three invariants every adapter MUST uphold: **scope isolation** (no cross-scope reads or writes), **expiry filtering** (`expiresAt` records are excluded from reads), and **id uniqueness** across all scopes. + +Built-in adapters live in `@tanstack/ai-memory`: + +```ts +import { inMemoryMemoryAdapter, redisMemoryAdapter } from '@tanstack/ai-memory' +``` + +Custom adapters implement `MemoryAdapter` from `@tanstack/ai/memory` — see the [Custom Adapter](./custom-adapter) guide for a complete walkthrough. + +## Scope and security + +`MemoryScope` is the isolation boundary. Every key is optional and orthogonal — the adapter rejects cross-scope reads and writes: + +```ts +import type { MemoryScope } from '@tanstack/ai/memory' + +type MemoryScope = { + tenantId?: string + userId?: string + sessionId?: string + threadId?: string + namespace?: string +} +``` + +**Always derive scope server-side from trusted state.** Accepting `tenantId` or `userId` from the request body is how one user reads another user's memory. The function form on `scope` is the recommended pattern — it runs per request and has access to the validated chat context: + +```ts +memoryMiddleware({ + adapter, + scope: (ctx) => { + const session = (ctx.context as AppCtx).session // server-validated + return { + tenantId: session.tenantId, + userId: session.userId, + threadId: session.activeThreadId, + } + }, +}) +``` + +Pass the validated session through `chat({ context: { session } })`. The static form (`scope: { tenantId: 'acme' }`) is fine for single-tenant or test fixtures, but the function form is safer in any multi-tenant deployment. + +## Retrieval flow + +Retrieval runs once per `chat()` invocation, during the `init` phase: + +1. `shouldRetrieve({ userText, scope })` — optional gate. Return `false` to skip retrieval entirely for this turn. +2. `adapter.search({ scope, text, embedding?, topK, minScore, kinds })` — the adapter decides whether to use the embedding (semantic), the text (lexical), or both (hybrid). +3. `rerank(hits, { scope, query, ctx })` — optional re-rank between search and render. Plug in MMR, RRF, or a cross-encoder. +4. `render(hits)` — formats the final hit set into a string injected into the prompt. Defaults to `defaultRenderMemory`. + +An `embedder` is **optional**. Adapters that support semantic search (Redis with vector ops, hosted vector DBs) need one; lexical-only setups don't. + +## Persistence flow + +Persistence is **deferred** via `ctx.defer` — it runs after the chat stream finishes and never blocks the response: + +1. `shouldRemember({ message, responseText })` — optional gate on whether to write at all this turn. +2. The middleware persists user and assistant turns as `kind: 'message'`. +3. `extractMemories({ userText, responseText, scope, adapter })` — return a `MemoryOp[]` (mixed add/update/delete) or `MemoryRecord[]` (treated as all-add) to capture facts, preferences, or summaries. +4. For each completed tool call, `onToolResult({ toolName, toolCallId, args, result, scope, adapter })` — same return shape, typically used to persist results as `kind: 'tool-result'`. +5. `afterPersist({ newRecords, scope, adapter })` — fires after `adapter.add` commits, with newly-added records (not updates or deletes). + +## Extension hooks + +| Hook | Phase | Use for | +|------|-------|---------| +| `shouldRetrieve` | before search | Skip retrieval for cheap turns or content-gated requests | +| `rerank` | between search and render | MMR, RRF, recency boosts, cross-encoder rerankers | +| `shouldRemember` | before persist | Drop short, sensitive, or transient messages | +| `extractMemories` | after model finishes | Mem0-style consolidation — extract facts and preferences | +| `onToolResult` | per completed tool call | Persist tool outputs as `kind: 'tool-result'` | +| `afterPersist` | after `adapter.add` commits | Background work — summarisation, eviction, indexing | + +`extractMemories` and `onToolResult` may return `MemoryRecord[]` (shorthand: all-add) or `MemoryOp[]` (mixed `add` / `update` / `delete`). + +## Devtools events + +The middleware emits five events on `aiEventClient` (from `@tanstack/ai-event-client`): + +| Event | When | +|-------|------| +| `memory:retrieve:started` | Retrieval path begins (after `shouldRetrieve` returns true) | +| `memory:retrieve:completed` | Final hit set is ready (post-rerank, pre-render) | +| `memory:persist:started` | Persist path is about to call `adapter.add` | +| `memory:persist:completed` | `adapter.add` succeeded | +| `memory:error` | Retrieval, persistence, or extraction threw | + +Hits and records carry a 200-character `preview` only — full text is never streamed by default, so devtools never leak full memory contents. + +For application telemetry that should not depend on devtools being installed, use the `events.*` callbacks on `MemoryMiddlewareOptions` (`onRetrieveStart`, `onRetrieveEnd`, `onPersistStart`, `onPersistEnd`, `onError`). + +## Failure modes + +By default `strict: false` — retrieval and persistence failures emit `memory:error` (and call `events.onError`), but the chat run continues with degraded memory. Set `strict: true` when memory correctness is more important than uptime, for example in compliance-sensitive deployments or in tests where a missed write is worse than a failed turn. + +## TypeScript types + +```ts +import type { + MemoryAdapter, + MemoryRecord, + MemoryRecordPatch, + MemoryScope, + MemoryQuery, + MemorySearchResult, + MemoryListOptions, + MemoryListResult, + MemoryHit, + MemoryKind, + MemoryRole, + MemoryEmbedder, + MemoryOp, + MemoryMiddlewareOptions, +} from '@tanstack/ai/memory' +``` + +## Next steps + +- [Memory Quickstart](./quickstart) — wire the middleware into a real `chat()` call in five steps +- [Custom Adapter](./custom-adapter) — implement `MemoryAdapter` for an unsupported backend +- [Middleware](../advanced/middleware) — the underlying `chat()` middleware lifecycle and hooks +- [Observability](../advanced/observability) — subscribe to `memory:*` events for tracing diff --git a/docs/memory/quickstart.md b/docs/memory/quickstart.md new file mode 100644 index 000000000..4be3a907a --- /dev/null +++ b/docs/memory/quickstart.md @@ -0,0 +1,143 @@ +--- +title: Quickstart +id: memory-quickstart +order: 2 +description: "Add cross-session memory to a TanStack AI chat() call in five steps — install the package, pick an adapter, wire memoryMiddleware, optionally add an embedder, and derive scope server-side." +keywords: + - tanstack ai + - memory + - quickstart + - in-memory adapter + - redis adapter + - chat middleware +--- + +You have a working `chat()` call and you want it to remember context across turns or sessions. By the end of this guide, you'll have `memoryMiddleware` retrieving relevant records into the prompt and persisting new turns through a real adapter, with scope derived safely from your server-validated session. + +> **Want the full contract first?** See the [Overview](./overview) page for the adapter interface, hooks, and devtools events. + +## Step 1 — Install the package + +`@tanstack/ai` is already installed. Add the adapter package: + +```bash +pnpm add @tanstack/ai-memory +``` + +`@tanstack/ai-memory` exports the built-in `inMemoryMemoryAdapter` and `redisMemoryAdapter`. The middleware itself (`memoryMiddleware`) and the type contract (`MemoryAdapter`, `MemoryScope`, `MemoryRecord`, ...) live on the `@tanstack/ai/memory` subpath of the core package — no extra install required for those. + +## Step 2 — Pick an adapter + +> **In-memory** — `inMemoryMemoryAdapter()` is zero-dependency and stores records in a `Map`. Use it for local development, Vitest / Playwright tests, and single-process demos. Records vanish on process restart. +> +> **Redis** — `redisMemoryAdapter({ redis })` persists across restarts and shares state across processes. Use it for production. Bring your own Redis client (`ioredis`, `redis`, Upstash, ...) — the adapter is BYO-client. + +Custom adapters implement the `MemoryAdapter` interface from `@tanstack/ai/memory`. See [Custom Adapter](./custom-adapter) for the full authoring journey. + +## Step 3 — Wire `memoryMiddleware` into `chat()` + +Start with the in-memory adapter — it's the fastest path to a working setup: + +```ts +import { chat } from '@tanstack/ai' +import { openaiText } from '@tanstack/ai-openai' +import { memoryMiddleware } from '@tanstack/ai/memory' +import { inMemoryMemoryAdapter } from '@tanstack/ai-memory' + +const memory = inMemoryMemoryAdapter() + +const stream = chat({ + adapter: openaiText('gpt-4o'), + messages, + middleware: [ + memoryMiddleware({ + adapter: memory, + scope: { tenantId: 'demo', userId: 'alice' }, + }), + ], +}) +``` + +That's a working setup. Each turn, the middleware retrieves relevant records into the system prompt (lexical search by default), then deferred-persists the user message and the assistant response after the stream finishes. + +When you're ready to ship, swap the adapter and keep everything else the same: + +```ts +import Redis from 'ioredis' +import { redisMemoryAdapter } from '@tanstack/ai-memory' + +const redis = new Redis(process.env.REDIS_URL!) +const memory = redisMemoryAdapter({ redis }) + +memoryMiddleware({ adapter: memory, scope }) +``` + +> **Using `redis` (node-redis v4+) instead of `ioredis`?** node-redis exposes a camelCase API by default (`sAdd`, `mGet`, …) which does not match the adapter's lowercase `RedisLike` contract. Wrap the client with `nodeRedisAsRedisLike` from `@tanstack/ai-memory` before passing it in. See the [Custom Adapter](./custom-adapter) guide and the [`tanstack-ai-memory-redis` skill](https://github.com/TanStack/ai/blob/main/packages/typescript/ai-memory/skills/tanstack-ai-memory-redis/SKILL.md) for the full example. + +## Step 4 — Add an embedder (optional) + +The middleware accepts an `embedder` for semantic search. **Add one when you need it; skip it when you don't:** + +- **Skip** if your scopes are small (a few hundred records per user) — lexical scoring handles this fine and there is no embedding cost or latency. +- **Add** when scopes grow large or queries don't share keywords with stored records, and your adapter supports vector search (Redis with vector ops, hosted vector DBs, custom adapters). + +```ts +import OpenAI from 'openai' +import { memoryMiddleware } from '@tanstack/ai/memory' + +const openai = new OpenAI() + +memoryMiddleware({ + adapter: memory, + scope, + embedder: { + async embed(text) { + // Use any embedding model — OpenAI, Cohere, a local model, etc. + const result = await openai.embeddings.create({ + model: 'text-embedding-3-small', + input: text, + }) + return result.data[0].embedding + }, + }, +}) +``` + +The embedder is invoked on the retrieval path (to embed the query) and may be invoked again on the persist path (to embed assistant text or extracted facts). Implementations should be idempotent. + +## Step 5 — Derive scope server-side + +`scope` is the isolation boundary. Static scopes are fine for fixtures, but in any real multi-tenant app you must derive scope per request from server-validated session data — never from the request body. + +```ts +import { chat } from '@tanstack/ai' +import { memoryMiddleware } from '@tanstack/ai/memory' + +type AppCtx = { session: { tenantId: string; userId: string; activeThreadId: string } } + +const stream = chat({ + adapter: openaiText('gpt-4o'), + messages, + context: { session }, // attached by your auth middleware, not from req.body + middleware: [ + memoryMiddleware({ + adapter: memory, + scope: (ctx) => { + const { session } = ctx.context as AppCtx + return { + tenantId: session.tenantId, + userId: session.userId, + threadId: session.activeThreadId, + } + }, + }), + ], +}) +``` + +If you accept `userId` or `tenantId` from the client, one user can read or overwrite another user's memory. The function form on `scope` is the safer default — it executes per request and only sees what your server attached to the chat context. + +## Where to go next + +- [Overview](./overview) — adapter contract, hooks reference, devtools events, failure modes +- [Custom Adapter](./custom-adapter) — implement `MemoryAdapter` for a backend not shipped (pgvector, MongoDB, Pinecone, …) diff --git a/knip.json b/knip.json index 7ece05b5b..b8b928395 100644 --- a/knip.json +++ b/knip.json @@ -37,6 +37,9 @@ "packages/typescript/ai-client": { "ignoreDependencies": ["@standard-schema/spec"] }, + "packages/typescript/ai-memory": { + "ignoreDependencies": ["ioredis", "redis"] + }, "packages/typescript/ai-react-ui": { "ignoreDependencies": ["react-dom"] }, diff --git a/packages/typescript/ai-event-client/src/index.ts b/packages/typescript/ai-event-client/src/index.ts index e934adf40..b16af230f 100644 --- a/packages/typescript/ai-event-client/src/index.ts +++ b/packages/typescript/ai-event-client/src/index.ts @@ -614,6 +614,68 @@ export interface VideoUsageEvent extends BaseEventContext { usage: TokenUsage } +// --------------------------------------------------------------------------- +// Memory events +// --------------------------------------------------------------------------- + +export type MemoryScopeLite = { + tenantId?: string + userId?: string + sessionId?: string + threadId?: string + namespace?: string +} + +export type MemoryKindLite = + | 'message' + | 'summary' + | 'fact' + | 'preference' + | 'tool-result' + +export type MemoryRoleLite = 'user' | 'assistant' | 'system' | 'tool' + +export interface MemoryRetrieveStartedEvent extends BaseEventContext { + scope: MemoryScopeLite + query: string + topK: number + minScore: number + embedderUsed: boolean +} + +export interface MemoryRetrieveCompletedEvent extends BaseEventContext { + scope: MemoryScopeLite + hits: Array<{ + id: string + kind: MemoryKindLite + score: number + preview: string + }> + durationMs: number +} + +export interface MemoryPersistStartedEvent extends BaseEventContext { + scope: MemoryScopeLite + records: Array<{ + id: string + kind: MemoryKindLite + role?: MemoryRoleLite + preview: string + }> +} + +export interface MemoryPersistCompletedEvent extends BaseEventContext { + scope: MemoryScopeLite + recordIds: Array + durationMs: number +} + +export interface MemoryErrorEvent extends BaseEventContext { + scope: MemoryScopeLite + phase: 'retrieve' | 'persist' | 'extract' + error: { name: string; message: string } +} + // =========================== // Client Events // =========================== @@ -729,6 +791,13 @@ export interface AIDevtoolsEventMap { 'client:messages:cleared': ClientMessagesClearedEvent 'client:reloaded': ClientReloadedEvent 'client:stopped': ClientStoppedEvent + + // Memory events + 'memory:retrieve:started': MemoryRetrieveStartedEvent + 'memory:retrieve:completed': MemoryRetrieveCompletedEvent + 'memory:persist:started': MemoryPersistStartedEvent + 'memory:persist:completed': MemoryPersistCompletedEvent + 'memory:error': MemoryErrorEvent } class AiEventClient extends EventClient { diff --git a/packages/typescript/ai-memory/package.json b/packages/typescript/ai-memory/package.json new file mode 100644 index 000000000..916e73e72 --- /dev/null +++ b/packages/typescript/ai-memory/package.json @@ -0,0 +1,71 @@ +{ + "name": "@tanstack/ai-memory", + "version": "0.0.0", + "description": "Pluggable memory adapters for TanStack AI memoryMiddleware", + "author": "", + "license": "MIT", + "repository": { + "type": "git", + "url": "git+https://github.com/TanStack/ai.git", + "directory": "packages/typescript/ai-memory" + }, + "type": "module", + "module": "./dist/esm/index.js", + "types": "./dist/esm/index.d.ts", + "exports": { + ".": { + "types": "./dist/esm/index.d.ts", + "import": "./dist/esm/index.js" + }, + "./adapters/in-memory": { + "types": "./dist/esm/adapters/in-memory.d.ts", + "import": "./dist/esm/adapters/in-memory.js" + }, + "./adapters/redis": { + "types": "./dist/esm/adapters/redis.d.ts", + "import": "./dist/esm/adapters/redis.js" + } + }, + "sideEffects": false, + "files": [ + "dist", + "src", + "skills" + ], + "scripts": { + "build": "vite build", + "clean": "premove ./build ./dist", + "lint:fix": "eslint ./src --fix", + "test:build": "publint --strict", + "test:eslint": "eslint ./src", + "test:lib": "vitest --passWithNoTests", + "test:lib:dev": "pnpm test:lib --watch", + "test:types": "tsc" + }, + "keywords": [ + "ai", + "tanstack", + "memory", + "redis", + "rag" + ], + "peerDependencies": { + "@tanstack/ai": "workspace:^", + "ioredis": ">=5.0.0", + "redis": ">=4.0.0" + }, + "peerDependenciesMeta": { + "ioredis": { + "optional": true + }, + "redis": { + "optional": true + } + }, + "devDependencies": { + "@tanstack/ai": "workspace:*", + "@vitest/coverage-v8": "4.0.14", + "ioredis-mock": "^8.9.0", + "redis": "^4.7.0" + } +} diff --git a/packages/typescript/ai-memory/project.json b/packages/typescript/ai-memory/project.json new file mode 100644 index 000000000..242f783af --- /dev/null +++ b/packages/typescript/ai-memory/project.json @@ -0,0 +1,3 @@ +{ + "name": "@tanstack/ai-memory" +} diff --git a/packages/typescript/ai-memory/skills/tanstack-ai-memory-in-memory/SKILL.md b/packages/typescript/ai-memory/skills/tanstack-ai-memory-in-memory/SKILL.md new file mode 100644 index 000000000..d5e558fb7 --- /dev/null +++ b/packages/typescript/ai-memory/skills/tanstack-ai-memory-in-memory/SKILL.md @@ -0,0 +1,42 @@ +--- +name: tanstack-ai-memory-in-memory +description: Use when wiring inMemoryMemoryAdapter from @tanstack/ai-memory — explains setup, when to pick it (dev/tests/single-process demos), and what NOT to use it for (anything multi-process or persistent). +--- + +# In-Memory Memory Adapter + +Zero-dependency `MemoryAdapter` backed by a `Map`. Records vanish on process restart. + +## When to use it + +- Local development. +- Vitest / Playwright tests. +- Single-process demos where users don't need persistence. + +## When NOT to use it + +- Production multi-process deployments — every worker has its own Map; users get inconsistent memory. +- Anything that needs survivability across restarts. + +For production, use `redisMemoryAdapter` (see `tanstack-ai-memory-redis` skill). + +## Setup + +```ts +import { memoryMiddleware } from '@tanstack/ai/memory' +import { inMemoryMemoryAdapter } from '@tanstack/ai-memory' + +const memory = inMemoryMemoryAdapter() + +memoryMiddleware({ adapter: memory, scope }) +``` + +That's the entire setup — there are no options and no peer dependencies. + +## Capacity + +The adapter holds records in a single `Map`. Don't load > ~100k records or search latency degrades (it scans every record per query). For larger workloads, switch to Redis. + +## Expiry + +`MemoryRecord.expiresAt` is honored — expired records are filtered from `search`/`list`/`get` and opportunistically swept on `add`. diff --git a/packages/typescript/ai-memory/skills/tanstack-ai-memory-redis/SKILL.md b/packages/typescript/ai-memory/skills/tanstack-ai-memory-redis/SKILL.md new file mode 100644 index 000000000..fda27203b --- /dev/null +++ b/packages/typescript/ai-memory/skills/tanstack-ai-memory-redis/SKILL.md @@ -0,0 +1,82 @@ +--- +name: tanstack-ai-memory-redis +description: Use when wiring redisMemoryAdapter from @tanstack/ai-memory in production — covers client setup (node-redis or ioredis), env wiring, storage model, plain-Redis vs RediSearch tradeoffs, and troubleshooting connection / serialization issues. +--- + +# Redis Memory Adapter + +Production-grade `MemoryAdapter` backed by plain Redis (no vector index required). + +## Setup + +Pick a Redis client and wire it in. Both `ioredis` and `redis` (node-redis v4+) are supported, but they expose different method-name styles, so the wiring differs. + +### Option A: `ioredis` (direct wiring) + +```bash +pnpm add ioredis +``` + +```ts +import Redis from 'ioredis' +import { memoryMiddleware } from '@tanstack/ai/memory' +import { redisMemoryAdapter } from '@tanstack/ai-memory' + +const redis = new Redis(process.env.REDIS_URL!) +const memory = redisMemoryAdapter({ redis, prefix: 'myapp:memory' }) + +memoryMiddleware({ adapter: memory, scope }) +``` + +`ioredis` exposes lowercase method names (`sadd`, `mget`, `scan(cursor, 'MATCH', ...)`) directly, which matches the adapter's `RedisLike` contract — no wrapper needed. + +### Option B: `redis` (node-redis v4+) — wrap with `nodeRedisAsRedisLike` + +```bash +pnpm add redis +``` + +```ts +import { createClient } from 'redis' +import { memoryMiddleware } from '@tanstack/ai/memory' +import { redisMemoryAdapter, nodeRedisAsRedisLike } from '@tanstack/ai-memory' + +const client = createClient({ url: process.env.REDIS_URL }) +await client.connect() + +const memory = redisMemoryAdapter({ + redis: nodeRedisAsRedisLike(client), + prefix: 'myapp:memory', +}) + +memoryMiddleware({ adapter: memory, scope }) +``` + +node-redis v4+ uses a camelCase API by default (`sAdd`, `mGet`, `scan(cursor, { MATCH, COUNT })`); `nodeRedisAsRedisLike` translates between the two shapes. Passing a raw node-redis v4+ client without the wrapper will throw `client.sadd is not a function` at runtime. + +(You can also use `createClient({ legacyMode: true })` and skip the wrapper, but the wrapper is the cleaner choice for new code — `legacyMode` is deprecated upstream.) + +### `RedisLike` shape + +The adapter accepts any client implementing the `RedisLike` shape: `get`, `set`, `del`, `sadd`, `srem`, `smembers`, `mget`, `scan` (ioredis-style variadic). Bring-your-own clients (e.g. Upstash, hand-rolled mocks) only need to implement that subset. + +## Storage model + +```text +{prefix}:record:{memoryId} → JSON-stringified MemoryRecord +{prefix}:index:{tenantId}:{userId}:{sessionId}:{threadId}:{namespace} → Set +``` + +Missing scope keys are encoded as `_`. Updates rewrite the JSON; deletes remove from both the record key and the scope set. + +## Plain Redis vs RediSearch / RedisVL + +This adapter performs ranking **client-side**: it loads every record for a scope into Node and computes lexical + cosine + recency + importance scores. That's fine up to ~10k records per scope. Beyond that, latency degrades. + +For larger scopes use a vector-index-aware adapter. None ships in v1; write one against the same `MemoryAdapter` contract or wait for a future `redisVectorMemoryAdapter`. + +## Troubleshooting + +- **Records not visible across processes:** check that all processes use the same `REDIS_URL` and `prefix`. The adapter does not auto-namespace by host. +- **Records expiring unexpectedly:** check whether your records carry `expiresAt`; the adapter sweeps these on read. If you do not want expiry, leave `expiresAt` undefined. +- **Malformed JSON rows:** if the JSON in `{prefix}:record:{id}` is malformed (older schema, third-party writer), the adapter silently skips the row. There is no exception you can catch — the only observable signal is a one-time `console.warn` per process. To detect drift, periodically run `list(scope)` and compare counts to your application's source of truth, then clean up the offending rows via `clear(scope)` or by deleting the underlying record keys directly. diff --git a/packages/typescript/ai-memory/src/adapters/in-memory.ts b/packages/typescript/ai-memory/src/adapters/in-memory.ts new file mode 100644 index 000000000..ab98940d7 --- /dev/null +++ b/packages/typescript/ai-memory/src/adapters/in-memory.ts @@ -0,0 +1,144 @@ +import { defaultScoreHit, isExpired, scopeMatches } from '@tanstack/ai/memory' +import type { + MemoryAdapter, + MemoryListOptions, + MemoryListResult, + MemoryQuery, + MemoryRecord, + MemoryScope, + MemorySearchResult, +} from '@tanstack/ai/memory' + +export function inMemoryMemoryAdapter(): MemoryAdapter { + const records = new Map() + + function liveRecords(): Array { + const now = Date.now() + const out: Array = [] + for (const r of records.values()) { + if (isExpired(r, now)) records.delete(r.id) + else out.push(r) + } + return out + } + + function scopedLive(scope: MemoryScope): Array { + return liveRecords().filter((r) => scopeMatches(r.scope, scope)) + } + + return { + name: 'in-memory', + + async add(input) { + const batch = Array.isArray(input) ? input : [input] + const now = Date.now() + for (const r of batch) { + records.set(r.id, { ...r, updatedAt: now }) + } + // Opportunistic sweep — cheap on a single Map. + liveRecords() + }, + + async get(id, scope) { + const r = records.get(id) + if (!r) return undefined + if (isExpired(r)) { + records.delete(id) + return undefined + } + if (!scopeMatches(r.scope, scope)) return undefined + return r + }, + + async update(id, scope, patch) { + const existing = records.get(id) + if (!existing) return undefined + if (isExpired(existing)) { + records.delete(id) + return undefined + } + if (!scopeMatches(existing.scope, scope)) return undefined + const next: MemoryRecord = { + ...existing, + ...patch, + id: existing.id, + scope: existing.scope, + createdAt: existing.createdAt, + updatedAt: Date.now(), + } + records.set(id, next) + return next + }, + + async search(query: MemoryQuery): Promise { + // Snapshot `now` once so every candidate in this pass shares the same + // recency reference time (mirrors redisMemoryAdapter.search). + const now = Date.now() + const candidates = scopedLive(query.scope).filter((r) => { + if (query.kinds?.length && !query.kinds.includes(r.kind)) return false + return true + }) + const minScore = query.minScore ?? 0 + const topK = query.topK ?? 6 + const scored = candidates + .map((record) => ({ + record, + score: defaultScoreHit({ record, query, now }), + })) + .filter((h) => h.score >= minScore) + .sort((a, b) => b.score - a.score) + + // Cursor support: encode an integer offset; nextCursor undefined when exhausted. + const offset = query.cursor ? Number.parseInt(query.cursor, 10) || 0 : 0 + const page = scored.slice(offset, offset + topK) + const nextCursor = + offset + topK < scored.length ? String(offset + topK) : undefined + return { hits: page, nextCursor } + }, + + async list( + scope, + options: MemoryListOptions = {}, + ): Promise { + let items = scopedLive(scope) + if (options.kinds?.length) { + const kinds = options.kinds + items = items.filter((r) => kinds.includes(r.kind)) + } + const order = options.order ?? 'createdAt:desc' + items = [...items].sort((a, b) => { + switch (order) { + case 'createdAt:asc': + return a.createdAt - b.createdAt + case 'updatedAt:desc': + return (b.updatedAt ?? b.createdAt) - (a.updatedAt ?? a.createdAt) + default: + return b.createdAt - a.createdAt + } + }) + const limit = options.limit ?? items.length + const offset = options.cursor + ? Number.parseInt(options.cursor, 10) || 0 + : 0 + const page = items.slice(offset, offset + limit) + const nextCursor = + offset + limit < items.length ? String(offset + limit) : undefined + return { items: page, nextCursor } + }, + + async delete(ids, scope) { + for (const id of ids) { + const r = records.get(id) + if (!r) continue + if (!scopeMatches(r.scope, scope)) continue + records.delete(id) + } + }, + + async clear(scope) { + for (const [id, r] of records) { + if (scopeMatches(r.scope, scope)) records.delete(id) + } + }, + } +} diff --git a/packages/typescript/ai-memory/src/adapters/redis.ts b/packages/typescript/ai-memory/src/adapters/redis.ts new file mode 100644 index 000000000..5e30f7beb --- /dev/null +++ b/packages/typescript/ai-memory/src/adapters/redis.ts @@ -0,0 +1,543 @@ +import { defaultScoreHit, isExpired, scopeMatches } from '@tanstack/ai/memory' +import type { + MemoryAdapter, + MemoryListOptions, + MemoryListResult, + MemoryQuery, + MemoryRecord, + MemoryRecordPatch, + MemoryScope, + MemorySearchResult, +} from '@tanstack/ai/memory' + +/** + * Minimal subset of the Redis client API this adapter uses. Shaped to match + * `ioredis` (and node-redis with `legacyMode: true`) directly — lowercase + * method names plus the variadic `scan(cursor, 'MATCH', pattern, 'COUNT', n)` + * form returning `[nextCursor, matchedKeys]`. + * + * For node-redis v4+'s default camelCase API (`sAdd`, `sRem`, `sMembers`, + * `mGet`, `scan(cursor, { MATCH, COUNT })`), wrap the client with + * {@link nodeRedisAsRedisLike} before passing it in. ioredis clients do not + * need a wrapper. + */ +export interface RedisLike { + set: (key: string, value: string) => Promise + get: (key: string) => Promise + del: (...keys: Array) => Promise + sadd: (key: string, ...members: Array) => Promise + srem: (key: string, ...members: Array) => Promise + smembers: (key: string) => Promise> + mget: (...keys: Array) => Promise> + scan: ( + cursor: string | number, + ...args: Array + ) => Promise<[string, Array]> +} + +export interface RedisMemoryAdapterOptions { + redis: RedisLike + /** Default 'tanstack-ai:memory'. */ + prefix?: string +} + +/** + * Minimal node-redis v4+ default-mode (camelCase) surface used by + * {@link nodeRedisAsRedisLike}. Real node-redis clients are structurally + * compatible with this shape — you do not need to construct one manually. + */ +export interface NodeRedisLike { + get: (key: string) => Promise + set: (key: string, value: string) => Promise + del: (keys: Array | string) => Promise + sAdd: (key: string, members: string | Array) => Promise + sRem: (key: string, members: string | Array) => Promise + sMembers: (key: string) => Promise> + mGet: (keys: Array) => Promise> + /** + * node-redis v4 accepts/returns `cursor: number`; node-redis v5 accepts + * and returns `cursor: string`. We widen both ends to `number | string` + * so the wrapper can thread either client's cursor through without + * lossy coercion (string cursors past `Number.MAX_SAFE_INTEGER` lose + * precision when round-tripped through `Number()`). + */ + scan: ( + cursor: number | string, + options?: { MATCH?: string; COUNT?: number }, + ) => Promise<{ cursor: number | string; keys: Array }> +} + +/** + * Adapter helper: wraps a node-redis v4+ default-mode client (camelCase API) + * into the lowercase {@link RedisLike} shape this adapter expects. Use when + * you have a `redis` package client and don't want to enable `legacyMode`. + * + * Pass the result into `redisMemoryAdapter({ redis: nodeRedisAsRedisLike(client) })`. + * + * For `ioredis`, no wrapper is needed — `redisMemoryAdapter({ redis: client })` + * works directly because ioredis already exposes lowercase method names. + * + * The wrapper translates the ioredis-style variadic `scan(cursor, 'MATCH', + * pattern, 'COUNT', n)` form this adapter uses into node-redis v4's + * options-object form, and unwraps the `{ cursor, keys }` reply back into + * the `[nextCursor, matchedKeys]` tuple ioredis returns. + */ +export function nodeRedisAsRedisLike(client: NodeRedisLike): RedisLike { + return { + get: (key) => client.get(key), + set: (key, value) => client.set(key, value), + del: (...keys) => client.del(keys).then((n) => n), + sadd: (key, ...members) => client.sAdd(key, members), + srem: (key, ...members) => client.sRem(key, members), + smembers: (key) => client.sMembers(key), + mget: (...keys) => client.mGet(keys), + scan: async (cursor, ...args) => { + // Translate variadic (cursor, 'MATCH', pattern, 'COUNT', count) into + // node-redis v4/v5's options-object form. Pairs are read positionally; + // unknown tokens are ignored rather than rejected so future extensions + // (e.g. TYPE) degrade gracefully if a caller passes them through. + let match: string | undefined + let count: number | undefined + for (let i = 0; i < args.length; i += 2) { + const key = String(args[i] ?? '').toUpperCase() + const value = args[i + 1] + if (key === 'MATCH' && typeof value === 'string') match = value + else if (key === 'COUNT' && value !== undefined) { + const n = Number(value) + // Redis rejects COUNT <= 0. Drop silently rather than throwing so + // a malformed caller-supplied COUNT degrades to "use server default" + // instead of breaking SCAN entirely. + if (Number.isFinite(n) && n > 0) count = n + } + } + // Pass the cursor through as-is. node-redis v4 typed `cursor: number`, + // v5 typed `cursor: string`. Coercing via `Number(cursor)` would lose + // precision for v5 cursors larger than `Number.MAX_SAFE_INTEGER`. The + // `as never` cast bridges the v4/v5 type divergence at the TS layer + // without forcing callers to pin a specific node-redis major. + const result = await client.scan(cursor as never, { + ...(match !== undefined ? { MATCH: match } : {}), + ...(count !== undefined ? { COUNT: count } : {}), + }) + return [String(result.cursor), result.keys] + }, + } +} + +/** + * Escape Redis glob metacharacters so a scope value can be safely interpolated + * into a `SCAN MATCH` pattern. Redis SCAN's MATCH glob recognises `*`, `?`, + * `[`, `]`, and `\` as metacharacters; the backslash is also the glob's escape + * character. Without this, a scope value like `tenantId: 't*'` would cause the + * SCAN pattern to match every other tenant's index bucket — a cross-tenant + * leak through the documented isolation boundary. + */ +function escapeGlob(value: string): string { + return value.replace(/[\\*?[\]]/g, '\\$&') +} + +/** + * Escape the `:` segment delimiter (and the `\` escape character itself) in a + * scope value before composing the colon-joined `scopeKey` tuple. Without this, + * a scope value containing `:` would shift the segment positions and a single- + * key scope `{ tenantId: 'a:b' }` would collide with a multi-key scope + * `{ tenantId: 'a', userId: 'b' }` — both would otherwise serialize to + * `a:b:_:_:_:_` and silently merge two different tenants' index buckets. + * + * This is the EXACT-MATCH counterpart to `escapeGlob`'s SCAN MATCH defence: + * together they close both sides of the cross-tenant leak through the documented + * isolation boundary. + */ +function escapeScopeValue(value: string): string { + // Escape : (our delimiter), \ (the escape character itself), and _ (the + // unset-key placeholder). Without escaping _, a user-supplied scope value + // of literal '_' would collide with the placeholder for an unset key — e.g. + // {tenantId:'t1', userId:'_'} would build the same index key as + // {tenantId:'t1'} (userId unset), allowing cross-leak via clear(). + return value.replace(/[\\:_]/g, '\\$&') +} + +const SCOPE_KEYS = [ + 'tenantId', + 'userId', + 'sessionId', + 'threadId', + 'namespace', +] as const + +/** + * Empty-string scope values are treated as undefined (mirrors `scopeMatches`). + * A scope value MUST be a non-empty string to be meaningful — otherwise it + * would be written as a literal empty segment (e.g. `:_:_:_:_`) that no + * partial-scope query could ever reach. + */ +function hasAnyScopeKey(scope: MemoryScope): boolean { + for (const key of SCOPE_KEYS) { + const v = scope[key] + if (v == null) continue + if (typeof v === 'string' && v.length === 0) continue + return true + } + return false +} + +// Module-level flag so we only emit the malformed-row warning once per +// process. The adapter still skips malformed rows; this just surfaces a +// hint to developers who happen to be watching the console. +let warnedMalformedRow = false +function warnMalformedRowOnce(id: string, err: unknown): void { + if (warnedMalformedRow) return + warnedMalformedRow = true + console.warn( + `[tanstack-ai-memory] redisMemoryAdapter: skipped malformed record JSON (id=${id}). ` + + `Subsequent malformed rows will be skipped silently. Reason: ${String(err)}`, + ) +} + +export function redisMemoryAdapter( + options: RedisMemoryAdapterOptions, +): MemoryAdapter { + const prefix = options.prefix ?? 'tanstack-ai:memory' + const redis = options.redis + + function scopeKey(scope: MemoryScope): string { + // Escape `:` and `\` in scope values so a value containing the delimiter + // (e.g. `{ tenantId: 'a:b' }`) cannot collide with a multi-key scope + // (e.g. `{ tenantId: 'a', userId: 'b' }`) that would otherwise serialize + // to the same `a:b:_:_:_:_` tuple. Empty-string scope values are + // normalised to the `_` placeholder per the same rule applied in + // `scopeMatches` and `hasAnyScopeKey`. + return SCOPE_KEYS.map((k) => { + const v = scope[k] + if (v == null) return '_' + const str = String(v) + if (str.length === 0) return '_' + return escapeScopeValue(str) + }).join(':') + } + function indexKey(scope: MemoryScope): string { + return `${prefix}:index:${scopeKey(scope)}` + } + function recordKey(id: string): string { + return `${prefix}:record:${id}` + } + + /** + * Scope-key equality across all five SCOPE_KEYS. Used by `add` to detect + * an upsert whose scope changed from the previously-stored record, so we + * can srem the id from the old scope's index before sadding to the new + * one. A simple per-key comparison is sufficient — `MemoryScope` values + * are plain strings. + */ + function scopesEqual(a: MemoryScope, b: MemoryScope): boolean { + for (const key of SCOPE_KEYS) { + if ((a[key] ?? null) !== (b[key] ?? null)) return false + } + return true + } + + /** + * Find every index bucket whose scope tuple is consistent with `scope`. + * + * The adapter stores records under an EXACT scope tuple + * `${tenantId or _}:${userId or _}:${sessionId or _}:${threadId or _}:${namespace or _}`. + * A partial query scope (e.g. `{ tenantId: 't1' }`) must therefore + * enumerate every bucket whose tuple positions match the defined keys — + * the rest can be anything, so we glob them with `*` and SCAN. + * + * Returns `[]` when `scope` has no defined keys: per the strict + * empty-scope semantics in `scopeMatches`, an empty scope matches + * nothing and so resolves to zero buckets. + * + * Two escape passes are applied to literal scope values, IN ORDER: + * 1. `escapeScopeValue` — escape `:` (the segment delimiter) so a scope + * value containing a colon does not shift segment positions in the + * SCAN pattern. This must run FIRST so the segment grid stays aligned + * with the EXACT-MATCH `scopeKey` form. + * 2. `escapeGlob` — escape `*`, `?`, `[`, `]`, and `\` so a scope value + * cannot glob-match other tenants' index buckets. + * + * Order matters: if `escapeGlob` ran first it would emit `\*` for a literal + * `*`, and `escapeScopeValue` would then re-escape that backslash as + * `\\\*`, producing a stray escape pair that does not match what `scopeKey` + * wrote. Running `escapeScopeValue` first leaves the glob characters + * untouched, then `escapeGlob` escapes them along with the backslashes + * `escapeScopeValue` introduced — yielding a pattern whose literal segments + * exactly match the `scopeKey` form. + * + * The `*` we substitute for unset scope keys is left unescaped because it + * is the SCAN wildcard we actually want. + */ + async function findIndexKeysForScope( + scope: MemoryScope, + ): Promise> { + if (!hasAnyScopeKey(scope)) return [] + const pattern = `${prefix}:index:${SCOPE_KEYS.map((k) => { + const v = scope[k] + if (v == null) return '*' + const str = String(v) + // Empty-string values are not "defined" per `hasAnyScopeKey`; if all + // were empty we'd have returned above. A single empty value among + // others should still glob ('*') so a partial-scope query that mixes + // a meaningful key with an empty-string fallback is interpreted the + // same as omitting the empty one entirely. + if (str.length === 0) return '*' + // Escape : FIRST (segment delimiter), THEN glob metacharacters. + return escapeGlob(escapeScopeValue(str)) + }).join(':')}` + const seen = new Set() + let cursor = '0' + do { + const [next, batch] = await redis.scan( + cursor, + 'MATCH', + pattern, + 'COUNT', + '100', + ) + for (const k of batch) seen.add(k) + cursor = next + } while (cursor !== '0') + return Array.from(seen) + } + + async function loadRecord(id: string): Promise { + const raw = await redis.get(recordKey(id)) + if (!raw) return undefined + try { + return JSON.parse(raw) as MemoryRecord + } catch (err) { + warnMalformedRowOnce(id, err) + return undefined + } + } + + /** + * Load and scope-filter every record reachable from `scope`. + * + * Iterates ALL index buckets whose scope tuple is consistent with the + * query scope (via `findIndexKeysForScope`), mGets the records, filters + * via `scopeMatches` (defensive — sub-bucket records that wouldn't + * satisfy a mid-tuple constraint must still be dropped), and sweeps + * expired/missing rows from each bucket they appeared in. + */ + async function loadAllForScope( + scope: MemoryScope, + ): Promise> { + if (!hasAnyScopeKey(scope)) return [] + const indexKeys = await findIndexKeysForScope(scope) + if (indexKeys.length === 0) return [] + + // Maintain id -> originating index key so srem of expired/missing rows + // targets the bucket the id actually lives in. + const idToIndexKey = new Map() + for (const idx of indexKeys) { + const members = await redis.smembers(idx) + for (const m of members) { + // First-write-wins is fine: each record only lives in exactly one + // index bucket in steady state, so duplicates here would only be a + // transient state we're about to clean up anyway. + if (!idToIndexKey.has(m)) idToIndexKey.set(m, idx) + } + } + if (idToIndexKey.size === 0) return [] + + const ids = Array.from(idToIndexKey.keys()) + const raws = await redis.mget(...ids.map(recordKey)) + const out: Array = [] + // Group expired/missing ids by their originating index key so we can + // srem them in a single call per bucket. + const expiredByIndex = new Map>() + function markExpired(id: string) { + const idx = idToIndexKey.get(id) + if (!idx) return + const arr = expiredByIndex.get(idx) ?? [] + arr.push(id) + expiredByIndex.set(idx, arr) + } + for (let i = 0; i < raws.length; i++) { + const raw = raws[i] as string | null + const id = ids[i] as string + if (!raw) { + markExpired(id) + continue + } + try { + const r = JSON.parse(raw) as MemoryRecord + if (isExpired(r)) { + markExpired(r.id) + continue + } + if (!scopeMatches(r.scope, scope)) continue + out.push(r) + } catch (err) { + warnMalformedRowOnce(id, err) + // Sweep malformed payloads from BOTH the index bucket and the record + // key — without this, the bad row stays at recordKey(id) and the id + // stays in the index, causing every subsequent loadAllForScope to + // re-parse and re-warn forever. Reuse `markExpired` so the expired/ + // missing/malformed paths share one cleanup pass per index bucket. + markExpired(id) + } + } + if (expiredByIndex.size > 0) { + const recordKeysToDelete: Array = [] + for (const [idx, ids2] of expiredByIndex) { + if (ids2.length === 0) continue + await redis.srem(idx, ...ids2) + for (const id of ids2) recordKeysToDelete.push(recordKey(id)) + } + if (recordKeysToDelete.length > 0) { + await redis.del(...recordKeysToDelete) + } + } + return out + } + + return { + name: 'redis', + + async add(input) { + const batch = Array.isArray(input) ? input : [input] + const now = Date.now() + for (const r of batch) { + // If this id already exists under a DIFFERENT scope, remove it + // from the old scope's index before we sadd to the new one. + // Without this the id would be reachable from the old bucket and + // surface in partial-scope traversals that happen to include it. + const prev = await loadRecord(r.id) + if (prev && !scopesEqual(prev.scope, r.scope)) { + await redis.srem(indexKey(prev.scope), r.id) + } + const next: MemoryRecord = { ...r, updatedAt: now } + await redis.set(recordKey(r.id), JSON.stringify(next)) + await redis.sadd(indexKey(r.scope), r.id) + } + }, + + async get(id, scope) { + const r = await loadRecord(id) + if (!r) return undefined + if (isExpired(r)) { + await redis.del(recordKey(id)) + await redis.srem(indexKey(r.scope), id) + return undefined + } + if (!scopeMatches(r.scope, scope)) return undefined + return r + }, + + async update(id, scope, patch: MemoryRecordPatch) { + const r = await loadRecord(id) + if (!r) return undefined + if (isExpired(r)) { + await redis.del(recordKey(id)) + await redis.srem(indexKey(r.scope), id) + return undefined + } + if (!scopeMatches(r.scope, scope)) return undefined + const next: MemoryRecord = { + ...r, + ...patch, + id: r.id, + scope: r.scope, + createdAt: r.createdAt, + updatedAt: Date.now(), + } + await redis.set(recordKey(id), JSON.stringify(next)) + return next + }, + + async search(query: MemoryQuery): Promise { + const records = await loadAllForScope(query.scope) + // Snapshot `now` once so every candidate in this pass is scored + // against the SAME reference time. Without this, `defaultScoreHit` + // calls `Date.now()` per record and later candidates in the same + // search get a slightly tinier recency contribution than earlier + // ones, perturbing the relative ranking of equally-recent records. + const now = Date.now() + const candidates = records.filter((r) => { + if (query.kinds?.length && !query.kinds.includes(r.kind)) return false + return true + }) + const minScore = query.minScore ?? 0 + const topK = query.topK ?? 6 + const scored = candidates + .map((record) => ({ + record, + score: defaultScoreHit({ record, query, now }), + })) + .filter((h) => h.score >= minScore) + .sort((a, b) => b.score - a.score) + const offset = query.cursor ? Number.parseInt(query.cursor, 10) || 0 : 0 + const page = scored.slice(offset, offset + topK) + const nextCursor = + offset + topK < scored.length ? String(offset + topK) : undefined + return { hits: page, nextCursor } + }, + + async list( + scope, + options: MemoryListOptions = {}, + ): Promise { + let items = await loadAllForScope(scope) + if (options.kinds?.length) { + const kinds = options.kinds + items = items.filter((r) => kinds.includes(r.kind)) + } + const order = options.order ?? 'createdAt:desc' + items = [...items].sort((a, b) => { + switch (order) { + case 'createdAt:asc': + return a.createdAt - b.createdAt + case 'updatedAt:desc': + return (b.updatedAt ?? b.createdAt) - (a.updatedAt ?? a.createdAt) + default: + return b.createdAt - a.createdAt + } + }) + const limit = options.limit ?? items.length + const offset = options.cursor + ? Number.parseInt(options.cursor, 10) || 0 + : 0 + const page = items.slice(offset, offset + limit) + const nextCursor = + offset + limit < items.length ? String(offset + limit) : undefined + return { items: page, nextCursor } + }, + + async delete(ids, scope) { + for (const id of ids) { + const r = await loadRecord(id) + if (!r) continue + if (!scopeMatches(r.scope, scope)) continue + await redis.del(recordKey(id)) + // srem against the RECORD'S actual scope, not the caller's scope. + // A partial-scope caller (e.g. `{ tenantId: 't1' }`) would otherwise + // try to srem from `t1:_:_:_:_` while the id actually lives in + // `t1:u1:_:_:_`, leaving a dangling index entry. + await redis.srem(indexKey(r.scope), id) + } + }, + + async clear(scope) { + // Empty-scope safety: refuse to wipe everything. The shared + // `scopeMatches` helper treats `{}` as "match nothing"; mirror that + // behaviour here so `clear({})` is a no-op rather than a tenant-wide + // wipe (the index key for an all-blank scope would otherwise enumerate + // a real bucket of records). + if (!hasAnyScopeKey(scope)) return + const indexKeys = await findIndexKeysForScope(scope) + if (indexKeys.length === 0) return + const idsToDelete = new Set() + for (const idx of indexKeys) { + const members = await redis.smembers(idx) + for (const m of members) idsToDelete.add(m) + } + if (idsToDelete.size > 0) { + await redis.del(...Array.from(idsToDelete).map(recordKey)) + } + await redis.del(...indexKeys) + }, + } +} diff --git a/packages/typescript/ai-memory/src/index.ts b/packages/typescript/ai-memory/src/index.ts new file mode 100644 index 000000000..4c2a4945c --- /dev/null +++ b/packages/typescript/ai-memory/src/index.ts @@ -0,0 +1,25 @@ +export { inMemoryMemoryAdapter } from './adapters/in-memory' + +export { + redisMemoryAdapter, + nodeRedisAsRedisLike, + type RedisMemoryAdapterOptions, + type RedisLike, + type NodeRedisLike, +} from './adapters/redis' + +export type { + MemoryAdapter, + MemoryRecord, + MemoryRecordPatch, + MemoryScope, + MemoryQuery, + MemoryHit, + MemoryKind, + MemoryRole, + MemoryEmbedder, + MemoryOp, + MemorySearchResult, + MemoryListOptions, + MemoryListResult, +} from '@tanstack/ai/memory' diff --git a/packages/typescript/ai-memory/tests/contract.ts b/packages/typescript/ai-memory/tests/contract.ts new file mode 100644 index 000000000..14a1e53c1 --- /dev/null +++ b/packages/typescript/ai-memory/tests/contract.ts @@ -0,0 +1,549 @@ +// packages/typescript/ai-memory/tests/contract.ts +import { describe, it, expect, beforeEach } from 'vitest' +import type { + MemoryAdapter, + MemoryRecord, + MemoryScope, +} from '@tanstack/ai/memory' + +export function runMemoryAdapterContract( + label: string, + factory: () => Promise | MemoryAdapter, +) { + describe(label, () => { + let adapter: MemoryAdapter + const scopeA: MemoryScope = { tenantId: 't1', userId: 'u1' } + const scopeB: MemoryScope = { tenantId: 't1', userId: 'u2' } + + beforeEach(async () => { + adapter = await factory() + }) + + function rec(over: Partial = {}): MemoryRecord { + return { + id: over.id ?? crypto.randomUUID(), + scope: over.scope ?? scopeA, + text: over.text ?? 'hello world', + kind: over.kind ?? 'fact', + createdAt: over.createdAt ?? Date.now(), + ...over, + } + } + + describe('add', () => { + it('inserts a single record', async () => { + const r = rec() + await adapter.add(r) + expect(await adapter.get(r.id, scopeA)).toMatchObject({ id: r.id }) + }) + + it('inserts an array of records in one call', async () => { + const a = rec({ id: 'a' }) + const b = rec({ id: 'b' }) + await adapter.add([a, b]) + expect(await adapter.get('a', scopeA)).toBeDefined() + expect(await adapter.get('b', scopeA)).toBeDefined() + }) + + it('upserts by id (replays the same id replace)', async () => { + const r = rec({ id: 'x', text: 'first' }) + await adapter.add(r) + const after1 = await adapter.get('x', scopeA) + expect(after1?.text).toBe('first') + expect(after1?.updatedAt).toBeGreaterThanOrEqual(after1!.createdAt) + + // Yield to the event loop so Date.now() can advance — without this, + // a tight double-add can land in the same millisecond and the + // strictly-greater assertion below would be flaky on fast machines. + await new Promise((resolve) => setTimeout(resolve, 2)) + + await adapter.add({ ...r, text: 'second' }) + const after2 = await adapter.get('x', scopeA) + expect(after2?.text).toBe('second') + expect(after2?.updatedAt).toBeGreaterThanOrEqual(after2!.createdAt) + // Load-bearing assertion: the second add MUST bump updatedAt. + // Without this, an adapter that sets updatedAt = createdAt once + // and never touches it again would silently pass the upsert + // contract test. + expect(after2!.updatedAt).toBeGreaterThan(after1!.updatedAt!) + }) + }) + + describe('get', () => { + it('returns undefined for unknown id', async () => { + expect(await adapter.get('nope', scopeA)).toBeUndefined() + }) + it('returns undefined when scope mismatches', async () => { + const r = rec({ id: 'q', scope: scopeA }) + await adapter.add(r) + expect(await adapter.get('q', scopeB)).toBeUndefined() + }) + it('returns undefined when record is expired', async () => { + const r = rec({ id: 'e', expiresAt: Date.now() - 1 }) + await adapter.add(r) + expect(await adapter.get('e', scopeA)).toBeUndefined() + }) + }) + + describe('update', () => { + it('patches text and bumps updatedAt, preserves createdAt', async () => { + const r = rec({ id: 'u', text: 'old', createdAt: 1000 }) + await adapter.add(r) + const before = Date.now() + const out = await adapter.update('u', scopeA, { text: 'new' }) + expect(out?.text).toBe('new') + expect(out?.createdAt).toBe(1000) + expect(out?.updatedAt ?? 0).toBeGreaterThanOrEqual(before) + }) + it('returns undefined for unknown id or wrong scope', async () => { + await adapter.add(rec({ id: 'u', scope: scopeA })) + expect(await adapter.update('u', scopeB, { text: 'x' })).toBeUndefined() + expect( + await adapter.update('nope', scopeA, { text: 'x' }), + ).toBeUndefined() + }) + }) + + describe('search', () => { + it('respects topK', async () => { + for (let i = 0; i < 10; i++) { + await adapter.add(rec({ id: `r${i}`, text: `word${i} same` })) + } + const out = await adapter.search({ + scope: scopeA, + text: 'same', + topK: 3, + }) + expect(out.hits.length).toBeLessThanOrEqual(3) + }) + + it('isolates scope', async () => { + await adapter.add(rec({ id: 'a', scope: scopeA, text: 'apples' })) + await adapter.add(rec({ id: 'b', scope: scopeB, text: 'apples' })) + const out = await adapter.search({ scope: scopeA, text: 'apples' }) + // Non-empty guard: `every` on [] is vacuously true and would mask + // an adapter that returned zero hits. + expect(out.hits.length).toBeGreaterThan(0) + expect(out.hits.every((h) => h.record.scope.userId === 'u1')).toBe(true) + }) + + it('filters by kinds', async () => { + await adapter.add(rec({ id: 'a', text: 'foo', kind: 'fact' })) + await adapter.add(rec({ id: 'b', text: 'foo', kind: 'preference' })) + const out = await adapter.search({ + scope: scopeA, + text: 'foo', + kinds: ['fact'], + }) + // Non-empty guard: `every` on [] is vacuously true. + expect(out.hits.length).toBeGreaterThan(0) + expect(out.hits.every((h) => h.record.kind === 'fact')).toBe(true) + }) + + it('does not return expired records', async () => { + await adapter.add( + rec({ id: 'e', text: 'orange', expiresAt: Date.now() - 1 }), + ) + await adapter.add(rec({ id: 'f', text: 'orange' })) + const out = await adapter.search({ scope: scopeA, text: 'orange' }) + expect(out.hits.find((h) => h.record.id === 'e')).toBeUndefined() + expect(out.hits.find((h) => h.record.id === 'f')).toBeDefined() + }) + + it('paginates with cursor and terminates', async () => { + for (let i = 0; i < 12; i++) { + await adapter.add(rec({ id: `p${i}`, text: `pagework${i}` })) + } + let cursor: string | undefined + const seen = new Set() + let pages = 0 + do { + const out = await adapter.search({ + scope: scopeA, + text: 'pagework', + topK: 4, + cursor, + }) + for (const h of out.hits) seen.add(h.record.id) + cursor = out.nextCursor + pages++ + if (pages > 10) throw new Error('cursor did not terminate') + } while (cursor) + // Load-bearing: every record must be visible exactly once across + // pages. Catches adapters that drop records between pages or + // return the same page repeatedly with a terminating cursor. + // Adapters MAY return all in one page (no nextCursor) OR paginate; + // either is fine, but the union of pages must cover all 12 ids. + expect(seen.size).toBe(12) + expect(pages).toBeGreaterThanOrEqual(1) + }) + }) + + describe('list', () => { + it('returns scoped records', async () => { + await adapter.add(rec({ id: 'a', scope: scopeA })) + await adapter.add(rec({ id: 'b', scope: scopeB })) + const out = await adapter.list(scopeA) + // Non-empty guard: `every` on [] is vacuously true. + expect(out.items.length).toBeGreaterThan(0) + expect(out.items.every((r) => r.scope.userId === 'u1')).toBe(true) + }) + it('respects limit', async () => { + for (let i = 0; i < 6; i++) await adapter.add(rec({ id: `l${i}` })) + const out = await adapter.list(scopeA, { limit: 2 }) + expect(out.items.length).toBeLessThanOrEqual(2) + }) + it('filters by kinds', async () => { + await adapter.add(rec({ id: 'a', kind: 'fact' })) + await adapter.add(rec({ id: 'b', kind: 'preference' })) + const out = await adapter.list(scopeA, { kinds: ['preference'] }) + // Non-empty guard: `every` on [] is vacuously true. + expect(out.items.length).toBeGreaterThan(0) + expect(out.items.every((r) => r.kind === 'preference')).toBe(true) + }) + }) + + describe('delete', () => { + it('removes records by id within scope', async () => { + await adapter.add(rec({ id: 'd' })) + await adapter.delete(['d'], scopeA) + expect(await adapter.get('d', scopeA)).toBeUndefined() + }) + it('does not remove records from another scope', async () => { + await adapter.add(rec({ id: 'd', scope: scopeA })) + await adapter.delete(['d'], scopeB) + expect(await adapter.get('d', scopeA)).toBeDefined() + }) + }) + + describe('clear', () => { + it('removes all records for a scope', async () => { + await adapter.add(rec({ id: 'c1', scope: scopeA })) + await adapter.add(rec({ id: 'c2', scope: scopeB })) + await adapter.clear(scopeA) + expect(await adapter.get('c1', scopeA)).toBeUndefined() + expect(await adapter.get('c2', scopeB)).toBeDefined() + }) + }) + + describe('empty scope safety', () => { + // Cross-tenant safety guard: an empty scope object MUST NOT match any + // record. See `scopeMatches` JSDoc — `clear({})` and `search({ scope: {} })` + // would otherwise wipe / leak every tenant's records. + it('search with empty scope returns no hits', async () => { + await adapter.add(rec({ id: 'a', scope: scopeA, text: 'apples' })) + await adapter.add(rec({ id: 'b', scope: scopeB, text: 'apples' })) + const out = await adapter.search({ scope: {}, text: 'apples' }) + expect(out.hits.length).toBe(0) + }) + + it('list with empty scope returns no items', async () => { + await adapter.add(rec({ id: 'a', scope: scopeA })) + await adapter.add(rec({ id: 'b', scope: scopeB })) + const out = await adapter.list({}) + expect(out.items.length).toBe(0) + }) + + it('clear with empty scope wipes nothing', async () => { + await adapter.add(rec({ id: 'a', scope: scopeA })) + await adapter.add(rec({ id: 'b', scope: scopeB })) + await adapter.clear({}) + expect(await adapter.get('a', scopeA)).toBeDefined() + expect(await adapter.get('b', scopeB)).toBeDefined() + }) + }) + + describe('partial scope semantics', () => { + it('search with a partial scope finds records added under sub-scopes', async () => { + const sub1: MemoryScope = { tenantId: 't1', userId: 'u1' } + const sub2: MemoryScope = { tenantId: 't1', userId: 'u2' } + const other: MemoryScope = { tenantId: 't2', userId: 'u1' } + await adapter.add(rec({ id: 'a', scope: sub1, text: 'apple' })) + await adapter.add(rec({ id: 'b', scope: sub2, text: 'apple' })) + await adapter.add(rec({ id: 'c', scope: other, text: 'apple' })) + + const out = await adapter.search({ + scope: { tenantId: 't1' }, + text: 'apple', + }) + const ids = new Set(out.hits.map((h) => h.record.id)) + expect(ids.has('a')).toBe(true) + expect(ids.has('b')).toBe(true) + expect(ids.has('c')).toBe(false) + }) + + it('list with a partial scope returns records from sub-scopes', async () => { + const sub1: MemoryScope = { tenantId: 't1', userId: 'u1' } + const sub2: MemoryScope = { tenantId: 't1', userId: 'u2' } + await adapter.add(rec({ id: 'a', scope: sub1 })) + await adapter.add(rec({ id: 'b', scope: sub2 })) + const out = await adapter.list({ tenantId: 't1' }) + expect(out.items.length).toBe(2) + }) + + it('clear with a partial scope wipes records from sub-scopes', async () => { + const sub1: MemoryScope = { tenantId: 't1', userId: 'u1' } + const sub2: MemoryScope = { tenantId: 't1', userId: 'u2' } + const other: MemoryScope = { tenantId: 't2', userId: 'u1' } + await adapter.add(rec({ id: 'a', scope: sub1 })) + await adapter.add(rec({ id: 'b', scope: sub2 })) + await adapter.add(rec({ id: 'c', scope: other })) + await adapter.clear({ tenantId: 't1' }) + expect(await adapter.get('a', sub1)).toBeUndefined() + expect(await adapter.get('b', sub2)).toBeUndefined() + expect(await adapter.get('c', other)).toBeDefined() + }) + + it('delete by id keeps the record findable via the actual scope after the call', async () => { + // NOT a partial-scope test, but it pins the srem-uses-record-scope fix. + const subScope: MemoryScope = { tenantId: 't1', userId: 'u1' } + await adapter.add(rec({ id: 'd', scope: subScope })) + await adapter.delete(['d'], { tenantId: 't1' }) // wider than record scope + expect(await adapter.get('d', subScope)).toBeUndefined() + // After the delete, list({tenantId:'t1'}) should also not return it + const listed = await adapter.list({ tenantId: 't1' }) + expect(listed.items.find((r) => r.id === 'd')).toBeUndefined() + }) + + it('add upsert with changed scope removes id from old scope index', async () => { + const oldScope: MemoryScope = { tenantId: 't1', userId: 'u1' } + const newScope: MemoryScope = { tenantId: 't1', userId: 'u2' } + await adapter.add(rec({ id: 'm', scope: oldScope, text: 'original' })) + await adapter.add(rec({ id: 'm', scope: newScope, text: 'rescoped' })) + // Record is no longer findable via old scope + expect(await adapter.get('m', oldScope)).toBeUndefined() + expect(await adapter.get('m', newScope)).toBeDefined() + // list under old scope shouldn't return it + const oldList = await adapter.list(oldScope) + expect(oldList.items.find((r) => r.id === 'm')).toBeUndefined() + // list under new scope should + const newList = await adapter.list(newScope) + expect(newList.items.find((r) => r.id === 'm')).toBeDefined() + }) + }) + + describe('scope value safety', () => { + // Defense-in-depth: scope values that happen to contain glob + // metacharacters (*, ?, [, ], \) MUST NOT cross-match other tenants' + // index buckets. The in-memory adapter doesn't use globs so this is + // a no-op there; for the redis adapter it pins the escapeGlob fix on + // findIndexKeysForScope's SCAN MATCH pattern. Without escaping, a + // scope value like `tenantId: 't*'` would cause the SCAN to glob + // every other tenant's index key and surface their records. + it('does not cross-match scope values that contain glob metacharacters', async () => { + const realTenant: MemoryScope = { tenantId: 'real-tenant' } + const otherTenant: MemoryScope = { tenantId: 'tenant-x' } + const attacker: MemoryScope = { tenantId: 't*' } + await adapter.add( + rec({ id: 'real', scope: realTenant, text: 'tenant data' }), + ) + await adapter.add( + rec({ id: 'other', scope: otherTenant, text: 'tenant data' }), + ) + const out = await adapter.search({ + scope: attacker, + text: 'tenant data', + }) + // Neither tenant's records are leaked — the attacker's literal + // `t*` scope must not glob-match `real-tenant` or `tenant-x`. + expect(out.hits.find((h) => h.record.id === 'real')).toBeUndefined() + expect(out.hits.find((h) => h.record.id === 'other')).toBeUndefined() + }) + + // EXACT-MATCH counterpart to the SCAN MATCH glob-escape test above. The + // redis adapter's `scopeKey` joins scope values with `:`. Without + // escaping, `{ tenantId: 'a:b' }` and `{ tenantId: 'a', userId: 'b' }` + // would both serialize to `a:b:_:_:_:_` and silently merge two + // different tenants' index buckets. The in-memory adapter is unaffected + // because it does not serialize scope to strings — it uses + // `scopeMatches` against the raw scope object — but the test still + // pins the same isolation guarantee. + // + // We assert ONLY the isolation property (no cross-leak), not the + // own-record retrieval, because ioredis-mock does not implement the + // SCAN MATCH backslash-escape mechanism Redis uses. In a real Redis + // deployment the escaped pattern correctly matches the literal key; + // here we verify the security-critical half — that buckets do not + // merge — and rely on the in-memory contract run for the + // own-record-reachability half. + it('does not cross-leak scope values that contain the segment delimiter', async () => { + const colonTenant: MemoryScope = { tenantId: 'a:b' } + const splitScope: MemoryScope = { tenantId: 'a', userId: 'b' } + await adapter.add( + rec({ id: 'colon', scope: colonTenant, text: 'colon data' }), + ) + await adapter.add( + rec({ id: 'split', scope: splitScope, text: 'split data' }), + ) + // Querying the split scope must NOT surface the colon-scope record — + // the previously-colliding bucket layout is now isolated. + const splitOut = await adapter.search({ + scope: splitScope, + text: 'data', + }) + expect( + splitOut.hits.find((h) => h.record.id === 'colon'), + ).toBeUndefined() + expect(splitOut.hits.find((h) => h.record.id === 'split')).toBeDefined() + // get() uses an id+scope check via scopeMatches against the raw + // scope object, so the own-record reachability half is also testable + // here without relying on SCAN MATCH escape semantics. + expect(await adapter.get('colon', colonTenant)).toBeDefined() + expect(await adapter.get('split', splitScope)).toBeDefined() + // And the cross-scope get must not leak either way. + expect(await adapter.get('colon', splitScope)).toBeUndefined() + expect(await adapter.get('split', colonTenant)).toBeUndefined() + }) + + it('does not cross-leak scope values that contain the escape character', async () => { + // Backslash is the escape character used by both `escapeScopeValue` + // (for `:`/`\`) and `escapeGlob` (for glob metacharacters). A naive + // escape that didn't escape `\` itself would let + // `tenantId: 'a\\backslash'` collide with another scope after + // unescaping. Same isolation-only assertion shape as the colon test. + const backslashTenant: MemoryScope = { tenantId: 'has\\backslash' } + const otherTenant: MemoryScope = { tenantId: 'has' } + await adapter.add( + rec({ id: 'bs', scope: backslashTenant, text: 'bs data' }), + ) + await adapter.add( + rec({ id: 'plain', scope: otherTenant, text: 'plain data' }), + ) + const out = await adapter.search({ + scope: otherTenant, + text: 'data', + }) + expect(out.hits.find((h) => h.record.id === 'plain')).toBeDefined() + expect(out.hits.find((h) => h.record.id === 'bs')).toBeUndefined() + // Own-record reachability via id+scope is testable without SCAN. + expect(await adapter.get('bs', backslashTenant)).toBeDefined() + expect(await adapter.get('plain', otherTenant)).toBeDefined() + }) + + // Underscore placeholder collision: the redis adapter uses literal `_` + // as the placeholder for an UNSET scope key in `scopeKey`. Without + // escaping `_` in `escapeScopeValue`, a user-supplied scope value of + // literal `'_'` (e.g. `userId: '_'`) would build the same index key as + // a scope with `userId` unset — opening a cross-leak surface on + // `clear()` (which deletes by exact index key, not via `scopeMatches`). + // The in-memory adapter is unaffected because it does not serialize + // scope to strings, but the contract test still pins isolation across + // both adapters. + it('clear({tenantId}) cascades to records with userId="_" via partial-scope semantics', async () => { + const baseTenant: MemoryScope = { tenantId: 't1' } + const subWithUnderscore: MemoryScope = { + tenantId: 't1', + userId: '_', + } + await adapter.add( + rec({ id: 'base', scope: baseTenant, text: 'base record' }), + ) + await adapter.add( + rec({ id: 'sub', scope: subWithUnderscore, text: 'sub record' }), + ) + await adapter.clear(baseTenant) + // Both records are wiped — `base` is directly under `baseTenant`, and + // `sub` is wiped because partial-scope clear cascades across + // sub-scopes (see "clear with a partial scope wipes records from + // sub-scopes" above). The key insight is that this is the CONSISTENT + // partial-scope contract, not an accidental key collision: the literal + // underscore value is escaped so it indexes distinctly from "unset". + expect(await adapter.get('base', baseTenant)).toBeUndefined() + expect(await adapter.get('sub', subWithUnderscore)).toBeUndefined() + }) + + it('userId="_" does not collide with userId unset', async () => { + // Same isolation-only assertion shape as the colon and backslash + // tests above: ioredis-mock does not implement SCAN MATCH + // backslash-escape, so we verify the security-critical half (no + // cross-leak from the underscore-user scope into the no-user + // bucket) via search, and the own-record reachability half via + // `adapter.get`, which uses `scopeMatches` against the raw scope + // object rather than SCAN MATCH. + const noUserScope: MemoryScope = { tenantId: 't1' } + const underscoreUserScope: MemoryScope = { + tenantId: 't1', + userId: '_', + } + const realUserScope: MemoryScope = { + tenantId: 't1', + userId: 'real', + } + await adapter.add( + rec({ id: 'no-user', scope: noUserScope, text: 'orange' }), + ) + await adapter.add( + rec({ id: 'us', scope: underscoreUserScope, text: 'orange' }), + ) + await adapter.add( + rec({ id: 'real-user', scope: realUserScope, text: 'orange' }), + ) + // Exact-match search for the underscore-user scope must NOT surface + // the no-user record (which would have collided pre-fix) nor the + // real-user record. + const out = await adapter.search({ + scope: underscoreUserScope, + text: 'orange', + }) + expect(out.hits.find((h) => h.record.id === 'no-user')).toBeUndefined() + expect( + out.hits.find((h) => h.record.id === 'real-user'), + ).toBeUndefined() + // Own-record reachability via id+scope is testable without SCAN. + expect(await adapter.get('no-user', noUserScope)).toBeDefined() + expect(await adapter.get('us', underscoreUserScope)).toBeDefined() + expect(await adapter.get('real-user', realUserScope)).toBeDefined() + // The narrower (underscore-user) query against the broader (no-user) + // record must NOT match — per `scopeMatches`, the query's defined + // `userId: '_'` does not match a missing `userId`. This is the + // partial-scope asymmetry; the converse (broader query, narrower + // record) is the legitimate partial-scope cascade and is not + // asserted here. + expect( + await adapter.get('no-user', underscoreUserScope), + ).toBeUndefined() + }) + + it('treats empty-string scope values as undefined (not as a distinct bucket)', async () => { + // A scope value of `''` is equivalent to the key being unset — see + // `scopeMatches` JSDoc. A record written with `{ tenantId: '' }` + // would otherwise produce a degenerate "blank-tenant" bucket that + // no normal query could reach. The empty-scope safety guard kicks + // in for `{ tenantId: '' }` (since the only defined key is empty) + // and turns clear/search/list into no-ops. + await adapter.add(rec({ id: 'a', scope: scopeA, text: 'apples' })) + await adapter.add(rec({ id: 'b', scope: scopeB, text: 'apples' })) + const out = await adapter.search({ + scope: { tenantId: '' }, + text: 'apples', + }) + expect(out.hits.length).toBe(0) + const listed = await adapter.list({ tenantId: '' }) + expect(listed.items.length).toBe(0) + // `clear({ tenantId: '' })` must NOT wipe real tenants. + await adapter.clear({ tenantId: '' }) + expect(await adapter.get('a', scopeA)).toBeDefined() + expect(await adapter.get('b', scopeB)).toBeDefined() + }) + }) + + describe('semantic vs lexical ranking', () => { + it('lexical-only when no embeddings', async () => { + await adapter.add(rec({ id: 'a', text: 'apple banana' })) + await adapter.add(rec({ id: 'b', text: 'totally unrelated' })) + const out = await adapter.search({ scope: scopeA, text: 'apple' }) + expect(out.hits[0]?.record.id).toBe('a') + }) + it('semantic match outranks lexical-only when embeddings present', async () => { + await adapter.add(rec({ id: 'lex', text: 'apple', embedding: [0, 1] })) + await adapter.add(rec({ id: 'sem', text: 'fruit', embedding: [1, 0] })) + const out = await adapter.search({ + scope: scopeA, + text: 'apple', + embedding: [1, 0], + }) + expect(out.hits[0]?.record.id).toBe('sem') + }) + }) + }) +} diff --git a/packages/typescript/ai-memory/tests/in-memory.test.ts b/packages/typescript/ai-memory/tests/in-memory.test.ts new file mode 100644 index 000000000..a3bd1191b --- /dev/null +++ b/packages/typescript/ai-memory/tests/in-memory.test.ts @@ -0,0 +1,4 @@ +import { inMemoryMemoryAdapter } from '../src/adapters/in-memory' +import { runMemoryAdapterContract } from './contract' + +runMemoryAdapterContract('inMemoryMemoryAdapter', () => inMemoryMemoryAdapter()) diff --git a/packages/typescript/ai-memory/tests/redis.test.ts b/packages/typescript/ai-memory/tests/redis.test.ts new file mode 100644 index 000000000..c0a8aa896 --- /dev/null +++ b/packages/typescript/ai-memory/tests/redis.test.ts @@ -0,0 +1,122 @@ +// @ts-expect-error -- ioredis-mock has no bundled types and we don't need them +// here; the contract test only exercises the RedisLike subset that +// redisMemoryAdapter consumes (cast to `never` below). +import RedisMock from 'ioredis-mock' +import { describe, expect, it } from 'vitest' +import { runMemoryAdapterContract } from './contract' +import { nodeRedisAsRedisLike, redisMemoryAdapter } from '../src/adapters/redis' + +runMemoryAdapterContract('redisMemoryAdapter', async () => { + const client = new RedisMock() + return redisMemoryAdapter({ + redis: client as never, + prefix: `test:${crypto.randomUUID()}`, + }) +}) + +describe('nodeRedisAsRedisLike', () => { + it('translates camelCase node-redis methods into lowercase RedisLike calls', async () => { + const calls: Array<{ method: string; args: Array }> = [] + const fakeNodeRedis = { + get: async (key: string) => { + calls.push({ method: 'get', args: [key] }) + return null + }, + set: async (key: string, value: string) => { + calls.push({ method: 'set', args: [key, value] }) + return 'OK' + }, + del: async (keys: Array | string) => { + calls.push({ method: 'del', args: [keys] }) + return Array.isArray(keys) ? keys.length : 1 + }, + sAdd: async (key: string, members: string | Array) => { + calls.push({ method: 'sAdd', args: [key, members] }) + return Array.isArray(members) ? members.length : 1 + }, + sRem: async (key: string, members: string | Array) => { + calls.push({ method: 'sRem', args: [key, members] }) + return Array.isArray(members) ? members.length : 1 + }, + sMembers: async (key: string) => { + calls.push({ method: 'sMembers', args: [key] }) + return [] + }, + mGet: async (keys: Array) => { + calls.push({ method: 'mGet', args: [keys] }) + return [] + }, + scan: async ( + cursor: number | string, + opts?: { MATCH?: string; COUNT?: number }, + ) => { + calls.push({ method: 'scan', args: [cursor, opts] }) + return { cursor: 0, keys: [] as Array } + }, + } + + const wrapped = nodeRedisAsRedisLike(fakeNodeRedis) + + await wrapped.set('k', 'v') + await wrapped.sadd('s', 'a', 'b') + await wrapped.sadd('s', 'c') + await wrapped.mget('k1', 'k2') + const scanResult = await wrapped.scan( + '0', + 'MATCH', + 'pattern:*', + 'COUNT', + '50', + ) + await wrapped.del('d1', 'd2') + + // Cursor passthrough — node-redis v5 uses string cursors and v4 uses + // number cursors. The wrapper must thread either through unchanged so + // a string cursor past Number.MAX_SAFE_INTEGER round-trips losslessly. + await wrapped.scan('0', 'MATCH', 'p:*') + await wrapped.scan(0, 'MATCH', 'p:*') + const bigCursor = '90071992547409930' // > Number.MAX_SAFE_INTEGER + await wrapped.scan(bigCursor, 'MATCH', 'p:*') + // COUNT <= 0 must be silently dropped — Redis rejects COUNT 0. + await wrapped.scan(0, 'MATCH', 'p:*', 'COUNT', '0') + + expect(calls.find((c) => c.method === 'set')).toMatchObject({ + args: ['k', 'v'], + }) + // First sAdd was called with two members; assert it was forwarded as an + // array (not as variadic args) so node-redis' single-or-array overload + // resolves to the array branch. + expect( + calls.find( + (c) => + c.method === 'sAdd' && + Array.isArray(c.args[1]) && + (c.args[1] as Array).length === 2, + ), + ).toBeTruthy() + expect(calls.find((c) => c.method === 'mGet')).toMatchObject({ + args: [['k1', 'k2']], + }) + const scanCalls = calls.filter((c) => c.method === 'scan') + // First scan: numeric COUNT translated correctly; cursor '0' threaded as-is + // (no Number() coercion). + expect(scanCalls[0]).toMatchObject({ + args: ['0', { MATCH: 'pattern:*', COUNT: 50 }], + }) + // String cursor passed through as a string (v5 shape). + expect(scanCalls[1]?.args[0]).toBe('0') + // Number cursor passed through as a number (v4 shape). + expect(scanCalls[2]?.args[0]).toBe(0) + // Big string cursor past Number.MAX_SAFE_INTEGER round-trips losslessly. + expect(scanCalls[3]?.args[0]).toBe('90071992547409930') + // COUNT 0 is silently dropped (Redis rejects COUNT <= 0). + expect(scanCalls[4]?.args[1]).toEqual({ MATCH: 'p:*' }) + expect(calls.find((c) => c.method === 'del')).toMatchObject({ + args: [['d1', 'd2']], + }) + + // The scan reply is unwrapped from { cursor, keys } back into the + // ioredis-style [nextCursor, matchedKeys] tuple the adapter consumes. + expect(scanResult).toEqual(['0', []]) + }) +}) diff --git a/packages/typescript/ai-memory/tsconfig.json b/packages/typescript/ai-memory/tsconfig.json new file mode 100644 index 000000000..377214afe --- /dev/null +++ b/packages/typescript/ai-memory/tsconfig.json @@ -0,0 +1,8 @@ +{ + "extends": "../../../tsconfig.json", + "compilerOptions": { + "outDir": "dist" + }, + "include": ["vite.config.ts", "./src", "./tests"], + "exclude": ["node_modules", "dist"] +} diff --git a/packages/typescript/ai-memory/vite.config.ts b/packages/typescript/ai-memory/vite.config.ts new file mode 100644 index 000000000..435aec10e --- /dev/null +++ b/packages/typescript/ai-memory/vite.config.ts @@ -0,0 +1,35 @@ +import { defineConfig, mergeConfig } from 'vitest/config' +import { tanstackViteConfig } from '@tanstack/vite-config' +import packageJson from './package.json' + +const config = defineConfig({ + test: { + name: packageJson.name, + dir: './', + watch: false, + globals: true, + environment: 'node', + include: ['tests/**/*.test.ts'], + coverage: { + provider: 'v8', + reporter: ['text', 'json', 'html', 'lcov'], + exclude: [ + 'node_modules/', + 'dist/', + 'tests/', + '**/*.test.ts', + '**/*.config.ts', + ], + include: ['src/**/*.ts'], + }, + }, +}) + +export default mergeConfig( + config, + tanstackViteConfig({ + entry: ['./src/index.ts'], + srcDir: './src', + cjs: false, + }), +) diff --git a/packages/typescript/ai/package.json b/packages/typescript/ai/package.json index 91c0843b1..7d96ddde2 100644 --- a/packages/typescript/ai/package.json +++ b/packages/typescript/ai/package.json @@ -29,6 +29,10 @@ "types": "./dist/esm/middlewares/otel.d.ts", "import": "./dist/esm/middlewares/otel.js" }, + "./memory": { + "types": "./dist/esm/memory/index.d.ts", + "import": "./dist/esm/memory/index.js" + }, "./adapter-internals": { "types": "./dist/esm/adapter-internals.d.ts", "import": "./dist/esm/adapter-internals.js" diff --git a/packages/typescript/ai/skills/tanstack-ai-memory/SKILL.md b/packages/typescript/ai/skills/tanstack-ai-memory/SKILL.md new file mode 100644 index 000000000..df5c1ab90 --- /dev/null +++ b/packages/typescript/ai/skills/tanstack-ai-memory/SKILL.md @@ -0,0 +1,115 @@ +--- +name: tanstack-ai-memory +description: Use when wiring memoryMiddleware from @tanstack/ai/memory into a chat() call — covers scope shape, server-side scope security, retrieval/persistence semantics, and the extension hooks (shouldRetrieve, rerank, extractMemories, onToolResult, afterPersist). +--- + +# TanStack AI Memory Middleware + +Use this when adding **server-side memory** to a `chat()` call. Memory persists across user turns and is retrieved relevance-first into the system prompt. + +## When to reach for it + +- A user expects "remember what I told you last time." +- Multi-tenant chat where each tenant/user/thread has its own context. +- A bot that should learn preferences or extracted facts over time. + +Do NOT use this just to keep recent messages — that's the `messages` array on `chat()`. Memory is for cross-turn / cross-session recall, not within-turn history. + +## Wire it up + +```ts +import { chat } from '@tanstack/ai' +import { openaiText } from '@tanstack/ai-openai' +import { memoryMiddleware } from '@tanstack/ai/memory' +import { inMemoryMemoryAdapter } from '@tanstack/ai-memory' + +const memory = inMemoryMemoryAdapter() // dev/tests only — see in-memory skill + +// In a real handler you'd attach the server-validated session (and any +// other per-request values you trust) via `chat({ context })`. Inside the +// middleware, scope is then derived from `ctx.context` — never from a +// request body field the client controls. +type AppCtx = { + session: { + tenantId: string + userId: string + activeThreadId: string + } +} + +// Stand-in for whichever embedding client you use (OpenAI, Cohere, local +// model, etc.). The middleware only requires `embed(text): number[]`. +declare const myEmbeddings: { + embed(text: string): Promise> +} + +const stream = chat({ + adapter: openaiText('gpt-4o'), + messages, + context: { session }, // attached by your auth middleware + middleware: [ + memoryMiddleware({ + adapter: memory, + scope: (ctx) => { + const { session } = ctx.context as AppCtx + return { + tenantId: session.tenantId, + userId: session.userId, + threadId: session.activeThreadId, + } + }, + // Optional: provide an embedder for semantic search. + embedder: { + async embed(text) { + return myEmbeddings.embed(text) + }, + }, + }), + ], +}) +``` + +## Scope security + +Scope is the isolation boundary. **Never trust client-supplied tenantId/userId.** Resolve scope server-side from session/auth: + +```ts +scope: (ctx) => { + const { session } = ctx.context as AppCtx + return { + tenantId: session.tenantId, // from server-validated session + userId: session.userId, // from server-validated session + threadId: session.activeThreadId, // server-side resolved thread + } +} +``` + +Pass the validated session through `chat({ context: { session } })`. If you need to accept a `threadId` from the request body, validate server-side that it belongs to `session.userId` BEFORE attaching it to the chat context — never feed an unvalidated body field straight into scope. + +## Adapters + +- `inMemoryMemoryAdapter()` — dev, tests, single-process demos. See `tanstack-ai-memory-in-memory` skill. +- `redisMemoryAdapter({ redis })` — production. See `tanstack-ai-memory-redis` skill. +- Custom — implement `MemoryAdapter` from `@tanstack/ai/memory`. + +## Extension hooks + +| Hook | When | Use for | +| ---------------------------------------------------------------------- | --------------------------- | ---------------------------------------------------- | +| `shouldRetrieve({ userText, scope })` | before search | Skip retrieval (cost, content gating) | +| `rerank(hits, { scope, query, ctx })` | after search, before render | MMR / RRF / cross-encoder rerankers | +| `shouldRemember({ message, responseText })` | before persist | Drop short / sensitive messages | +| `extractMemories({ userText, responseText, scope, adapter })` | after model finishes | Add/update/delete records (Mem0-style consolidation) | +| `onToolResult({ toolName, toolCallId, args, result, scope, adapter })` | per completed tool call | Persist tool outputs as `kind: 'tool-result'` | +| `afterPersist({ newRecords, scope, adapter })` | after add | Background work: summarization, eviction | + +`extractMemories` and `onToolResult` may return `MemoryRecord[]` (treated as all-add) or `MemoryOp[]` for mixed ADD/UPDATE/DELETE. + +## Failure modes + +Default `strict: false` — retrieval/persist failures emit `memory:error` devtools events and a callback (`events.onError`), but the chat run continues. Set `strict: true` in tests or compliance-sensitive deploys to make failures throw. + +## Devtools + +Five events on `aiEventClient` (from `@tanstack/ai-event-client`): +`memory:retrieve:started`, `memory:retrieve:completed`, `memory:persist:started`, `memory:persist:completed`, `memory:error`. Hits and records carry a 200-char `preview` only — full text is never streamed by default. diff --git a/packages/typescript/ai/src/memory/helpers.ts b/packages/typescript/ai/src/memory/helpers.ts new file mode 100644 index 000000000..f7ca849db --- /dev/null +++ b/packages/typescript/ai/src/memory/helpers.ts @@ -0,0 +1,147 @@ +import type { MemoryHit, MemoryQuery, MemoryRecord, MemoryScope } from './types' + +const DEFAULT_HALF_LIFE_MS = 1000 * 60 * 60 * 24 * 30 // 30 days + +/** + * Decide whether a record's scope satisfies a query scope. + * + * **Strict-by-default empty-scope semantics.** When `queryScope` has no + * defined keys (every key is `undefined`/null, the empty string, or the + * object is `{}`), this returns `false` — i.e. an empty query scope matches + * NOTHING. This is a deliberate cross-tenant safety guard: callers like + * `clear({})` or `search({ scope: {}, ... })` would otherwise wipe / leak + * every tenant's records. Adapters that want to operate on a specific scope + * key (e.g. all records for a tenant regardless of user) must pass that key + * explicitly, e.g. `{ tenantId: 't1' }`. + * + * **Empty-string scope values are treated as undefined.** Scope values MUST + * be non-empty strings to be meaningful. A query of `{ tenantId: '' }` is + * equivalent to `{}` and matches nothing — this prevents callers from + * accidentally producing a degenerate "blank-tenant" bucket that would be + * unreachable from any normal query and indistinguishable from records whose + * scope key was simply unset. + */ +export function scopeMatches( + recordScope: MemoryScope, + queryScope: MemoryScope, +): boolean { + let definedKeys = 0 + for (const key of Object.keys(queryScope) as Array) { + const value = queryScope[key] + if (value == null) continue + // Empty strings are treated as undefined — they cannot be a defined + // scope value. Mirrored in adapters' `hasAnyScopeKey` guards so the same + // rule applies at every isolation boundary. + if (typeof value === 'string' && value.length === 0) continue + definedKeys++ + if (recordScope[key] !== value) return false + } + if (definedKeys === 0) return false + return true +} + +export function cosine(a?: Array, b?: Array): number { + if (!a || !b || a.length !== b.length || a.length === 0) return 0 + let dot = 0 + let aMag = 0 + let bMag = 0 + for (let i = 0; i < a.length; i++) { + const av = a[i] as number + const bv = b[i] as number + dot += av * bv + aMag += av ** 2 + bMag += bv ** 2 + } + if (aMag === 0 || bMag === 0) return 0 + return dot / (Math.sqrt(aMag) * Math.sqrt(bMag)) +} + +export function lexicalOverlap(query: string, text: string): number { + const queryTokens = new Set(query.toLowerCase().split(/\W+/).filter(Boolean)) + if (queryTokens.size === 0) return 0 + const textTokens = new Set(text.toLowerCase().split(/\W+/).filter(Boolean)) + let overlap = 0 + for (const token of queryTokens) { + if (textTokens.has(token)) overlap++ + } + return overlap / queryTokens.size +} + +/** + * Exponential decay score over record age. + * + * @param createdAt Record creation timestamp (epoch ms). + * @param halfLifeMs Time (ms) at which the score reaches 0.5. Defaults to 30 days. + * @param now Reference "current" time (epoch ms). Defaults to `Date.now()`. + * Callers MAY pass an explicit `now` to make scoring deterministic + * (e.g. in tests or batch re-scoring jobs). + */ +export function recencyScore( + createdAt: number, + halfLifeMs: number = DEFAULT_HALF_LIFE_MS, + now: number = Date.now(), +): number { + const age = Math.max(0, now - createdAt) + return Math.pow(0.5, age / halfLifeMs) +} + +export function isExpired( + record: MemoryRecord, + now: number = Date.now(), +): boolean { + return record.expiresAt !== undefined && record.expiresAt < now +} + +/** + * Reference ranking function used by adapters that want a sensible default. + * + * Weighted sum of four signals, each in `[0, 1]`: + * - semantic similarity (cosine) — 0.55 + * - lexical overlap — 0.20 + * - recency (exp decay) — 0.15 + * - importance — 0.10 + * + * Importance is read from `record.importance`. **If unset, importance + * contributes 0** — the function deliberately does NOT fall back to a + * mid-range default. With the `MemoryMiddlewareOptions.minScore` floor at + * `0.15`, a non-zero importance default would push every recent record over + * the floor regardless of relevance. Callers who want recent records to + * float MUST set `importance` on the record explicitly. + * + * @param args.now Optional reference "current" time (epoch ms) threaded + * through to `recencyScore` so callers can score + * deterministically. Defaults to `Date.now()`. + */ +export function defaultScoreHit(args: { + record: MemoryRecord + query: MemoryQuery + now?: number +}): number { + const { record, query, now } = args + const semantic = cosine(query.embedding, record.embedding) + const lexical = lexicalOverlap(query.text, record.text) + const recency = recencyScore(record.createdAt, undefined, now) + // No default fallback for importance — unset means "no importance signal", + // which contributes 0 to the score. See JSDoc above for rationale. + const importance = record.importance ?? 0 + return semantic * 0.55 + lexical * 0.2 + recency * 0.15 + importance * 0.1 +} + +export function defaultRenderMemory(hits: Array): string { + if (hits.length === 0) return '' + return [ + 'Relevant memory:', + 'Use this information only when it is relevant to the current user request.', + 'Do not mention memory directly unless the user asks about it.', + 'If current conversation context contradicts memory, prefer the current conversation.', + '', + // JSON.stringify the record text so persisted memory containing newlines + // or instruction-shaped content cannot break out of the list structure + // and steer subsequent turns at system priority. The double-quoted form + // also makes the content visibly data-shaped rather than instruction-shaped. + ...hits.map( + (hit, index) => + `${index + 1}. [${hit.record.kind}] ${JSON.stringify(hit.record.text)}`, + ), + ].join('\n') +} diff --git a/packages/typescript/ai/src/memory/index.ts b/packages/typescript/ai/src/memory/index.ts new file mode 100644 index 000000000..aa76eaa8a --- /dev/null +++ b/packages/typescript/ai/src/memory/index.ts @@ -0,0 +1,28 @@ +export type { + MemoryScope, + MemoryKind, + MemoryRole, + MemoryRecord, + MemoryRecordPatch, + MemoryHit, + MemoryQuery, + MemorySearchResult, + MemoryListOptions, + MemoryListResult, + MemoryAdapter, + MemoryEmbedder, + MemoryOp, + MemoryMiddlewareOptions, +} from './types' + +export { + scopeMatches, + cosine, + lexicalOverlap, + recencyScore, + isExpired, + defaultRenderMemory, + defaultScoreHit, +} from './helpers' + +export { memoryMiddleware } from './middleware' diff --git a/packages/typescript/ai/src/memory/middleware.ts b/packages/typescript/ai/src/memory/middleware.ts new file mode 100644 index 000000000..241414fbb --- /dev/null +++ b/packages/typescript/ai/src/memory/middleware.ts @@ -0,0 +1,706 @@ +import { aiEventClient } from '@tanstack/ai-event-client' +import { defaultRenderMemory } from './helpers' +import type { + ChatMiddleware, + ChatMiddlewareConfig, + ChatMiddlewareContext, +} from '../activities/chat/middleware/types' +import type { ModelMessage } from '../types' +import type { + MemoryHit, + MemoryMiddlewareOptions, + MemoryOp, + MemoryRecord, + MemoryScope, +} from './types' + +/** + * Per-request scratch state. Keyed by `ChatMiddlewareContext` in a + * module-level `WeakMap` so the SAME `memoryMiddleware()` factory output can + * be safely shared across many concurrent `chat()` calls — each request gets + * its own `MemoryRequestState`. Mirrors the OTEL middleware's pattern. + */ +interface MemoryRequestState { + resolvedScope?: MemoryScope + lastUserText: string + lastUserEmbedding?: Array + retrievedHits: Array + /** + * Tool-result ops buffered from `onAfterToolCall` until `onFinish`. Flushed + * inside `persistTurn` AFTER the per-turn `shouldRemember` gate passes — + * returning `false` from `shouldRemember` short-circuits both base records, + * `extractMemories`, AND these tool-result ops, matching the documented + * "short-circuits the entire persist path for the current turn" contract. + */ + pendingToolOps: Array +} + +const stateByCtx = new WeakMap() + +/** + * Server-side memory middleware. See docs/middlewares/memory.md and the + * tanstack-ai-memory skill for usage. + */ +export function memoryMiddleware( + options: MemoryMiddlewareOptions, +): ChatMiddleware { + async function resolveScope( + ctx: ChatMiddlewareContext, + state: MemoryRequestState, + ): Promise { + if (state.resolvedScope) return state.resolvedScope + state.resolvedScope = + typeof options.scope === 'function' + ? await options.scope(ctx) + : options.scope + return state.resolvedScope + } + + return { + name: 'memory', + + async onConfig(ctx, config) { + if (ctx.phase !== 'init') return + + // Allocate per-request state once at the init phase. + const state: MemoryRequestState = { + lastUserText: '', + retrievedHits: [], + pendingToolOps: [], + } + stateByCtx.set(ctx, state) + + const lastUser = findLastUserMessage(config.messages) + state.lastUserText = getMessageText(lastUser) + if (!state.lastUserText) return + + const scope = await resolveScope(ctx, state) + + if (options.shouldRetrieve) { + const ok = await options.shouldRetrieve({ + userText: state.lastUserText, + scope, + }) + if (!ok) return + } + + const startedAt = Date.now() + try { + safeEmit('memory:retrieve:started', { + scope, + query: preview(state.lastUserText), + topK: options.topK ?? 6, + minScore: options.minScore ?? 0.15, + embedderUsed: !!options.embedder, + timestamp: startedAt, + }) + await options.events?.onRetrieveStart?.({ + scope, + query: state.lastUserText, + }) + + if (options.embedder) { + state.lastUserEmbedding = await options.embedder.embed( + state.lastUserText, + ) + } + + state.retrievedHits = await searchAllPages( + options, + scope, + state.lastUserText, + state.lastUserEmbedding, + ) + + if (options.rerank && state.retrievedHits.length > 0) { + state.retrievedHits = await options.rerank(state.retrievedHits, { + scope, + query: state.lastUserText, + ctx, + }) + } + + safeEmit('memory:retrieve:completed', { + scope, + hits: state.retrievedHits.map((h) => ({ + id: h.record.id, + kind: h.record.kind, + score: h.score, + preview: preview(h.record.text), + })), + durationMs: Date.now() - startedAt, + timestamp: Date.now(), + }) + await options.events?.onRetrieveEnd?.({ + scope, + hits: state.retrievedHits, + }) + } catch (error) { + safeEmit('memory:error', { + scope, + phase: 'retrieve', + error: errorInfo(error), + timestamp: Date.now(), + }) + await emitError(options, scope, 'retrieve', error) + if (options.strict) throw error + return + } + + if (state.retrievedHits.length === 0) return + + const memoryPrompt = + options.render?.(state.retrievedHits) ?? + defaultRenderMemory(state.retrievedHits) + + return { + systemPrompts: [...config.systemPrompts, memoryPrompt], + } satisfies Partial + }, + + async onAfterToolCall(ctx, info) { + if (!options.onToolResult || !info.ok) return + const state = stateByCtx.get(ctx) + if (!state) return + const scope = await resolveScope(ctx, state) + try { + let parsedArgs: unknown = {} + try { + const raw = info.toolCall.function.arguments + if (typeof raw === 'string' && raw.length > 0) { + parsedArgs = JSON.parse(raw) + } + } catch (parseError) { + // Tool-args JSON parse failure: the engine yielded malformed + // tool-call arguments. We still want `onToolResult` to run with the + // result it has — but observers MUST see this as a real failure + // because callers receive `args: {}` regardless of what the model + // actually sent. Fire `memory:error` (phase: 'extract') and route + // through `events.onError` so the failure isn't silent. + // + // Intentionally NOT rethrowing on strict: the malformed payload is + // an engine/provider bug, not a memory failure, and rethrowing here + // would also cause the outer `onAfterToolCall` catch to emit a + // second `phase: 'extract'` event for the same root cause. Falling + // back to `parsedArgs = {}` lets `onToolResult` still derive a + // record from `result`, which is the more useful signal anyway. + parsedArgs = {} + safeEmit('memory:error', { + scope, + phase: 'extract', + error: errorInfo(parseError), + timestamp: Date.now(), + }) + await emitError(options, scope, 'extract', parseError) + } + const out = await options.onToolResult({ + toolName: info.toolName, + toolCallId: info.toolCallId, + args: parsedArgs, + result: info.result, + scope, + adapter: options.adapter, + }) + if (!out) return + // Buffer the tool-result ops for the per-turn `shouldRemember` gate + // inside `persistTurn`. Per the JSDoc contract on `shouldRemember`, + // returning `false` short-circuits the ENTIRE persist path for the + // current turn — including tool-result memories. Persist then flushes + // these buffered ops in a single observed round at finish-turn time + // alongside base records and `extractMemories` output. + state.pendingToolOps.push(...normalizeOps(out)) + } catch (error) { + // Errors from `onToolResult` itself (synchronous extraction failure) + // — the persist phase is wrapped separately above. + safeEmit('memory:error', { + scope, + phase: 'extract', + error: errorInfo(error), + timestamp: Date.now(), + }) + await emitError(options, scope, 'extract', error) + if (options.strict) throw error + } + }, + + async onFinish(ctx, info) { + const state = stateByCtx.get(ctx) + if (!state) return + const responseText = info.content + if (!state.lastUserText && !responseText) { + stateByCtx.delete(ctx) + return + } + const scope = await resolveScope(ctx, state) + const userText = state.lastUserText + const userEmbedding = state.lastUserEmbedding + const retrievedMemoryIds = state.retrievedHits.map((h) => h.record.id) + // Snapshot tool-result ops buffered by `onAfterToolCall` so they can be + // gated by `shouldRemember` and flushed in the same observed persist + // round as base records + `extractMemories` output. + const pendingToolOps = state.pendingToolOps + // Done with state — drop the WeakMap entry now so the deferred work + // below cannot accidentally observe stale fields. (The WeakMap would + // GC the entry once `ctx` is dropped anyway; this is just defensive.) + stateByCtx.delete(ctx) + ctx.defer( + persistTurn({ + options, + scope, + userText, + userEmbedding, + responseText, + retrievedMemoryIds, + pendingToolOps, + }), + ) + }, + } +} + +// =========================== +// Internals +// =========================== + +async function searchAllPages( + options: MemoryMiddlewareOptions, + scope: MemoryScope, + text: string, + embedding: Array | undefined, +): Promise> { + const topK = options.topK ?? 6 + const minScore = options.minScore ?? 0.15 + const all: Array = [] + let cursor: string | undefined + do { + const page = await options.adapter.search({ + scope, + text, + embedding, + topK, + minScore, + kinds: options.kinds, + cursor, + }) + all.push(...page.hits) + cursor = page.nextCursor + if (all.length >= topK) break + } while (cursor) + return all.slice(0, topK) +} + +function normalizeOps( + input: Array | Array, +): Array { + if (input.length === 0) return [] + const first = input[0] + if (first && 'op' in first) return input as Array + return (input as Array).map((record) => ({ + op: 'add' as const, + record, + })) +} + +/** + * Apply ops in array order, dispatching each to the matching adapter method. + * + * **Order matters.** A previous implementation batched all `add` ops to the + * end so they could be flushed in one `adapter.add(records[])` call; that + * meant `[{add X}, {update X}]` silently no-op'd because the update fired + * against an empty store before the add committed. Strict in-order dispatch + * is correct at the cost of per-op round-trips. For high-throughput callers, + * `afterPersist` is the right place to do bulk fan-out. + * + * **Scope is enforced on add.** The resolved scope overrides whatever scope + * the user-supplied record carried. A buggy or hostile `extractMemories` / + * `onToolResult` callback cannot write into another tenant's bucket — the + * record's scope is silently corrected to the resolved scope before + * `adapter.add`. Update and delete already take `scope` as an explicit + * parameter, so they're isolated by the adapter's own `scopeMatches` check. + */ +async function applyOps( + options: MemoryMiddlewareOptions, + scope: MemoryScope, + ops: Array, +): Promise> { + const newRecords: Array = [] + for (const op of ops) { + if (op.op === 'add') { + // Force the resolved scope onto user-supplied records to prevent a + // buggy extractMemories / onToolResult callback from writing into + // another tenant. This is defence-in-depth: the contract docs already + // promise tenant isolation, but enforcing it here means a single + // mistaken `scope: { tenantId: 'wrong' }` in a callback cannot breach + // the boundary. + const record: MemoryRecord = { ...op.record, scope } + await options.adapter.add(record) + newRecords.push(record) + } else if (op.op === 'update') { + await options.adapter.update(op.id, scope, op.patch) + } else { + await options.adapter.delete([op.id], scope) + } + } + return newRecords +} + +/** + * Run a persist batch with the full observability pipeline: + * 1. Emit `memory:persist:started` (skipped when there are no `add` ops, to + * avoid noise on update-only / delete-only batches). + * 2. Fire `events.onPersistStart` with the to-be-added records. + * 3. Apply ops via `applyOps`. + * 4. Emit `memory:persist:completed`. + * 5. Fire `events.onPersistEnd` with the actually-added records. + * 6. Call `options.afterPersist` with the newly-added records. + * + * Used by BOTH finish-turn persistence (via `persistTurn`) and `onToolResult` + * deferred persistence so that observability is symmetric across the two + * paths — `afterPersist` and the persist devtools events fire for every + * `adapter.add` commit, not just the finish-turn one. + * + * Adapter failures surface via `memory:error` + `events.onError` and (in + * strict mode) re-throw so a deferred persist promise rejects rather than + * being silently swallowed by the chat engine's `Promise.allSettled`. + */ +async function runObservedPersist( + options: MemoryMiddlewareOptions, + scope: MemoryScope, + ops: Array, +): Promise> { + if (ops.length === 0) return [] + const startedAt = Date.now() + const adds = ops.filter( + (o): o is Extract => o.op === 'add', + ) + // Only emit persist:started when there's at least one add. Update-only or + // delete-only batches don't represent a new write that observers care about. + if (adds.length > 0) { + safeEmit('memory:persist:started', { + scope, + records: adds.map((o) => { + const r = o.record + return { + id: r.id, + kind: r.kind, + role: r.role, + preview: preview(r.text), + } + }), + timestamp: startedAt, + }) + await options.events?.onPersistStart?.({ + scope, + records: adds.map((o) => o.record), + }) + } + let newRecords: Array = [] + try { + newRecords = await applyOps(options, scope, ops) + } catch (error) { + safeEmit('memory:error', { + scope, + phase: 'persist', + error: errorInfo(error), + timestamp: Date.now(), + }) + await emitError(options, scope, 'persist', error) + if (options.strict) throw error + return [] + } + if (adds.length > 0) { + safeEmit('memory:persist:completed', { + scope, + recordIds: newRecords.map((r) => r.id), + durationMs: Date.now() - startedAt, + timestamp: Date.now(), + }) + await options.events?.onPersistEnd?.({ scope, records: newRecords }) + } + if (options.afterPersist && newRecords.length > 0) { + try { + await options.afterPersist({ + newRecords, + scope, + adapter: options.adapter, + }) + } catch (error) { + // afterPersist is documented as background work — surface failures via + // the same plumbing as adapter failures so they aren't swallowed, but + // route through phase: 'persist' since it's part of the persist arc. + safeEmit('memory:error', { + scope, + phase: 'persist', + error: errorInfo(error), + timestamp: Date.now(), + }) + await emitError(options, scope, 'persist', error) + if (options.strict) throw error + } + } + return newRecords +} + +async function persistTurn(args: { + options: MemoryMiddlewareOptions + scope: MemoryScope + userText: string + userEmbedding?: Array + responseText: string + retrievedMemoryIds: Array + /** + * Tool-result ops buffered by `onAfterToolCall` during the turn. Flushed + * AFTER the `shouldRemember` gate passes so a `false` return short-circuits + * tool-result memories along with base records and `extractMemories`. + */ + pendingToolOps: Array +}): Promise { + const { options, scope } = args + // Hoisted out of the try block so the outer catch can read them when + // deciding whether the thrown value is the strict-mode extract re-throw + // (already-emitted, must not double-emit). + let extractError: unknown + let extractFailed = false + // OUTERMOST try/catch so any throw — extract, persist, afterPersist — + // routes through the same error plumbing and (in strict mode) rejects the + // deferred promise via the engine's `Promise.allSettled` collector. + try { + const now = Date.now() + + // Per-turn `shouldRemember` gate. Per JSDoc: "Returning `false` + // short-circuits `extractMemories` and the persist path for the current + // turn." We evaluate ONCE here with the user message + responseText — + // returning `false` skips both the base records and `extractMemories`. + if (options.shouldRemember) { + const keep = await options.shouldRemember({ + message: { role: 'user', content: args.userText }, + responseText: args.responseText, + }) + if (!keep) return + } + + const baseRecords: Array = [] + if (args.userText) { + baseRecords.push({ + id: crypto.randomUUID(), + scope, + text: args.userText, + kind: 'message', + role: 'user', + createdAt: now, + importance: 0.4, + embedding: args.userEmbedding, + }) + } + if (args.responseText) { + // The assistant-side embedder call lives OUTSIDE `runObservedPersist`, + // so a throw here would bypass the persist-phase observability if it + // escaped uncaught. Wrap it locally and route failures through the same + // `memory:error` + `events.onError` plumbing as every other site. + // Mirrors the user-text embedder catch in `onConfig`'s retrieval block. + // In strict mode we rethrow so the outer catch turns it into a deferred + // persist rejection. In non-strict mode we continue with + // `embedding: undefined` so the assistant record still lands. + let assistantEmbedding: Array | undefined + if (options.embedder) { + try { + assistantEmbedding = await options.embedder.embed(args.responseText) + } catch (error) { + safeEmit('memory:error', { + scope, + phase: 'persist', + error: errorInfo(error), + timestamp: Date.now(), + }) + await emitError(options, scope, 'persist', error) + if (options.strict) throw error + // Non-strict: leave `assistantEmbedding` undefined and continue. + } + } + baseRecords.push({ + id: crypto.randomUUID(), + scope, + text: args.responseText, + kind: 'message', + role: 'assistant', + createdAt: now, + importance: 0.4, + embedding: assistantEmbedding, + metadata: { retrievedMemoryIds: args.retrievedMemoryIds }, + }) + } + + // Op ordering is intentional and documented: + // 1. base records (user, assistant) — always first + // 2. extractMemories output — appended after base + // 3. pendingToolOps — appended last + // `applyOps` dispatches in array order (see its JSDoc for why ordering + // matters), so `[{add X}, {update X}]` from extractMemories will see the + // base records already committed, and tool-result ops referring to ids + // that extractMemories created will be applied last. + let ops: Array = baseRecords.map((record) => ({ + op: 'add' as const, + record, + })) + + // Strict-mode `extractMemories` failure semantics: + // 1. The error is emitted exactly ONCE via `memory:error`/`onError` + // with `phase: 'extract'` — the outer persist catch is suppressed + // below so it does not re-emit with `phase: 'persist'`. + // 2. Base user/assistant records still land. We commit `applyOps` for + // the records already accumulated before re-throwing so an extract + // failure does not silently lose the conversation turn. + // 3. In strict mode the original extract error is re-thrown AFTER + // `applyOps` commits, so the deferred persist promise rejects and + // the engine surfaces the failure through `Promise.allSettled`. + // 4. In non-strict mode the error is swallowed after the single emit + // and persistence continues with the base records. + if (options.extractMemories) { + try { + const extras = await options.extractMemories({ + userText: args.userText, + responseText: args.responseText, + scope, + adapter: options.adapter, + }) + if (extras) ops = ops.concat(normalizeOps(extras)) + } catch (error) { + extractFailed = true + extractError = error + safeEmit('memory:error', { + scope, + phase: 'extract', + error: errorInfo(error), + timestamp: Date.now(), + }) + await emitError(options, scope, 'extract', error) + // Intentionally NOT re-throwing here — see note (2)/(3) above. The + // re-throw happens after `applyOps` so base records still persist. + } + } + + // Append tool-result ops (buffered from `onAfterToolCall`) AFTER the + // shouldRemember gate has passed. This is what enforces the contract: + // returning `false` from `shouldRemember` discards tool-result memories + // along with base records and `extractMemories` output, since none of + // them ever reach `runObservedPersist`. + if (args.pendingToolOps.length > 0) { + ops = ops.concat(args.pendingToolOps) + } + + // `runObservedPersist` owns the persist:started/completed events, the + // onPersistStart/onPersistEnd callbacks, afterPersist, and the + // memory:error+strict rethrow on adapter failure. Letting it handle + // strict-mode rethrows itself means the catch below ONLY has to deal + // with the strict-mode extract rethrow (and a guard against double- + // emitting memory:error for that case). + await runObservedPersist(options, scope, ops) + + // Strict-mode extract failure: base records have now been committed via + // `runObservedPersist`. Re-throw the original extract error so the + // deferred persist promise rejects. The outer catch below recognises + // this case and does NOT re-emit `memory:error` (it would otherwise + // fire a second event with phase: 'persist' for the same failure). + if (extractFailed && options.strict) throw extractError + } catch (error) { + // By the time we reach this catch, `memory:error` has ALREADY been + // emitted at the source — either: + // (a) Strict-mode extract rethrow: the inner extract catch above + // emitted `phase: 'extract'`. The `extractFailed` / + // `extractError` hoisted state lets future maintainers verify + // at a glance that this branch is reachable. + // (b) Strict-mode adapter or afterPersist rethrow: emitted inside + // `runObservedPersist` with `phase: 'persist'` immediately + // before it threw. + // (c) Strict-mode assistant-side embedder rethrow: the local + // try/catch around the assistant embedder call above emitted + // `phase: 'persist'` before rethrowing. + // Either way the event already fired with the correct phase; re- + // emitting here would produce a duplicate event for the same failure. + // So this catch is intentionally a pass-through in non-strict mode + // and a rethrow-only path in strict mode. + if (options.strict) throw error + } +} + +async function emitError( + options: MemoryMiddlewareOptions, + scope: MemoryScope, + phase: 'retrieve' | 'persist' | 'extract', + error: unknown, +): Promise { + await options.events?.onError?.({ scope, phase, error }) +} + +/** + * Extract a `{ name, message }` pair from an unknown thrown value. The + * runtime can't trust `error` to be an `Error` instance (anything is throwable + * in JS), so we narrow defensively and fall back to stringification. + */ +function errorInfo(error: unknown): { name: string; message: string } { + if (error instanceof Error) { + return { name: error.name, message: error.message } + } + if ( + error && + typeof error === 'object' && + 'name' in error && + typeof (error as { name: unknown }).name === 'string' + ) { + return { + name: (error as { name: string }).name, + message: String((error as { message?: unknown }).message ?? error), + } + } + return { name: 'Error', message: String(error) } +} + +function findLastUserMessage( + messages: ReadonlyArray, +): ModelMessage | undefined { + for (let i = messages.length - 1; i >= 0; i--) { + const message = messages[i] + if (message && message.role === 'user') return message + } + return undefined +} + +function preview(text: string, max = 200): string { + return text.length > max ? text.slice(0, max) + '…' : text +} + +/** + * Defensive devtools emit. Devtools events should be fire-and-forget — if the + * event client throws synchronously (misconfigured global, broken transport), + * we swallow it so middleware behaviour never depends on devtools health. + */ +const safeEmit: typeof aiEventClient.emit = (...args) => { + try { + return aiEventClient.emit(...args) + } catch { + // ignored — telemetry failures must not affect chat behaviour + } +} + +function getMessageText(message?: ModelMessage): string { + if (!message) return '' + if (typeof message.content === 'string') return message.content + if (Array.isArray(message.content)) { + // Per `TextPart` in ../types.ts the text payload lives on `content`, not + // `text`. Bare strings are still tolerated because a handful of adapters + // pass them through in the content array. All other ContentPart kinds + // (tool-call, tool-result, image, audio, …) yield '' so they don't + // pollute the retrieval query or persisted record text. + return message.content + .map((part) => { + if (typeof part === 'string') return part + if (part.type === 'text' && typeof part.content === 'string') { + return part.content + } + return '' + }) + .filter(Boolean) + .join('\n') + } + return '' +} diff --git a/packages/typescript/ai/src/memory/types.ts b/packages/typescript/ai/src/memory/types.ts new file mode 100644 index 000000000..70b300664 --- /dev/null +++ b/packages/typescript/ai/src/memory/types.ts @@ -0,0 +1,617 @@ +/** + * Memory subsystem type definitions. + * + * This module defines the public contract for the memory adapter ecosystem: + * the storage-shaped {@link MemoryAdapter} interface, the record/query/op shapes + * adapters operate on, and the {@link MemoryMiddlewareOptions} surface used to + * wire memory into a chat run via the memory middleware. + * + * The architectural split is intentional: + * - **Adapters are thin storage.** They persist, fetch, search, and scope-filter + * records. They do not decide what to remember, when to retrieve, or how to + * render hits into a prompt. + * - **Policy lives in the middleware.** Decisions like "should we retrieve here?", + * "what facts should we extract from this turn?", or "how do we render hits + * into a system prompt?" are configured on the middleware, not the adapter. + * + * Third-party adapter implementers should treat this file as the source of truth + * for the contract. Method-level semantics (upsert behaviour, scope isolation, + * expiry filtering, error vs. no-op for unknown ids) are documented on each + * member of {@link MemoryAdapter} below. + */ + +import type { ChatMiddlewareContext } from '../activities/chat/middleware/types' + +// =========================== +// Scope & primitives +// =========================== + +/** + * Multi-dimensional scope used to isolate memory records across tenants, + * users, sessions, threads, and arbitrary namespaces. + * + * Each key is optional and orthogonal: + * - `tenantId` — top-level organisation / workspace boundary in multi-tenant apps. + * - `userId` — end-user identity within a tenant. + * - `sessionId` — short-lived session (e.g. browser session, anonymous visitor). + * - `threadId` — conversation / thread identifier within a session. + * - `namespace` — application-defined bucket (e.g. `'preferences'`, `'kb'`). + * + * Adapters MUST treat scope as a strict isolation boundary: a `get`/`search`/ + * `list`/`update`/`delete` call against scope `A` MUST NOT return, mutate, or + * remove records that belong to a different scope `B`. Cross-contamination + * between scopes is a correctness bug, especially for multi-tenant deployments. + */ +export type MemoryScope = { + tenantId?: string + userId?: string + sessionId?: string + threadId?: string + namespace?: string +} + +/** + * Classification of a stored memory record. + * + * - `'message'` — a raw conversation turn (user or assistant utterance) captured verbatim. + * - `'summary'` — a compressed summary of prior conversation history, used to keep + * long threads within context windows. + * - `'fact'` — an extracted statement of fact about the user or world + * (e.g. "user lives in Berlin"). + * - `'preference'` — an extracted user preference (e.g. "prefers concise answers"). + * - `'tool-result'` — a persisted tool execution result, kept for future recall + * (e.g. cached search results, expensive computations). + * + * Middleware can filter retrieval by `kinds` to scope what gets surfaced into + * a given prompt (for example, retrieve only `'fact'` and `'preference'` for + * persona injection, or only `'tool-result'` for cache-style recall). + */ +export type MemoryKind = + | 'message' + | 'summary' + | 'fact' + | 'preference' + | 'tool-result' + +/** + * Role attached to a memory record when it represents a conversation turn. + * Mirrors the standard chat role taxonomy. + */ +export type MemoryRole = 'user' | 'assistant' | 'system' | 'tool' + +// =========================== +// Records +// =========================== + +/** + * A single memory record persisted by an adapter. + */ +export type MemoryRecord = { + /** + * Globally unique identifier within the adapter. The adapter owns id-space + * uniqueness across all scopes — two records with the same `id` MUST NOT + * coexist in the adapter, regardless of scope. + */ + id: string + /** Scope this record belongs to. Used by adapters for isolation. */ + scope: MemoryScope + /** Human-readable text content of the memory. Indexed for search. */ + text: string + /** Classification — see {@link MemoryKind}. */ + kind: MemoryKind + /** Optional originating role when this record represents a chat turn. */ + role?: MemoryRole + /** Creation timestamp in epoch milliseconds. Set by the adapter on `add` if absent. */ + createdAt: number + /** + * Last update timestamp in epoch milliseconds. Bumped automatically by the + * adapter on `update`. Equal to `createdAt` for never-updated records. + */ + updatedAt?: number + /** + * Optional epoch-ms expiration. Adapters MUST filter expired records out of + * `search`/`list`/`get` and SHOULD opportunistically remove them on `add`. + */ + expiresAt?: number + /** + * Importance hint in the range `0..1` (higher = more important). This is a + * soft signal a re-ranker, eviction policy, or summariser may consult — it + * is not enforced by the adapter contract. + * + * The reference `defaultScoreHit` ranker treats unset importance as `0` + * (no contribution to the score) — it deliberately does NOT fall back to + * a mid-range default. Set this explicitly (e.g. `0.4` for raw turns, `1` + * for pinned facts) to bias retrieval; otherwise the record competes on + * semantic, lexical, and recency signals alone. + */ + importance?: number + /** + * Optional precomputed embedding vector. Length is consumer-defined (model- + * dependent) — the adapter does not validate dimensionality, but all records + * within a single adapter deployment SHOULD share a consistent dimension if + * vector search is used. + */ + embedding?: Array + /** Free-form metadata bag for adapter-specific or app-specific annotations. */ + metadata?: Record +} + +/** + * Patch shape for in-place updates. + * + * `id`, `scope`, and `createdAt` are immutable and cannot be patched. The + * adapter preserves `createdAt` and bumps `updatedAt` automatically on every + * successful `update` call — callers SHOULD NOT set `updatedAt` themselves. + */ +export type MemoryRecordPatch = Partial< + Omit +> + +/** + * A single search result: the matched record plus the relevance score the + * adapter assigned. Score semantics (cosine, BM25, hybrid, etc.) are + * adapter-defined; consumers should treat scores as relative within a single + * search result set, not as absolute values across adapters. + */ +export type MemoryHit = { record: MemoryRecord; score: number } + +// =========================== +// Queries +// =========================== + +/** + * Relevance-ranked search query passed to {@link MemoryAdapter.search}. + */ +export type MemoryQuery = { + /** Scope to search within. Records outside this scope MUST NOT be returned. */ + scope: MemoryScope + /** Query text used by the adapter for ranking (lexical, semantic, or hybrid). */ + text: string + /** Optional precomputed query embedding. If provided, the adapter MAY use it instead of embedding `text`. */ + embedding?: Array + /** Maximum number of hits to return. */ + topK?: number + /** Drop hits with `score < minScore`. */ + minScore?: number + /** Restrict matches to the given record kinds. */ + kinds?: Array + /** + * Opaque pagination cursor returned from a previous `search` call. The + * cursor format is adapter-defined and MUST NOT be parsed by callers. + */ + cursor?: string +} + +/** + * Result of a {@link MemoryAdapter.search} call. + */ +export type MemorySearchResult = { + /** Hits ordered by descending relevance. */ + hits: Array + /** Opaque cursor for fetching the next page, or `undefined` if no more results. */ + nextCursor?: string +} + +/** + * Options for non-relevance browsing via {@link MemoryAdapter.list}. + */ +export type MemoryListOptions = { + /** Restrict to the given record kinds. */ + kinds?: Array + /** Maximum number of records to return. */ + limit?: number + /** Opaque pagination cursor returned from a previous `list` call. */ + cursor?: string + /** Sort order. Defaults are adapter-defined when omitted. */ + order?: 'createdAt:desc' | 'createdAt:asc' | 'updatedAt:desc' +} + +/** + * Result of a {@link MemoryAdapter.list} call. + */ +export type MemoryListResult = { + /** Records ordered per `MemoryListOptions.order`. */ + items: Array + /** Opaque cursor for fetching the next page, or `undefined` if no more records. */ + nextCursor?: string +} + +// =========================== +// Adapter contract +// =========================== + +/** + * Storage-shaped contract every memory backend implements. + * + * **Design principle: thin storage; policy lives in the middleware.** Adapters + * are responsible for persistence, retrieval, scope isolation, and expiry + * filtering — nothing else. Decisions about what to remember, when to retrieve, + * how to rank, or how to render hits into a prompt belong on + * {@link MemoryMiddlewareOptions}, not on the adapter. + * + * Cross-cutting invariants every adapter MUST uphold: + * - **Scope isolation.** No method may return, mutate, or delete records that + * live outside the supplied scope. See {@link MemoryScope}. + * - **Expiry filtering.** Records whose `expiresAt` has passed MUST be filtered + * out of `search`, `list`, and `get`. Adapters SHOULD opportunistically remove + * them on `add`. + * - **Id uniqueness.** Ids are globally unique within the adapter, across all scopes. + */ +export interface MemoryAdapter { + /** Stable adapter name (used for logging, devtools, and diagnostics). */ + name: string + + /** + * Upsert one or more records by id. + * + * `add` is **upsert-by-id**, not insert-only: if a record with the same `id` + * already exists, it is replaced. The single-record form + * (`add(record)`) and the array form (`add([record, ...])`) behave + * identically — passing a single record is exactly equivalent to passing a + * one-element array. + * + * Adapters SHOULD opportunistically evict expired records on `add`. + */ + add: (records: MemoryRecord | Array) => Promise + + /** + * Fetch a record by id within a scope. + * + * Returns `undefined` when: + * - no record exists with the given id, OR + * - a record exists but its scope does not match the supplied `scope`, OR + * - the record has expired (`expiresAt` is in the past). + * + * In all three cases the adapter returns `undefined` — it does not throw and + * does not leak the existence of out-of-scope records. + */ + get: (id: string, scope: MemoryScope) => Promise + + /** + * Patch a record in place. + * + * On success, returns the updated record. The adapter: + * - preserves `id`, `scope`, and `createdAt` (these cannot be patched), + * - bumps `updatedAt` to the current epoch ms, + * - merges the supplied patch over the existing record. + * + * Returns `undefined` when the target record does not exist, lives in a + * different scope, or has expired — symmetric with {@link MemoryAdapter.get}. + */ + update: ( + id: string, + scope: MemoryScope, + patch: MemoryRecordPatch, + ) => Promise + + /** + * Run a relevance-ranked search within a scope. + * + * The ranking strategy (lexical, semantic, hybrid) is adapter-defined. + * Pagination is via the opaque `query.cursor` / `result.nextCursor` pair — + * the cursor format is adapter-internal and MUST NOT be parsed by callers. + * Expired records are filtered out. + * + * An empty `query.scope` (`{}`) matches NOTHING — adapters MUST return an + * empty hit set rather than treating it as a wildcard. This is the + * symmetric counterpart of the empty-scope safety guard on `clear` and + * the reference `scopeMatches` helper. + */ + search: (query: MemoryQuery) => Promise + + /** + * Browse records by scope without relevance ranking. + * + * This is the non-relevance counterpart to `search`, intended for inspector + * UIs, admin tooling, and bulk export. Ordering is controlled by + * `options.order`. Expired records are filtered out. + * + * An empty `scope` (`{}`) matches NOTHING — adapters MUST return an empty + * item set rather than treating it as a wildcard. Same cross-tenant + * safety rationale as `search` and `clear`. + */ + list: ( + scope: MemoryScope, + options?: MemoryListOptions, + ) => Promise + + /** + * Delete records by id within a scope. + * + * Ids that do not exist or whose record lives in a different scope are + * silently no-op'd — `delete` does not throw on missing ids, and it MUST NOT + * cross scope boundaries. + */ + delete: (ids: Array, scope: MemoryScope) => Promise + + /** + * Remove ALL records that match the supplied scope. + * + * Scope matching uses the same isolation semantics as every other method: + * only records whose scope matches the supplied scope are removed. An empty + * scope (`{}`) matches NOTHING — adapters MUST treat empty-scope + * `clear({})` as a no-op rather than a global wipe. The reference + * `scopeMatches` helper rejects empty query scopes precisely so this is + * the default for any adapter built on top of it. Implementations that + * bypass `scopeMatches` (e.g. index-driven optimisations like the Redis + * adapter) MUST add an equivalent empty-scope check before deleting. + * + * Callers who actually intend to wipe an entire scope dimension must pass + * the relevant scope key explicitly (e.g. `{ tenantId: 't1' }` to clear + * every record for tenant `t1`). + */ + clear: (scope: MemoryScope) => Promise +} + +/** + * Pluggable embedding provider. Used by the middleware to compute query and + * record embeddings when the adapter relies on vector search. + * + * `embed` may be invoked multiple times within a single chat run — once on the + * retrieval path (to embed the user query) and optionally again on the persist + * path (to embed assistant text or extracted facts). Implementations SHOULD be + * idempotent: embedding the same input twice should yield the same vector. + */ +export interface MemoryEmbedder { + embed: (text: string) => Promise> +} + +// =========================== +// Mutation ops +// =========================== + +/** + * A single memory mutation, used as the return type of `extractMemories` and + * `onToolResult` to express add/update/delete intent in one stream. + * + * As shorthand, those hooks may also return a plain `MemoryRecord[]`, which + * the middleware treats as `[{ op: 'add', record }, ...]` — one add per + * record. + */ +export type MemoryOp = + | { op: 'add'; record: MemoryRecord } + | { op: 'update'; id: string; patch: MemoryRecordPatch } + | { op: 'delete'; id: string } + +// =========================== +// Middleware options +// =========================== + +/** + * Configuration for the memory middleware. + * + * The middleware orchestrates two paths around a chat run: + * - **Retrieval (read-side)**: gated by `shouldRetrieve`, runs `adapter.search`, + * optionally pipes hits through `rerank`, then renders into the prompt via + * `render`. + * - **Persistence (write-side)**: gated by `shouldRemember`, calls + * `extractMemories` at finish (and `onToolResult` per completed tool call), + * commits ops to the adapter, then invokes `afterPersist` with the records + * that were newly added. + * + * `events.*` callbacks are app-level lifecycle hooks that fire alongside the + * devtools events — use them for application telemetry that should not depend + * on devtools being installed. + */ +export interface MemoryMiddlewareOptions { + /** The storage adapter to read from / write to. */ + adapter: MemoryAdapter + + /** + * Scope for every adapter call this middleware makes. + * + * The function form is the safer default for multi-tenant apps: it lets the + * middleware derive scope per request from the chat context (e.g. from + * authenticated session info attached by the host). Scope MUST be derived + * server-side from trusted state — never accept scope fields directly from + * client input, or one user's request can read or write another user's + * memory. + */ + scope: + | MemoryScope + | ((ctx: ChatMiddlewareContext) => MemoryScope | Promise) + + /** + * Optional embedding provider. Required when the configured adapter relies + * on vector search and records / queries do not arrive pre-embedded. + */ + embedder?: MemoryEmbedder + + /** Maximum number of hits to retrieve per turn. Defaults to `6`. */ + topK?: number + /** Drop hits with `score < minScore`. Defaults to `0.15`. */ + minScore?: number + /** Restrict retrieval to the given record kinds. Defaults to all kinds. */ + kinds?: Array + /** + * Render retrieved hits into a string injected into the prompt. Replaces + * the built-in `defaultRenderMemory` formatter when provided. + */ + render?: (hits: Array) => string + + /** + * Write-side gate: decide whether a given turn should produce memories at + * all. Evaluated **once per turn** (not per record) with the latest user + * message and the assistant `responseText`. Returning `false` short- + * circuits the entire persist path — base user/assistant records, + * `extractMemories`, and `afterPersist` are all skipped for the current + * turn. Use this when the application has a hard rule for the whole turn + * (e.g. PII guard, opt-out flag); use `extractMemories` itself for + * per-record decisions. + */ + shouldRemember?: (args: { + message: { role: MemoryRole; content: string } + responseText?: string + }) => boolean | Promise + + /** + * Read-side gate: decide whether to run retrieval for the current user + * message. Returning `false` skips the entire retrieval path (search, + * rerank, render) for this turn — symmetric with `shouldRemember` on the + * write side. + */ + shouldRetrieve?: (args: { + userText: string + scope: MemoryScope + }) => boolean | Promise + + /** + * Optional re-ranker. Runs after `adapter.search` returns hits and before + * `render` formats them into the prompt — use this to apply application- + * specific ranking signals (recency boosts, importance weighting, + * cross-encoder reranking, etc.). + */ + rerank?: ( + hits: Array, + args: { scope: MemoryScope; query: string; ctx: ChatMiddlewareContext }, + ) => Array | Promise> + + /** + * Extract memory mutations from a completed turn. Runs at finish, after the + * assistant response is fully accumulated. + * + * May return a mixed `MemoryOp[]` to express adds, updates, and deletes in a + * single batch, or — as shorthand — a plain `MemoryRecord[]`, which the + * middleware treats as all-add (`[{ op: 'add', record }, ...]`). Returning + * `undefined` is a no-op. + * + * **Failure semantics.** If this callback throws, the middleware emits a + * single `memory:error` event with `phase: 'extract'` and calls + * `events.onError({ phase: 'extract' })`. Base user/assistant records are + * still committed to the adapter regardless — an extract failure must not + * silently drop the raw turn. In non-strict mode (the default) the error + * is then swallowed and chat continues. In strict mode (`strict: true`) + * the original extract error is re-thrown AFTER the base records have + * committed, so the deferred persist promise rejects — but `memory:error` + * still fires exactly once with `phase: 'extract'` (NOT a second time + * with `phase: 'persist'`). + * + * **Scope is enforced.** Records returned by this callback have their + * `scope` field overridden with the resolved scope before being persisted, + * regardless of what scope the callback set. This is a defence-in-depth + * guarantee — callers cannot accidentally (or maliciously) write into + * another tenant's scope by returning a record with a different `scope`. + */ + extractMemories?: (args: { + userText: string + responseText: string + scope: MemoryScope + adapter: MemoryAdapter + }) => + | Promise | Array | undefined> + | Array + | Array + | undefined + + /** + * Per-tool-call persistence hook. Runs once for each completed tool call + * with its arguments and result, allowing the app to persist tool output as + * memory (typical `kind` is `'tool-result'`). + * + * The middleware buffers the returned ops and flushes them in the + * finish-turn persist round so the per-turn `shouldRemember` gate applies + * uniformly to base records, `extractMemories` output, AND tool-result + * memories. Same return-shape conventions as `extractMemories` — + * `MemoryOp[]`, `MemoryRecord[]` shorthand, or `undefined`. + * + * **Persist events fire once per turn.** A single `memory:persist:started` + * / `:completed` pair (and one `events.onPersistStart` / `onPersistEnd` / + * `afterPersist` invocation) covers base records, extracted ops, and + * tool-result ops together — they all commit in one observed round. + * + * **Scope is enforced.** Records returned by this callback have their + * `scope` field overridden with the resolved scope before being persisted, + * regardless of what scope the callback set. This is a defence-in-depth + * guarantee — callers cannot accidentally (or maliciously) write into + * another tenant's scope by returning a record with a different `scope`. + */ + onToolResult?: (args: { + toolName: string + toolCallId: string + args: unknown + result: unknown + scope: MemoryScope + adapter: MemoryAdapter + }) => + | Promise | Array | undefined> + | Array + | Array + | undefined + + /** + * Post-persist callback invoked after `adapter.add` commits successfully. + * + * `newRecords` contains only the records that were newly added on this + * turn — it does NOT include records that were updated or deleted. Use this + * for "memory was just written" side-effects (analytics, indexing, + * notifications). + */ + afterPersist?: (args: { + newRecords: Array + scope: MemoryScope + adapter: MemoryAdapter + }) => Promise | void + + /** + * Application-level lifecycle callbacks. + * + * These fire in addition to (not instead of) the devtools events emitted by + * the middleware — they are the appropriate place to wire app telemetry, + * logging, or custom progress UX that should not depend on devtools. + */ + events?: { + /** Fired before the retrieval path runs. */ + onRetrieveStart?: (args: { + scope: MemoryScope + query: string + }) => void | Promise + /** Fired after retrieval completes, with the final hit set (post-rerank). */ + onRetrieveEnd?: (args: { + scope: MemoryScope + hits: Array + }) => void | Promise + /** Fired before the persist path commits records to the adapter. */ + onPersistStart?: (args: { + scope: MemoryScope + records: Array + }) => void | Promise + /** Fired after the persist path commits records to the adapter. */ + onPersistEnd?: (args: { + scope: MemoryScope + records: Array + }) => void | Promise + /** + * Fired when retrieval, persistence, or extraction throws. Always paired + * with a `memory:error` devtools event for the same failure. + * + * Phase taxonomy: + * - `'retrieve'` — failures during the retrieval arc: the user-text + * `embedder.embed` call, `adapter.search` (including paginated + * continuations), and `rerank` failures. + * - `'persist'` — failures during the persist arc: `adapter.add`, + * `adapter.update`, `adapter.delete` against the configured adapter, + * the assistant-side `embedder.embed` call inside the finish-turn + * persist (NOT the user-side embed; that is `'retrieve'`), and any + * throw from `afterPersist`. + * - `'extract'` — failures from extraction-shaped callbacks: + * `extractMemories` throwing, `onToolResult` throwing, and the JSON + * parse of tool-call arguments inside `onAfterToolCall` (parse failure + * is non-fatal — `onToolResult` still runs with `args: {}` — but the + * event is emitted so observers can see the malformed payload). + */ + onError?: (args: { + scope: MemoryScope + phase: 'retrieve' | 'persist' | 'extract' + error: unknown + }) => void | Promise + } + + /** + * Strict mode. When `false` (the default) the middleware swallows retrieval + * and persistence failures so chat continues to function even if memory is + * degraded. When `true`, those failures throw and abort the run — choose + * this when memory correctness is critical (e.g. compliance contexts where + * a missed write is worse than a failed turn). + */ + strict?: boolean +} diff --git a/packages/typescript/ai/tests/memory/helpers.test.ts b/packages/typescript/ai/tests/memory/helpers.test.ts new file mode 100644 index 000000000..8b2ee44cb --- /dev/null +++ b/packages/typescript/ai/tests/memory/helpers.test.ts @@ -0,0 +1,224 @@ +import { describe, it, expect } from 'vitest' +import { + scopeMatches, + cosine, + lexicalOverlap, + recencyScore, + defaultRenderMemory, + defaultScoreHit, + isExpired, +} from '../../src/memory/helpers' +import type { MemoryRecord } from '../../src/memory/types' + +describe('scopeMatches', () => { + it('rejects empty query scope (strict-by-default cross-tenant guard)', () => { + // An empty query scope ({}) intentionally matches NOTHING — see JSDoc on + // scopeMatches. This prevents `clear({})` / `search({ scope: {} })` from + // wiping or leaking every tenant's records. + expect(scopeMatches({ tenantId: 'a' }, {})).toBe(false) + }) + it('rejects query scope with only nullish values', () => { + expect( + scopeMatches( + { tenantId: 'a' }, + { tenantId: undefined, userId: undefined }, + ), + ).toBe(false) + }) + it('matches when all query keys are equal', () => { + expect( + scopeMatches({ tenantId: 'a', userId: 'u' }, { tenantId: 'a' }), + ).toBe(true) + }) + it('rejects when any provided key differs', () => { + expect(scopeMatches({ tenantId: 'a' }, { tenantId: 'b' })).toBe(false) + }) + + describe('empty-string scope values', () => { + // Empty-string values are treated as undefined per the JSDoc on + // `scopeMatches` — a degenerate "blank-tenant" bucket would otherwise be + // unreachable from any normal query and indistinguishable from records + // whose scope key was simply unset. Mirrored in adapters' `hasAnyScopeKey` + // so the same rule applies at every isolation boundary. + it('treats empty-string scope values as undefined in the query', () => { + // A query with all empty-string values is equivalent to {} — matches nothing. + expect(scopeMatches({ tenantId: 't1' }, { tenantId: '' })).toBe(false) + expect( + scopeMatches({ tenantId: 't1' }, { tenantId: '', userId: '' }), + ).toBe(false) + }) + + it('a record with an empty-string scope value is unreachable via that key', () => { + // Defensive check: callers should not write empty-string scopes, but if + // they slip through (e.g. via a buggy callback), an empty-string query + // STILL matches nothing rather than colliding with the record. + expect(scopeMatches({ tenantId: '' }, { tenantId: '' })).toBe(false) + }) + + it('skips empty-string keys but still honours other defined keys', () => { + // `{ tenantId: 't1', userId: '' }` is equivalent to `{ tenantId: 't1' }` + // — the empty userId is ignored and tenant matching proceeds normally. + expect( + scopeMatches( + { tenantId: 't1', userId: 'u1' }, + { tenantId: 't1', userId: '' }, + ), + ).toBe(true) + expect( + scopeMatches( + { tenantId: 't2', userId: 'u1' }, + { tenantId: 't1', userId: '' }, + ), + ).toBe(false) + }) + }) +}) + +describe('cosine', () => { + it('returns 0 for missing vectors or mismatched length', () => { + expect(cosine(undefined, [1])).toBe(0) + expect(cosine([1, 2], [1])).toBe(0) + }) + it('returns 1 for identical unit-length vectors', () => { + expect(cosine([1, 0], [1, 0])).toBeCloseTo(1, 5) + }) + it('returns 0 for orthogonal vectors', () => { + expect(cosine([1, 0], [0, 1])).toBeCloseTo(0, 5) + }) +}) + +describe('lexicalOverlap', () => { + it('returns 0 when query has no tokens', () => { + expect(lexicalOverlap('', 'anything')).toBe(0) + }) + it('returns fraction of query tokens present in text', () => { + expect(lexicalOverlap('foo bar baz', 'foo bar')).toBeCloseTo(2 / 3, 5) + }) +}) + +describe('recencyScore', () => { + it('returns ~1 for now', () => { + expect(recencyScore(Date.now())).toBeGreaterThan(0.99) + }) + it('halves at one half-life', () => { + const halfLife = 1000 + const now = Date.now() + const t = now - halfLife + expect(recencyScore(t, halfLife, now)).toBeCloseTo(0.5, 5) + }) +}) + +describe('isExpired', () => { + it('false when expiresAt is unset', () => { + expect(isExpired({ expiresAt: undefined } as MemoryRecord)).toBe(false) + }) + it('true when expiresAt < now', () => { + expect(isExpired({ expiresAt: Date.now() - 1 } as MemoryRecord)).toBe(true) + }) + it('false when expiresAt > now', () => { + expect(isExpired({ expiresAt: Date.now() + 10000 } as MemoryRecord)).toBe( + false, + ) + }) +}) + +describe('defaultRenderMemory', () => { + it('renders empty hits as empty string-ish', () => { + expect(defaultRenderMemory([])).toBe('') + }) + it('renders kinds and text in numbered list', () => { + const out = defaultRenderMemory([ + { + score: 1, + record: { + id: '1', + scope: {}, + kind: 'fact', + text: 'User is on Windows.', + createdAt: 0, + }, + }, + ]) + expect(out).toContain('Relevant memory:') + // Text is JSON.stringify'd so memory content cannot break out of the + // list structure (see defaultRenderMemory implementation). + expect(out).toContain('1. [fact] "User is on Windows."') + }) +}) + +describe('defaultScoreHit', () => { + it('weighted sum stays in [0,1] for in-range inputs', () => { + const score = defaultScoreHit({ + record: { + id: 'r', + scope: {}, + kind: 'fact', + text: 'foo bar', + createdAt: Date.now(), + embedding: [1, 0], + importance: 1, + }, + query: { scope: {}, text: 'foo bar', embedding: [1, 0] }, + }) + expect(score).toBeGreaterThan(0) + expect(score).toBeLessThanOrEqual(1) + }) + + it('threads `now` through to recencyScore for deterministic scoring', () => { + // Fixed-timestamps regression test: passing `now` MUST make the score + // independent of wall-clock time. Two calls with the same `now` must + // return exactly the same score even if `Date.now()` has advanced + // between them. + const record: MemoryRecord = { + id: 'r', + scope: {}, + kind: 'fact', + text: 'foo bar', + createdAt: 1000, + embedding: [1, 0], + importance: 1, + } + const query = { scope: {}, text: 'foo bar', embedding: [1, 0] } + const a = defaultScoreHit({ record, query, now: 2000 }) + const b = defaultScoreHit({ record, query, now: 2000 }) + expect(a).toBe(b) + // And a different `now` must produce a (lower) recency contribution — + // the older effective age means recencyScore drops, so the total drops. + const c = defaultScoreHit({ + record, + query, + now: 2000 + 1000 * 60 * 60 * 24 * 30, // +1 half-life + }) + expect(c).toBeLessThan(a) + }) + + it('unset importance contributes 0 (record with no relevance scores below default minScore)', () => { + // Default ranking floor regression test. With the previous default of + // `importance ?? 0.5`, a recent record with zero semantic + zero lexical + // match scored ~0.20 — over the default minScore floor of 0.15, so + // every recent irrelevant record leaked into retrieval. The new default + // (no fallback) keeps the score below the floor. + // + // We use `now` slightly ahead of `createdAt` so recency decays a hair + // below 1.0; the score is then strictly < 0.15 (the default minScore). + const createdAt = 1000 + const now = createdAt + 1000 * 60 * 60 * 24 // one day later + const score = defaultScoreHit({ + record: { + id: 'r', + scope: {}, + kind: 'fact', + text: 'completely unrelated content', // no overlap with query + createdAt, + // no embedding, no importance + }, + query: { scope: {}, text: 'foo bar' }, + now, + }) + expect(score).toBeLessThan(0.15) + + // Sanity-check the converse: the OLD default of importance=0.5 would + // have pushed the same record above the 0.15 floor. + expect(score + 0.5 * 0.1).toBeGreaterThan(0.15) + }) +}) diff --git a/packages/typescript/ai/tests/middlewares/memory.test.ts b/packages/typescript/ai/tests/middlewares/memory.test.ts new file mode 100644 index 000000000..5466ae12b --- /dev/null +++ b/packages/typescript/ai/tests/middlewares/memory.test.ts @@ -0,0 +1,1028 @@ +// packages/typescript/ai/tests/middlewares/memory.test.ts +import { describe, expect, it, vi } from 'vitest' +import { aiEventClient } from '@tanstack/ai-event-client' +import { chat } from '../../src/activities/chat/index' +import { memoryMiddleware } from '../../src/memory' +import type { + MemoryAdapter, + MemoryHit, + MemoryListResult, + MemoryQuery, + MemoryRecord, + MemoryScope, + MemorySearchResult, +} from '../../src/memory' +import type { StreamChunk } from '../../src/types' +import { ev, createMockAdapter, collectChunks } from '../test-utils' + +// Local test double — keeps tests isolated from @tanstack/ai-memory. +function fakeAdapter(seed: MemoryRecord[] = []): MemoryAdapter & { + store: Map + searchCalls: MemoryQuery[] +} { + const store = new Map() + for (const r of seed) store.set(r.id, r) + const searchCalls: MemoryQuery[] = [] + return { + name: 'fake', + store, + searchCalls, + async add(input) { + const list = Array.isArray(input) ? input : [input] + for (const r of list) store.set(r.id, { ...r, updatedAt: Date.now() }) + }, + async get(id, scope) { + const r = store.get(id) + if (!r) return undefined + // simple scope check + for (const k of Object.keys(scope) as Array) { + if (scope[k] && r.scope[k] !== scope[k]) return undefined + } + return r + }, + async update(id, scope, patch) { + const existing = await this.get(id, scope) + if (!existing) return undefined + const next = { ...existing, ...patch, updatedAt: Date.now() } + store.set(id, next) + return next + }, + async search(query): Promise { + searchCalls.push(query) + const hits: MemoryHit[] = [] + for (const r of store.values()) { + let match = true + for (const k of Object.keys(query.scope) as Array) { + if (query.scope[k] && r.scope[k] !== query.scope[k]) { + match = false + break + } + } + if (!match) continue + if (query.kinds && !query.kinds.includes(r.kind)) continue + hits.push({ record: r, score: 0.9 }) + } + return { hits: hits.slice(0, query.topK ?? 6) } + }, + async list(scope, options): Promise { + const items: MemoryRecord[] = [] + for (const r of store.values()) { + let match = true + for (const k of Object.keys(scope) as Array) { + if (scope[k] && r.scope[k] !== scope[k]) { + match = false + break + } + } + if (match) items.push(r) + } + return { items: items.slice(0, options?.limit ?? items.length) } + }, + async delete(ids) { + for (const id of ids) store.delete(id) + }, + async clear() { + store.clear() + }, + } +} + +const baseScope: MemoryScope = { tenantId: 't1', userId: 'u1' } + +function rec(over: Partial = {}): MemoryRecord { + return { + id: over.id ?? crypto.randomUUID(), + scope: over.scope ?? baseScope, + text: over.text ?? 'sample', + kind: over.kind ?? 'fact', + createdAt: over.createdAt ?? Date.now(), + ...over, + } +} + +describe('memoryMiddleware — retrieval', () => { + it('is a no-op when there is no user message', async () => { + const { adapter } = createMockAdapter({ + iterations: [ + [ev.runStarted(), ev.textContent('hi'), ev.runFinished('stop')], + ], + }) + const memory = fakeAdapter([rec({ text: 'X' })]) + const stream = chat({ + adapter, + messages: [], + middleware: [memoryMiddleware({ adapter: memory, scope: baseScope })], + }) + await collectChunks(stream as AsyncIterable) + expect(memory.searchCalls).toHaveLength(0) + }) + + it('retrieves at init and injects a memory system prompt', async () => { + const memory = fakeAdapter([ + rec({ text: 'User likes TS.', kind: 'preference' }), + ]) + const { adapter, calls } = createMockAdapter({ + iterations: [ + [ev.runStarted(), ev.textContent('ok'), ev.runFinished('stop')], + ], + }) + const stream = chat({ + adapter, + messages: [{ role: 'user', content: 'hi' }], + middleware: [memoryMiddleware({ adapter: memory, scope: baseScope })], + }) + await collectChunks(stream as AsyncIterable) + const first = calls[0] as { systemPrompts?: string[] } + expect(first.systemPrompts?.some((p) => p.includes('User likes TS.'))).toBe( + true, + ) + }) + + it('does not re-inject across agent-loop iterations', async () => { + const memory = fakeAdapter([rec({ text: 'X' })]) + const { adapter, calls } = createMockAdapter({ + iterations: [ + [ + ev.runStarted(), + ev.toolStart('c1', 't'), + ev.toolArgs('c1', '{}'), + ev.toolEnd('c1', 't'), + ev.runFinished('tool_calls'), + ], + [ev.runStarted(), ev.textContent('done'), ev.runFinished('stop')], + ], + }) + const stream = chat({ + adapter, + messages: [{ role: 'user', content: 'hi' }], + tools: [{ name: 't', description: 'noop', execute: async () => ({}) }], + middleware: [memoryMiddleware({ adapter: memory, scope: baseScope })], + }) + await collectChunks(stream as AsyncIterable) + const iter1 = + (calls[0] as { systemPrompts?: string[] }).systemPrompts?.length ?? 0 + const iter2 = + (calls[1] as { systemPrompts?: string[] }).systemPrompts?.length ?? 0 + // Guard against the degenerate case where injection is fully broken in + // BOTH iterations: `iter1 === iter2 === 0` would still satisfy the + // equality below but defeat the regression's intent (memory was actually + // injected on iteration 1 and not re-injected on iteration 2). + expect(iter1).toBeGreaterThan(0) + expect(iter1).toBe(iter2) + }) + + it('skips retrieval and injection when shouldRetrieve returns false', async () => { + const memory = fakeAdapter([rec({ text: 'X' })]) + const { adapter } = createMockAdapter({ + iterations: [ + [ev.runStarted(), ev.textContent('ok'), ev.runFinished('stop')], + ], + }) + const stream = chat({ + adapter, + messages: [{ role: 'user', content: 'hi' }], + middleware: [ + memoryMiddleware({ + adapter: memory, + scope: baseScope, + shouldRetrieve: () => false, + }), + ], + }) + await collectChunks(stream as AsyncIterable) + expect(memory.searchCalls).toHaveLength(0) + }) + + it('calls rerank between search and render', async () => { + const memory = fakeAdapter([ + rec({ id: 'a', text: 'A' }), + rec({ id: 'b', text: 'B' }), + ]) + const { adapter, calls } = createMockAdapter({ + iterations: [ + [ev.runStarted(), ev.textContent('ok'), ev.runFinished('stop')], + ], + }) + const rerank = vi.fn(async (hits: MemoryHit[]) => [...hits].reverse()) + const stream = chat({ + adapter, + messages: [{ role: 'user', content: 'hi' }], + middleware: [ + memoryMiddleware({ adapter: memory, scope: baseScope, rerank }), + ], + }) + await collectChunks(stream as AsyncIterable) + expect(rerank).toHaveBeenCalledTimes(1) + const promptText = ( + calls[0] as { systemPrompts: string[] } + ).systemPrompts.join('\n') + expect(promptText.indexOf('B')).toBeLessThan(promptText.indexOf('A')) + }) + + it('handles structured content (ContentPart[]) on the user message', async () => { + // Regression: `getMessageText` previously read `part.text`, but the + // actual TextPart shape (see ../../src/types.ts) carries the string on + // `part.content`. With the bug, a structured user message yielded + // lastUserText === '', which silently disabled retrieval AND skipped + // the user-side persist record. Verify retrieval IS attempted with the + // structured text and the user record IS persisted with that text. + const memory = fakeAdapter([rec({ text: 'X' })]) + const { adapter } = createMockAdapter({ + iterations: [ + [ev.runStarted(), ev.textContent('ok'), ev.runFinished('stop')], + ], + }) + const stream = chat({ + adapter, + messages: [ + { + role: 'user', + content: [{ type: 'text', content: 'hello structured' }], + }, + ], + middleware: [memoryMiddleware({ adapter: memory, scope: baseScope })], + }) + await collectChunks(stream as AsyncIterable) + expect(memory.searchCalls.length).toBeGreaterThan(0) + expect(memory.searchCalls[0]?.text).toBe('hello structured') + const userRecord = [...memory.store.values()].find((r) => r.role === 'user') + expect(userRecord?.text).toBe('hello structured') + }) + + it('resolves function-form scope once and caches it', async () => { + const memory = fakeAdapter([rec({ text: 'X' })]) + const { adapter } = createMockAdapter({ + iterations: [ + [ev.runStarted(), ev.textContent('ok'), ev.runFinished('stop')], + ], + }) + const scopeFn = vi.fn(() => baseScope) + const stream = chat({ + adapter, + messages: [{ role: 'user', content: 'hi' }], + middleware: [memoryMiddleware({ adapter: memory, scope: scopeFn })], + }) + await collectChunks(stream as AsyncIterable) + expect(scopeFn).toHaveBeenCalledTimes(1) + }) +}) + +describe('memoryMiddleware — persistence', () => { + it('persists user and assistant messages on finish', async () => { + const memory = fakeAdapter() + const { adapter } = createMockAdapter({ + iterations: [ + [ev.runStarted(), ev.textContent('Pong.'), ev.runFinished('stop')], + ], + }) + const stream = chat({ + adapter, + messages: [{ role: 'user', content: 'Ping' }], + middleware: [memoryMiddleware({ adapter: memory, scope: baseScope })], + }) + await collectChunks(stream as AsyncIterable) + const texts = [...memory.store.values()].map((r) => r.text).sort() + expect(texts).toEqual(['Ping', 'Pong.']) + }) + + it('shouldRemember=false skips the entire turn (base records and extractMemories)', async () => { + // Per-turn semantics: shouldRemember is evaluated ONCE per turn and + // gates the whole persist path. The user message is short ("hi", 2 + // chars) so the gate returns false and NOTHING is persisted — the + // assistant message is dropped too, and `extractMemories` is never + // called. + const memory = fakeAdapter() + const { adapter } = createMockAdapter({ + iterations: [ + [ + ev.runStarted(), + ev.textContent('long enough response text'), + ev.runFinished('stop'), + ], + ], + }) + const extractMemories = vi.fn(async () => [ + rec({ text: 'should not run', kind: 'fact' }), + ]) + const stream = chat({ + adapter, + messages: [{ role: 'user', content: 'hi' }], + middleware: [ + memoryMiddleware({ + adapter: memory, + scope: baseScope, + shouldRemember: ({ message }) => message.content.length > 10, + extractMemories, + }), + ], + }) + await collectChunks(stream as AsyncIterable) + expect([...memory.store.values()]).toEqual([]) + expect(extractMemories).not.toHaveBeenCalled() + }) + + it('shouldRemember=true persists user, assistant, and extracted records', async () => { + const memory = fakeAdapter() + const { adapter } = createMockAdapter({ + iterations: [ + [ + ev.runStarted(), + ev.textContent('long enough response text'), + ev.runFinished('stop'), + ], + ], + }) + const stream = chat({ + adapter, + messages: [{ role: 'user', content: 'a meaningful user message' }], + middleware: [ + memoryMiddleware({ + adapter: memory, + scope: baseScope, + // 25-char user message + non-empty response — gate keeps the turn. + shouldRemember: ({ message }) => message.content.length > 10, + }), + ], + }) + await collectChunks(stream as AsyncIterable) + const texts = [...memory.store.values()].map((r) => r.text).sort() + expect(texts).toEqual([ + 'a meaningful user message', + 'long enough response text', + ]) + }) + + it('extractMemories returning records adds them as kind: fact', async () => { + const memory = fakeAdapter() + const { adapter } = createMockAdapter({ + iterations: [ + [ev.runStarted(), ev.textContent('R'), ev.runFinished('stop')], + ], + }) + const extractMemories = vi.fn(async () => [ + rec({ text: 'extracted', kind: 'fact' }), + ]) + const stream = chat({ + adapter, + messages: [{ role: 'user', content: 'U' }], + middleware: [ + memoryMiddleware({ + adapter: memory, + scope: baseScope, + extractMemories, + }), + ], + }) + await collectChunks(stream as AsyncIterable) + expect(extractMemories).toHaveBeenCalledTimes(1) + const kinds = [...memory.store.values()].map((r) => r.kind).sort() + expect(kinds).toEqual(['fact', 'message', 'message']) + }) + + it('extractMemories MemoryOp[] dispatches to add/update/delete', async () => { + const existing = rec({ id: 'old', text: 'old text', kind: 'fact' }) + const memory = fakeAdapter([existing]) + const { adapter } = createMockAdapter({ + iterations: [ + [ev.runStarted(), ev.textContent('R'), ev.runFinished('stop')], + ], + }) + const stream = chat({ + adapter, + messages: [{ role: 'user', content: 'U' }], + middleware: [ + memoryMiddleware({ + adapter: memory, + scope: baseScope, + extractMemories: () => [ + { op: 'add', record: rec({ text: 'new fact', kind: 'fact' }) }, + { op: 'update', id: 'old', patch: { text: 'updated text' } }, + ], + }), + ], + }) + await collectChunks(stream as AsyncIterable) + expect(memory.store.get('old')?.text).toBe('updated text') + expect([...memory.store.values()].some((r) => r.text === 'new fact')).toBe( + true, + ) + }) + + it('applies ops in array order: update after add in same batch sees the add', async () => { + // Order-sensitivity regression test. Previously, all `add` ops were + // batched and flushed at the END after updates/deletes, meaning an + // `update` of an id added in the SAME batch silently no-op'd. With + // strict in-order dispatch the update now sees the just-added record. + const memory = fakeAdapter() + const { adapter } = createMockAdapter({ + iterations: [ + [ev.runStarted(), ev.textContent('R'), ev.runFinished('stop')], + ], + }) + const stream = chat({ + adapter, + messages: [{ role: 'user', content: 'U' }], + middleware: [ + memoryMiddleware({ + adapter: memory, + scope: baseScope, + extractMemories: () => [ + { + op: 'add', + record: rec({ id: 'X', text: 'initial', kind: 'fact' }), + }, + { op: 'update', id: 'X', patch: { text: 'patched' } }, + ], + }), + ], + }) + await collectChunks(stream as AsyncIterable) + expect(memory.store.get('X')?.text).toBe('patched') + }) + + it('forces the resolved scope onto records returned by extractMemories', async () => { + // Defence-in-depth: a buggy or hostile `extractMemories` callback that + // returns a record with a DIFFERENT scope than the resolved one must NOT + // be able to write into another tenant's bucket. The middleware silently + // overrides the record's scope with the resolved scope before persisting. + const memory = fakeAdapter() + const { adapter } = createMockAdapter({ + iterations: [ + [ev.runStarted(), ev.textContent('R'), ev.runFinished('stop')], + ], + }) + const stream = chat({ + adapter, + messages: [{ role: 'user', content: 'U' }], + middleware: [ + memoryMiddleware({ + adapter: memory, + scope: baseScope, + extractMemories: () => [ + // Buggy callback returning a record under a DIFFERENT scope — + // middleware must override to baseScope before persisting. + rec({ + scope: { tenantId: 'wrong-tenant' } as MemoryScope, + text: 'leaked', + kind: 'fact', + }), + ], + }), + ], + }) + await collectChunks(stream as AsyncIterable) + // Allow deferred persist to settle. + await new Promise((resolve) => setTimeout(resolve, 0)) + const leaked = [...memory.store.values()].find((r) => r.text === 'leaked') + expect(leaked).toBeDefined() + // The wrong scope was overridden to baseScope — defence-in-depth holds. + expect(leaked?.scope).toEqual(baseScope) + }) + + it('forces the resolved scope onto records returned by onToolResult', async () => { + // Same defence-in-depth guarantee as `extractMemories`, but on the + // tool-result path which dispatches via `runObservedPersist` from + // `onAfterToolCall` rather than from `persistTurn`. + const memory = fakeAdapter() + const { adapter } = createMockAdapter({ + iterations: [ + [ + ev.runStarted(), + ev.toolStart('c1', 'echo'), + ev.toolArgs('c1', '{}'), + ev.toolEnd('c1', 'echo'), + ev.runFinished('tool_calls'), + ], + [ev.runStarted(), ev.textContent('done'), ev.runFinished('stop')], + ], + }) + const stream = chat({ + adapter, + messages: [{ role: 'user', content: 'U' }], + tools: [ + { name: 'echo', description: 'noop', execute: async () => ({ ok: 1 }) }, + ], + middleware: [ + memoryMiddleware({ + adapter: memory, + scope: baseScope, + onToolResult: () => [ + rec({ + scope: { tenantId: 'wrong-tenant' } as MemoryScope, + text: 'tool-leaked', + kind: 'tool-result', + role: 'tool', + }), + ], + }), + ], + }) + await collectChunks(stream as AsyncIterable) + await new Promise((resolve) => setTimeout(resolve, 0)) + const leaked = [...memory.store.values()].find( + (r) => r.text === 'tool-leaked', + ) + expect(leaked).toBeDefined() + expect(leaked?.scope).toEqual(baseScope) + }) + + it('afterPersist receives newly-added records (not updates/deletes)', async () => { + const memory = fakeAdapter() + const { adapter } = createMockAdapter({ + iterations: [ + [ev.runStarted(), ev.textContent('R'), ev.runFinished('stop')], + ], + }) + const afterPersist = vi.fn() + const stream = chat({ + adapter, + messages: [{ role: 'user', content: 'U' }], + middleware: [ + memoryMiddleware({ adapter: memory, scope: baseScope, afterPersist }), + ], + }) + await collectChunks(stream as AsyncIterable) + expect(afterPersist).toHaveBeenCalledTimes(1) + const arg = afterPersist.mock.calls[0]?.[0] as + | { newRecords: MemoryRecord[] } + | undefined + expect(arg?.newRecords.length).toBe(2) // user + assistant + }) + + it('onToolResult persists kind: tool-result records', async () => { + const memory = fakeAdapter() + const { adapter } = createMockAdapter({ + iterations: [ + [ + ev.runStarted(), + ev.toolStart('c1', 'echo'), + ev.toolArgs('c1', '{}'), + ev.toolEnd('c1', 'echo'), + ev.runFinished('tool_calls'), + ], + [ev.runStarted(), ev.textContent('done'), ev.runFinished('stop')], + ], + }) + const stream = chat({ + adapter, + messages: [{ role: 'user', content: 'U' }], + tools: [ + { name: 'echo', description: 'noop', execute: async () => ({ ok: 1 }) }, + ], + middleware: [ + memoryMiddleware({ + adapter: memory, + scope: baseScope, + onToolResult: ({ toolName, result }) => [ + rec({ + text: `${toolName}:${JSON.stringify(result)}`, + kind: 'tool-result', + role: 'tool', + }), + ], + }), + ], + }) + await collectChunks(stream as AsyncIterable) + const toolResults = [...memory.store.values()].filter( + (r) => r.kind === 'tool-result', + ) + expect(toolResults).toHaveLength(1) + expect(toolResults[0]?.text).toContain('echo') + }) + + it('shouldRemember=false skips tool-result memories from onToolResult', async () => { + // Regression: previously `onToolResult` deferred persists fired + // immediately and `shouldRemember` only gated the finish-turn path, + // so a `false` return left tool-result memories already committed. + // After buffering + flushing inside `persistTurn`, `shouldRemember` + // gates the entire turn — tool-result ops included. + const memory = fakeAdapter() + const { adapter } = createMockAdapter({ + iterations: [ + [ + ev.runStarted(), + ev.toolStart('c1', 'echo'), + ev.toolArgs('c1', '{}'), + ev.toolEnd('c1', 'echo'), + ev.runFinished('tool_calls'), + ], + [ev.runStarted(), ev.textContent('done'), ev.runFinished('stop')], + ], + }) + const stream = chat({ + adapter, + messages: [{ role: 'user', content: 'U' }], + tools: [ + { name: 'echo', description: 'noop', execute: async () => ({ ok: 1 }) }, + ], + middleware: [ + memoryMiddleware({ + adapter: memory, + scope: baseScope, + shouldRemember: () => false, + onToolResult: ({ toolName, result }) => [ + rec({ + text: `${toolName}:${JSON.stringify(result)}`, + kind: 'tool-result', + role: 'tool', + }), + ], + }), + ], + }) + await collectChunks(stream as AsyncIterable) + // Wait a tick for any deferred work — there should be none. + await new Promise((resolve) => setTimeout(resolve, 0)) + expect(memory.store.size).toBe(0) + }) + + it('onToolResult ops flow through finish-turn observability pipeline', async () => { + // Behaviour: `onToolResult` returned ops are buffered on per-request + // state and flushed inside the finish-turn persist round AFTER the + // per-turn `shouldRemember` gate passes. They share a single observed + // persist with base + extracted records, so persist:started/completed, + // events.onPersistStart/End, and afterPersist each fire ONCE per turn + // (not once per tool call + once for finish-turn). + const memory = fakeAdapter() + const { adapter } = createMockAdapter({ + iterations: [ + [ + ev.runStarted(), + ev.toolStart('c1', 'echo'), + ev.toolArgs('c1', '{"q":"x"}'), + ev.toolEnd('c1', 'echo'), + ev.runFinished('tool_calls'), + ], + [ev.runStarted(), ev.textContent('done'), ev.runFinished('stop')], + ], + }) + const startCount = { n: 0 } + const endCount = { n: 0 } + const onPersistStart = vi.fn() + const onPersistEnd = vi.fn() + const afterPersist = vi.fn() + const opts = { withEventTarget: true } as const + const off1 = aiEventClient.on( + 'memory:persist:started', + () => { + startCount.n++ + }, + opts, + ) + const off2 = aiEventClient.on( + 'memory:persist:completed', + () => { + endCount.n++ + }, + opts, + ) + try { + const stream = chat({ + adapter, + messages: [{ role: 'user', content: 'U' }], + tools: [ + { + name: 'echo', + description: 'noop', + execute: async () => ({ ok: 1 }), + }, + ], + middleware: [ + memoryMiddleware({ + adapter: memory, + scope: baseScope, + afterPersist, + events: { onPersistStart, onPersistEnd }, + onToolResult: ({ toolName, result }) => [ + rec({ + text: `${toolName}:${JSON.stringify(result)}`, + kind: 'tool-result', + role: 'tool', + }), + ], + }), + ], + }) + await collectChunks(stream as AsyncIterable) + // Wait for deferred work to settle. + await new Promise((resolve) => setTimeout(resolve, 0)) + } finally { + off1() + off2() + } + // Single unified finish-turn persist round covers base + extracted + + // tool-result records — exactly one start/end pair per turn. + expect(startCount.n).toBe(1) + expect(endCount.n).toBe(1) + expect(onPersistStart).toHaveBeenCalledTimes(1) + expect(onPersistEnd).toHaveBeenCalledTimes(1) + expect(afterPersist).toHaveBeenCalledTimes(1) + // Tool-result records still visible to afterPersist (folded into the + // single newRecords array passed to the callback). + const allNewRecords = afterPersist.mock.calls.flatMap( + (c) => (c[0] as { newRecords: Array<{ kind: string }> }).newRecords, + ) + expect(allNewRecords.some((r) => r.kind === 'tool-result')).toBe(true) + }) +}) + +describe('memoryMiddleware — failure handling', () => { + it('non-strict: retrieval failure does not abort chat', async () => { + const memory = fakeAdapter() + memory.search = async () => { + throw new Error('boom') + } + const { adapter } = createMockAdapter({ + iterations: [ + [ev.runStarted(), ev.textContent('ok'), ev.runFinished('stop')], + ], + }) + const stream = chat({ + adapter, + messages: [{ role: 'user', content: 'hi' }], + middleware: [memoryMiddleware({ adapter: memory, scope: baseScope })], + }) + const chunks = await collectChunks(stream as AsyncIterable) + expect(chunks.some((c) => c.type === 'TEXT_MESSAGE_CONTENT')).toBe(true) + }) + + it('strict: retrieval failure rejects the stream', async () => { + const memory = fakeAdapter() + memory.search = async () => { + throw new Error('boom') + } + const { adapter } = createMockAdapter({ + iterations: [ + [ev.runStarted(), ev.textContent('ok'), ev.runFinished('stop')], + ], + }) + const stream = chat({ + adapter, + messages: [{ role: 'user', content: 'hi' }], + middleware: [ + memoryMiddleware({ adapter: memory, scope: baseScope, strict: true }), + ], + }) + await expect( + collectChunks(stream as AsyncIterable), + ).rejects.toThrow('boom') + }) + + it('strict: extractMemories failure persists base records and emits exactly one memory:error (phase: extract)', async () => { + // Regression: previously the inner try/catch rethrew on strict, then + // the outer persist catch caught the rethrow and emitted a SECOND + // memory:error with phase: 'persist'. The double-emit also bypassed + // applyOps, so base user/assistant records never landed. New behaviour: + // - memory:error fires exactly ONCE with phase: 'extract' + // - base user + assistant records DO persist + const memory = fakeAdapter() + const { adapter } = createMockAdapter({ + iterations: [ + [ev.runStarted(), ev.textContent('Pong.'), ev.runFinished('stop')], + ], + }) + const errorEvents: Array<{ phase: string }> = [] + const opts = { withEventTarget: true } as const + const off = aiEventClient.on( + 'memory:error', + (e) => errorEvents.push({ phase: e.payload.phase }), + opts, + ) + try { + const stream = chat({ + adapter, + messages: [{ role: 'user', content: 'Ping' }], + middleware: [ + memoryMiddleware({ + adapter: memory, + scope: baseScope, + strict: true, + extractMemories: () => { + throw new Error('extract-boom') + }, + }), + ], + }) + // Stream itself succeeds — the deferred persist promise is the one + // that rejects in strict mode. Drain chunks normally. + await collectChunks(stream as AsyncIterable) + // Give the deferred persist promise a tick to settle before + // asserting on side-effects (event emissions, store state). + await new Promise((resolve) => setTimeout(resolve, 0)) + } finally { + off() + } + // Exactly one error event, with the correct phase. + expect(errorEvents).toEqual([{ phase: 'extract' }]) + // Base records still landed despite the strict extract failure. + const texts = [...memory.store.values()].map((r) => r.text).sort() + expect(texts).toEqual(['Ping', 'Pong.']) + }) +}) + +describe('memoryMiddleware — devtools events', () => { + it('emits retrieve and persist events in order', async () => { + const memory = fakeAdapter([rec({ text: 'X' })]) + const { adapter } = createMockAdapter({ + iterations: [ + [ev.runStarted(), ev.textContent('R'), ev.runFinished('stop')], + ], + }) + const seen: string[] = [] + const opts = { withEventTarget: true } as const + const off1 = aiEventClient.on( + 'memory:retrieve:started', + () => seen.push('retrieve:started'), + opts, + ) + const off2 = aiEventClient.on( + 'memory:retrieve:completed', + () => seen.push('retrieve:completed'), + opts, + ) + const off3 = aiEventClient.on( + 'memory:persist:started', + () => seen.push('persist:started'), + opts, + ) + const off4 = aiEventClient.on( + 'memory:persist:completed', + () => seen.push('persist:completed'), + opts, + ) + try { + const stream = chat({ + adapter, + messages: [{ role: 'user', content: 'U' }], + middleware: [memoryMiddleware({ adapter: memory, scope: baseScope })], + }) + await collectChunks(stream as AsyncIterable) + expect(seen).toEqual([ + 'retrieve:started', + 'retrieve:completed', + 'persist:started', + 'persist:completed', + ]) + } finally { + off1() + off2() + off3() + off4() + } + }) +}) + +describe('memoryMiddleware — error-path observability', () => { + it('emits memory:error with phase: persist when assistant embedder fails (non-strict)', async () => { + // Round 3 finding: when `options.embedder.embed(args.responseText)` throws + // inside `persistTurn`, the assistant-side embed lives OUTSIDE + // `runObservedPersist` and therefore bypassed the persist-phase event + // pipeline. The fix wraps that call locally; this test pins the + // observable contract. + const memory = fakeAdapter() + const { adapter } = createMockAdapter({ + iterations: [ + [ev.runStarted(), ev.textContent('R'), ev.runFinished('stop')], + ], + }) + const flakyEmbedder = { + // Fail only on the assistant-side embed; succeed for the user-side + // query embed so the failure under test is unambiguously the + // assistant-side one. + async embed(text: string) { + if (text === 'R') throw new Error('embedder boom') + return [1, 0] + }, + } + const errorEvents: Array<{ phase: string; message: string }> = [] + const opts = { withEventTarget: true } as const + const off = aiEventClient.on( + 'memory:error', + (e) => + errorEvents.push({ + phase: e.payload.phase, + message: e.payload.error.message, + }), + opts, + ) + try { + const stream = chat({ + adapter, + messages: [{ role: 'user', content: 'U' }], + middleware: [ + memoryMiddleware({ + adapter: memory, + scope: baseScope, + embedder: flakyEmbedder, + }), + ], + }) + await collectChunks(stream as AsyncIterable) + // Allow deferred persist to settle. + await new Promise((resolve) => setTimeout(resolve, 0)) + // Both base records still land (user with embedding, assistant without). + expect(memory.store.size).toBeGreaterThanOrEqual(2) + const stored = [...memory.store.values()] + const assistantRecord = stored.find((r) => r.role === 'assistant') + expect(assistantRecord?.embedding).toBeUndefined() + // Exactly one persist-phase memory:error fired with the embedder cause. + const persistErrors = errorEvents.filter((e) => e.phase === 'persist') + expect(persistErrors.length).toBe(1) + expect(persistErrors[0]?.message).toContain('boom') + } finally { + off() + } + }) + + it('emits memory:error with phase: extract when tool args fail to parse', async () => { + // Convergence-audit fix: the tool-args JSON parse fallback in + // `onAfterToolCall` used to silently coerce malformed payloads to `{}`. + // Observers now get a `memory:error` (phase: 'extract') for the same + // failure while the surrounding `onToolResult` path still runs. + // + // The chat engine itself fails fast on malformed tool arguments BEFORE + // `onAfterToolCall` fires, so the only way to exercise the defensive + // parse-catch in middleware.ts is to invoke the hook directly with a + // synthesized `info.toolCall.function.arguments` payload — this is the + // pure-unit test of that branch. + const memory = fakeAdapter() + const errorEvents: Array<{ phase: string }> = [] + const opts = { withEventTarget: true } as const + const off = aiEventClient.on( + 'memory:error', + (e) => errorEvents.push({ phase: e.payload.phase }), + opts, + ) + try { + const mw = memoryMiddleware({ + adapter: memory, + scope: baseScope, + onToolResult: ({ args }) => [ + rec({ + text: `args=${JSON.stringify(args)}`, + kind: 'tool-result', + role: 'tool', + }), + ], + }) + // Minimal `ChatMiddlewareContext` covering the fields the memory + // middleware actually reads (resolveScope needs none beyond its + // closure; onAfterToolCall calls `ctx.defer`). + const deferred: Array> = [] + const ctx = { + requestId: 'req-1', + streamId: 'stream-1', + phase: 'init' as const, + iteration: 0, + chunkIndex: 0, + abort: () => {}, + context: undefined, + defer: (p: Promise) => { + deferred.push(p) + }, + provider: 'mock', + model: 'm', + source: 'server' as const, + streaming: true, + systemPrompts: [], + messageCount: 1, + hasTools: true, + currentMessageId: null, + accumulatedContent: '', + messages: [{ role: 'user' as const, content: 'U' }], + createId: (p: string) => `${p}-id`, + } + // Prime per-request state via onConfig — `onAfterToolCall` short- + // circuits when state is missing. + await mw.onConfig?.(ctx as never, { + messages: [{ role: 'user', content: 'U' }], + systemPrompts: [], + tools: [], + }) + // Synthesize a tool call whose `arguments` is structurally a string + // but not valid JSON. The engine never produces this in practice (it + // throws first), so direct invocation is the only path that exercises + // the defensive parse-catch. + await mw.onAfterToolCall?.(ctx as never, { + toolCall: { + id: 'c1', + type: 'function', + function: { name: 'echo', arguments: 'NOT-VALID-JSON{' }, + }, + tool: undefined, + toolName: 'echo', + toolCallId: 'c1', + ok: true, + duration: 1, + result: { ok: 1 }, + }) + // Drain any deferred persists. + await Promise.all(deferred) + // The malformed args produced a memory:error with phase: 'extract'. + expect(errorEvents.some((e) => e.phase === 'extract')).toBe(true) + } finally { + off() + } + }) +}) diff --git a/packages/typescript/ai/vite.config.ts b/packages/typescript/ai/vite.config.ts index 580db682e..01a43f553 100644 --- a/packages/typescript/ai/vite.config.ts +++ b/packages/typescript/ai/vite.config.ts @@ -34,6 +34,7 @@ export default mergeConfig( './src/activities/index.ts', './src/middlewares/index.ts', './src/middlewares/otel.ts', + './src/memory/index.ts', './src/adapter-internals.ts', ], srcDir: './src', diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index f05b781d9..f2ea8f38e 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1278,6 +1278,25 @@ importers: specifier: 4.0.14 version: 4.0.14(vitest@4.1.4) + packages/typescript/ai-memory: + dependencies: + ioredis: + specifier: '>=5.0.0' + version: 5.9.2 + devDependencies: + '@tanstack/ai': + specifier: workspace:* + version: link:../ai + '@vitest/coverage-v8': + specifier: 4.0.14 + version: 4.0.14(vitest@4.1.4) + ioredis-mock: + specifier: ^8.9.0 + version: 8.13.1(@types/ioredis-mock@8.2.7(ioredis@5.9.2))(ioredis@5.9.2) + redis: + specifier: ^4.7.0 + version: 4.7.1 + packages/typescript/ai-ollama: dependencies: ollama: @@ -1828,7 +1847,7 @@ importers: version: 1.159.5(crossws@0.4.5(srvx@0.11.15))(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(vite-plugin-solid@2.11.10(solid-js@1.9.10)(vite@7.2.7(@types/node@24.10.3)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)))(vite@7.2.7(@types/node@24.10.3)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)) '@tanstack/start': specifier: ^1.120.20 - version: 1.120.20(@types/node@24.10.3)(crossws@0.4.5(srvx@0.11.15))(db0@0.3.4)(ioredis@5.8.2)(jiti@2.6.1)(lightningcss@1.30.2)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(rolldown@1.0.0-rc.17)(terser@5.44.1)(tsx@4.21.0)(vite-plugin-solid@2.11.10(solid-js@1.9.10)(vite@7.2.7(@types/node@24.10.3)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)))(vite@7.2.7(@types/node@24.10.3)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2))(yaml@2.8.2) + version: 1.120.20(@types/node@24.10.3)(crossws@0.4.5(srvx@0.11.15))(db0@0.3.4)(ioredis@5.9.2)(jiti@2.6.1)(lightningcss@1.30.2)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(rolldown@1.0.0-rc.17)(terser@5.44.1)(tsx@4.21.0)(vite-plugin-solid@2.11.10(solid-js@1.9.10)(vite@7.2.7(@types/node@24.10.3)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)))(vite@7.2.7(@types/node@24.10.3)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2))(yaml@2.8.2) highlight.js: specifier: ^11.11.1 version: 11.11.1 @@ -3303,8 +3322,8 @@ packages: '@types/node': optional: true - '@ioredis/commands@1.4.0': - resolution: {integrity: sha512-aFT2yemJJo+TZCmieA7qnYGQooOS7QfNmYrzGtsYd3g9j5iDP8AimYYAesf79ohjbLG12XxC4nG5DyEnC88AsQ==} + '@ioredis/as-callback@3.0.0': + resolution: {integrity: sha512-Kqv1rZ3WbgOrS+hgzJ5xG5WQuhvzzSTRYvNeyPMLOAM78MHSnuKI20JeJGbpuAt//LCuP0vsexZcorqW7kWhJg==} '@ioredis/commands@1.5.0': resolution: {integrity: sha512-eUgLqrMf8nJkZxT24JvVRrQya1vZkQh8BBeYNwGDqa5I0VUi8ACx7uFvAaLxintokpTenkK6DASvo/bvNbBGow==} @@ -4733,6 +4752,35 @@ packages: '@radix-ui/rect@1.1.1': resolution: {integrity: sha512-HPwpGIzkl28mWyZqG52jiqDJ12waP11Pa1lGoiyUkIEuMLBP0oeK/C89esbXrxsky5we7dfd8U58nm0SgAWpVw==} + '@redis/bloom@1.2.0': + resolution: {integrity: sha512-HG2DFjYKbpNmVXsa0keLHp/3leGJz1mjh09f2RLGGLQZzSHpkmZWuwJbAvo3QcRY8p80m5+ZdXZdYOSBLlp7Cg==} + peerDependencies: + '@redis/client': ^1.0.0 + + '@redis/client@1.6.1': + resolution: {integrity: sha512-/KCsg3xSlR+nCK8/8ZYSknYxvXHwubJrU82F3Lm1Fp6789VQ0/3RJKfsmRXjqfaTA++23CvC3hqmqe/2GEt6Kw==} + engines: {node: '>=14'} + + '@redis/graph@1.1.1': + resolution: {integrity: sha512-FEMTcTHZozZciLRl6GiiIB4zGm5z5F3F6a6FZCyrfxdKOhFlGkiAqlexWMBzCi4DcRoyiOsuLfW+cjlGWyExOw==} + peerDependencies: + '@redis/client': ^1.0.0 + + '@redis/json@1.0.7': + resolution: {integrity: sha512-6UyXfjVaTBTJtKNG4/9Z8PSpKE6XgSyEb8iwaqDcy+uKrd/DGYHTWkUdnQDyzm727V7p21WUMhsqz5oy65kPcQ==} + peerDependencies: + '@redis/client': ^1.0.0 + + '@redis/search@1.2.0': + resolution: {integrity: sha512-tYoDBbtqOVigEDMAcTGsRlMycIIjwMCgD8eR2t0NANeQmgK/lvxNAvYyb6bZDD4frHRhIHkJu2TBRvB0ERkOmw==} + peerDependencies: + '@redis/client': ^1.0.0 + + '@redis/time-series@1.1.0': + resolution: {integrity: sha512-c1Q99M5ljsIuc4YdaCwfUEXsofakb9c8+Zse2qxTadu8TalLXuAESzLvFAvNVbkmSlvlzIQOLpBCmWI9wTOt+g==} + peerDependencies: + '@redis/client': ^1.0.0 + '@rolldown/binding-android-arm64@1.0.0-beta.53': resolution: {integrity: sha512-Ok9V8o7o6YfSdTTYA/uHH30r3YtOxLD6G3wih/U9DO0ucBBFq8WPt/DslU53OgfteLRHITZny9N/qCUxMf9kjQ==} engines: {node: ^20.19.0 || >=22.12.0} @@ -6342,6 +6390,11 @@ packages: '@types/hast@3.0.4': resolution: {integrity: sha512-WPs+bbQw5aCj+x6laNGWLH3wviHtoCv/P3+otBhbOhJgG8qtpdAMlTCxLtsTWA7LH1Oh/bFCHsBn0TPS5m30EQ==} + '@types/ioredis-mock@8.2.7': + resolution: {integrity: sha512-YsGiaOIYBKeVvu/7GYziAD8qX3LJem5LK00d5PKykzsQJMLysAqXA61AkNuYWCekYl64tbMTqVOMF4SYoCPbQg==} + peerDependencies: + ioredis: '>=5' + '@types/json-schema@7.0.15': resolution: {integrity: sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==} @@ -8074,6 +8127,14 @@ packages: picomatch: optional: true + fengari-interop@0.1.4: + resolution: {integrity: sha512-4/CW/3PJUo3ebD4ACgE1g/3NGEYSq7OQAyETyypsAl/WeySDBbxExikkayNkZzbpgyC9GyJp8v1DU2VOXxNq7Q==} + peerDependencies: + fengari: ^0.1.0 + + fengari@0.1.5: + resolution: {integrity: sha512-0DS4Nn4rV8qyFlQCpKK8brT61EUtswynrpfFTcgLErcilBIBskSMQ86fO2WVuybr14ywyKdRjv91FiRZwnEuvQ==} + fetch-blob@3.2.0: resolution: {integrity: sha512-7yAQpD2UMJzLi1Dqv7qFYnPbaPx7ZfFK6PiIxQ4PfkGPyNyl2Ugx+a/umUonmKqjhM4DnfbMvdX6otXq83soQQ==} engines: {node: ^12.20 || >= 14.13} @@ -8230,6 +8291,10 @@ packages: resolution: {integrity: sha512-zV/5HKTfCeKWnxG0Dmrw51hEWFGfcF2xiXqcA3+J90WDuP0SvoiSO5ORvcBsifmx/FoIjgQN3oNOGaQ5PhLFkg==} engines: {node: '>=18'} + generic-pool@3.9.0: + resolution: {integrity: sha512-hymDOu5B53XvN4QT9dBmZxPX4CWhBPPLguTZ9MMFeFa/Kg0xWVfylOVNlJji/E7yTZWFd/q9GO5TxDLq156D7g==} + engines: {node: '>= 4'} + gensync@1.0.0-beta.2: resolution: {integrity: sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==} engines: {node: '>=6.9.0'} @@ -8578,9 +8643,12 @@ packages: resolution: {integrity: sha512-5Hh7Y1wQbvY5ooGgPbDaL5iYLAPzMTUrjMulskHLH6wnv/A+1q5rgEaiuqEjB+oxGXIVZs1FF+R/KPN3ZSQYYg==} engines: {node: '>=12'} - ioredis@5.8.2: - resolution: {integrity: sha512-C6uC+kleiIMmjViJINWk80sOQw5lEzse1ZmvD+S/s8p8CWapftSaC+kocGTx6xrbrJ4WmYQGC08ffHLr6ToR6Q==} - engines: {node: '>=12.22.0'} + ioredis-mock@8.13.1: + resolution: {integrity: sha512-Wsi50AU+cMiI32nAgfwpUaJVBtb4iQdVsOHl9M6R3tePCO/8vGsToCVIG82XWAxN4Se55TZoOzVseu+QngFLyw==} + engines: {node: '>=12.22'} + peerDependencies: + '@types/ioredis-mock': ^8 + ioredis: ^5 ioredis@5.9.2: resolution: {integrity: sha512-tAAg/72/VxOUW7RQSX1pIxJVucYKcjFjfvj60L57jrZpYCHC3XN0WCQ3sNYL4Gmvv+7GPvTAjc+KSdeNuE8oWQ==} @@ -10217,6 +10285,10 @@ packages: resolution: {integrity: sha512-9u/XQ1pvrQtYyMpZe7DXKv2p5CNvyVwzUB6uhLAnQwHMSgKMBR62lc7AHljaeteeHXn11XTAaLLUVZYVZyuRBQ==} engines: {node: '>= 20.19.0'} + readline-sync@1.4.10: + resolution: {integrity: sha512-gNva8/6UAe8QYepIQH/jQ2qn91Qj0B9sYjMBBs3QOB8F2CXcKgLxQaJRP76sWVRQt+QU+8fAkCbCvjjMFu7Ycw==} + engines: {node: '>= 0.8.0'} + recast@0.23.11: resolution: {integrity: sha512-YTUo+Flmw4ZXiWfQKGcwwc11KnoRAYgzAE2E7mXKCjSviTKShtxBsN6YUUBB2gtaBzKzeKunxhUwNHQuRryhWA==} engines: {node: '>= 4'} @@ -10239,6 +10311,9 @@ packages: resolution: {integrity: sha512-DJnGAeenTdpMEH6uAJRK/uiyEIH9WVsUmoLwzudwGJUwZPp80PDBWPHXSAGNPwNvIXAbe7MSUB1zQFugFml66A==} engines: {node: '>=4'} + redis@4.7.1: + resolution: {integrity: sha512-S1bJDnqLftzHXHP8JsT5II/CtHWQrASX5K96REjWjlmWKrviSOLWmM7QnRLstAWsu1VBBV1ffV6DzCvxNP0UJQ==} + regexp.prototype.flags@1.5.4: resolution: {integrity: sha512-dYqgNSZbDwkaJ2ceRd9ojCGjBq+mOm9LmtXnAnEGyHhN/5R7iDW2TRw3h+o/jCFxus3P2LfWIIiwowAjANm7IA==} engines: {node: '>= 0.4'} @@ -10673,6 +10748,9 @@ packages: sprintf-js@1.0.3: resolution: {integrity: sha512-D9cPgkvLlV3t3IzL0D0YLvGA9Ahk4PcvVwUbN0dSGr1aP0Nrt4AEnTUbuGvquEC0mA64Gqt1fzirlRs5ibXx8g==} + sprintf-js@1.1.3: + resolution: {integrity: sha512-Oo+0REFV59/rz3gfJNKQiBlwfHaSESl1pcGyABQsnnIfWOFt6JNj5gCog2U6MLZ//IGYD+nA8nI+mTShREReaA==} + srvx@0.10.1: resolution: {integrity: sha512-A//xtfak4eESMWWydSRFUVvCTQbSwivnGCEf8YGPe2eHU0+Z6znfUTCPF0a7oV3sObSOcrXHlL6Bs9vVctfXdg==} engines: {node: '>=20.16.0'} @@ -13179,7 +13257,7 @@ snapshots: optionalDependencies: '@types/node': 24.10.3 - '@ioredis/commands@1.4.0': {} + '@ioredis/as-callback@3.0.0': {} '@ioredis/commands@1.5.0': {} @@ -14556,6 +14634,32 @@ snapshots: '@radix-ui/rect@1.1.1': {} + '@redis/bloom@1.2.0(@redis/client@1.6.1)': + dependencies: + '@redis/client': 1.6.1 + + '@redis/client@1.6.1': + dependencies: + cluster-key-slot: 1.1.2 + generic-pool: 3.9.0 + yallist: 4.0.0 + + '@redis/graph@1.1.1(@redis/client@1.6.1)': + dependencies: + '@redis/client': 1.6.1 + + '@redis/json@1.0.7(@redis/client@1.6.1)': + dependencies: + '@redis/client': 1.6.1 + + '@redis/search@1.2.0(@redis/client@1.6.1)': + dependencies: + '@redis/client': 1.6.1 + + '@redis/time-series@1.1.0(@redis/client@1.6.1)': + dependencies: + '@redis/client': 1.6.1 + '@rolldown/binding-android-arm64@1.0.0-beta.53': optional: true @@ -15731,11 +15835,11 @@ snapshots: - webpack - xml2js - '@tanstack/react-start-router-manifest@1.120.19(@types/node@24.10.3)(db0@0.3.4)(ioredis@5.8.2)(jiti@2.6.1)(lightningcss@1.30.2)(rolldown@1.0.0-rc.17)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)': + '@tanstack/react-start-router-manifest@1.120.19(@types/node@24.10.3)(db0@0.3.4)(ioredis@5.9.2)(jiti@2.6.1)(lightningcss@1.30.2)(rolldown@1.0.0-rc.17)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)': dependencies: '@tanstack/router-core': 1.157.16 tiny-invariant: 1.3.3 - vinxi: 0.5.3(@types/node@24.10.3)(db0@0.3.4)(ioredis@5.8.2)(jiti@2.6.1)(lightningcss@1.30.2)(rolldown@1.0.0-rc.17)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2) + vinxi: 0.5.3(@types/node@24.10.3)(db0@0.3.4)(ioredis@5.9.2)(jiti@2.6.1)(lightningcss@1.30.2)(rolldown@1.0.0-rc.17)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2) transitivePeerDependencies: - '@azure/app-configuration' - '@azure/cosmos' @@ -16248,11 +16352,11 @@ snapshots: '@tanstack/store': 0.8.0 solid-js: 1.9.10 - '@tanstack/start-api-routes@1.120.19(@types/node@24.10.3)(crossws@0.4.5(srvx@0.11.15))(db0@0.3.4)(ioredis@5.8.2)(jiti@2.6.1)(lightningcss@1.30.2)(rolldown@1.0.0-rc.17)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)': + '@tanstack/start-api-routes@1.120.19(@types/node@24.10.3)(crossws@0.4.5(srvx@0.11.15))(db0@0.3.4)(ioredis@5.9.2)(jiti@2.6.1)(lightningcss@1.30.2)(rolldown@1.0.0-rc.17)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)': dependencies: '@tanstack/router-core': 1.157.16 '@tanstack/start-server-core': 1.141.1(crossws@0.4.5(srvx@0.11.15)) - vinxi: 0.5.3(@types/node@24.10.3)(db0@0.3.4)(ioredis@5.8.2)(jiti@2.6.1)(lightningcss@1.30.2)(rolldown@1.0.0-rc.17)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2) + vinxi: 0.5.3(@types/node@24.10.3)(db0@0.3.4)(ioredis@5.9.2)(jiti@2.6.1)(lightningcss@1.30.2)(rolldown@1.0.0-rc.17)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2) transitivePeerDependencies: - '@azure/app-configuration' - '@azure/cosmos' @@ -16324,7 +16428,7 @@ snapshots: tiny-invariant: 1.3.3 tiny-warning: 1.0.3 - '@tanstack/start-config@1.120.20(@types/node@24.10.3)(crossws@0.4.5(srvx@0.11.15))(db0@0.3.4)(ioredis@5.8.2)(jiti@2.6.1)(lightningcss@1.30.2)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(rolldown@1.0.0-rc.17)(terser@5.44.1)(tsx@4.21.0)(vite-plugin-solid@2.11.10(solid-js@1.9.10)(vite@7.2.7(@types/node@24.10.3)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)))(vite@7.2.7(@types/node@24.10.3)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2))(yaml@2.8.2)': + '@tanstack/start-config@1.120.20(@types/node@24.10.3)(crossws@0.4.5(srvx@0.11.15))(db0@0.3.4)(ioredis@5.9.2)(jiti@2.6.1)(lightningcss@1.30.2)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(rolldown@1.0.0-rc.17)(terser@5.44.1)(tsx@4.21.0)(vite-plugin-solid@2.11.10(solid-js@1.9.10)(vite@7.2.7(@types/node@24.10.3)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)))(vite@7.2.7(@types/node@24.10.3)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2))(yaml@2.8.2)': dependencies: '@tanstack/react-router': 1.159.5(react-dom@19.2.3(react@19.2.3))(react@19.2.3) '@tanstack/react-start-plugin': 1.131.50(@tanstack/react-router@1.159.5(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(@vitejs/plugin-react@4.7.0(vite@7.2.7(@types/node@24.10.3)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)))(rolldown@1.0.0-rc.17)(vite-plugin-solid@2.11.10(solid-js@1.9.10)(vite@7.2.7(@types/node@24.10.3)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)))(vite@7.2.7(@types/node@24.10.3)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)) @@ -16338,7 +16442,7 @@ snapshots: ofetch: 1.5.1 react: 19.2.3 react-dom: 19.2.3(react@19.2.3) - vinxi: 0.5.3(@types/node@24.10.3)(db0@0.3.4)(ioredis@5.8.2)(jiti@2.6.1)(lightningcss@1.30.2)(rolldown@1.0.0-rc.17)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2) + vinxi: 0.5.3(@types/node@24.10.3)(db0@0.3.4)(ioredis@5.9.2)(jiti@2.6.1)(lightningcss@1.30.2)(rolldown@1.0.0-rc.17)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2) vite: 7.2.7(@types/node@24.10.3)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2) zod: 3.25.76 transitivePeerDependencies: @@ -16674,13 +16778,13 @@ snapshots: dependencies: '@tanstack/router-core': 1.159.4 - '@tanstack/start@1.120.20(@types/node@24.10.3)(crossws@0.4.5(srvx@0.11.15))(db0@0.3.4)(ioredis@5.8.2)(jiti@2.6.1)(lightningcss@1.30.2)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(rolldown@1.0.0-rc.17)(terser@5.44.1)(tsx@4.21.0)(vite-plugin-solid@2.11.10(solid-js@1.9.10)(vite@7.2.7(@types/node@24.10.3)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)))(vite@7.2.7(@types/node@24.10.3)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2))(yaml@2.8.2)': + '@tanstack/start@1.120.20(@types/node@24.10.3)(crossws@0.4.5(srvx@0.11.15))(db0@0.3.4)(ioredis@5.9.2)(jiti@2.6.1)(lightningcss@1.30.2)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(rolldown@1.0.0-rc.17)(terser@5.44.1)(tsx@4.21.0)(vite-plugin-solid@2.11.10(solid-js@1.9.10)(vite@7.2.7(@types/node@24.10.3)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)))(vite@7.2.7(@types/node@24.10.3)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2))(yaml@2.8.2)': dependencies: '@tanstack/react-start-client': 1.141.1(react-dom@19.2.3(react@19.2.3))(react@19.2.3) - '@tanstack/react-start-router-manifest': 1.120.19(@types/node@24.10.3)(db0@0.3.4)(ioredis@5.8.2)(jiti@2.6.1)(lightningcss@1.30.2)(rolldown@1.0.0-rc.17)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2) + '@tanstack/react-start-router-manifest': 1.120.19(@types/node@24.10.3)(db0@0.3.4)(ioredis@5.9.2)(jiti@2.6.1)(lightningcss@1.30.2)(rolldown@1.0.0-rc.17)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2) '@tanstack/react-start-server': 1.141.1(crossws@0.4.5(srvx@0.11.15))(react-dom@19.2.3(react@19.2.3))(react@19.2.3) - '@tanstack/start-api-routes': 1.120.19(@types/node@24.10.3)(crossws@0.4.5(srvx@0.11.15))(db0@0.3.4)(ioredis@5.8.2)(jiti@2.6.1)(lightningcss@1.30.2)(rolldown@1.0.0-rc.17)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2) - '@tanstack/start-config': 1.120.20(@types/node@24.10.3)(crossws@0.4.5(srvx@0.11.15))(db0@0.3.4)(ioredis@5.8.2)(jiti@2.6.1)(lightningcss@1.30.2)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(rolldown@1.0.0-rc.17)(terser@5.44.1)(tsx@4.21.0)(vite-plugin-solid@2.11.10(solid-js@1.9.10)(vite@7.2.7(@types/node@24.10.3)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)))(vite@7.2.7(@types/node@24.10.3)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2))(yaml@2.8.2) + '@tanstack/start-api-routes': 1.120.19(@types/node@24.10.3)(crossws@0.4.5(srvx@0.11.15))(db0@0.3.4)(ioredis@5.9.2)(jiti@2.6.1)(lightningcss@1.30.2)(rolldown@1.0.0-rc.17)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2) + '@tanstack/start-config': 1.120.20(@types/node@24.10.3)(crossws@0.4.5(srvx@0.11.15))(db0@0.3.4)(ioredis@5.9.2)(jiti@2.6.1)(lightningcss@1.30.2)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(rolldown@1.0.0-rc.17)(terser@5.44.1)(tsx@4.21.0)(vite-plugin-solid@2.11.10(solid-js@1.9.10)(vite@7.2.7(@types/node@24.10.3)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)))(vite@7.2.7(@types/node@24.10.3)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2))(yaml@2.8.2) '@tanstack/start-server-functions-client': 1.131.50(vite@7.2.7(@types/node@24.10.3)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)) '@tanstack/start-server-functions-handler': 1.120.19(crossws@0.4.5(srvx@0.11.15)) '@tanstack/start-server-functions-server': 1.131.2(vite@7.2.7(@types/node@24.10.3)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)) @@ -16899,6 +17003,10 @@ snapshots: dependencies: '@types/unist': 3.0.3 + '@types/ioredis-mock@8.2.7(ioredis@5.9.2)': + dependencies: + ioredis: 5.9.2 + '@types/json-schema@7.0.15': {} '@types/mdast@4.0.4': @@ -18990,6 +19098,16 @@ snapshots: optionalDependencies: picomatch: 4.0.4 + fengari-interop@0.1.4(fengari@0.1.5): + dependencies: + fengari: 0.1.5 + + fengari@0.1.5: + dependencies: + readline-sync: 1.4.10 + sprintf-js: 1.1.3 + tmp: 0.2.5 + fetch-blob@3.2.0: dependencies: node-domexception: 1.0.0 @@ -19145,6 +19263,8 @@ snapshots: transitivePeerDependencies: - supports-color + generic-pool@3.9.0: {} + gensync@1.0.0-beta.2: {} get-caller-file@2.0.5: {} @@ -19598,19 +19718,15 @@ snapshots: internmap@2.0.3: {} - ioredis@5.8.2: + ioredis-mock@8.13.1(@types/ioredis-mock@8.2.7(ioredis@5.9.2))(ioredis@5.9.2): dependencies: - '@ioredis/commands': 1.4.0 - cluster-key-slot: 1.1.2 - debug: 4.4.3 - denque: 2.1.0 - lodash.defaults: 4.2.0 - lodash.isarguments: 3.1.0 - redis-errors: 1.2.0 - redis-parser: 3.0.0 - standard-as-callback: 2.1.0 - transitivePeerDependencies: - - supports-color + '@ioredis/as-callback': 3.0.0 + '@ioredis/commands': 1.5.0 + '@types/ioredis-mock': 8.2.7(ioredis@5.9.2) + fengari: 0.1.5 + fengari-interop: 0.1.4(fengari@0.1.5) + ioredis: 5.9.2 + semver: 7.7.4 ioredis@5.9.2: dependencies: @@ -20827,7 +20943,7 @@ snapshots: h3: 1.15.5 hookable: 5.5.3 httpxy: 0.1.7 - ioredis: 5.8.2 + ioredis: 5.9.2 jiti: 2.6.1 klona: 2.0.6 knitwork: 1.3.0 @@ -20860,7 +20976,7 @@ snapshots: unenv: 2.0.0-rc.24 unimport: 5.5.0 unplugin-utils: 0.3.1 - unstorage: 1.17.4(db0@0.3.4)(ioredis@5.8.2) + unstorage: 1.17.4(db0@0.3.4)(ioredis@5.9.2) untyped: 2.0.0 unwasm: 0.3.11 youch: 4.1.0-beta.13 @@ -21830,6 +21946,8 @@ snapshots: readdirp@5.0.0: {} + readline-sync@1.4.10: {} + recast@0.23.11: dependencies: ast-types: 0.16.1 @@ -21861,6 +21979,15 @@ snapshots: dependencies: redis-errors: 1.2.0 + redis@4.7.1: + dependencies: + '@redis/bloom': 1.2.0(@redis/client@1.6.1) + '@redis/client': 1.6.1 + '@redis/graph': 1.1.1(@redis/client@1.6.1) + '@redis/json': 1.0.7(@redis/client@1.6.1) + '@redis/search': 1.2.0(@redis/client@1.6.1) + '@redis/time-series': 1.1.0(@redis/client@1.6.1) + regexp.prototype.flags@1.5.4: dependencies: call-bind: 1.0.8 @@ -22477,6 +22604,8 @@ snapshots: sprintf-js@1.0.3: {} + sprintf-js@1.1.3: {} + srvx@0.10.1: {} srvx@0.11.15: {} @@ -23115,20 +23244,6 @@ snapshots: dependencies: rolldown: 1.0.0-beta.53 - unstorage@1.17.4(db0@0.3.4)(ioredis@5.8.2): - dependencies: - anymatch: 3.1.3 - chokidar: 5.0.0 - destr: 2.0.5 - h3: 1.15.5 - lru-cache: 11.2.4 - node-fetch-native: 1.6.7 - ofetch: 1.5.1 - ufo: 1.6.3 - optionalDependencies: - db0: 0.3.4 - ioredis: 5.8.2 - unstorage@1.17.4(db0@0.3.4)(ioredis@5.9.2): dependencies: anymatch: 3.1.3 @@ -23256,7 +23371,7 @@ snapshots: d3-time: 3.1.0 d3-timer: 3.0.1 - vinxi@0.5.3(@types/node@24.10.3)(db0@0.3.4)(ioredis@5.8.2)(jiti@2.6.1)(lightningcss@1.30.2)(rolldown@1.0.0-rc.17)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2): + vinxi@0.5.3(@types/node@24.10.3)(db0@0.3.4)(ioredis@5.9.2)(jiti@2.6.1)(lightningcss@1.30.2)(rolldown@1.0.0-rc.17)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2): dependencies: '@babel/core': 7.28.5 '@babel/plugin-syntax-jsx': 7.27.1(@babel/core@7.28.5) @@ -23289,7 +23404,7 @@ snapshots: ufo: 1.6.1 unctx: 2.4.1 unenv: 1.10.0 - unstorage: 1.17.4(db0@0.3.4)(ioredis@5.8.2) + unstorage: 1.17.4(db0@0.3.4)(ioredis@5.9.2) vite: 6.4.1(@types/node@24.10.3)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2) zod: 3.25.76 transitivePeerDependencies: