From f4f2ef25598e9e839ff3c8fefcf2077c97114205 Mon Sep 17 00:00:00 2001 From: Alem Tuzlak Date: Sun, 10 May 2026 18:23:05 +0200 Subject: [PATCH 01/36] feat(ai): add memory types --- packages/typescript/ai/src/memory/types.ts | 529 +++++++++++++++++++++ 1 file changed, 529 insertions(+) create mode 100644 packages/typescript/ai/src/memory/types.ts diff --git a/packages/typescript/ai/src/memory/types.ts b/packages/typescript/ai/src/memory/types.ts new file mode 100644 index 000000000..7eb4f2dfc --- /dev/null +++ b/packages/typescript/ai/src/memory/types.ts @@ -0,0 +1,529 @@ +/** + * 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. + */ + 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?: number[] + /** 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?: number[] + /** Maximum number of hits to return. */ + topK?: number + /** Drop hits with `score < minScore`. */ + minScore?: number + /** Restrict matches to the given record kinds. */ + kinds?: MemoryKind[] + /** + * 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: MemoryHit[] + /** 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?: MemoryKind[] + /** 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: MemoryRecord[] + /** 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 | MemoryRecord[]): 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. + */ + 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. + */ + 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: string[], 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 everything by definition, but adapters MUST NOT treat + * `clear({})` as a casual "wipe the database" operation. Implementations + * SHOULD either reject empty-scope `clear` outright or guard it behind an + * explicit safety check; treating it as a silent global wipe is considered + * misuse. + */ + 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?: MemoryKind[] + /** + * Render retrieved hits into a string injected into the prompt. Replaces + * the built-in `defaultRenderMemory` formatter when provided. + */ + render?: (hits: MemoryHit[]) => string + + /** + * Write-side gate: decide whether a given turn should produce memories at + * all. Returning `false` short-circuits `extractMemories` and the persist + * path for the current turn. + */ + 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: MemoryHit[], + args: { scope: MemoryScope; query: string; ctx: ChatMiddlewareContext }, + ) => MemoryHit[] | 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. + */ + extractMemories?: (args: { + userText: string + responseText: string + scope: MemoryScope + adapter: MemoryAdapter + }) => + | Promise + | MemoryOp[] + | MemoryRecord[] + | 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 defers the resulting work via `ctx.defer` so it does not + * block the chat stream. Same return-shape conventions as `extractMemories` + * — `MemoryOp[]`, `MemoryRecord[]` shorthand, or `undefined`. + */ + onToolResult?: (args: { + toolName: string + toolCallId: string + args: unknown + result: unknown + scope: MemoryScope + adapter: MemoryAdapter + }) => + | Promise + | MemoryOp[] + | MemoryRecord[] + | 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: MemoryRecord[] + 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: MemoryHit[] }) => void | Promise + /** Fired before the persist path commits records to the adapter. */ + onPersistStart?: (args: { scope: MemoryScope; records: MemoryRecord[] }) => void | Promise + /** Fired after the persist path commits records to the adapter. */ + onPersistEnd?: (args: { scope: MemoryScope; records: MemoryRecord[] }) => void | Promise + /** Fired when retrieval, persistence, or extraction throws. */ + 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 +} From fca462434d38a43437013ffdc69861b68d6bac68 Mon Sep 17 00:00:00 2001 From: Alem Tuzlak Date: Sun, 10 May 2026 18:31:31 +0200 Subject: [PATCH 02/36] feat(ai): add memory helper functions --- packages/typescript/ai/src/memory/helpers.ts | 86 +++++++++++++++ .../ai/tests/memory/helpers.test.ts | 101 ++++++++++++++++++ 2 files changed, 187 insertions(+) create mode 100644 packages/typescript/ai/src/memory/helpers.ts create mode 100644 packages/typescript/ai/tests/memory/helpers.test.ts diff --git a/packages/typescript/ai/src/memory/helpers.ts b/packages/typescript/ai/src/memory/helpers.ts new file mode 100644 index 000000000..74ab9a79a --- /dev/null +++ b/packages/typescript/ai/src/memory/helpers.ts @@ -0,0 +1,86 @@ +import type { + MemoryHit, + MemoryQuery, + MemoryRecord, + MemoryScope, +} from './types' + +const DEFAULT_HALF_LIFE_MS = 1000 * 60 * 60 * 24 * 30 // 30 days + +export function scopeMatches( + recordScope: MemoryScope, + queryScope: MemoryScope, +): boolean { + for (const key of Object.keys(queryScope) as Array) { + const value = queryScope[key] + if (value == null) continue + if (recordScope[key] !== value) return false + } + return true +} + +export function cosine(a?: number[], b?: number[]): 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 +} + +export function recencyScore( + createdAt: number, + halfLifeMs: number = DEFAULT_HALF_LIFE_MS, +): number { + const age = Math.max(0, Date.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 +} + +export function defaultScoreHit(args: { + record: MemoryRecord + query: MemoryQuery + now?: number +}): number { + const { record, query } = args + const semantic = cosine(query.embedding, record.embedding) + const lexical = lexicalOverlap(query.text, record.text) + const recency = recencyScore(record.createdAt) + const importance = record.importance ?? 0.5 + return semantic * 0.55 + lexical * 0.2 + recency * 0.15 + importance * 0.1 +} + +export function defaultRenderMemory(hits: MemoryHit[]): 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.', + '', + ...hits.map( + (hit, index) => `${index + 1}. [${hit.record.kind}] ${hit.record.text}`, + ), + ].join('\n') +} 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..cfc02ac87 --- /dev/null +++ b/packages/typescript/ai/tests/memory/helpers.test.ts @@ -0,0 +1,101 @@ +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('matches when query keys are absent', () => { + expect(scopeMatches({ tenantId: 'a' }, {})).toBe(true) + }) + 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('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 t = Date.now() - halfLife + expect(recencyScore(t, halfLife)).toBeCloseTo(0.5, 2) + }) +}) + +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:') + 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) + }) +}) From 42904a24e7772fed62070b980820534506553b5f Mon Sep 17 00:00:00 2001 From: Alem Tuzlak Date: Sun, 10 May 2026 18:34:19 +0200 Subject: [PATCH 03/36] feat(ai): expose @tanstack/ai/memory subpath --- packages/typescript/ai/package.json | 4 ++++ packages/typescript/ai/src/memory/index.ts | 28 ++++++++++++++++++++++ packages/typescript/ai/vite.config.ts | 1 + 3 files changed, 33 insertions(+) create mode 100644 packages/typescript/ai/src/memory/index.ts 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/src/memory/index.ts b/packages/typescript/ai/src/memory/index.ts new file mode 100644 index 000000000..c6d6d4589 --- /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' + +// memoryMiddleware export added in Task B2. 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', From 474eb4a940e1c4ce974e28452781554384139d86 Mon Sep 17 00:00:00 2001 From: Alem Tuzlak Date: Sun, 10 May 2026 18:37:39 +0200 Subject: [PATCH 04/36] test(ai): add failing memory middleware tests --- .../ai/tests/middlewares/memory.test.ts | 348 ++++++++++++++++++ 1 file changed, 348 insertions(+) create mode 100644 packages/typescript/ai/tests/middlewares/memory.test.ts 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..c4d00be41 --- /dev/null +++ b/packages/typescript/ai/tests/middlewares/memory.test.ts @@ -0,0 +1,348 @@ +// packages/typescript/ai/tests/middlewares/memory.test.ts +import { describe, expect, it, vi } from 'vitest' +import { chat } from '../../src/activities/chat/index' +import { memoryMiddleware } from '../../src/memory' +import type { + MemoryAdapter, + MemoryHit, + MemoryListOptions, + MemoryListResult, + MemoryQuery, + MemoryRecord, + MemoryRecordPatch, + 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 + 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('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('drops records rejected by shouldRemember', 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: 'hi' }], + middleware: [ + memoryMiddleware({ + adapter: memory, + scope: baseScope, + shouldRemember: ({ message }) => message.content.length > 10, + }), + ], + }) + await collectChunks(stream as AsyncIterable) + const texts = [...memory.store.values()].map((r) => r.text) + expect(texts).toEqual(['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('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[] } + 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') + }) +}) + +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') + }) +}) From 397098c0aed3c0b1bde6a30f78df63ca80171efe Mon Sep 17 00:00:00 2001 From: Alem Tuzlak Date: Sun, 10 May 2026 18:42:02 +0200 Subject: [PATCH 05/36] feat(ai): add memoryMiddleware --- packages/typescript/ai/src/memory/index.ts | 2 +- .../typescript/ai/src/memory/middleware.ts | 348 ++++++++++++++++++ 2 files changed, 349 insertions(+), 1 deletion(-) create mode 100644 packages/typescript/ai/src/memory/middleware.ts diff --git a/packages/typescript/ai/src/memory/index.ts b/packages/typescript/ai/src/memory/index.ts index c6d6d4589..aa76eaa8a 100644 --- a/packages/typescript/ai/src/memory/index.ts +++ b/packages/typescript/ai/src/memory/index.ts @@ -25,4 +25,4 @@ export { defaultScoreHit, } from './helpers' -// memoryMiddleware export added in Task B2. +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..2a30b9c81 --- /dev/null +++ b/packages/typescript/ai/src/memory/middleware.ts @@ -0,0 +1,348 @@ +import type { + ChatMiddleware, + ChatMiddlewareConfig, + ChatMiddlewareContext, +} from '../activities/chat/middleware/types' +import type { ModelMessage } from '../types' +import type { + MemoryHit, + MemoryMiddlewareOptions, + MemoryOp, + MemoryRecord, + MemoryScope, +} from './types' +import { defaultRenderMemory } from './helpers' + +/** + * Server-side memory middleware. See docs/middlewares/memory.md and the + * tanstack-ai-memory skill for usage. + */ +export function memoryMiddleware( + options: MemoryMiddlewareOptions, +): ChatMiddleware { + // Per-request closure state. The chat engine creates one ChatMiddleware + // instance per chat() call (no cross-request leakage). + let resolvedScope: MemoryScope | undefined + let lastUserText = '' + let lastUserEmbedding: number[] | undefined + let retrievedHits: MemoryHit[] = [] + + async function resolveScope( + ctx: ChatMiddlewareContext, + ): Promise { + if (resolvedScope) return resolvedScope + resolvedScope = + typeof options.scope === 'function' + ? await options.scope(ctx) + : options.scope + return resolvedScope + } + + return { + name: 'memory', + + async onConfig(ctx, config) { + if (ctx.phase !== 'init') return + + const lastUser = findLastUserMessage(config.messages) + lastUserText = getMessageText(lastUser) + if (!lastUserText) return + + const scope = await resolveScope(ctx) + + if (options.shouldRetrieve) { + const ok = await options.shouldRetrieve({ + userText: lastUserText, + scope, + }) + if (!ok) return + } + + try { + await options.events?.onRetrieveStart?.({ + scope, + query: lastUserText, + }) + + if (options.embedder) { + lastUserEmbedding = await options.embedder.embed(lastUserText) + } + + retrievedHits = await searchAllPages( + options, + scope, + lastUserText, + lastUserEmbedding, + ) + + if (options.rerank && retrievedHits.length > 0) { + retrievedHits = await options.rerank(retrievedHits, { + scope, + query: lastUserText, + ctx, + }) + } + + await options.events?.onRetrieveEnd?.({ scope, hits: retrievedHits }) + } catch (error) { + await emitError(options, scope, 'retrieve', error) + if (options.strict) throw error + return + } + + if (retrievedHits.length === 0) return + + const memoryPrompt = + options.render?.(retrievedHits) ?? defaultRenderMemory(retrievedHits) + + return { + systemPrompts: [...config.systemPrompts, memoryPrompt], + } satisfies Partial + }, + + async onAfterToolCall(ctx, info) { + if (!options.onToolResult || !info.ok) return + const scope = await resolveScope(ctx) + try { + let parsedArgs: unknown = {} + try { + const raw = info.toolCall?.function?.arguments + if (typeof raw === 'string' && raw.length > 0) { + parsedArgs = JSON.parse(raw) + } + } catch { + parsedArgs = {} + } + const out = await options.onToolResult({ + toolName: info.toolName, + toolCallId: info.toolCallId, + args: parsedArgs, + result: info.result, + scope, + adapter: options.adapter, + }) + if (!out) return + ctx.defer(applyOps(options, scope, normalizeOps(out))) + } catch (error) { + await emitError(options, scope, 'extract', error) + if (options.strict) throw error + } + }, + + async onFinish(ctx, info) { + const responseText = info.content ?? '' + if (!lastUserText && !responseText) return + const scope = await resolveScope(ctx) + ctx.defer( + persistTurn({ + options, + scope, + userText: lastUserText, + userEmbedding: lastUserEmbedding, + responseText, + retrievedMemoryIds: retrievedHits.map((h) => h.record.id), + }), + ) + }, + } +} + +// =========================== +// Internals +// =========================== + +async function searchAllPages( + options: MemoryMiddlewareOptions, + scope: MemoryScope, + text: string, + embedding: number[] | undefined, +): Promise { + const topK = options.topK ?? 6 + const minScore = options.minScore ?? 0.15 + const all: MemoryHit[] = [] + 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: MemoryOp[] | MemoryRecord[]): MemoryOp[] { + if (input.length === 0) return [] + const first = input[0] + if (first && 'op' in first) return input as MemoryOp[] + return (input as MemoryRecord[]).map((record) => ({ + op: 'add' as const, + record, + })) +} + +async function applyOps( + options: MemoryMiddlewareOptions, + scope: MemoryScope, + ops: MemoryOp[], +): Promise { + const newRecords: MemoryRecord[] = [] + const adds: MemoryRecord[] = [] + for (const op of ops) { + if (op.op === 'add') { + adds.push(op.record) + newRecords.push(op.record) + } else if (op.op === 'update') { + await options.adapter.update(op.id, scope, op.patch) + } else { + await options.adapter.delete([op.id], scope) + } + } + if (adds.length > 0) await options.adapter.add(adds) + return newRecords +} + +async function persistTurn(args: { + options: MemoryMiddlewareOptions + scope: MemoryScope + userText: string + userEmbedding?: number[] + responseText: string + retrievedMemoryIds: string[] +}): Promise { + const { options, scope } = args + const now = Date.now() + const baseRecords: MemoryRecord[] = [] + + 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) { + baseRecords.push({ + id: crypto.randomUUID(), + scope, + text: args.responseText, + kind: 'message', + role: 'assistant', + createdAt: now, + importance: 0.4, + embedding: options.embedder + ? await options.embedder.embed(args.responseText) + : undefined, + metadata: { retrievedMemoryIds: args.retrievedMemoryIds }, + }) + } + + // shouldRemember filter + const filtered: MemoryRecord[] = [] + for (const record of baseRecords) { + if (!options.shouldRemember) { + filtered.push(record) + continue + } + const keep = await options.shouldRemember({ + message: { role: record.role ?? 'assistant', content: record.text }, + responseText: args.responseText, + }) + if (keep) filtered.push(record) + } + + // extractMemories ops + let ops: MemoryOp[] = filtered.map((record) => ({ + op: 'add' as const, + record, + })) + 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) { + await emitError(options, scope, 'extract', error) + if (options.strict) throw error + } + } + + try { + await options.events?.onPersistStart?.({ + scope, + records: ops + .filter((o) => o.op === 'add') + .map((o) => (o as Extract).record), + }) + const newRecords = await applyOps(options, scope, ops) + await options.events?.onPersistEnd?.({ scope, records: newRecords }) + if (options.afterPersist) { + await options.afterPersist({ + newRecords, + scope, + adapter: options.adapter, + }) + } + } catch (error) { + await emitError(options, scope, 'persist', error) + 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 }) +} + +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 getMessageText(message?: ModelMessage): string { + if (!message) return '' + if (typeof message.content === 'string') return message.content + if (Array.isArray(message.content)) { + return message.content + .map((part) => { + if (typeof part === 'string') return part + if ( + part && + typeof part === 'object' && + 'text' in part && + typeof part.text === 'string' + ) { + return part.text + } + return '' + }) + .filter(Boolean) + .join('\n') + } + return '' +} From c60faa0a1ec746dfd3649c184404d2b458d833ce Mon Sep 17 00:00:00 2001 From: Alem Tuzlak Date: Sun, 10 May 2026 18:46:25 +0200 Subject: [PATCH 06/36] fix(ai): tighten memory middleware test types for noUncheckedIndexedAccess --- packages/typescript/ai/tests/middlewares/memory.test.ts | 8 +++----- 1 file changed, 3 insertions(+), 5 deletions(-) diff --git a/packages/typescript/ai/tests/middlewares/memory.test.ts b/packages/typescript/ai/tests/middlewares/memory.test.ts index c4d00be41..80436d5e8 100644 --- a/packages/typescript/ai/tests/middlewares/memory.test.ts +++ b/packages/typescript/ai/tests/middlewares/memory.test.ts @@ -5,11 +5,9 @@ import { memoryMiddleware } from '../../src/memory' import type { MemoryAdapter, MemoryHit, - MemoryListOptions, MemoryListResult, MemoryQuery, MemoryRecord, - MemoryRecordPatch, MemoryScope, MemorySearchResult, } from '../../src/memory' @@ -283,8 +281,8 @@ describe('memoryMiddleware — persistence', () => { }) await collectChunks(stream as AsyncIterable) expect(afterPersist).toHaveBeenCalledTimes(1) - const arg = afterPersist.mock.calls[0][0] as { newRecords: MemoryRecord[] } - expect(arg.newRecords.length).toBe(2) // user + assistant + 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 () => { @@ -312,7 +310,7 @@ describe('memoryMiddleware — persistence', () => { 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') + expect(toolResults[0]?.text).toContain('echo') }) }) From f9945a7b1b261189128510ddeb100b4b2da6b36b Mon Sep 17 00:00:00 2001 From: Alem Tuzlak Date: Sun, 10 May 2026 18:48:24 +0200 Subject: [PATCH 07/36] feat(ai-event-client): add memory devtools events --- .../typescript/ai-event-client/src/index.ts | 69 +++++++++++++++++++ 1 file changed, 69 insertions(+) diff --git a/packages/typescript/ai-event-client/src/index.ts b/packages/typescript/ai-event-client/src/index.ts index e934adf40..99203ce6b 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: string[] + 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 { From c88c65d2b9212de6b1adc1c6f5e86d3da427d0b2 Mon Sep 17 00:00:00 2001 From: Alem Tuzlak Date: Sun, 10 May 2026 18:54:22 +0200 Subject: [PATCH 08/36] feat(ai): emit memory devtools events from middleware --- .../typescript/ai/src/memory/middleware.ts | 87 +++++++++++++++++++ .../ai/tests/middlewares/memory.test.ts | 30 +++++++ 2 files changed, 117 insertions(+) diff --git a/packages/typescript/ai/src/memory/middleware.ts b/packages/typescript/ai/src/memory/middleware.ts index 2a30b9c81..d7f0b0708 100644 --- a/packages/typescript/ai/src/memory/middleware.ts +++ b/packages/typescript/ai/src/memory/middleware.ts @@ -1,3 +1,4 @@ +import { aiEventClient } from '@tanstack/ai-event-client' import type { ChatMiddleware, ChatMiddlewareConfig, @@ -58,7 +59,16 @@ export function memoryMiddleware( if (!ok) return } + const startedAt = Date.now() try { + safeEmit('memory:retrieve:started', { + scope, + query: lastUserText, + topK: options.topK ?? 6, + minScore: options.minScore ?? 0.15, + embedderUsed: !!options.embedder, + timestamp: startedAt, + }) await options.events?.onRetrieveStart?.({ scope, query: lastUserText, @@ -83,8 +93,28 @@ export function memoryMiddleware( }) } + safeEmit('memory:retrieve:completed', { + scope, + hits: 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: retrievedHits }) } catch (error) { + safeEmit('memory:error', { + scope, + phase: 'retrieve', + error: { + name: (error as Error)?.name ?? 'Error', + message: String((error as Error)?.message ?? error), + }, + timestamp: Date.now(), + }) await emitError(options, scope, 'retrieve', error) if (options.strict) throw error return @@ -219,6 +249,7 @@ async function persistTurn(args: { }): Promise { const { options, scope } = args const now = Date.now() + const startedAt = now const baseRecords: MemoryRecord[] = [] if (args.userText) { @@ -278,12 +309,36 @@ async function persistTurn(args: { }) if (extras) ops = ops.concat(normalizeOps(extras)) } catch (error) { + safeEmit('memory:error', { + scope, + phase: 'extract', + error: { + name: (error as Error)?.name ?? 'Error', + message: String((error as Error)?.message ?? error), + }, + timestamp: Date.now(), + }) await emitError(options, scope, 'extract', error) if (options.strict) throw error } } try { + safeEmit('memory:persist:started', { + scope, + records: ops + .filter((o) => o.op === 'add') + .map((o) => { + const r = (o as Extract).record + return { + id: r.id, + kind: r.kind, + role: r.role, + preview: preview(r.text), + } + }), + timestamp: Date.now(), + }) await options.events?.onPersistStart?.({ scope, records: ops @@ -291,6 +346,12 @@ async function persistTurn(args: { .map((o) => (o as Extract).record), }) const newRecords = await applyOps(options, scope, ops) + 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) { await options.afterPersist({ @@ -300,6 +361,15 @@ async function persistTurn(args: { }) } } catch (error) { + safeEmit('memory:error', { + scope, + phase: 'persist', + error: { + name: (error as Error)?.name ?? 'Error', + message: String((error as Error)?.message ?? error), + }, + timestamp: Date.now(), + }) await emitError(options, scope, 'persist', error) if (options.strict) throw error } @@ -324,6 +394,23 @@ function findLastUserMessage( 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 diff --git a/packages/typescript/ai/tests/middlewares/memory.test.ts b/packages/typescript/ai/tests/middlewares/memory.test.ts index 80436d5e8..8815422a1 100644 --- a/packages/typescript/ai/tests/middlewares/memory.test.ts +++ b/packages/typescript/ai/tests/middlewares/memory.test.ts @@ -1,5 +1,6 @@ // 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 { @@ -344,3 +345,32 @@ describe('memoryMiddleware — failure handling', () => { await expect(collectChunks(stream as AsyncIterable)).rejects.toThrow('boom') }) }) + +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() + } + }) +}) From ab7dc976cc8d0012473accadd966cbf817d0c037 Mon Sep 17 00:00:00 2001 From: Alem Tuzlak Date: Sun, 10 May 2026 19:01:10 +0200 Subject: [PATCH 09/36] feat(ai-memory): scaffold new package --- packages/typescript/ai-memory/package.json | 51 +++++++ packages/typescript/ai-memory/project.json | 3 + packages/typescript/ai-memory/src/index.ts | 3 + packages/typescript/ai-memory/tsconfig.json | 12 ++ packages/typescript/ai-memory/vite.config.ts | 32 ++++ pnpm-lock.yaml | 148 +++++++++++++++++++ 6 files changed, 249 insertions(+) create mode 100644 packages/typescript/ai-memory/package.json create mode 100644 packages/typescript/ai-memory/project.json create mode 100644 packages/typescript/ai-memory/src/index.ts create mode 100644 packages/typescript/ai-memory/tsconfig.json create mode 100644 packages/typescript/ai-memory/vite.config.ts diff --git a/packages/typescript/ai-memory/package.json b/packages/typescript/ai-memory/package.json new file mode 100644 index 000000000..3590b92a9 --- /dev/null +++ b/packages/typescript/ai-memory/package.json @@ -0,0 +1,51 @@ +{ + "name": "@tanstack/ai-memory", + "version": "0.1.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" + } + }, + "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:^", + "redis": ">=4.0.0" + }, + "peerDependenciesMeta": { + "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/src/index.ts b/packages/typescript/ai-memory/src/index.ts new file mode 100644 index 000000000..eb9cbbdef --- /dev/null +++ b/packages/typescript/ai-memory/src/index.ts @@ -0,0 +1,3 @@ +// @tanstack/ai-memory +// Adapters land in Phase E (in-memory) and Phase F (redis). +export {} diff --git a/packages/typescript/ai-memory/tsconfig.json b/packages/typescript/ai-memory/tsconfig.json new file mode 100644 index 000000000..4518a9027 --- /dev/null +++ b/packages/typescript/ai-memory/tsconfig.json @@ -0,0 +1,12 @@ +{ + "extends": "../../../tsconfig.json", + "compilerOptions": { + "outDir": "dist" + }, + "include": [ + "vite.config.ts", + "./src", + "./tests" + ], + "exclude": ["node_modules", "dist", "**/*.config.ts"] +} diff --git a/packages/typescript/ai-memory/vite.config.ts b/packages/typescript/ai-memory/vite.config.ts new file mode 100644 index 000000000..0e09e85c1 --- /dev/null +++ b/packages/typescript/ai-memory/vite.config.ts @@ -0,0 +1,32 @@ +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/pnpm-lock.yaml b/pnpm-lock.yaml index f05b781d9..e4fe3ba75 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1278,6 +1278,21 @@ importers: specifier: 4.0.14 version: 4.0.14(vitest@4.1.4) + packages/typescript/ai-memory: + 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: @@ -3303,6 +3318,9 @@ packages: '@types/node': optional: true + '@ioredis/as-callback@3.0.0': + resolution: {integrity: sha512-Kqv1rZ3WbgOrS+hgzJ5xG5WQuhvzzSTRYvNeyPMLOAM78MHSnuKI20JeJGbpuAt//LCuP0vsexZcorqW7kWhJg==} + '@ioredis/commands@1.4.0': resolution: {integrity: sha512-aFT2yemJJo+TZCmieA7qnYGQooOS7QfNmYrzGtsYd3g9j5iDP8AimYYAesf79ohjbLG12XxC4nG5DyEnC88AsQ==} @@ -4733,6 +4751,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 +6389,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 +8126,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 +8290,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,6 +8642,13 @@ packages: resolution: {integrity: sha512-5Hh7Y1wQbvY5ooGgPbDaL5iYLAPzMTUrjMulskHLH6wnv/A+1q5rgEaiuqEjB+oxGXIVZs1FF+R/KPN3ZSQYYg==} engines: {node: '>=12'} + 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.8.2: resolution: {integrity: sha512-C6uC+kleiIMmjViJINWk80sOQw5lEzse1ZmvD+S/s8p8CWapftSaC+kocGTx6xrbrJ4WmYQGC08ffHLr6ToR6Q==} engines: {node: '>=12.22.0'} @@ -10217,6 +10288,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 +10314,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 +10751,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,6 +13260,8 @@ snapshots: optionalDependencies: '@types/node': 24.10.3 + '@ioredis/as-callback@3.0.0': {} + '@ioredis/commands@1.4.0': {} '@ioredis/commands@1.5.0': {} @@ -14556,6 +14639,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 @@ -16899,6 +17008,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 +19103,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 +19268,8 @@ snapshots: transitivePeerDependencies: - supports-color + generic-pool@3.9.0: {} + gensync@1.0.0-beta.2: {} get-caller-file@2.0.5: {} @@ -19598,6 +19723,16 @@ snapshots: internmap@2.0.3: {} + ioredis-mock@8.13.1(@types/ioredis-mock@8.2.7(ioredis@5.9.2))(ioredis@5.9.2): + dependencies: + '@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.8.2: dependencies: '@ioredis/commands': 1.4.0 @@ -21830,6 +21965,8 @@ snapshots: readdirp@5.0.0: {} + readline-sync@1.4.10: {} + recast@0.23.11: dependencies: ast-types: 0.16.1 @@ -21861,6 +21998,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 +22623,8 @@ snapshots: sprintf-js@1.0.3: {} + sprintf-js@1.1.3: {} + srvx@0.10.1: {} srvx@0.11.15: {} From 01ba8a8891c31bd39c9e9cf63b9f3f1b0c7bc155 Mon Sep 17 00:00:00 2001 From: Alem Tuzlak Date: Sun, 10 May 2026 19:03:48 +0200 Subject: [PATCH 10/36] test(ai-memory): add shared adapter contract suite --- .../typescript/ai-memory/tests/contract.ts | 199 ++++++++++++++++++ 1 file changed, 199 insertions(+) create mode 100644 packages/typescript/ai-memory/tests/contract.ts diff --git a/packages/typescript/ai-memory/tests/contract.ts b/packages/typescript/ai-memory/tests/contract.ts new file mode 100644 index 000000000..d517c72d0 --- /dev/null +++ b/packages/typescript/ai-memory/tests/contract.ts @@ -0,0 +1,199 @@ +// 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) + await adapter.add({ ...r, text: 'second' }) + const got = await adapter.get('x', scopeA) + expect(got?.text).toBe('second') + expect(got?.updatedAt).toBeGreaterThanOrEqual(got!.createdAt) + }) + }) + + 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' }) + 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'] }) + 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) + // Either single page if adapter returns everything, or multi-page if it streams. + expect(seen.size).toBeGreaterThan(0) + }) + }) + + 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) + 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'] }) + 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('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') + }) + }) + }) +} From 72cc2b668a3bdc1288eac6ad60a9adac59d7a5e4 Mon Sep 17 00:00:00 2001 From: Alem Tuzlak Date: Sun, 10 May 2026 19:06:50 +0200 Subject: [PATCH 11/36] feat(ai-memory): add inMemoryMemoryAdapter --- .../ai-memory/src/adapters/in-memory.ts | 142 ++++++++++++++++++ packages/typescript/ai-memory/src/index.ts | 20 ++- .../ai-memory/tests/in-memory.test.ts | 4 + 3 files changed, 163 insertions(+), 3 deletions(-) create mode 100644 packages/typescript/ai-memory/src/adapters/in-memory.ts create mode 100644 packages/typescript/ai-memory/tests/in-memory.test.ts 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..4201b9181 --- /dev/null +++ b/packages/typescript/ai-memory/src/adapters/in-memory.ts @@ -0,0 +1,142 @@ +import { + defaultScoreHit, + isExpired, + scopeMatches, + type MemoryAdapter, + type MemoryListOptions, + type MemoryListResult, + type MemoryQuery, + type MemoryRecord, + type MemoryScope, + type MemorySearchResult, +} from '@tanstack/ai/memory' + +export function inMemoryMemoryAdapter(): MemoryAdapter { + const records = new Map() + + function liveRecords(): MemoryRecord[] { + const now = Date.now() + const out: MemoryRecord[] = [] + for (const r of records.values()) { + if (isExpired(r, now)) records.delete(r.id) + else out.push(r) + } + return out + } + + function scopedLive(scope: MemoryScope): MemoryRecord[] { + 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 { + 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 }) })) + .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/index.ts b/packages/typescript/ai-memory/src/index.ts index eb9cbbdef..67cb8193d 100644 --- a/packages/typescript/ai-memory/src/index.ts +++ b/packages/typescript/ai-memory/src/index.ts @@ -1,3 +1,17 @@ -// @tanstack/ai-memory -// Adapters land in Phase E (in-memory) and Phase F (redis). -export {} +export { inMemoryMemoryAdapter } from './adapters/in-memory' + +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/in-memory.test.ts b/packages/typescript/ai-memory/tests/in-memory.test.ts new file mode 100644 index 000000000..aef1ee00c --- /dev/null +++ b/packages/typescript/ai-memory/tests/in-memory.test.ts @@ -0,0 +1,4 @@ +import { runMemoryAdapterContract } from './contract' +import { inMemoryMemoryAdapter } from '../src/adapters/in-memory' + +runMemoryAdapterContract('inMemoryMemoryAdapter', () => inMemoryMemoryAdapter()) From 40be46223686705e3b9dfbb6634c0dc05b13066b Mon Sep 17 00:00:00 2001 From: Alem Tuzlak Date: Sun, 10 May 2026 19:14:50 +0200 Subject: [PATCH 12/36] fix(ai-memory): tighten in-memory adapter lint compliance --- .../ai-memory/src/adapters/in-memory.ts | 26 +++++++++---------- 1 file changed, 12 insertions(+), 14 deletions(-) diff --git a/packages/typescript/ai-memory/src/adapters/in-memory.ts b/packages/typescript/ai-memory/src/adapters/in-memory.ts index 4201b9181..7a17a225a 100644 --- a/packages/typescript/ai-memory/src/adapters/in-memory.ts +++ b/packages/typescript/ai-memory/src/adapters/in-memory.ts @@ -1,22 +1,20 @@ -import { - defaultScoreHit, - isExpired, - scopeMatches, - type MemoryAdapter, - type MemoryListOptions, - type MemoryListResult, - type MemoryQuery, - type MemoryRecord, - type MemoryScope, - type MemorySearchResult, +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(): MemoryRecord[] { + function liveRecords(): Array { const now = Date.now() - const out: MemoryRecord[] = [] + const out: Array = [] for (const r of records.values()) { if (isExpired(r, now)) records.delete(r.id) else out.push(r) @@ -24,7 +22,7 @@ export function inMemoryMemoryAdapter(): MemoryAdapter { return out } - function scopedLive(scope: MemoryScope): MemoryRecord[] { + function scopedLive(scope: MemoryScope): Array { return liveRecords().filter((r) => scopeMatches(r.scope, scope)) } From 055cd5055dc5be2e8e68e9302c32d4f64568e2f9 Mon Sep 17 00:00:00 2001 From: Alem Tuzlak Date: Sun, 10 May 2026 19:14:57 +0200 Subject: [PATCH 13/36] feat(ai-memory): add redisMemoryAdapter --- .../ai-memory/src/adapters/redis.ts | 216 ++++++++++++++++++ packages/typescript/ai-memory/src/index.ts | 6 + .../typescript/ai-memory/tests/redis.test.ts | 14 ++ 3 files changed, 236 insertions(+) create mode 100644 packages/typescript/ai-memory/src/adapters/redis.ts create mode 100644 packages/typescript/ai-memory/tests/redis.test.ts 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..03ac166f9 --- /dev/null +++ b/packages/typescript/ai-memory/src/adapters/redis.ts @@ -0,0 +1,216 @@ +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. + * Compatible with both `redis` (node-redis v4+) and `ioredis` shapes. + * Real users pass an instance of either. + */ +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> +} + +export interface RedisMemoryAdapterOptions { + redis: RedisLike + /** Default 'tanstack-ai:memory'. */ + prefix?: string +} + +const SCOPE_KEYS = [ + 'tenantId', + 'userId', + 'sessionId', + 'threadId', + 'namespace', +] as const + +export function redisMemoryAdapter( + options: RedisMemoryAdapterOptions, +): MemoryAdapter { + const prefix = options.prefix ?? 'tanstack-ai:memory' + const redis = options.redis + + function scopeKey(scope: MemoryScope): string { + return SCOPE_KEYS.map((k) => scope[k] ?? '_').join(':') + } + function indexKey(scope: MemoryScope): string { + return `${prefix}:index:${scopeKey(scope)}` + } + function recordKey(id: string): string { + return `${prefix}:record:${id}` + } + + 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 { + return undefined + } + } + + async function loadAllForScope( + scope: MemoryScope, + ): Promise> { + const ids = await redis.smembers(indexKey(scope)) + if (ids.length === 0) return [] + const raws = await redis.mget(...ids.map(recordKey)) + const out: Array = [] + const expired: Array = [] + for (let i = 0; i < raws.length; i++) { + const raw = raws[i] as string | null + const id = ids[i] as string + if (!raw) { + expired.push(id) + continue + } + try { + const r = JSON.parse(raw) as MemoryRecord + if (isExpired(r)) { + expired.push(r.id) + continue + } + if (!scopeMatches(r.scope, scope)) continue + out.push(r) + } catch { + /* skip malformed */ + } + } + if (expired.length > 0) { + await redis.srem(indexKey(scope), ...expired) + await redis.del(...expired.map(recordKey)) + } + return out + } + + return { + name: 'redis', + + async add(input) { + const batch = Array.isArray(input) ? input : [input] + const now = Date.now() + for (const r of batch) { + 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) + 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 }) })) + .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)) + await redis.srem(indexKey(scope), id) + } + }, + + async clear(scope) { + const ids = await redis.smembers(indexKey(scope)) + if (ids.length === 0) return + await redis.del(...ids.map(recordKey)) + await redis.del(indexKey(scope)) + }, + } +} diff --git a/packages/typescript/ai-memory/src/index.ts b/packages/typescript/ai-memory/src/index.ts index 67cb8193d..3fd33318f 100644 --- a/packages/typescript/ai-memory/src/index.ts +++ b/packages/typescript/ai-memory/src/index.ts @@ -1,5 +1,11 @@ export { inMemoryMemoryAdapter } from './adapters/in-memory' +export { + redisMemoryAdapter, + type RedisMemoryAdapterOptions, + type RedisLike, +} from './adapters/redis' + export type { MemoryAdapter, MemoryRecord, 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..2fbf65242 --- /dev/null +++ b/packages/typescript/ai-memory/tests/redis.test.ts @@ -0,0 +1,14 @@ +// @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 { runMemoryAdapterContract } from './contract' +import { redisMemoryAdapter } from '../src/adapters/redis' + +runMemoryAdapterContract('redisMemoryAdapter', async () => { + const client = new RedisMock() + return redisMemoryAdapter({ + redis: client as never, + prefix: `test:${crypto.randomUUID()}`, + }) +}) From d6df97961e898dde9cf4de6b601ae403fe1c0809 Mon Sep 17 00:00:00 2001 From: Alem Tuzlak Date: Sun, 10 May 2026 19:16:53 +0200 Subject: [PATCH 14/36] docs(ai): add tanstack-ai-memory skill --- .../ai/skills/tanstack-ai-memory/SKILL.md | 86 +++++++++++++++++++ 1 file changed, 86 insertions(+) create mode 100644 packages/typescript/ai/skills/tanstack-ai-memory/SKILL.md 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..3e1084df0 --- /dev/null +++ b/packages/typescript/ai/skills/tanstack-ai-memory/SKILL.md @@ -0,0 +1,86 @@ +--- +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 + +const stream = chat({ + adapter: openaiText('gpt-4o'), + messages, + middleware: [ + memoryMiddleware({ + adapter: memory, + scope: ({ context }) => { + // Server-validated session data — NOT request body. + const session = (context as { session: { tenantId: string; userId: string } }).session + return { tenantId: session.tenantId, userId: session.userId, threadId: body.threadId } + }, + // Optional: provide an embedder for semantic search. + embedder: { async embed(text) { return embed(text) } }, + }), + ], +}) +``` + +## Scope security + +Scope is the isolation boundary. **Never trust client-supplied tenantId/userId.** Resolve scope server-side from session/auth: + +```ts +scope: ({ context }) => ({ + tenantId: requireSession(context).tenantId, // throws if missing + userId: requireSession(context).userId, + threadId: body.threadId, // OK to take from request — validate it belongs to userId +}) +``` + +Pass the validated session through `chat({ context: { session } })`. + +## 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. From e0913b2143429e12ca2c6116906b49a9080ef097 Mon Sep 17 00:00:00 2001 From: Alem Tuzlak Date: Sun, 10 May 2026 19:18:01 +0200 Subject: [PATCH 15/36] docs(ai-memory): add in-memory adapter skill --- .../tanstack-ai-memory-in-memory/SKILL.md | 42 +++++++++++++++++++ 1 file changed, 42 insertions(+) create mode 100644 packages/typescript/ai-memory/skills/tanstack-ai-memory-in-memory/SKILL.md 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`. From 32b15ded8035ad339e07851eeb78cd6fc88c47c4 Mon Sep 17 00:00:00 2001 From: Alem Tuzlak Date: Sun, 10 May 2026 19:19:22 +0200 Subject: [PATCH 16/36] docs(ai-memory): add redis adapter skill --- .../skills/tanstack-ai-memory-redis/SKILL.md | 52 +++++++++++++++++++ 1 file changed, 52 insertions(+) create mode 100644 packages/typescript/ai-memory/skills/tanstack-ai-memory-redis/SKILL.md 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..889b1aba2 --- /dev/null +++ b/packages/typescript/ai-memory/skills/tanstack-ai-memory-redis/SKILL.md @@ -0,0 +1,52 @@ +--- +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 + +```bash +pnpm add redis # or: pnpm add ioredis +``` + +Pass the connected client into the adapter: + +```ts +import { createClient } from 'redis' +import { memoryMiddleware } from '@tanstack/ai/memory' +import { redisMemoryAdapter } from '@tanstack/ai-memory' + +const redis = createClient({ url: process.env.REDIS_URL }) +await redis.connect() + +const memory = redisMemoryAdapter({ redis, prefix: 'myapp:memory' }) + +memoryMiddleware({ adapter: memory, scope }) +``` + +The adapter accepts any client implementing the `RedisLike` shape (a small subset: `get`, `set`, `del`, `sadd`, `srem`, `smembers`, `mget`). Both `redis` (node-redis v4+) and `ioredis` work. + +## Storage model + +``` +{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. +- **`SerializationError` on read:** the JSON in `{prefix}:record:{id}` is malformed — likely from an older schema or a third-party writer. The adapter skips malformed rows but you'll want to clean them up via `clear(scope)`. From 74e7136df0a664c7097401503c81a6c696fba5c4 Mon Sep 17 00:00:00 2001 From: Alem Tuzlak Date: Sun, 10 May 2026 19:23:16 +0200 Subject: [PATCH 17/36] docs: add memory middleware concept and quickstart pages --- docs/config.json | 18 ++++ docs/guides/memory-quickstart.md | 136 +++++++++++++++++++++++++ docs/middlewares/memory.md | 170 +++++++++++++++++++++++++++++++ 3 files changed, 324 insertions(+) create mode 100644 docs/guides/memory-quickstart.md create mode 100644 docs/middlewares/memory.md diff --git a/docs/config.json b/docs/config.json index 89d4f5abc..e079abd74 100644 --- a/docs/config.json +++ b/docs/config.json @@ -164,6 +164,24 @@ } ] }, + { + "label": "Middlewares", + "children": [ + { + "label": "Memory", + "to": "middlewares/memory" + } + ] + }, + { + "label": "Guides", + "children": [ + { + "label": "Memory Quickstart", + "to": "guides/memory-quickstart" + } + ] + }, { "label": "Advanced", "children": [ diff --git a/docs/guides/memory-quickstart.md b/docs/guides/memory-quickstart.md new file mode 100644 index 000000000..d5ddd5435 --- /dev/null +++ b/docs/guides/memory-quickstart.md @@ -0,0 +1,136 @@ +--- +title: Memory Quickstart +id: memory-quickstart +order: 1 +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 [Memory Middleware](../middlewares/memory) concept 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`. + +## 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 }) +``` + +## 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 { memoryMiddleware } from '@tanstack/ai/memory' + +memoryMiddleware({ + adapter: memory, + scope, + embedder: { + async embed(text) { + // Use any embedding model — OpenAI, Cohere, a local model, etc. + const result = await embeddings.create({ 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 + +- [Memory Middleware](../middlewares/memory) — adapter contract, hooks reference, devtools events, failure modes +- [In-memory adapter skill](https://github.com/TanStack/ai) — `tanstack-ai-memory-in-memory` (when to use, capacity limits) +- [Redis adapter skill](https://github.com/TanStack/ai) — `tanstack-ai-memory-redis` (vector search, key layout, ops) diff --git a/docs/middlewares/memory.md b/docs/middlewares/memory.md new file mode 100644 index 000000000..19305faa1 --- /dev/null +++ b/docs/middlewares/memory.md @@ -0,0 +1,170 @@ +--- +title: Memory Middleware +id: memory-middleware +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](../guides/memory-quickstart) 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`. + +## 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](../guides/memory-quickstart) — wire the middleware into a real `chat()` call in five steps +- [Middleware](../advanced/middleware) — the underlying `chat()` middleware lifecycle and hooks +- [Observability](../advanced/observability) — subscribe to `memory:*` events for tracing From 1dd988adb9277f353ae12c4581b391778c7be6b0 Mon Sep 17 00:00:00 2001 From: Alem Tuzlak Date: Sun, 10 May 2026 19:24:40 +0200 Subject: [PATCH 18/36] chore: changeset for memory middleware --- .changeset/memory-middleware.md | 21 +++++++++++++++++++++ 1 file changed, 21 insertions(+) create mode 100644 .changeset/memory-middleware.md diff --git a/.changeset/memory-middleware.md b/.changeset/memory-middleware.md new file mode 100644 index 000000000..193629d4b --- /dev/null +++ b/.changeset/memory-middleware.md @@ -0,0 +1,21 @@ +--- +'@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 (`redis` listed as optional peer dependency). +- 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). From c93e7f62c2ed254a10739b4ff4612e48afda60fb Mon Sep 17 00:00:00 2001 From: Alem Tuzlak Date: Sun, 10 May 2026 19:34:01 +0200 Subject: [PATCH 19/36] chore: final formatting --- .changeset/memory-middleware.md | 3 + packages/typescript/ai-memory/package.json | 12 +- .../ai-memory/src/adapters/in-memory.ts | 9 +- .../ai-memory/src/adapters/redis.ts | 9 +- .../typescript/ai-memory/tests/contract.ts | 35 ++- packages/typescript/ai-memory/tsconfig.json | 6 +- packages/typescript/ai-memory/vite.config.ts | 7 +- .../ai/skills/tanstack-ai-memory/SKILL.md | 32 ++- packages/typescript/ai/src/memory/helpers.ts | 12 +- packages/typescript/ai/src/memory/types.ts | 25 ++- .../ai/tests/memory/helpers.test.ts | 22 +- .../ai/tests/middlewares/memory.test.ts | 209 ++++++++++++++---- 12 files changed, 284 insertions(+), 97 deletions(-) diff --git a/.changeset/memory-middleware.md b/.changeset/memory-middleware.md index 193629d4b..e5b8b5328 100644 --- a/.changeset/memory-middleware.md +++ b/.changeset/memory-middleware.md @@ -9,13 +9,16 @@ 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 (`redis` listed as optional peer dependency). - 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/packages/typescript/ai-memory/package.json b/packages/typescript/ai-memory/package.json index 3590b92a9..65a62d5e0 100644 --- a/packages/typescript/ai-memory/package.json +++ b/packages/typescript/ai-memory/package.json @@ -34,13 +34,21 @@ "test:lib:dev": "pnpm test:lib --watch", "test:types": "tsc" }, - "keywords": ["ai", "tanstack", "memory", "redis", "rag"], + "keywords": [ + "ai", + "tanstack", + "memory", + "redis", + "rag" + ], "peerDependencies": { "@tanstack/ai": "workspace:^", "redis": ">=4.0.0" }, "peerDependenciesMeta": { - "redis": { "optional": true } + "redis": { + "optional": true + } }, "devDependencies": { "@tanstack/ai": "workspace:*", diff --git a/packages/typescript/ai-memory/src/adapters/in-memory.ts b/packages/typescript/ai-memory/src/adapters/in-memory.ts index 7a17a225a..037b17587 100644 --- a/packages/typescript/ai-memory/src/adapters/in-memory.ts +++ b/packages/typescript/ai-memory/src/adapters/in-memory.ts @@ -78,7 +78,10 @@ export function inMemoryMemoryAdapter(): MemoryAdapter { const minScore = query.minScore ?? 0 const topK = query.topK ?? 6 const scored = candidates - .map((record) => ({ record, score: defaultScoreHit({ record, query }) })) + .map((record) => ({ + record, + score: defaultScoreHit({ record, query }), + })) .filter((h) => h.score >= minScore) .sort((a, b) => b.score - a.score) @@ -105,9 +108,7 @@ export function inMemoryMemoryAdapter(): MemoryAdapter { case 'createdAt:asc': return a.createdAt - b.createdAt case 'updatedAt:desc': - return ( - (b.updatedAt ?? b.createdAt) - (a.updatedAt ?? a.createdAt) - ) + return (b.updatedAt ?? b.createdAt) - (a.updatedAt ?? a.createdAt) default: return b.createdAt - a.createdAt } diff --git a/packages/typescript/ai-memory/src/adapters/redis.ts b/packages/typescript/ai-memory/src/adapters/redis.ts index 03ac166f9..a301c35de 100644 --- a/packages/typescript/ai-memory/src/adapters/redis.ts +++ b/packages/typescript/ai-memory/src/adapters/redis.ts @@ -154,7 +154,10 @@ export function redisMemoryAdapter( const minScore = query.minScore ?? 0 const topK = query.topK ?? 6 const scored = candidates - .map((record) => ({ record, score: defaultScoreHit({ record, query }) })) + .map((record) => ({ + record, + score: defaultScoreHit({ record, query }), + })) .filter((h) => h.score >= minScore) .sort((a, b) => b.score - a.score) const offset = query.cursor ? Number.parseInt(query.cursor, 10) || 0 : 0 @@ -179,9 +182,7 @@ export function redisMemoryAdapter( case 'createdAt:asc': return a.createdAt - b.createdAt case 'updatedAt:desc': - return ( - (b.updatedAt ?? b.createdAt) - (a.updatedAt ?? a.createdAt) - ) + return (b.updatedAt ?? b.createdAt) - (a.updatedAt ?? a.createdAt) default: return b.createdAt - a.createdAt } diff --git a/packages/typescript/ai-memory/tests/contract.ts b/packages/typescript/ai-memory/tests/contract.ts index d517c72d0..6a3539574 100644 --- a/packages/typescript/ai-memory/tests/contract.ts +++ b/packages/typescript/ai-memory/tests/contract.ts @@ -15,7 +15,9 @@ export function runMemoryAdapterContract( const scopeA: MemoryScope = { tenantId: 't1', userId: 'u1' } const scopeB: MemoryScope = { tenantId: 't1', userId: 'u2' } - beforeEach(async () => { adapter = await factory() }) + beforeEach(async () => { + adapter = await factory() + }) function rec(over: Partial = {}): MemoryRecord { return { @@ -82,7 +84,9 @@ export function runMemoryAdapterContract( 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() + expect( + await adapter.update('nope', scopeA, { text: 'x' }), + ).toBeUndefined() }) }) @@ -91,7 +95,11 @@ export function runMemoryAdapterContract( 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 }) + const out = await adapter.search({ + scope: scopeA, + text: 'same', + topK: 3, + }) expect(out.hits.length).toBeLessThanOrEqual(3) }) @@ -105,12 +113,18 @@ export function runMemoryAdapterContract( 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'] }) + const out = await adapter.search({ + scope: scopeA, + text: 'foo', + kinds: ['fact'], + }) 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: '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() @@ -125,7 +139,12 @@ export function runMemoryAdapterContract( const seen = new Set() let pages = 0 do { - const out = await adapter.search({ scope: scopeA, text: 'pagework', topK: 4, cursor }) + 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++ @@ -190,7 +209,9 @@ export function runMemoryAdapterContract( 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], + scope: scopeA, + text: 'apple', + embedding: [1, 0], }) expect(out.hits[0]?.record.id).toBe('sem') }) diff --git a/packages/typescript/ai-memory/tsconfig.json b/packages/typescript/ai-memory/tsconfig.json index 4518a9027..31b14bdfe 100644 --- a/packages/typescript/ai-memory/tsconfig.json +++ b/packages/typescript/ai-memory/tsconfig.json @@ -3,10 +3,6 @@ "compilerOptions": { "outDir": "dist" }, - "include": [ - "vite.config.ts", - "./src", - "./tests" - ], + "include": ["vite.config.ts", "./src", "./tests"], "exclude": ["node_modules", "dist", "**/*.config.ts"] } diff --git a/packages/typescript/ai-memory/vite.config.ts b/packages/typescript/ai-memory/vite.config.ts index 0e09e85c1..435aec10e 100644 --- a/packages/typescript/ai-memory/vite.config.ts +++ b/packages/typescript/ai-memory/vite.config.ts @@ -14,8 +14,11 @@ const config = defineConfig({ provider: 'v8', reporter: ['text', 'json', 'html', 'lcov'], exclude: [ - 'node_modules/', 'dist/', 'tests/', - '**/*.test.ts', '**/*.config.ts', + 'node_modules/', + 'dist/', + 'tests/', + '**/*.test.ts', + '**/*.config.ts', ], include: ['src/**/*.ts'], }, diff --git a/packages/typescript/ai/skills/tanstack-ai-memory/SKILL.md b/packages/typescript/ai/skills/tanstack-ai-memory/SKILL.md index 3e1084df0..41b7681d4 100644 --- a/packages/typescript/ai/skills/tanstack-ai-memory/SKILL.md +++ b/packages/typescript/ai/skills/tanstack-ai-memory/SKILL.md @@ -33,11 +33,21 @@ const stream = chat({ adapter: memory, scope: ({ context }) => { // Server-validated session data — NOT request body. - const session = (context as { session: { tenantId: string; userId: string } }).session - return { tenantId: session.tenantId, userId: session.userId, threadId: body.threadId } + const session = ( + context as { session: { tenantId: string; userId: string } } + ).session + return { + tenantId: session.tenantId, + userId: session.userId, + threadId: body.threadId, + } }, // Optional: provide an embedder for semantic search. - embedder: { async embed(text) { return embed(text) } }, + embedder: { + async embed(text) { + return embed(text) + }, + }, }), ], }) @@ -65,14 +75,14 @@ Pass the validated session through `chat({ context: { session } })`. ## 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 | +| 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. diff --git a/packages/typescript/ai/src/memory/helpers.ts b/packages/typescript/ai/src/memory/helpers.ts index 74ab9a79a..67e119ab0 100644 --- a/packages/typescript/ai/src/memory/helpers.ts +++ b/packages/typescript/ai/src/memory/helpers.ts @@ -1,9 +1,4 @@ -import type { - MemoryHit, - MemoryQuery, - MemoryRecord, - MemoryScope, -} from './types' +import type { MemoryHit, MemoryQuery, MemoryRecord, MemoryScope } from './types' const DEFAULT_HALF_LIFE_MS = 1000 * 60 * 60 * 24 * 30 // 30 days @@ -54,7 +49,10 @@ export function recencyScore( return Math.pow(0.5, age / halfLifeMs) } -export function isExpired(record: MemoryRecord, now: number = Date.now()): boolean { +export function isExpired( + record: MemoryRecord, + now: number = Date.now(), +): boolean { return record.expiresAt !== undefined && record.expiresAt < now } diff --git a/packages/typescript/ai/src/memory/types.ts b/packages/typescript/ai/src/memory/types.ts index 7eb4f2dfc..6c927a6cf 100644 --- a/packages/typescript/ai/src/memory/types.ts +++ b/packages/typescript/ai/src/memory/types.ts @@ -295,7 +295,10 @@ export interface MemoryAdapter { * UIs, admin tooling, and bulk export. Ordering is controlled by * `options.order`. Expired records are filtered out. */ - list(scope: MemoryScope, options?: MemoryListOptions): Promise + list( + scope: MemoryScope, + options?: MemoryListOptions, + ): Promise /** * Delete records by id within a scope. @@ -503,13 +506,25 @@ export interface MemoryMiddlewareOptions { */ events?: { /** Fired before the retrieval path runs. */ - onRetrieveStart?: (args: { scope: MemoryScope; query: string }) => void | Promise + onRetrieveStart?: (args: { + scope: MemoryScope + query: string + }) => void | Promise /** Fired after retrieval completes, with the final hit set (post-rerank). */ - onRetrieveEnd?: (args: { scope: MemoryScope; hits: MemoryHit[] }) => void | Promise + onRetrieveEnd?: (args: { + scope: MemoryScope + hits: MemoryHit[] + }) => void | Promise /** Fired before the persist path commits records to the adapter. */ - onPersistStart?: (args: { scope: MemoryScope; records: MemoryRecord[] }) => void | Promise + onPersistStart?: (args: { + scope: MemoryScope + records: MemoryRecord[] + }) => void | Promise /** Fired after the persist path commits records to the adapter. */ - onPersistEnd?: (args: { scope: MemoryScope; records: MemoryRecord[] }) => void | Promise + onPersistEnd?: (args: { + scope: MemoryScope + records: MemoryRecord[] + }) => void | Promise /** Fired when retrieval, persistence, or extraction throws. */ onError?: (args: { scope: MemoryScope diff --git a/packages/typescript/ai/tests/memory/helpers.test.ts b/packages/typescript/ai/tests/memory/helpers.test.ts index cfc02ac87..f10d24f8b 100644 --- a/packages/typescript/ai/tests/memory/helpers.test.ts +++ b/packages/typescript/ai/tests/memory/helpers.test.ts @@ -15,7 +15,9 @@ describe('scopeMatches', () => { expect(scopeMatches({ tenantId: 'a' }, {})).toBe(true) }) it('matches when all query keys are equal', () => { - expect(scopeMatches({ tenantId: 'a', userId: 'u' }, { tenantId: 'a' })).toBe(true) + 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) @@ -63,7 +65,9 @@ describe('isExpired', () => { 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) + expect(isExpired({ expiresAt: Date.now() + 10000 } as MemoryRecord)).toBe( + false, + ) }) }) @@ -76,7 +80,10 @@ describe('defaultRenderMemory', () => { { score: 1, record: { - id: '1', scope: {}, kind: 'fact', text: 'User is on Windows.', + id: '1', + scope: {}, + kind: 'fact', + text: 'User is on Windows.', createdAt: 0, }, }, @@ -90,8 +97,13 @@ 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, + id: 'r', + scope: {}, + kind: 'fact', + text: 'foo bar', + createdAt: Date.now(), + embedding: [1, 0], + importance: 1, }, query: { scope: {}, text: 'foo bar', embedding: [1, 0] }, }) diff --git a/packages/typescript/ai/tests/middlewares/memory.test.ts b/packages/typescript/ai/tests/middlewares/memory.test.ts index 8815422a1..44393ee54 100644 --- a/packages/typescript/ai/tests/middlewares/memory.test.ts +++ b/packages/typescript/ai/tests/middlewares/memory.test.ts @@ -53,7 +53,10 @@ function fakeAdapter(seed: MemoryRecord[] = []): MemoryAdapter & { 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 (query.scope[k] && r.scope[k] !== query.scope[k]) { + match = false + break + } } if (!match) continue if (query.kinds && !query.kinds.includes(r.kind)) continue @@ -66,14 +69,21 @@ function fakeAdapter(seed: MemoryRecord[] = []): MemoryAdapter & { 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 (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() }, + async delete(ids) { + for (const id of ids) store.delete(id) + }, + async clear() { + store.clear() + }, } } @@ -93,7 +103,9 @@ function rec(over: Partial = {}): MemoryRecord { 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')]], + iterations: [ + [ev.runStarted(), ev.textContent('hi'), ev.runFinished('stop')], + ], }) const memory = fakeAdapter([rec({ text: 'X' })]) const stream = chat({ @@ -106,9 +118,13 @@ describe('memoryMiddleware — retrieval', () => { }) it('retrieves at init and injects a memory system prompt', async () => { - const memory = fakeAdapter([rec({ text: 'User likes TS.', kind: 'preference' })]) + const memory = fakeAdapter([ + rec({ text: 'User likes TS.', kind: 'preference' }), + ]) const { adapter, calls } = createMockAdapter({ - iterations: [[ev.runStarted(), ev.textContent('ok'), ev.runFinished('stop')]], + iterations: [ + [ev.runStarted(), ev.textContent('ok'), ev.runFinished('stop')], + ], }) const stream = chat({ adapter, @@ -117,14 +133,22 @@ describe('memoryMiddleware — retrieval', () => { }) await collectChunks(stream as AsyncIterable) const first = calls[0] as { systemPrompts?: string[] } - expect(first.systemPrompts?.some((p) => p.includes('User likes TS.'))).toBe(true) + 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.toolStart('c1', 't'), + ev.toolArgs('c1', '{}'), + ev.toolEnd('c1', 't'), + ev.runFinished('tool_calls'), + ], [ev.runStarted(), ev.textContent('done'), ev.runFinished('stop')], ], }) @@ -135,20 +159,30 @@ describe('memoryMiddleware — retrieval', () => { 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 + const iter1 = + (calls[0] as { systemPrompts?: string[] }).systemPrompts?.length ?? 0 + const iter2 = + (calls[1] as { systemPrompts?: string[] }).systemPrompts?.length ?? 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')]], + 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 })], + middleware: [ + memoryMiddleware({ + adapter: memory, + scope: baseScope, + shouldRetrieve: () => false, + }), + ], }) await collectChunks(stream as AsyncIterable) expect(memory.searchCalls).toHaveLength(0) @@ -160,24 +194,32 @@ describe('memoryMiddleware — retrieval', () => { rec({ id: 'b', text: 'B' }), ]) const { adapter, calls } = createMockAdapter({ - iterations: [[ev.runStarted(), ev.textContent('ok'), ev.runFinished('stop')]], + 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 })], + 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') + const promptText = ( + calls[0] as { systemPrompts: string[] } + ).systemPrompts.join('\n') expect(promptText.indexOf('B')).toBeLessThan(promptText.indexOf('A')) }) 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')]], + iterations: [ + [ev.runStarted(), ev.textContent('ok'), ev.runFinished('stop')], + ], }) const scopeFn = vi.fn(() => baseScope) const stream = chat({ @@ -194,7 +236,9 @@ 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')]], + iterations: [ + [ev.runStarted(), ev.textContent('Pong.'), ev.runFinished('stop')], + ], }) const stream = chat({ adapter, @@ -209,7 +253,13 @@ describe('memoryMiddleware — persistence', () => { it('drops records rejected by shouldRemember', async () => { const memory = fakeAdapter() const { adapter } = createMockAdapter({ - iterations: [[ev.runStarted(), ev.textContent('long enough response text'), ev.runFinished('stop')]], + iterations: [ + [ + ev.runStarted(), + ev.textContent('long enough response text'), + ev.runFinished('stop'), + ], + ], }) const stream = chat({ adapter, @@ -230,13 +280,23 @@ describe('memoryMiddleware — persistence', () => { 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')]], + iterations: [ + [ev.runStarted(), ev.textContent('R'), ev.runFinished('stop')], + ], }) - const extractMemories = vi.fn(async () => [rec({ text: 'extracted', kind: 'fact' })]) + 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 })], + middleware: [ + memoryMiddleware({ + adapter: memory, + scope: baseScope, + extractMemories, + }), + ], }) await collectChunks(stream as AsyncIterable) expect(extractMemories).toHaveBeenCalledTimes(1) @@ -248,7 +308,9 @@ describe('memoryMiddleware — persistence', () => { 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')]], + iterations: [ + [ev.runStarted(), ev.textContent('R'), ev.runFinished('stop')], + ], }) const stream = chat({ adapter, @@ -266,23 +328,31 @@ describe('memoryMiddleware — persistence', () => { }) 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) + expect([...memory.store.values()].some((r) => r.text === 'new fact')).toBe( + true, + ) }) 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')]], + 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 })], + 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 + const arg = afterPersist.mock.calls[0]?.[0] as + | { newRecords: MemoryRecord[] } + | undefined expect(arg?.newRecords.length).toBe(2) // user + assistant }) @@ -290,26 +360,40 @@ describe('memoryMiddleware — persistence', () => { 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.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 }) }], + 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' }), + 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') + const toolResults = [...memory.store.values()].filter( + (r) => r.kind === 'tool-result', + ) expect(toolResults).toHaveLength(1) expect(toolResults[0]?.text).toContain('echo') }) @@ -318,9 +402,13 @@ describe('memoryMiddleware — persistence', () => { describe('memoryMiddleware — failure handling', () => { it('non-strict: retrieval failure does not abort chat', async () => { const memory = fakeAdapter() - memory.search = async () => { throw new Error('boom') } + memory.search = async () => { + throw new Error('boom') + } const { adapter } = createMockAdapter({ - iterations: [[ev.runStarted(), ev.textContent('ok'), ev.runFinished('stop')]], + iterations: [ + [ev.runStarted(), ev.textContent('ok'), ev.runFinished('stop')], + ], }) const stream = chat({ adapter, @@ -333,16 +421,24 @@ describe('memoryMiddleware — failure handling', () => { it('strict: retrieval failure rejects the stream', async () => { const memory = fakeAdapter() - memory.search = async () => { throw new Error('boom') } + memory.search = async () => { + throw new Error('boom') + } const { adapter } = createMockAdapter({ - iterations: [[ev.runStarted(), ev.textContent('ok'), ev.runFinished('stop')]], + 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 })], + middleware: [ + memoryMiddleware({ adapter: memory, scope: baseScope, strict: true }), + ], }) - await expect(collectChunks(stream as AsyncIterable)).rejects.toThrow('boom') + await expect( + collectChunks(stream as AsyncIterable), + ).rejects.toThrow('boom') }) }) @@ -350,14 +446,32 @@ 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')]], + 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) + 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, @@ -366,11 +480,16 @@ describe('memoryMiddleware — devtools events', () => { }) await collectChunks(stream as AsyncIterable) expect(seen).toEqual([ - 'retrieve:started', 'retrieve:completed', - 'persist:started', 'persist:completed', + 'retrieve:started', + 'retrieve:completed', + 'persist:started', + 'persist:completed', ]) } finally { - off1(); off2(); off3(); off4() + off1() + off2() + off3() + off4() } }) }) From ecd38ac20f76206541f3cf667805b045c85fcfcc Mon Sep 17 00:00:00 2001 From: Alem Tuzlak Date: Sun, 10 May 2026 19:44:10 +0200 Subject: [PATCH 20/36] fix(ai, ai-memory): clean up lint and knip findings --- knip.json | 3 + .../typescript/ai-event-client/src/index.ts | 2 +- packages/typescript/ai/src/memory/helpers.ts | 4 +- .../typescript/ai/src/memory/middleware.ts | 83 +++++++++++-------- packages/typescript/ai/src/memory/types.ts | 60 +++++++------- 5 files changed, 84 insertions(+), 68 deletions(-) diff --git a/knip.json b/knip.json index 7ece05b5b..347730acd 100644 --- a/knip.json +++ b/knip.json @@ -37,6 +37,9 @@ "packages/typescript/ai-client": { "ignoreDependencies": ["@standard-schema/spec"] }, + "packages/typescript/ai-memory": { + "ignoreDependencies": ["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 99203ce6b..b16af230f 100644 --- a/packages/typescript/ai-event-client/src/index.ts +++ b/packages/typescript/ai-event-client/src/index.ts @@ -666,7 +666,7 @@ export interface MemoryPersistStartedEvent extends BaseEventContext { export interface MemoryPersistCompletedEvent extends BaseEventContext { scope: MemoryScopeLite - recordIds: string[] + recordIds: Array durationMs: number } diff --git a/packages/typescript/ai/src/memory/helpers.ts b/packages/typescript/ai/src/memory/helpers.ts index 67e119ab0..187cc0a33 100644 --- a/packages/typescript/ai/src/memory/helpers.ts +++ b/packages/typescript/ai/src/memory/helpers.ts @@ -14,7 +14,7 @@ export function scopeMatches( return true } -export function cosine(a?: number[], b?: number[]): number { +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 @@ -69,7 +69,7 @@ export function defaultScoreHit(args: { return semantic * 0.55 + lexical * 0.2 + recency * 0.15 + importance * 0.1 } -export function defaultRenderMemory(hits: MemoryHit[]): string { +export function defaultRenderMemory(hits: Array): string { if (hits.length === 0) return '' return [ 'Relevant memory:', diff --git a/packages/typescript/ai/src/memory/middleware.ts b/packages/typescript/ai/src/memory/middleware.ts index d7f0b0708..7d6f7bc99 100644 --- a/packages/typescript/ai/src/memory/middleware.ts +++ b/packages/typescript/ai/src/memory/middleware.ts @@ -1,4 +1,5 @@ import { aiEventClient } from '@tanstack/ai-event-client' +import { defaultRenderMemory } from './helpers' import type { ChatMiddleware, ChatMiddlewareConfig, @@ -12,7 +13,6 @@ import type { MemoryRecord, MemoryScope, } from './types' -import { defaultRenderMemory } from './helpers' /** * Server-side memory middleware. See docs/middlewares/memory.md and the @@ -25,8 +25,8 @@ export function memoryMiddleware( // instance per chat() call (no cross-request leakage). let resolvedScope: MemoryScope | undefined let lastUserText = '' - let lastUserEmbedding: number[] | undefined - let retrievedHits: MemoryHit[] = [] + let lastUserEmbedding: Array | undefined + let retrievedHits: Array = [] async function resolveScope( ctx: ChatMiddlewareContext, @@ -109,10 +109,7 @@ export function memoryMiddleware( safeEmit('memory:error', { scope, phase: 'retrieve', - error: { - name: (error as Error)?.name ?? 'Error', - message: String((error as Error)?.message ?? error), - }, + error: errorInfo(error), timestamp: Date.now(), }) await emitError(options, scope, 'retrieve', error) @@ -136,7 +133,7 @@ export function memoryMiddleware( try { let parsedArgs: unknown = {} try { - const raw = info.toolCall?.function?.arguments + const raw = info.toolCall.function.arguments if (typeof raw === 'string' && raw.length > 0) { parsedArgs = JSON.parse(raw) } @@ -160,7 +157,7 @@ export function memoryMiddleware( }, async onFinish(ctx, info) { - const responseText = info.content ?? '' + const responseText = info.content if (!lastUserText && !responseText) return const scope = await resolveScope(ctx) ctx.defer( @@ -185,11 +182,11 @@ async function searchAllPages( options: MemoryMiddlewareOptions, scope: MemoryScope, text: string, - embedding: number[] | undefined, -): Promise { + embedding: Array | undefined, +): Promise> { const topK = options.topK ?? 6 const minScore = options.minScore ?? 0.15 - const all: MemoryHit[] = [] + const all: Array = [] let cursor: string | undefined do { const page = await options.adapter.search({ @@ -208,11 +205,11 @@ async function searchAllPages( return all.slice(0, topK) } -function normalizeOps(input: MemoryOp[] | MemoryRecord[]): MemoryOp[] { +function normalizeOps(input: Array | Array): Array { if (input.length === 0) return [] const first = input[0] - if (first && 'op' in first) return input as MemoryOp[] - return (input as MemoryRecord[]).map((record) => ({ + if (first && 'op' in first) return input as Array + return (input as Array).map((record) => ({ op: 'add' as const, record, })) @@ -221,10 +218,10 @@ function normalizeOps(input: MemoryOp[] | MemoryRecord[]): MemoryOp[] { async function applyOps( options: MemoryMiddlewareOptions, scope: MemoryScope, - ops: MemoryOp[], -): Promise { - const newRecords: MemoryRecord[] = [] - const adds: MemoryRecord[] = [] + ops: Array, +): Promise> { + const newRecords: Array = [] + const adds: Array = [] for (const op of ops) { if (op.op === 'add') { adds.push(op.record) @@ -243,14 +240,14 @@ async function persistTurn(args: { options: MemoryMiddlewareOptions scope: MemoryScope userText: string - userEmbedding?: number[] + userEmbedding?: Array responseText: string - retrievedMemoryIds: string[] + retrievedMemoryIds: Array }): Promise { const { options, scope } = args const now = Date.now() const startedAt = now - const baseRecords: MemoryRecord[] = [] + const baseRecords: Array = [] if (args.userText) { baseRecords.push({ @@ -281,7 +278,7 @@ async function persistTurn(args: { } // shouldRemember filter - const filtered: MemoryRecord[] = [] + const filtered: Array = [] for (const record of baseRecords) { if (!options.shouldRemember) { filtered.push(record) @@ -295,7 +292,7 @@ async function persistTurn(args: { } // extractMemories ops - let ops: MemoryOp[] = filtered.map((record) => ({ + let ops: Array = filtered.map((record) => ({ op: 'add' as const, record, })) @@ -312,10 +309,7 @@ async function persistTurn(args: { safeEmit('memory:error', { scope, phase: 'extract', - error: { - name: (error as Error)?.name ?? 'Error', - message: String((error as Error)?.message ?? error), - }, + error: errorInfo(error), timestamp: Date.now(), }) await emitError(options, scope, 'extract', error) @@ -329,7 +323,7 @@ async function persistTurn(args: { records: ops .filter((o) => o.op === 'add') .map((o) => { - const r = (o as Extract).record + const r = (o).record return { id: r.id, kind: r.kind, @@ -343,7 +337,7 @@ async function persistTurn(args: { scope, records: ops .filter((o) => o.op === 'add') - .map((o) => (o as Extract).record), + .map((o) => (o).record), }) const newRecords = await applyOps(options, scope, ops) safeEmit('memory:persist:completed', { @@ -364,10 +358,7 @@ async function persistTurn(args: { safeEmit('memory:error', { scope, phase: 'persist', - error: { - name: (error as Error)?.name ?? 'Error', - message: String((error as Error)?.message ?? error), - }, + error: errorInfo(error), timestamp: Date.now(), }) await emitError(options, scope, 'persist', error) @@ -384,6 +375,29 @@ async function emitError( 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 { @@ -419,7 +433,6 @@ function getMessageText(message?: ModelMessage): string { .map((part) => { if (typeof part === 'string') return part if ( - part && typeof part === 'object' && 'text' in part && typeof part.text === 'string' diff --git a/packages/typescript/ai/src/memory/types.ts b/packages/typescript/ai/src/memory/types.ts index 6c927a6cf..b5a72a37d 100644 --- a/packages/typescript/ai/src/memory/types.ts +++ b/packages/typescript/ai/src/memory/types.ts @@ -125,7 +125,7 @@ export type MemoryRecord = { * within a single adapter deployment SHOULD share a consistent dimension if * vector search is used. */ - embedding?: number[] + embedding?: Array /** Free-form metadata bag for adapter-specific or app-specific annotations. */ metadata?: Record } @@ -162,13 +162,13 @@ export type MemoryQuery = { /** 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?: number[] + 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?: MemoryKind[] + kinds?: Array /** * Opaque pagination cursor returned from a previous `search` call. The * cursor format is adapter-defined and MUST NOT be parsed by callers. @@ -181,7 +181,7 @@ export type MemoryQuery = { */ export type MemorySearchResult = { /** Hits ordered by descending relevance. */ - hits: MemoryHit[] + hits: Array /** Opaque cursor for fetching the next page, or `undefined` if no more results. */ nextCursor?: string } @@ -191,7 +191,7 @@ export type MemorySearchResult = { */ export type MemoryListOptions = { /** Restrict to the given record kinds. */ - kinds?: MemoryKind[] + kinds?: Array /** Maximum number of records to return. */ limit?: number /** Opaque pagination cursor returned from a previous `list` call. */ @@ -205,7 +205,7 @@ export type MemoryListOptions = { */ export type MemoryListResult = { /** Records ordered per `MemoryListOptions.order`. */ - items: MemoryRecord[] + items: Array /** Opaque cursor for fetching the next page, or `undefined` if no more records. */ nextCursor?: string } @@ -246,7 +246,7 @@ export interface MemoryAdapter { * * Adapters SHOULD opportunistically evict expired records on `add`. */ - add(records: MemoryRecord | MemoryRecord[]): Promise + add: (records: MemoryRecord | Array) => Promise /** * Fetch a record by id within a scope. @@ -259,7 +259,7 @@ export interface MemoryAdapter { * 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 + get: (id: string, scope: MemoryScope) => Promise /** * Patch a record in place. @@ -272,11 +272,11 @@ export interface MemoryAdapter { * Returns `undefined` when the target record does not exist, lives in a * different scope, or has expired — symmetric with {@link MemoryAdapter.get}. */ - update( + update: ( id: string, scope: MemoryScope, patch: MemoryRecordPatch, - ): Promise + ) => Promise /** * Run a relevance-ranked search within a scope. @@ -286,7 +286,7 @@ export interface MemoryAdapter { * the cursor format is adapter-internal and MUST NOT be parsed by callers. * Expired records are filtered out. */ - search(query: MemoryQuery): Promise + search: (query: MemoryQuery) => Promise /** * Browse records by scope without relevance ranking. @@ -295,10 +295,10 @@ export interface MemoryAdapter { * UIs, admin tooling, and bulk export. Ordering is controlled by * `options.order`. Expired records are filtered out. */ - list( + list: ( scope: MemoryScope, options?: MemoryListOptions, - ): Promise + ) => Promise /** * Delete records by id within a scope. @@ -307,7 +307,7 @@ export interface MemoryAdapter { * silently no-op'd — `delete` does not throw on missing ids, and it MUST NOT * cross scope boundaries. */ - delete(ids: string[], scope: MemoryScope): Promise + delete: (ids: Array, scope: MemoryScope) => Promise /** * Remove ALL records that match the supplied scope. @@ -320,7 +320,7 @@ export interface MemoryAdapter { * explicit safety check; treating it as a silent global wipe is considered * misuse. */ - clear(scope: MemoryScope): Promise + clear: (scope: MemoryScope) => Promise } /** @@ -333,7 +333,7 @@ export interface MemoryAdapter { * idempotent: embedding the same input twice should yield the same vector. */ export interface MemoryEmbedder { - embed(text: string): Promise + embed: (text: string) => Promise> } // =========================== @@ -402,12 +402,12 @@ export interface MemoryMiddlewareOptions { /** Drop hits with `score < minScore`. Defaults to `0.15`. */ minScore?: number /** Restrict retrieval to the given record kinds. Defaults to all kinds. */ - kinds?: MemoryKind[] + kinds?: Array /** * Render retrieved hits into a string injected into the prompt. Replaces * the built-in `defaultRenderMemory` formatter when provided. */ - render?: (hits: MemoryHit[]) => string + render?: (hits: Array) => string /** * Write-side gate: decide whether a given turn should produce memories at @@ -437,9 +437,9 @@ export interface MemoryMiddlewareOptions { * cross-encoder reranking, etc.). */ rerank?: ( - hits: MemoryHit[], + hits: Array, args: { scope: MemoryScope; query: string; ctx: ChatMiddlewareContext }, - ) => MemoryHit[] | Promise + ) => Array | Promise> /** * Extract memory mutations from a completed turn. Runs at finish, after the @@ -456,9 +456,9 @@ export interface MemoryMiddlewareOptions { scope: MemoryScope adapter: MemoryAdapter }) => - | Promise - | MemoryOp[] - | MemoryRecord[] + | Promise | Array | undefined> + | Array + | Array | undefined /** @@ -478,9 +478,9 @@ export interface MemoryMiddlewareOptions { scope: MemoryScope adapter: MemoryAdapter }) => - | Promise - | MemoryOp[] - | MemoryRecord[] + | Promise | Array | undefined> + | Array + | Array | undefined /** @@ -492,7 +492,7 @@ export interface MemoryMiddlewareOptions { * notifications). */ afterPersist?: (args: { - newRecords: MemoryRecord[] + newRecords: Array scope: MemoryScope adapter: MemoryAdapter }) => Promise | void @@ -513,17 +513,17 @@ export interface MemoryMiddlewareOptions { /** Fired after retrieval completes, with the final hit set (post-rerank). */ onRetrieveEnd?: (args: { scope: MemoryScope - hits: MemoryHit[] + hits: Array }) => void | Promise /** Fired before the persist path commits records to the adapter. */ onPersistStart?: (args: { scope: MemoryScope - records: MemoryRecord[] + records: Array }) => void | Promise /** Fired after the persist path commits records to the adapter. */ onPersistEnd?: (args: { scope: MemoryScope - records: MemoryRecord[] + records: Array }) => void | Promise /** Fired when retrieval, persistence, or extraction throws. */ onError?: (args: { From d1fb33713cae5578aea4dc04bd13937d1d8291fd Mon Sep 17 00:00:00 2001 From: Alem Tuzlak Date: Sun, 10 May 2026 20:00:20 +0200 Subject: [PATCH 21/36] fix(ai, ai-memory): address whole-feature audit findings - WeakMap-keyed per-request state to prevent cross-request leak when memoryMiddleware is reused (matches otel middleware pattern) - scopeMatches treats empty scope as 'no match' to prevent clear({}) / search({scope:{}}) cross-tenant wipes - Wrap deferred persist + tool-result writes so strict-mode failures surface via Promise.allSettled instead of being silently swallowed - applyOps applies ops in array order; updates after adds in the same batch now find the inserted record - shouldRemember gates the entire turn (including extractMemories) matching its documented JSDoc - Add empty-scope safety tests to the shared adapter contract suite --- .../ai-memory/src/adapters/redis.ts | 13 + .../typescript/ai-memory/tests/contract.ts | 27 ++ packages/typescript/ai/src/memory/helpers.ts | 15 + .../typescript/ai/src/memory/middleware.ts | 293 ++++++++++++------ packages/typescript/ai/src/memory/types.ts | 9 +- .../ai/tests/memory/helpers.test.ts | 12 +- .../ai/tests/middlewares/memory.test.ts | 72 ++++- 7 files changed, 332 insertions(+), 109 deletions(-) diff --git a/packages/typescript/ai-memory/src/adapters/redis.ts b/packages/typescript/ai-memory/src/adapters/redis.ts index a301c35de..13ef7b01e 100644 --- a/packages/typescript/ai-memory/src/adapters/redis.ts +++ b/packages/typescript/ai-memory/src/adapters/redis.ts @@ -39,6 +39,13 @@ const SCOPE_KEYS = [ 'namespace', ] as const +function hasAnyScopeKey(scope: MemoryScope): boolean { + for (const key of SCOPE_KEYS) { + if (scope[key] != null) return true + } + return false +} + export function redisMemoryAdapter( options: RedisMemoryAdapterOptions, ): MemoryAdapter { @@ -208,6 +215,12 @@ export function redisMemoryAdapter( }, 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 ids = await redis.smembers(indexKey(scope)) if (ids.length === 0) return await redis.del(...ids.map(recordKey)) diff --git a/packages/typescript/ai-memory/tests/contract.ts b/packages/typescript/ai-memory/tests/contract.ts index 6a3539574..97dd7baa4 100644 --- a/packages/typescript/ai-memory/tests/contract.ts +++ b/packages/typescript/ai-memory/tests/contract.ts @@ -198,6 +198,33 @@ export function runMemoryAdapterContract( }) }) + 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('semantic vs lexical ranking', () => { it('lexical-only when no embeddings', async () => { await adapter.add(rec({ id: 'a', text: 'apple banana' })) diff --git a/packages/typescript/ai/src/memory/helpers.ts b/packages/typescript/ai/src/memory/helpers.ts index 187cc0a33..8225e9858 100644 --- a/packages/typescript/ai/src/memory/helpers.ts +++ b/packages/typescript/ai/src/memory/helpers.ts @@ -2,15 +2,30 @@ 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, 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' }`. + */ 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 + definedKeys++ if (recordScope[key] !== value) return false } + if (definedKeys === 0) return false return true } diff --git a/packages/typescript/ai/src/memory/middleware.ts b/packages/typescript/ai/src/memory/middleware.ts index 7d6f7bc99..4eccb2862 100644 --- a/packages/typescript/ai/src/memory/middleware.ts +++ b/packages/typescript/ai/src/memory/middleware.ts @@ -14,6 +14,21 @@ import type { 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 +} + +const stateByCtx = new WeakMap() + /** * Server-side memory middleware. See docs/middlewares/memory.md and the * tanstack-ai-memory skill for usage. @@ -21,22 +36,16 @@ import type { export function memoryMiddleware( options: MemoryMiddlewareOptions, ): ChatMiddleware { - // Per-request closure state. The chat engine creates one ChatMiddleware - // instance per chat() call (no cross-request leakage). - let resolvedScope: MemoryScope | undefined - let lastUserText = '' - let lastUserEmbedding: Array | undefined - let retrievedHits: Array = [] - async function resolveScope( ctx: ChatMiddlewareContext, + state: MemoryRequestState, ): Promise { - if (resolvedScope) return resolvedScope - resolvedScope = + if (state.resolvedScope) return state.resolvedScope + state.resolvedScope = typeof options.scope === 'function' ? await options.scope(ctx) : options.scope - return resolvedScope + return state.resolvedScope } return { @@ -45,15 +54,22 @@ export function memoryMiddleware( async onConfig(ctx, config) { if (ctx.phase !== 'init') return + // Allocate per-request state once at the init phase. + const state: MemoryRequestState = { + lastUserText: '', + retrievedHits: [], + } + stateByCtx.set(ctx, state) + const lastUser = findLastUserMessage(config.messages) - lastUserText = getMessageText(lastUser) - if (!lastUserText) return + state.lastUserText = getMessageText(lastUser) + if (!state.lastUserText) return - const scope = await resolveScope(ctx) + const scope = await resolveScope(ctx, state) if (options.shouldRetrieve) { const ok = await options.shouldRetrieve({ - userText: lastUserText, + userText: state.lastUserText, scope, }) if (!ok) return @@ -63,7 +79,7 @@ export function memoryMiddleware( try { safeEmit('memory:retrieve:started', { scope, - query: lastUserText, + query: state.lastUserText, topK: options.topK ?? 6, minScore: options.minScore ?? 0.15, embedderUsed: !!options.embedder, @@ -71,31 +87,33 @@ export function memoryMiddleware( }) await options.events?.onRetrieveStart?.({ scope, - query: lastUserText, + query: state.lastUserText, }) if (options.embedder) { - lastUserEmbedding = await options.embedder.embed(lastUserText) + state.lastUserEmbedding = await options.embedder.embed( + state.lastUserText, + ) } - retrievedHits = await searchAllPages( + state.retrievedHits = await searchAllPages( options, scope, - lastUserText, - lastUserEmbedding, + state.lastUserText, + state.lastUserEmbedding, ) - if (options.rerank && retrievedHits.length > 0) { - retrievedHits = await options.rerank(retrievedHits, { + if (options.rerank && state.retrievedHits.length > 0) { + state.retrievedHits = await options.rerank(state.retrievedHits, { scope, - query: lastUserText, + query: state.lastUserText, ctx, }) } safeEmit('memory:retrieve:completed', { scope, - hits: retrievedHits.map((h) => ({ + hits: state.retrievedHits.map((h) => ({ id: h.record.id, kind: h.record.kind, score: h.score, @@ -104,7 +122,10 @@ export function memoryMiddleware( durationMs: Date.now() - startedAt, timestamp: Date.now(), }) - await options.events?.onRetrieveEnd?.({ scope, hits: retrievedHits }) + await options.events?.onRetrieveEnd?.({ + scope, + hits: state.retrievedHits, + }) } catch (error) { safeEmit('memory:error', { scope, @@ -117,10 +138,11 @@ export function memoryMiddleware( return } - if (retrievedHits.length === 0) return + if (state.retrievedHits.length === 0) return const memoryPrompt = - options.render?.(retrievedHits) ?? defaultRenderMemory(retrievedHits) + options.render?.(state.retrievedHits) ?? + defaultRenderMemory(state.retrievedHits) return { systemPrompts: [...config.systemPrompts, memoryPrompt], @@ -129,7 +151,9 @@ export function memoryMiddleware( async onAfterToolCall(ctx, info) { if (!options.onToolResult || !info.ok) return - const scope = await resolveScope(ctx) + const state = stateByCtx.get(ctx) + if (!state) return + const scope = await resolveScope(ctx, state) try { let parsedArgs: unknown = {} try { @@ -149,25 +173,50 @@ export function memoryMiddleware( adapter: options.adapter, }) if (!out) return - ctx.defer(applyOps(options, scope, normalizeOps(out))) + // Wrap the deferred write so adapter.add/update/delete failures emit + // memory:error, fire events.onError, and (in strict mode) reject the + // deferred promise — instead of being silently swallowed. + ctx.defer( + deferredApplyOps(options, scope, normalizeOps(out)).then(() => {}), + ) } 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 (!lastUserText && !responseText) return - const scope = await resolveScope(ctx) + 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) + // 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: lastUserText, - userEmbedding: lastUserEmbedding, + userText, + userEmbedding, responseText, - retrievedMemoryIds: retrievedHits.map((h) => h.record.id), + retrievedMemoryIds, }), ) }, @@ -215,16 +264,25 @@ function normalizeOps(input: Array | Array): Array, ): Promise> { const newRecords: Array = [] - const adds: Array = [] for (const op of ops) { if (op.op === 'add') { - adds.push(op.record) + await options.adapter.add(op.record) newRecords.push(op.record) } else if (op.op === 'update') { await options.adapter.update(op.id, scope, op.patch) @@ -232,10 +290,38 @@ async function applyOps( await options.adapter.delete([op.id], scope) } } - if (adds.length > 0) await options.adapter.add(adds) return newRecords } +/** + * Wrap `applyOps` so a deferred write surfaces failures via the same + * devtools/events/strict-mode plumbing as the synchronous paths. + * + * Without this wrapper, a rejecting `ctx.defer(applyOps(...))` is collected + * by `Promise.allSettled` in the chat engine — silently swallowed, with no + * `memory:error` event and no `events.onError` call. That's a debuggability + * cliff for adapter outages (e.g. a Redis blip). + */ +async function deferredApplyOps( + options: MemoryMiddlewareOptions, + scope: MemoryScope, + ops: Array, +): Promise> { + try { + return 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 [] + } +} + async function persistTurn(args: { options: MemoryMiddlewareOptions scope: MemoryScope @@ -245,79 +331,80 @@ async function persistTurn(args: { retrievedMemoryIds: Array }): Promise { const { options, scope } = args - const now = Date.now() - const startedAt = now - 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) { - baseRecords.push({ - id: crypto.randomUUID(), - scope, - text: args.responseText, - kind: 'message', - role: 'assistant', - createdAt: now, - importance: 0.4, - embedding: options.embedder - ? await options.embedder.embed(args.responseText) - : undefined, - metadata: { retrievedMemoryIds: args.retrievedMemoryIds }, - }) - } - - // shouldRemember filter - const filtered: Array = [] - for (const record of baseRecords) { - if (!options.shouldRemember) { - filtered.push(record) - continue + // 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() + const startedAt = 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 keep = await options.shouldRemember({ - message: { role: record.role ?? 'assistant', content: record.text }, - responseText: args.responseText, - }) - if (keep) filtered.push(record) - } - // extractMemories ops - let ops: Array = filtered.map((record) => ({ - op: 'add' as const, - record, - })) - if (options.extractMemories) { - try { - const extras = await options.extractMemories({ - userText: args.userText, - responseText: args.responseText, + const baseRecords: Array = [] + if (args.userText) { + baseRecords.push({ + id: crypto.randomUUID(), scope, - adapter: options.adapter, + text: args.userText, + kind: 'message', + role: 'user', + createdAt: now, + importance: 0.4, + embedding: args.userEmbedding, }) - if (extras) ops = ops.concat(normalizeOps(extras)) - } catch (error) { - safeEmit('memory:error', { + } + if (args.responseText) { + baseRecords.push({ + id: crypto.randomUUID(), scope, - phase: 'extract', - error: errorInfo(error), - timestamp: Date.now(), + text: args.responseText, + kind: 'message', + role: 'assistant', + createdAt: now, + importance: 0.4, + embedding: options.embedder + ? await options.embedder.embed(args.responseText) + : undefined, + metadata: { retrievedMemoryIds: args.retrievedMemoryIds }, }) - await emitError(options, scope, 'extract', error) - if (options.strict) throw error } - } - try { + let ops: Array = baseRecords.map((record) => ({ + op: 'add' as const, + record, + })) + + 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) { + safeEmit('memory:error', { + scope, + phase: 'extract', + error: errorInfo(error), + timestamp: Date.now(), + }) + await emitError(options, scope, 'extract', error) + if (options.strict) throw error + } + } + safeEmit('memory:persist:started', { scope, records: ops @@ -339,7 +426,9 @@ async function persistTurn(args: { .filter((o) => o.op === 'add') .map((o) => (o).record), }) + const newRecords = await applyOps(options, scope, ops) + safeEmit('memory:persist:completed', { scope, recordIds: newRecords.map((r) => r.id), diff --git a/packages/typescript/ai/src/memory/types.ts b/packages/typescript/ai/src/memory/types.ts index b5a72a37d..34932d479 100644 --- a/packages/typescript/ai/src/memory/types.ts +++ b/packages/typescript/ai/src/memory/types.ts @@ -411,8 +411,13 @@ export interface MemoryMiddlewareOptions { /** * Write-side gate: decide whether a given turn should produce memories at - * all. Returning `false` short-circuits `extractMemories` and the persist - * path for the current turn. + * 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 } diff --git a/packages/typescript/ai/tests/memory/helpers.test.ts b/packages/typescript/ai/tests/memory/helpers.test.ts index f10d24f8b..96b0285cf 100644 --- a/packages/typescript/ai/tests/memory/helpers.test.ts +++ b/packages/typescript/ai/tests/memory/helpers.test.ts @@ -11,8 +11,16 @@ import { import type { MemoryRecord } from '../../src/memory/types' describe('scopeMatches', () => { - it('matches when query keys are absent', () => { - expect(scopeMatches({ tenantId: 'a' }, {})).toBe(true) + 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( diff --git a/packages/typescript/ai/tests/middlewares/memory.test.ts b/packages/typescript/ai/tests/middlewares/memory.test.ts index 44393ee54..755607644 100644 --- a/packages/typescript/ai/tests/middlewares/memory.test.ts +++ b/packages/typescript/ai/tests/middlewares/memory.test.ts @@ -250,7 +250,12 @@ describe('memoryMiddleware — persistence', () => { expect(texts).toEqual(['Ping', 'Pong.']) }) - it('drops records rejected by shouldRemember', async () => { + 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: [ @@ -261,6 +266,9 @@ describe('memoryMiddleware — persistence', () => { ], ], }) + const extractMemories = vi.fn(async () => [ + rec({ text: 'should not run', kind: 'fact' }), + ]) const stream = chat({ adapter, messages: [{ role: 'user', content: 'hi' }], @@ -269,12 +277,41 @@ describe('memoryMiddleware — persistence', () => { 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) - expect(texts).toEqual(['long enough response text']) + 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 () => { @@ -333,6 +370,35 @@ describe('memoryMiddleware — persistence', () => { ) }) + 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('afterPersist receives newly-added records (not updates/deletes)', async () => { const memory = fakeAdapter() const { adapter } = createMockAdapter({ From 6576f7c0e4633adc22968273261013f5e0bdffe3 Mon Sep 17 00:00:00 2001 From: "autofix-ci[bot]" <114827586+autofix-ci[bot]@users.noreply.github.com> Date: Sun, 10 May 2026 18:06:40 +0000 Subject: [PATCH 22/36] ci: apply automated fixes --- packages/typescript/ai/src/memory/middleware.ts | 10 +++++----- packages/typescript/ai/tests/memory/helpers.test.ts | 5 ++++- .../typescript/ai/tests/middlewares/memory.test.ts | 10 ++++++++-- 3 files changed, 17 insertions(+), 8 deletions(-) diff --git a/packages/typescript/ai/src/memory/middleware.ts b/packages/typescript/ai/src/memory/middleware.ts index 4eccb2862..3e658abe6 100644 --- a/packages/typescript/ai/src/memory/middleware.ts +++ b/packages/typescript/ai/src/memory/middleware.ts @@ -254,7 +254,9 @@ async function searchAllPages( return all.slice(0, topK) } -function normalizeOps(input: Array | Array): Array { +function normalizeOps( + input: Array | Array, +): Array { if (input.length === 0) return [] const first = input[0] if (first && 'op' in first) return input as Array @@ -410,7 +412,7 @@ async function persistTurn(args: { records: ops .filter((o) => o.op === 'add') .map((o) => { - const r = (o).record + const r = o.record return { id: r.id, kind: r.kind, @@ -422,9 +424,7 @@ async function persistTurn(args: { }) await options.events?.onPersistStart?.({ scope, - records: ops - .filter((o) => o.op === 'add') - .map((o) => (o).record), + records: ops.filter((o) => o.op === 'add').map((o) => o.record), }) const newRecords = await applyOps(options, scope, ops) diff --git a/packages/typescript/ai/tests/memory/helpers.test.ts b/packages/typescript/ai/tests/memory/helpers.test.ts index 96b0285cf..a01d68353 100644 --- a/packages/typescript/ai/tests/memory/helpers.test.ts +++ b/packages/typescript/ai/tests/memory/helpers.test.ts @@ -19,7 +19,10 @@ describe('scopeMatches', () => { }) it('rejects query scope with only nullish values', () => { expect( - scopeMatches({ tenantId: 'a' }, { tenantId: undefined, userId: undefined }), + scopeMatches( + { tenantId: 'a' }, + { tenantId: undefined, userId: undefined }, + ), ).toBe(false) }) it('matches when all query keys are equal', () => { diff --git a/packages/typescript/ai/tests/middlewares/memory.test.ts b/packages/typescript/ai/tests/middlewares/memory.test.ts index 755607644..cd759d713 100644 --- a/packages/typescript/ai/tests/middlewares/memory.test.ts +++ b/packages/typescript/ai/tests/middlewares/memory.test.ts @@ -311,7 +311,10 @@ describe('memoryMiddleware — persistence', () => { }) 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']) + expect(texts).toEqual([ + 'a meaningful user message', + 'long enough response text', + ]) }) it('extractMemories returning records adds them as kind: fact', async () => { @@ -389,7 +392,10 @@ describe('memoryMiddleware — persistence', () => { adapter: memory, scope: baseScope, extractMemories: () => [ - { op: 'add', record: rec({ id: 'X', text: 'initial', kind: 'fact' }) }, + { + op: 'add', + record: rec({ id: 'X', text: 'initial', kind: 'fact' }), + }, { op: 'update', id: 'X', patch: { text: 'patched' } }, ], }), From 54bec7170a71c607925911cf6ce4da9bf212e322 Mon Sep 17 00:00:00 2001 From: Alem Tuzlak Date: Sun, 10 May 2026 20:22:51 +0200 Subject: [PATCH 23/36] fix(ai): address CR Round 1 core middleware findings - getMessageText reads ContentPart.content (not .text); structured user messages now feed retrieval and persistence correctly - extractMemories strict-mode failure no longer double-emits memory:error or drops base user/assistant records - defaultScoreHit threads its 'now' parameter through to recencyScore so callers can score deterministically - Default importance contribution drops from 0.5 to 0 so a recent record with zero lexical/semantic match no longer clears the default minScore=0.15 floor - MemoryAdapter.clear/search/list JSDoc fixed: empty scope matches NOTHING (matches scopeMatches and the contract suite) --- packages/typescript/ai/src/memory/helpers.ts | 40 ++++++++- .../typescript/ai/src/memory/middleware.ts | 62 ++++++++++---- packages/typescript/ai/src/memory/types.ts | 41 ++++++++-- .../ai/tests/memory/helpers.test.ts | 58 +++++++++++++ .../ai/tests/middlewares/memory.test.ts | 81 +++++++++++++++++++ 5 files changed, 259 insertions(+), 23 deletions(-) diff --git a/packages/typescript/ai/src/memory/helpers.ts b/packages/typescript/ai/src/memory/helpers.ts index 8225e9858..03e28d35c 100644 --- a/packages/typescript/ai/src/memory/helpers.ts +++ b/packages/typescript/ai/src/memory/helpers.ts @@ -56,11 +56,21 @@ export function lexicalOverlap(query: string, text: string): number { 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, Date.now() - createdAt) + const age = Math.max(0, now - createdAt) return Math.pow(0.5, age / halfLifeMs) } @@ -71,16 +81,38 @@ export function isExpired( 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 } = args + const { record, query, now } = args const semantic = cosine(query.embedding, record.embedding) const lexical = lexicalOverlap(query.text, record.text) - const recency = recencyScore(record.createdAt) - const importance = record.importance ?? 0.5 + 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 } diff --git a/packages/typescript/ai/src/memory/middleware.ts b/packages/typescript/ai/src/memory/middleware.ts index 3e658abe6..b6407fec4 100644 --- a/packages/typescript/ai/src/memory/middleware.ts +++ b/packages/typescript/ai/src/memory/middleware.ts @@ -333,6 +333,11 @@ async function persistTurn(args: { retrievedMemoryIds: 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. @@ -386,6 +391,18 @@ async function persistTurn(args: { 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({ @@ -396,6 +413,8 @@ async function persistTurn(args: { }) if (extras) ops = ops.concat(normalizeOps(extras)) } catch (error) { + extractFailed = true + extractError = error safeEmit('memory:error', { scope, phase: 'extract', @@ -403,7 +422,8 @@ async function persistTurn(args: { timestamp: Date.now(), }) await emitError(options, scope, 'extract', error) - if (options.strict) throw error + // Intentionally NOT re-throwing here — see note (2)/(3) above. The + // re-throw happens after `applyOps` so base records still persist. } } @@ -443,14 +463,27 @@ async function persistTurn(args: { adapter: options.adapter, }) } + + // Strict-mode extract failure: base records have now been committed via + // `applyOps`. 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) { - safeEmit('memory:error', { - scope, - phase: 'persist', - error: errorInfo(error), - timestamp: Date.now(), - }) - await emitError(options, scope, 'persist', error) + // Skip re-emit/re-callback when the error is the strict-mode extract + // re-throw we just performed — `memory:error` (phase: 'extract') already + // fired in the inner catch above. Emitting again here would produce a + // duplicate event with the wrong phase ('persist') for one failure. + if (!(extractFailed && error === extractError)) { + safeEmit('memory:error', { + scope, + phase: 'persist', + error: errorInfo(error), + timestamp: Date.now(), + }) + await emitError(options, scope, 'persist', error) + } if (options.strict) throw error } } @@ -518,15 +551,16 @@ 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 ( - typeof part === 'object' && - 'text' in part && - typeof part.text === 'string' - ) { - return part.text + if (part.type === 'text' && typeof part.content === 'string') { + return part.content } return '' }) diff --git a/packages/typescript/ai/src/memory/types.ts b/packages/typescript/ai/src/memory/types.ts index 34932d479..b8b22cd87 100644 --- a/packages/typescript/ai/src/memory/types.ts +++ b/packages/typescript/ai/src/memory/types.ts @@ -117,6 +117,12 @@ export type MemoryRecord = { * 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 /** @@ -285,6 +291,11 @@ export interface MemoryAdapter { * 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 @@ -294,6 +305,10 @@ export interface MemoryAdapter { * 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, @@ -314,11 +329,16 @@ export interface MemoryAdapter { * * 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 everything by definition, but adapters MUST NOT treat - * `clear({})` as a casual "wipe the database" operation. Implementations - * SHOULD either reject empty-scope `clear` outright or guard it behind an - * explicit safety check; treating it as a silent global wipe is considered - * misuse. + * 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 } @@ -454,6 +474,17 @@ export interface MemoryMiddlewareOptions { * 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'`). */ extractMemories?: (args: { userText: string diff --git a/packages/typescript/ai/tests/memory/helpers.test.ts b/packages/typescript/ai/tests/memory/helpers.test.ts index a01d68353..3ec39baa8 100644 --- a/packages/typescript/ai/tests/memory/helpers.test.ts +++ b/packages/typescript/ai/tests/memory/helpers.test.ts @@ -121,4 +121,62 @@ describe('defaultScoreHit', () => { 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 index cd759d713..24f269272 100644 --- a/packages/typescript/ai/tests/middlewares/memory.test.ts +++ b/packages/typescript/ai/tests/middlewares/memory.test.ts @@ -214,6 +214,36 @@ describe('memoryMiddleware — retrieval', () => { 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({ @@ -512,6 +542,57 @@ describe('memoryMiddleware — failure handling', () => { 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', () => { From 2c3588cc79e979510f6e5df17f49b77e6456bbd4 Mon Sep 17 00:00:00 2001 From: Alem Tuzlak Date: Sun, 10 May 2026 20:27:55 +0200 Subject: [PATCH 24/36] fix(ai-memory): redis adapter scope semantics - search/list/clear with a partial scope now traverse all matching index buckets via SCAN instead of just the exact-match bucket - delete srem now keys off record scope (not caller scope) so ids in narrower index buckets are properly cleaned up - add upsert removes the id from the old scope's index when the scope of an existing record changes - skill troubleshooting drops the false SerializationError claim; malformed rows now log once per process via console.warn - Contract suite gains 5 partial-scope tests covering search, list, clear, delete, and upsert; in-memory and redis must both pass them --- .../skills/tanstack-ai-memory-redis/SKILL.md | 2 +- .../ai-memory/src/adapters/redis.ts | 170 ++++++++++++++++-- .../typescript/ai-memory/tests/contract.ts | 69 +++++++ 3 files changed, 225 insertions(+), 16 deletions(-) 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 index 889b1aba2..b3997c650 100644 --- a/packages/typescript/ai-memory/skills/tanstack-ai-memory-redis/SKILL.md +++ b/packages/typescript/ai-memory/skills/tanstack-ai-memory-redis/SKILL.md @@ -49,4 +49,4 @@ For larger scopes use a vector-index-aware adapter. None ships in v1; write one - **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. -- **`SerializationError` on read:** the JSON in `{prefix}:record:{id}` is malformed — likely from an older schema or a third-party writer. The adapter skips malformed rows but you'll want to clean them up via `clear(scope)`. +- **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/redis.ts b/packages/typescript/ai-memory/src/adapters/redis.ts index 13ef7b01e..d5a39f76c 100644 --- a/packages/typescript/ai-memory/src/adapters/redis.ts +++ b/packages/typescript/ai-memory/src/adapters/redis.ts @@ -14,6 +14,12 @@ import type { * Minimal subset of the Redis client API this adapter uses. * Compatible with both `redis` (node-redis v4+) and `ioredis` shapes. * Real users pass an instance of either. + * + * NOTE: `scan` follows the lowercase variadic form used by ioredis and + * node-redis legacyMode: `scan(cursor, 'MATCH', pattern, 'COUNT', n)` + * returning `[nextCursor, matchedKeys]`. node-redis v4+'s default + * camelCase shape (`scan(cursor, { MATCH, COUNT })`) is not handled + * here — Group C will address camelCase compatibility separately. */ export interface RedisLike { set: (key: string, value: string) => Promise @@ -23,6 +29,10 @@ export interface RedisLike { 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 { @@ -46,6 +56,19 @@ function hasAnyScopeKey(scope: MemoryScope): boolean { 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 { @@ -62,46 +85,144 @@ export function redisMemoryAdapter( 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. + * + * Assumption: scope values are app-supplied strings that don't contain + * Redis glob metacharacters (`*`, `?`, `[`). The practical risk is low; + * we don't escape here. Group C may revisit if a real bug surfaces. + */ + async function findIndexKeysForScope( + scope: MemoryScope, + ): Promise> { + if (!hasAnyScopeKey(scope)) return [] + const pattern = `${prefix}:index:${SCOPE_KEYS.map((k) => + scope[k] != null ? String(scope[k]) : '*', + ).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 { + } 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> { - const ids = await redis.smembers(indexKey(scope)) - if (ids.length === 0) return [] + 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 = [] - const expired: 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) { - expired.push(id) + markExpired(id) continue } try { const r = JSON.parse(raw) as MemoryRecord if (isExpired(r)) { - expired.push(r.id) + markExpired(r.id) continue } if (!scopeMatches(r.scope, scope)) continue out.push(r) - } catch { + } catch (err) { + warnMalformedRowOnce(id, err) /* skip malformed */ } } - if (expired.length > 0) { - await redis.srem(indexKey(scope), ...expired) - await redis.del(...expired.map(recordKey)) + 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 } @@ -113,6 +234,14 @@ export function redisMemoryAdapter( 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) @@ -210,7 +339,11 @@ export function redisMemoryAdapter( if (!r) continue if (!scopeMatches(r.scope, scope)) continue await redis.del(recordKey(id)) - await redis.srem(indexKey(scope), 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) } }, @@ -221,10 +354,17 @@ export function redisMemoryAdapter( // wipe (the index key for an all-blank scope would otherwise enumerate // a real bucket of records). if (!hasAnyScopeKey(scope)) return - const ids = await redis.smembers(indexKey(scope)) - if (ids.length === 0) return - await redis.del(...ids.map(recordKey)) - await redis.del(indexKey(scope)) + 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/tests/contract.ts b/packages/typescript/ai-memory/tests/contract.ts index 97dd7baa4..cf5fe07cc 100644 --- a/packages/typescript/ai-memory/tests/contract.ts +++ b/packages/typescript/ai-memory/tests/contract.ts @@ -225,6 +225,75 @@ export function runMemoryAdapterContract( }) }) + 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('semantic vs lexical ranking', () => { it('lexical-only when no embeddings', async () => { await adapter.add(rec({ id: 'a', text: 'apple banana' })) From 5600b3bcefa6f394f1df981723cc9447f61985f8 Mon Sep 17 00:00:00 2001 From: Alem Tuzlak Date: Sun, 10 May 2026 20:33:48 +0200 Subject: [PATCH 25/36] feat(ai-memory): nodeRedisAsRedisLike helper for node-redis v4+ The RedisLike interface is lowercase to match ioredis directly. node-redis v4+ uses camelCase by default, which previously required users to enable legacyMode to wire it up. The new nodeRedisAsRedisLike(client) helper translates camelCase to the RedisLike shape so users can wire node-redis v4+ default-mode clients with a one-line wrapper. Skill and quickstart docs updated with separate ioredis vs node-redis wiring examples. ioredis added as a parallel optional peer dep alongside redis. New unit test for the helper. --- .changeset/memory-middleware.md | 3 +- docs/guides/memory-quickstart.md | 2 + knip.json | 2 +- packages/typescript/ai-memory/package.json | 4 + .../skills/tanstack-ai-memory-redis/SKILL.md | 44 +++++++-- .../ai-memory/src/adapters/redis.ts | 86 ++++++++++++++++-- packages/typescript/ai-memory/src/index.ts | 2 + .../typescript/ai-memory/tests/redis.test.ts | 89 ++++++++++++++++++- 8 files changed, 214 insertions(+), 18 deletions(-) diff --git a/.changeset/memory-middleware.md b/.changeset/memory-middleware.md index e5b8b5328..6598bfa11 100644 --- a/.changeset/memory-middleware.md +++ b/.changeset/memory-middleware.md @@ -20,5 +20,6 @@ A new `memoryMiddleware` from `@tanstack/ai/memory` retrieves relevant memories `@tanstack/ai-memory` (new package): - `inMemoryMemoryAdapter()` — zero-dep adapter for dev/tests. -- `redisMemoryAdapter({ redis, prefix? })` — production adapter for plain Redis (`redis` listed as optional peer dependency). +- `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/guides/memory-quickstart.md b/docs/guides/memory-quickstart.md index d5ddd5435..d75c6ca69 100644 --- a/docs/guides/memory-quickstart.md +++ b/docs/guides/memory-quickstart.md @@ -72,6 +72,8 @@ 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 [Redis adapter skill](https://github.com/TanStack/ai) 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:** diff --git a/knip.json b/knip.json index 347730acd..b8b928395 100644 --- a/knip.json +++ b/knip.json @@ -38,7 +38,7 @@ "ignoreDependencies": ["@standard-schema/spec"] }, "packages/typescript/ai-memory": { - "ignoreDependencies": ["redis"] + "ignoreDependencies": ["ioredis", "redis"] }, "packages/typescript/ai-react-ui": { "ignoreDependencies": ["react-dom"] diff --git a/packages/typescript/ai-memory/package.json b/packages/typescript/ai-memory/package.json index 65a62d5e0..a523a6802 100644 --- a/packages/typescript/ai-memory/package.json +++ b/packages/typescript/ai-memory/package.json @@ -43,9 +43,13 @@ ], "peerDependencies": { "@tanstack/ai": "workspace:^", + "ioredis": ">=5.0.0", "redis": ">=4.0.0" }, "peerDependenciesMeta": { + "ioredis": { + "optional": true + }, "redis": { "optional": true } 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 index b3997c650..fe31b12c2 100644 --- a/packages/typescript/ai-memory/skills/tanstack-ai-memory-redis/SKILL.md +++ b/packages/typescript/ai-memory/skills/tanstack-ai-memory-redis/SKILL.md @@ -9,26 +9,56 @@ 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 redis # or: pnpm add ioredis +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 }) ``` -Pass the connected client into the adapter: +`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 } from '@tanstack/ai-memory' +import { redisMemoryAdapter, nodeRedisAsRedisLike } from '@tanstack/ai-memory' -const redis = createClient({ url: process.env.REDIS_URL }) -await redis.connect() +const client = createClient({ url: process.env.REDIS_URL }) +await client.connect() -const memory = redisMemoryAdapter({ redis, prefix: 'myapp:memory' }) +const memory = redisMemoryAdapter({ + redis: nodeRedisAsRedisLike(client), + prefix: 'myapp:memory', +}) memoryMiddleware({ adapter: memory, scope }) ``` -The adapter accepts any client implementing the `RedisLike` shape (a small subset: `get`, `set`, `del`, `sadd`, `srem`, `smembers`, `mget`). Both `redis` (node-redis v4+) and `ioredis` work. +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 diff --git a/packages/typescript/ai-memory/src/adapters/redis.ts b/packages/typescript/ai-memory/src/adapters/redis.ts index d5a39f76c..ebf55c517 100644 --- a/packages/typescript/ai-memory/src/adapters/redis.ts +++ b/packages/typescript/ai-memory/src/adapters/redis.ts @@ -11,15 +11,15 @@ import type { } from '@tanstack/ai/memory' /** - * Minimal subset of the Redis client API this adapter uses. - * Compatible with both `redis` (node-redis v4+) and `ioredis` shapes. - * Real users pass an instance of either. + * 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]`. * - * NOTE: `scan` follows the lowercase variadic form used by ioredis and - * node-redis legacyMode: `scan(cursor, 'MATCH', pattern, 'COUNT', n)` - * returning `[nextCursor, matchedKeys]`. node-redis v4+'s default - * camelCase shape (`scan(cursor, { MATCH, COUNT })`) is not handled - * here — Group C will address camelCase compatibility separately. + * 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 @@ -41,6 +41,76 @@ export interface RedisMemoryAdapterOptions { 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> + scan: ( + cursor: number, + options?: { MATCH?: string; COUNT?: number }, + ) => Promise<{ cursor: number; 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'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 = 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) + if (!Number.isNaN(n)) count = n + } + } + const numericCursor = + typeof cursor === 'number' ? cursor : Number(cursor) || 0 + const result = await client.scan(numericCursor, { + ...(match !== undefined ? { MATCH: match } : {}), + ...(count !== undefined ? { COUNT: count } : {}), + }) + return [String(result.cursor), result.keys] + }, + } +} + const SCOPE_KEYS = [ 'tenantId', 'userId', diff --git a/packages/typescript/ai-memory/src/index.ts b/packages/typescript/ai-memory/src/index.ts index 3fd33318f..4c2a4945c 100644 --- a/packages/typescript/ai-memory/src/index.ts +++ b/packages/typescript/ai-memory/src/index.ts @@ -2,8 +2,10 @@ export { inMemoryMemoryAdapter } from './adapters/in-memory' export { redisMemoryAdapter, + nodeRedisAsRedisLike, type RedisMemoryAdapterOptions, type RedisLike, + type NodeRedisLike, } from './adapters/redis' export type { diff --git a/packages/typescript/ai-memory/tests/redis.test.ts b/packages/typescript/ai-memory/tests/redis.test.ts index 2fbf65242..0f06fca8d 100644 --- a/packages/typescript/ai-memory/tests/redis.test.ts +++ b/packages/typescript/ai-memory/tests/redis.test.ts @@ -2,8 +2,9 @@ // 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 { redisMemoryAdapter } from '../src/adapters/redis' +import { nodeRedisAsRedisLike, redisMemoryAdapter } from '../src/adapters/redis' runMemoryAdapterContract('redisMemoryAdapter', async () => { const client = new RedisMock() @@ -12,3 +13,89 @@ runMemoryAdapterContract('redisMemoryAdapter', async () => { 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, + 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') + + 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']], + }) + expect(calls.find((c) => c.method === 'scan')).toMatchObject({ + args: [0, { MATCH: 'pattern:*', COUNT: 50 }], + }) + 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', []]) + }) +}) From 2eb1425f96ea57a778e7ede20626b4d234c3859d Mon Sep 17 00:00:00 2001 From: Alem Tuzlak Date: Sun, 10 May 2026 20:36:56 +0200 Subject: [PATCH 26/36] test(ai, ai-memory): tighten flaky and vacuous CR assertions - recencyScore half-life test passes 'now' explicitly so it does not race the internal Date.now() call (Group A added the param) - Pagination contract test now asserts every record is visible across pages (catches adapters that drop or duplicate) - Upsert contract test now verifies updatedAt strictly advances on the second add, not merely that updatedAt >= createdAt - Tightened 'every(...)' assertions in the contract suite with preceding non-empty length checks so they cannot silently pass on an empty result set --- .../typescript/ai-memory/tests/contract.ts | 38 ++++++++++++++++--- .../ai/tests/memory/helpers.test.ts | 5 ++- 2 files changed, 36 insertions(+), 7 deletions(-) diff --git a/packages/typescript/ai-memory/tests/contract.ts b/packages/typescript/ai-memory/tests/contract.ts index cf5fe07cc..1ba464a86 100644 --- a/packages/typescript/ai-memory/tests/contract.ts +++ b/packages/typescript/ai-memory/tests/contract.ts @@ -48,10 +48,24 @@ export function runMemoryAdapterContract( 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 got = await adapter.get('x', scopeA) - expect(got?.text).toBe('second') - expect(got?.updatedAt).toBeGreaterThanOrEqual(got!.createdAt) + 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!) }) }) @@ -107,6 +121,9 @@ export function runMemoryAdapterContract( 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) }) @@ -118,6 +135,8 @@ export function runMemoryAdapterContract( 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) }) @@ -150,8 +169,13 @@ export function runMemoryAdapterContract( pages++ if (pages > 10) throw new Error('cursor did not terminate') } while (cursor) - // Either single page if adapter returns everything, or multi-page if it streams. - expect(seen.size).toBeGreaterThan(0) + // 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) }) }) @@ -160,6 +184,8 @@ export function runMemoryAdapterContract( 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 () => { @@ -171,6 +197,8 @@ export function runMemoryAdapterContract( 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) }) }) diff --git a/packages/typescript/ai/tests/memory/helpers.test.ts b/packages/typescript/ai/tests/memory/helpers.test.ts index 3ec39baa8..d389687c5 100644 --- a/packages/typescript/ai/tests/memory/helpers.test.ts +++ b/packages/typescript/ai/tests/memory/helpers.test.ts @@ -63,8 +63,9 @@ describe('recencyScore', () => { }) it('halves at one half-life', () => { const halfLife = 1000 - const t = Date.now() - halfLife - expect(recencyScore(t, halfLife)).toBeCloseTo(0.5, 2) + const now = Date.now() + const t = now - halfLife + expect(recencyScore(t, halfLife, now)).toBeCloseTo(0.5, 5) }) }) From ac100b8323284d77a81f82561daad44880734c62 Mon Sep 17 00:00:00 2001 From: Alem Tuzlak Date: Sun, 10 May 2026 20:38:28 +0200 Subject: [PATCH 27/36] chore(ai-memory): set initial version to 0.0.0 for first publish Changesets bumps from the package.json version, so a minor entry on 0.1.0 would publish 0.2.0 (skipping 0.1.0). Setting the baseline to 0.0.0 makes the same minor changeset land at 0.1.0 on first release. --- packages/typescript/ai-memory/package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/typescript/ai-memory/package.json b/packages/typescript/ai-memory/package.json index a523a6802..689984c63 100644 --- a/packages/typescript/ai-memory/package.json +++ b/packages/typescript/ai-memory/package.json @@ -1,6 +1,6 @@ { "name": "@tanstack/ai-memory", - "version": "0.1.0", + "version": "0.0.0", "description": "Pluggable memory adapters for TanStack AI memoryMiddleware", "author": "", "license": "MIT", From 9fcb483b5e7fbe2eb45a93fb9c736a17119d3529 Mon Sep 17 00:00:00 2001 From: Alem Tuzlak Date: Sun, 10 May 2026 20:56:33 +0200 Subject: [PATCH 28/36] fix(ai, ai-memory): address CR Round 2 bucket-a findings - Redis SCAN MATCH patterns now escape glob metacharacters in scope values (*, ?, [, ], \) so a scope like tenantId='t*' cannot cross- match other tenants' buckets - onToolResult deferred persistence now flows through the same observability pipeline as finish-turn persist: emits memory:persist:started/completed, fires events.onPersistStart/End, and calls afterPersist with the newly-added tool-result records - Skill and docs examples replaced with self-contained snippets so copy-pasters don't hit ReferenceError on undeclared 'body' or an infinite-recursion 'embed' shadow - nodeRedisAsRedisLike scan now passes cursor through unchanged (no Number() coercion) so node-redis v5 string cursors past MAX_SAFE_INTEGER round-trip correctly; COUNT now validated >0 --- docs/guides/memory-quickstart.md | 8 +- .../ai-memory/src/adapters/redis.ts | 56 ++++-- .../typescript/ai-memory/tests/contract.ts | 29 +++ .../typescript/ai-memory/tests/redis.test.ts | 27 ++- .../ai/skills/tanstack-ai-memory/SKILL.md | 45 +++-- .../typescript/ai/src/memory/middleware.ts | 173 +++++++++++------- .../ai/tests/middlewares/memory.test.ts | 86 +++++++++ 7 files changed, 325 insertions(+), 99 deletions(-) diff --git a/docs/guides/memory-quickstart.md b/docs/guides/memory-quickstart.md index d75c6ca69..d18bf5a10 100644 --- a/docs/guides/memory-quickstart.md +++ b/docs/guides/memory-quickstart.md @@ -82,15 +82,21 @@ The middleware accepts an `embedder` for semantic search. **Add one when you nee - **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 embeddings.create({ input: text }) + const result = await openai.embeddings.create({ + model: 'text-embedding-3-small', + input: text, + }) return result.data[0].embedding }, }, diff --git a/packages/typescript/ai-memory/src/adapters/redis.ts b/packages/typescript/ai-memory/src/adapters/redis.ts index ebf55c517..445bf089f 100644 --- a/packages/typescript/ai-memory/src/adapters/redis.ts +++ b/packages/typescript/ai-memory/src/adapters/redis.ts @@ -54,10 +54,17 @@ export interface NodeRedisLike { 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, + cursor: number | string, options?: { MATCH?: string; COUNT?: number }, - ) => Promise<{ cursor: number; keys: Array }> + ) => Promise<{ cursor: number | string; keys: Array }> } /** @@ -86,23 +93,29 @@ export function nodeRedisAsRedisLike(client: NodeRedisLike): RedisLike { mget: (...keys) => client.mGet(keys), scan: async (cursor, ...args) => { // Translate variadic (cursor, 'MATCH', pattern, 'COUNT', count) into - // node-redis v4's options-object form. Pairs are read positionally; + // 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 = args[i]?.toUpperCase() + 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) - if (!Number.isNaN(n)) count = n + // 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 } } - const numericCursor = - typeof cursor === 'number' ? cursor : Number(cursor) || 0 - const result = await client.scan(numericCursor, { + // 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 } : {}), }) @@ -111,6 +124,18 @@ export function nodeRedisAsRedisLike(client: NodeRedisLike): RedisLike { } } +/** + * 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, '\\$&') +} + const SCOPE_KEYS = [ 'tenantId', 'userId', @@ -182,17 +207,20 @@ export function redisMemoryAdapter( * empty-scope semantics in `scopeMatches`, an empty scope matches * nothing and so resolves to zero buckets. * - * Assumption: scope values are app-supplied strings that don't contain - * Redis glob metacharacters (`*`, `?`, `[`). The practical risk is low; - * we don't escape here. Group C may revisit if a real bug surfaces. + * Glob metacharacters are escaped before being passed to SCAN MATCH so + * that scope values containing `*`, `?`, `[`, `]`, or `\` cannot + * cross-match other tenants' index buckets. Only literal scope values + * are escaped — the `*` we substitute for unset scope keys is left + * unescaped because it is the wildcard we actually want. */ async function findIndexKeysForScope( scope: MemoryScope, ): Promise> { if (!hasAnyScopeKey(scope)) return [] - const pattern = `${prefix}:index:${SCOPE_KEYS.map((k) => - scope[k] != null ? String(scope[k]) : '*', - ).join(':')}` + const pattern = `${prefix}:index:${SCOPE_KEYS.map((k) => { + const v = scope[k] + return v != null ? escapeGlob(String(v)) : '*' + }).join(':')}` const seen = new Set() let cursor = '0' do { diff --git a/packages/typescript/ai-memory/tests/contract.ts b/packages/typescript/ai-memory/tests/contract.ts index 1ba464a86..87e341828 100644 --- a/packages/typescript/ai-memory/tests/contract.ts +++ b/packages/typescript/ai-memory/tests/contract.ts @@ -322,6 +322,35 @@ export function runMemoryAdapterContract( }) }) + 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() + }) + }) + describe('semantic vs lexical ranking', () => { it('lexical-only when no embeddings', async () => { await adapter.add(rec({ id: 'a', text: 'apple banana' })) diff --git a/packages/typescript/ai-memory/tests/redis.test.ts b/packages/typescript/ai-memory/tests/redis.test.ts index 0f06fca8d..c0a8aa896 100644 --- a/packages/typescript/ai-memory/tests/redis.test.ts +++ b/packages/typescript/ai-memory/tests/redis.test.ts @@ -47,7 +47,7 @@ describe('nodeRedisAsRedisLike', () => { return [] }, scan: async ( - cursor: number, + cursor: number | string, opts?: { MATCH?: string; COUNT?: number }, ) => { calls.push({ method: 'scan', args: [cursor, opts] }) @@ -70,6 +70,16 @@ describe('nodeRedisAsRedisLike', () => { ) 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'], }) @@ -87,9 +97,20 @@ describe('nodeRedisAsRedisLike', () => { expect(calls.find((c) => c.method === 'mGet')).toMatchObject({ args: [['k1', 'k2']], }) - expect(calls.find((c) => c.method === 'scan')).toMatchObject({ - args: [0, { MATCH: 'pattern:*', COUNT: 50 }], + 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']], }) diff --git a/packages/typescript/ai/skills/tanstack-ai-memory/SKILL.md b/packages/typescript/ai/skills/tanstack-ai-memory/SKILL.md index 41b7681d4..df5c1ab90 100644 --- a/packages/typescript/ai/skills/tanstack-ai-memory/SKILL.md +++ b/packages/typescript/ai/skills/tanstack-ai-memory/SKILL.md @@ -25,27 +25,43 @@ 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: ({ context }) => { - // Server-validated session data — NOT request body. - const session = ( - context as { session: { tenantId: string; userId: string } } - ).session + scope: (ctx) => { + const { session } = ctx.context as AppCtx return { tenantId: session.tenantId, userId: session.userId, - threadId: body.threadId, + threadId: session.activeThreadId, } }, // Optional: provide an embedder for semantic search. embedder: { async embed(text) { - return embed(text) + return myEmbeddings.embed(text) }, }, }), @@ -58,14 +74,17 @@ const stream = chat({ Scope is the isolation boundary. **Never trust client-supplied tenantId/userId.** Resolve scope server-side from session/auth: ```ts -scope: ({ context }) => ({ - tenantId: requireSession(context).tenantId, // throws if missing - userId: requireSession(context).userId, - threadId: body.threadId, // OK to take from request — validate it belongs to userId -}) +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 } })`. +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 diff --git a/packages/typescript/ai/src/memory/middleware.ts b/packages/typescript/ai/src/memory/middleware.ts index b6407fec4..dff50c1c5 100644 --- a/packages/typescript/ai/src/memory/middleware.ts +++ b/packages/typescript/ai/src/memory/middleware.ts @@ -173,12 +173,13 @@ export function memoryMiddleware( adapter: options.adapter, }) if (!out) return - // Wrap the deferred write so adapter.add/update/delete failures emit - // memory:error, fire events.onError, and (in strict mode) reject the - // deferred promise — instead of being silently swallowed. - ctx.defer( - deferredApplyOps(options, scope, normalizeOps(out)).then(() => {}), - ) + // Deferred tool-result persistence flows through the SAME observability + // pipeline as finish-turn persist: emits memory:persist:started / + // completed, fires events.onPersistStart / onPersistEnd, and calls + // afterPersist with the newly-added records. `runObservedPersist` + // also wraps adapter failures in memory:error + events.onError and + // (in strict mode) rejects the deferred promise. + ctx.defer(runObservedPersist(options, scope, normalizeOps(out))) } catch (error) { // Errors from `onToolResult` itself (synchronous extraction failure) // — the persist phase is wrapped separately above. @@ -296,21 +297,56 @@ async function applyOps( } /** - * Wrap `applyOps` so a deferred write surfaces failures via the same - * devtools/events/strict-mode plumbing as the synchronous paths. + * 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. * - * Without this wrapper, a rejecting `ctx.defer(applyOps(...))` is collected - * by `Promise.allSettled` in the chat engine — silently swallowed, with no - * `memory:error` event and no `events.onError` call. That's a debuggability - * cliff for adapter outages (e.g. a Redis blip). + * 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 deferredApplyOps( +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 { - return await applyOps(options, scope, ops) + newRecords = await applyOps(options, scope, ops) } catch (error) { safeEmit('memory:error', { scope, @@ -322,6 +358,37 @@ async function deferredApplyOps( 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: { @@ -343,7 +410,6 @@ async function persistTurn(args: { // deferred promise via the engine's `Promise.allSettled` collector. try { const now = Date.now() - const startedAt = now // Per-turn `shouldRemember` gate. Per JSDoc: "Returning `false` // short-circuits `extractMemories` and the persist path for the current @@ -427,63 +493,34 @@ async function persistTurn(args: { } } - safeEmit('memory:persist:started', { - scope, - records: ops - .filter((o) => o.op === 'add') - .map((o) => { - const r = o.record - return { - id: r.id, - kind: r.kind, - role: r.role, - preview: preview(r.text), - } - }), - timestamp: Date.now(), - }) - await options.events?.onPersistStart?.({ - scope, - records: ops.filter((o) => o.op === 'add').map((o) => o.record), - }) - - const newRecords = await applyOps(options, scope, ops) - - 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) { - await options.afterPersist({ - newRecords, - scope, - adapter: options.adapter, - }) - } + // `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 - // `applyOps`. 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). + // `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) { - // Skip re-emit/re-callback when the error is the strict-mode extract - // re-throw we just performed — `memory:error` (phase: 'extract') already - // fired in the inner catch above. Emitting again here would produce a - // duplicate event with the wrong phase ('persist') for one failure. - if (!(extractFailed && error === extractError)) { - safeEmit('memory:error', { - scope, - phase: 'persist', - error: errorInfo(error), - timestamp: Date.now(), - }) - await emitError(options, scope, 'persist', 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. + // 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 } } diff --git a/packages/typescript/ai/tests/middlewares/memory.test.ts b/packages/typescript/ai/tests/middlewares/memory.test.ts index 24f269272..7a950e9b1 100644 --- a/packages/typescript/ai/tests/middlewares/memory.test.ts +++ b/packages/typescript/ai/tests/middlewares/memory.test.ts @@ -499,6 +499,92 @@ describe('memoryMiddleware — persistence', () => { expect(toolResults).toHaveLength(1) expect(toolResults[0]?.text).toContain('echo') }) + + it('onToolResult deferred persist flows through the same observability pipeline as finish-turn persist', async () => { + // Regression: previously, `onToolResult` returned ops were committed via + // `deferredApplyOps` which did NOT emit persist:started/completed, did + // NOT call events.onPersistStart/End, and did NOT call afterPersist. + // The unified pipeline (runObservedPersist) now fires for both paths. + 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() + } + // Tool-result persist + finish-turn persist = at least 2 starts + 2 ends. + expect(startCount.n).toBeGreaterThanOrEqual(2) + expect(endCount.n).toBeGreaterThanOrEqual(2) + expect(onPersistStart.mock.calls.length).toBeGreaterThanOrEqual(2) + expect(onPersistEnd.mock.calls.length).toBeGreaterThanOrEqual(2) + // afterPersist fires once per persist call (tool-result + finish-turn). + expect(afterPersist).toHaveBeenCalledTimes(2) + // Tool-result records visible to afterPersist. + 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', () => { From 64a3872fc7589c8bcfad7cbf9ac99b4e7e9dff2c Mon Sep 17 00:00:00 2001 From: Alem Tuzlak Date: Sun, 10 May 2026 21:11:13 +0200 Subject: [PATCH 29/36] fix(ai): close error-path observability gaps in memory middleware Convergence-audit response to Round 2 (onToolResult) + Round 3 (embedder failure) findings sharing the same root-cause class: error paths that did not emit memory:error. - persistTurn assistant-side embedder failure now emits memory:error (phase: persist) and continues with embedding: undefined in non-strict mode (matches retrieval-side embedder handling) - onAfterToolCall tool-args JSON parse failure now emits memory:error (phase: extract) before falling back to {} - All catch blocks in middleware.ts now uniformly do safeEmit + events.onError + strict-rethrow before exiting - types.ts onError JSDoc updated to document which sub-cases each phase covers --- .../typescript/ai/src/memory/middleware.ts | 53 +++++- packages/typescript/ai/src/memory/types.ts | 20 ++- .../ai/tests/middlewares/memory.test.ts | 154 ++++++++++++++++++ 3 files changed, 222 insertions(+), 5 deletions(-) diff --git a/packages/typescript/ai/src/memory/middleware.ts b/packages/typescript/ai/src/memory/middleware.ts index dff50c1c5..545ac3427 100644 --- a/packages/typescript/ai/src/memory/middleware.ts +++ b/packages/typescript/ai/src/memory/middleware.ts @@ -161,8 +161,28 @@ export function memoryMiddleware( if (typeof raw === 'string' && raw.length > 0) { parsedArgs = JSON.parse(raw) } - } catch { + } 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, @@ -437,6 +457,30 @@ async function persistTurn(args: { }) } 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, @@ -445,9 +489,7 @@ async function persistTurn(args: { role: 'assistant', createdAt: now, importance: 0.4, - embedding: options.embedder - ? await options.embedder.embed(args.responseText) - : undefined, + embedding: assistantEmbedding, metadata: { retrievedMemoryIds: args.retrievedMemoryIds }, }) } @@ -517,6 +559,9 @@ async function persistTurn(args: { // (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 diff --git a/packages/typescript/ai/src/memory/types.ts b/packages/typescript/ai/src/memory/types.ts index b8b22cd87..f0a3d67d6 100644 --- a/packages/typescript/ai/src/memory/types.ts +++ b/packages/typescript/ai/src/memory/types.ts @@ -561,7 +561,25 @@ export interface MemoryMiddlewareOptions { scope: MemoryScope records: Array }) => void | Promise - /** Fired when retrieval, persistence, or extraction throws. */ + /** + * 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' diff --git a/packages/typescript/ai/tests/middlewares/memory.test.ts b/packages/typescript/ai/tests/middlewares/memory.test.ts index 7a950e9b1..d5fd113d4 100644 --- a/packages/typescript/ai/tests/middlewares/memory.test.ts +++ b/packages/typescript/ai/tests/middlewares/memory.test.ts @@ -732,3 +732,157 @@ describe('memoryMiddleware — devtools events', () => { } }) }) + +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() + } + }) +}) From 59ec97ee48baf7738b5080f512e73be99db9d1cc Mon Sep 17 00:00:00 2001 From: Alem Tuzlak Date: Sun, 10 May 2026 21:28:43 +0200 Subject: [PATCH 30/36] fix(ai, ai-memory): close remaining scope-value-validation gaps Round 4 convergence-audit fixes for scope as a tenant-isolation boundary: - redis.ts scopeKey now escapes : and \ in scope values so a tenant whose value contains a colon cannot collide with a multi-key scope that produces the same delimiter pattern (analogous to the Group F glob-metacharacter escape, applied to the EXACT-MATCH path) - scopeMatches treats empty-string scope values as undefined; a query with all-empty-string keys matches nothing (same safety guarantee as the {} empty-scope guard) - applyOps now overrides the scope on records returned by extractMemories/onToolResult to the resolved scope before persisting; a buggy or hostile callback cannot write into another tenant's bucket Contract suite gains scope-value safety tests for both adapters; the middleware test suite gains a regression for the extract-scope override. --- .../ai-memory/src/adapters/redis.ts | 76 ++++++++++++++-- .../typescript/ai-memory/tests/contract.ts | 90 +++++++++++++++++++ packages/typescript/ai/src/memory/helpers.ts | 25 ++++-- .../typescript/ai/src/memory/middleware.ts | 18 +++- packages/typescript/ai/src/memory/types.ts | 12 +++ .../ai/tests/memory/helpers.test.ts | 33 +++++++ .../ai/tests/middlewares/memory.test.ts | 86 ++++++++++++++++++ 7 files changed, 323 insertions(+), 17 deletions(-) diff --git a/packages/typescript/ai-memory/src/adapters/redis.ts b/packages/typescript/ai-memory/src/adapters/redis.ts index 445bf089f..85038c05d 100644 --- a/packages/typescript/ai-memory/src/adapters/redis.ts +++ b/packages/typescript/ai-memory/src/adapters/redis.ts @@ -136,6 +136,23 @@ 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) and \ (the escape character itself). + return value.replace(/[\\:]/g, '\\$&') +} + const SCOPE_KEYS = [ 'tenantId', 'userId', @@ -144,9 +161,18 @@ const SCOPE_KEYS = [ '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) { - if (scope[key] != null) return true + const v = scope[key] + if (v == null) continue + if (typeof v === 'string' && v.length === 0) continue + return true } return false } @@ -171,7 +197,19 @@ export function redisMemoryAdapter( const redis = options.redis function scopeKey(scope: MemoryScope): string { - return SCOPE_KEYS.map((k) => scope[k] ?? '_').join(':') + // 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)}` @@ -207,11 +245,24 @@ export function redisMemoryAdapter( * empty-scope semantics in `scopeMatches`, an empty scope matches * nothing and so resolves to zero buckets. * - * Glob metacharacters are escaped before being passed to SCAN MATCH so - * that scope values containing `*`, `?`, `[`, `]`, or `\` cannot - * cross-match other tenants' index buckets. Only literal scope values - * are escaped — the `*` we substitute for unset scope keys is left - * unescaped because it is the wildcard we actually want. + * 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, @@ -219,7 +270,16 @@ export function redisMemoryAdapter( if (!hasAnyScopeKey(scope)) return [] const pattern = `${prefix}:index:${SCOPE_KEYS.map((k) => { const v = scope[k] - return v != null ? escapeGlob(String(v)) : '*' + 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' diff --git a/packages/typescript/ai-memory/tests/contract.ts b/packages/typescript/ai-memory/tests/contract.ts index 87e341828..4b674dee6 100644 --- a/packages/typescript/ai-memory/tests/contract.ts +++ b/packages/typescript/ai-memory/tests/contract.ts @@ -349,6 +349,96 @@ export function runMemoryAdapterContract( 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() + }) + + 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', () => { diff --git a/packages/typescript/ai/src/memory/helpers.ts b/packages/typescript/ai/src/memory/helpers.ts index 03e28d35c..b9390b5c0 100644 --- a/packages/typescript/ai/src/memory/helpers.ts +++ b/packages/typescript/ai/src/memory/helpers.ts @@ -6,13 +6,20 @@ 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, 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' }`. + * 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, @@ -22,6 +29,10 @@ export function scopeMatches( 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 } diff --git a/packages/typescript/ai/src/memory/middleware.ts b/packages/typescript/ai/src/memory/middleware.ts index 545ac3427..050f2f16e 100644 --- a/packages/typescript/ai/src/memory/middleware.ts +++ b/packages/typescript/ai/src/memory/middleware.ts @@ -296,6 +296,13 @@ function normalizeOps( * 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, @@ -305,8 +312,15 @@ async function applyOps( const newRecords: Array = [] for (const op of ops) { if (op.op === 'add') { - await options.adapter.add(op.record) - newRecords.push(op.record) + // 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 { diff --git a/packages/typescript/ai/src/memory/types.ts b/packages/typescript/ai/src/memory/types.ts index f0a3d67d6..ec0b97875 100644 --- a/packages/typescript/ai/src/memory/types.ts +++ b/packages/typescript/ai/src/memory/types.ts @@ -485,6 +485,12 @@ export interface MemoryMiddlewareOptions { * 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 @@ -505,6 +511,12 @@ export interface MemoryMiddlewareOptions { * The middleware defers the resulting work via `ctx.defer` so it does not * block the chat stream. Same return-shape conventions as `extractMemories` * — `MemoryOp[]`, `MemoryRecord[]` shorthand, or `undefined`. + * + * **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 diff --git a/packages/typescript/ai/tests/memory/helpers.test.ts b/packages/typescript/ai/tests/memory/helpers.test.ts index d389687c5..0be6b240f 100644 --- a/packages/typescript/ai/tests/memory/helpers.test.ts +++ b/packages/typescript/ai/tests/memory/helpers.test.ts @@ -33,6 +33,39 @@ describe('scopeMatches', () => { 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', () => { diff --git a/packages/typescript/ai/tests/middlewares/memory.test.ts b/packages/typescript/ai/tests/middlewares/memory.test.ts index d5fd113d4..a1865250d 100644 --- a/packages/typescript/ai/tests/middlewares/memory.test.ts +++ b/packages/typescript/ai/tests/middlewares/memory.test.ts @@ -435,6 +435,92 @@ describe('memoryMiddleware — persistence', () => { 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({ From 8a8d5998112d3b200a5bf4e6f2b81746ecc41e20 Mon Sep 17 00:00:00 2001 From: Alem Tuzlak Date: Sun, 10 May 2026 21:45:57 +0200 Subject: [PATCH 31/36] fix(ai-memory): escape _ in scope values to prevent placeholder collision Round 5 convergence fix completing the scope-value-validation class closed in Group H. The Redis adapter uses literal '_' as the placeholder for an UNSET scope key, but Group H's escapeScopeValue only escaped ':' and '\'. A user-supplied scope value of literal '_' (e.g., userId: '_') would have produced the same index key as 'userId unset', creating a cross-leak surface on clear(). Now '_' is also escaped, so {tenantId: 't1', userId: '_'} indexes distinctly from {tenantId: 't1'}. Contract suite gains 2 tests verifying isolation under literal underscore scope values (run against both adapters). --- .../ai-memory/src/adapters/redis.ts | 8 +- .../typescript/ai-memory/tests/contract.ts | 84 +++++++++++++++++++ 2 files changed, 90 insertions(+), 2 deletions(-) diff --git a/packages/typescript/ai-memory/src/adapters/redis.ts b/packages/typescript/ai-memory/src/adapters/redis.ts index 85038c05d..676005e73 100644 --- a/packages/typescript/ai-memory/src/adapters/redis.ts +++ b/packages/typescript/ai-memory/src/adapters/redis.ts @@ -149,8 +149,12 @@ function escapeGlob(value: string): string { * isolation boundary. */ function escapeScopeValue(value: string): string { - // Escape : (our delimiter) and \ (the escape character itself). - return value.replace(/[\\:]/g, '\\$&') + // 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 = [ diff --git a/packages/typescript/ai-memory/tests/contract.ts b/packages/typescript/ai-memory/tests/contract.ts index 4b674dee6..c4e38c030 100644 --- a/packages/typescript/ai-memory/tests/contract.ts +++ b/packages/typescript/ai-memory/tests/contract.ts @@ -418,6 +418,90 @@ export function runMemoryAdapterContract( 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: '' }` From 94359d168128e91e51b8698b17fb909db87ebae0 Mon Sep 17 00:00:00 2001 From: Alem Tuzlak Date: Sun, 10 May 2026 21:48:15 +0200 Subject: [PATCH 32/36] chore: refresh pnpm-lock.yaml for ai-memory ioredis peer dep CI was failing with ERR_PNPM_OUTDATED_LOCKFILE because Group C added ioredis as an optional peer dependency without regenerating the lockfile. --- pnpm-lock.yaml | 71 ++++++++++++++------------------------------------ 1 file changed, 19 insertions(+), 52 deletions(-) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index e4fe3ba75..f2ea8f38e 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1279,6 +1279,10 @@ importers: 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:* @@ -1843,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 @@ -3321,9 +3325,6 @@ packages: '@ioredis/as-callback@3.0.0': resolution: {integrity: sha512-Kqv1rZ3WbgOrS+hgzJ5xG5WQuhvzzSTRYvNeyPMLOAM78MHSnuKI20JeJGbpuAt//LCuP0vsexZcorqW7kWhJg==} - '@ioredis/commands@1.4.0': - resolution: {integrity: sha512-aFT2yemJJo+TZCmieA7qnYGQooOS7QfNmYrzGtsYd3g9j5iDP8AimYYAesf79ohjbLG12XxC4nG5DyEnC88AsQ==} - '@ioredis/commands@1.5.0': resolution: {integrity: sha512-eUgLqrMf8nJkZxT24JvVRrQya1vZkQh8BBeYNwGDqa5I0VUi8ACx7uFvAaLxintokpTenkK6DASvo/bvNbBGow==} @@ -8649,10 +8650,6 @@ packages: '@types/ioredis-mock': ^8 ioredis: ^5 - ioredis@5.8.2: - resolution: {integrity: sha512-C6uC+kleiIMmjViJINWk80sOQw5lEzse1ZmvD+S/s8p8CWapftSaC+kocGTx6xrbrJ4WmYQGC08ffHLr6ToR6Q==} - engines: {node: '>=12.22.0'} - ioredis@5.9.2: resolution: {integrity: sha512-tAAg/72/VxOUW7RQSX1pIxJVucYKcjFjfvj60L57jrZpYCHC3XN0WCQ3sNYL4Gmvv+7GPvTAjc+KSdeNuE8oWQ==} engines: {node: '>=12.22.0'} @@ -13262,8 +13259,6 @@ snapshots: '@ioredis/as-callback@3.0.0': {} - '@ioredis/commands@1.4.0': {} - '@ioredis/commands@1.5.0': {} '@isaacs/balanced-match@4.0.1': {} @@ -15840,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' @@ -16357,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' @@ -16433,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)) @@ -16447,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: @@ -16783,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)) @@ -19733,20 +19728,6 @@ snapshots: ioredis: 5.9.2 semver: 7.7.4 - ioredis@5.8.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@5.9.2: dependencies: '@ioredis/commands': 1.5.0 @@ -20962,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 @@ -20995,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 @@ -23263,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 @@ -23404,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) @@ -23437,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: From d17ae31b341c1f8d24ed3bcd463d7b540ea2b2c4 Mon Sep 17 00:00:00 2001 From: "autofix-ci[bot]" <114827586+autofix-ci[bot]@users.noreply.github.com> Date: Sun, 10 May 2026 19:49:12 +0000 Subject: [PATCH 33/36] ci: apply automated fixes --- packages/typescript/ai-memory/tests/contract.ts | 12 +++++++----- packages/typescript/ai/src/memory/middleware.ts | 4 +++- packages/typescript/ai/tests/memory/helpers.test.ts | 10 ++++++++-- 3 files changed, 18 insertions(+), 8 deletions(-) diff --git a/packages/typescript/ai-memory/tests/contract.ts b/packages/typescript/ai-memory/tests/contract.ts index c4e38c030..14a1e53c1 100644 --- a/packages/typescript/ai-memory/tests/contract.ts +++ b/packages/typescript/ai-memory/tests/contract.ts @@ -381,7 +381,9 @@ export function runMemoryAdapterContract( scope: splitScope, text: 'data', }) - expect(splitOut.hits.find((h) => h.record.id === 'colon')).toBeUndefined() + 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 @@ -483,9 +485,7 @@ export function runMemoryAdapterContract( scope: underscoreUserScope, text: 'orange', }) - expect( - out.hits.find((h) => h.record.id === 'no-user'), - ).toBeUndefined() + expect(out.hits.find((h) => h.record.id === 'no-user')).toBeUndefined() expect( out.hits.find((h) => h.record.id === 'real-user'), ).toBeUndefined() @@ -499,7 +499,9 @@ export function runMemoryAdapterContract( // 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() + expect( + await adapter.get('no-user', underscoreUserScope), + ).toBeUndefined() }) it('treats empty-string scope values as undefined (not as a distinct bucket)', async () => { diff --git a/packages/typescript/ai/src/memory/middleware.ts b/packages/typescript/ai/src/memory/middleware.ts index 050f2f16e..8c8831c3e 100644 --- a/packages/typescript/ai/src/memory/middleware.ts +++ b/packages/typescript/ai/src/memory/middleware.ts @@ -356,7 +356,9 @@ async function runObservedPersist( ): Promise> { if (ops.length === 0) return [] const startedAt = Date.now() - const adds = ops.filter((o): o is Extract => o.op === 'add') + 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) { diff --git a/packages/typescript/ai/tests/memory/helpers.test.ts b/packages/typescript/ai/tests/memory/helpers.test.ts index 0be6b240f..360512bb7 100644 --- a/packages/typescript/ai/tests/memory/helpers.test.ts +++ b/packages/typescript/ai/tests/memory/helpers.test.ts @@ -59,10 +59,16 @@ describe('scopeMatches', () => { // `{ 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: '' }), + scopeMatches( + { tenantId: 't1', userId: 'u1' }, + { tenantId: 't1', userId: '' }, + ), ).toBe(true) expect( - scopeMatches({ tenantId: 't2', userId: 'u1' }, { tenantId: 't1', userId: '' }), + scopeMatches( + { tenantId: 't2', userId: 'u1' }, + { tenantId: 't1', userId: '' }, + ), ).toBe(false) }) }) From ed23b50b25e4a345a4df57c947e8621bba05502b Mon Sep 17 00:00:00 2001 From: Alem Tuzlak Date: Sun, 10 May 2026 21:52:52 +0200 Subject: [PATCH 34/36] docs: consolidate memory pages into a top-level Memory section - Move docs/middlewares/memory.md -> docs/memory/overview.md (rename frontmatter title to 'Overview') - Move docs/guides/memory-quickstart.md -> docs/memory/quickstart.md - Add docs/memory/custom-adapter.md authoring guide covering the 8-member contract, the three isolation invariants, the shared contract suite, common pitfalls (delimiter escaping, atomicity, partial-scope cascade), and packaging conventions - Replace single-child 'Middlewares' and 'Guides' sidebar sections with a unified 'Memory' section - Rewire internal cross-links between the three pages --- docs/config.json | 21 +- docs/memory/custom-adapter.md | 315 ++++++++++++++++++ .../memory.md => memory/overview.md} | 11 +- .../quickstart.md} | 13 +- 4 files changed, 337 insertions(+), 23 deletions(-) create mode 100644 docs/memory/custom-adapter.md rename docs/{middlewares/memory.md => memory/overview.md} (94%) rename docs/{guides/memory-quickstart.md => memory/quickstart.md} (90%) diff --git a/docs/config.json b/docs/config.json index e079abd74..ba48ad3ea 100644 --- a/docs/config.json +++ b/docs/config.json @@ -165,20 +165,19 @@ ] }, { - "label": "Middlewares", + "label": "Memory", "children": [ { - "label": "Memory", - "to": "middlewares/memory" - } - ] - }, - { - "label": "Guides", - "children": [ + "label": "Overview", + "to": "memory/overview" + }, + { + "label": "Quickstart", + "to": "memory/quickstart" + }, { - "label": "Memory Quickstart", - "to": "guides/memory-quickstart" + "label": "Custom Adapter", + "to": "memory/custom-adapter" } ] }, diff --git a/docs/memory/custom-adapter.md b/docs/memory/custom-adapter.md new file mode 100644 index 000000000..fb11b9d9d --- /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 (tenant_id IS NOT DISTINCT FROM $2) + AND (user_id IS NOT DISTINCT FROM $3) + AND (session_id IS NOT DISTINCT FROM $4) + AND (thread_id IS NOT DISTINCT FROM $5) + AND (namespace IS NOT DISTINCT FROM $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, colon and underscore safety, and the resolved-scope override for records returned by `extractMemories`. If your adapter passes, every 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/middlewares/memory.md b/docs/memory/overview.md similarity index 94% rename from docs/middlewares/memory.md rename to docs/memory/overview.md index 19305faa1..2e49c35e7 100644 --- a/docs/middlewares/memory.md +++ b/docs/memory/overview.md @@ -1,6 +1,6 @@ --- -title: Memory Middleware -id: memory-middleware +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: @@ -16,7 +16,7 @@ keywords: `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](../guides/memory-quickstart) guide. +> **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 @@ -52,7 +52,7 @@ Built-in adapters live in `@tanstack/ai-memory`: import { inMemoryMemoryAdapter, redisMemoryAdapter } from '@tanstack/ai-memory' ``` -Custom adapters implement `MemoryAdapter` 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 @@ -165,6 +165,7 @@ import type { ## Next steps -- [Memory Quickstart](../guides/memory-quickstart) — wire the middleware into a real `chat()` call in five 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/guides/memory-quickstart.md b/docs/memory/quickstart.md similarity index 90% rename from docs/guides/memory-quickstart.md rename to docs/memory/quickstart.md index d18bf5a10..7e74e793a 100644 --- a/docs/guides/memory-quickstart.md +++ b/docs/memory/quickstart.md @@ -1,7 +1,7 @@ --- -title: Memory Quickstart +title: Quickstart id: memory-quickstart -order: 1 +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 @@ -14,7 +14,7 @@ keywords: 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 [Memory Middleware](../middlewares/memory) concept page for the adapter interface, hooks, and devtools events. +> **Want the full contract first?** See the [Overview](./overview) page for the adapter interface, hooks, and devtools events. ## Step 1 — Install the package @@ -32,7 +32,7 @@ pnpm add @tanstack/ai-memory > **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`. +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()` @@ -139,6 +139,5 @@ If you accept `userId` or `tenantId` from the client, one user can read or overw ## Where to go next -- [Memory Middleware](../middlewares/memory) — adapter contract, hooks reference, devtools events, failure modes -- [In-memory adapter skill](https://github.com/TanStack/ai) — `tanstack-ai-memory-in-memory` (when to use, capacity limits) -- [Redis adapter skill](https://github.com/TanStack/ai) — `tanstack-ai-memory-redis` (vector search, key layout, ops) +- [Overview](./overview) — adapter contract, hooks reference, devtools events, failure modes +- [Custom Adapter](./custom-adapter) — implement `MemoryAdapter` for a backend not shipped (pgvector, MongoDB, Pinecone, …) From 224f805d50a68913694a74d28ef0ea573d80aa6d Mon Sep 17 00:00:00 2001 From: Alem Tuzlak Date: Sun, 10 May 2026 22:43:23 +0200 Subject: [PATCH 35/36] fix(ai, ai-memory): address CodeRabbit code review feedback - middleware.ts: preview-cap memory:retrieve:started query payload (was emitting full lastUserText, breaking the documented 200-char preview contract for devtools events) - helpers.ts: JSON-stringify memory text in defaultRenderMemory so newline-or-instruction-shaped persisted memory cannot break out of the list structure and steer subsequent turns at system priority - middleware.ts: shouldRemember now gates tool-result memories from onToolResult; buffered ops flush inside persistTurn after the gate passes, matching the documented 'short-circuits the entire persist path for the current turn' contract. Persist events now fire once per turn (covers base + extracted + tool-result records together) - redis.ts: malformed JSON rows in loadAllForScope are now swept from the index and record key (was warned-and-skipped, leaving the bad payload to be reparsed on every subsequent read) - redis.ts and in-memory.ts: snapshot now once per search and thread through defaultScoreHit so recency ranking is stable across same- pass candidates --- .../ai-memory/src/adapters/in-memory.ts | 5 +- .../ai-memory/src/adapters/redis.ts | 15 +++- packages/typescript/ai/src/memory/helpers.ts | 7 +- .../typescript/ai/src/memory/middleware.ts | 53 +++++++++++-- packages/typescript/ai/src/memory/types.ts | 13 +++- .../ai/tests/memory/helpers.test.ts | 4 +- .../ai/tests/middlewares/memory.test.ts | 75 +++++++++++++++---- 7 files changed, 143 insertions(+), 29 deletions(-) diff --git a/packages/typescript/ai-memory/src/adapters/in-memory.ts b/packages/typescript/ai-memory/src/adapters/in-memory.ts index 037b17587..ab98940d7 100644 --- a/packages/typescript/ai-memory/src/adapters/in-memory.ts +++ b/packages/typescript/ai-memory/src/adapters/in-memory.ts @@ -71,6 +71,9 @@ export function inMemoryMemoryAdapter(): MemoryAdapter { }, 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 @@ -80,7 +83,7 @@ export function inMemoryMemoryAdapter(): MemoryAdapter { const scored = candidates .map((record) => ({ record, - score: defaultScoreHit({ record, query }), + score: defaultScoreHit({ record, query, now }), })) .filter((h) => h.score >= minScore) .sort((a, b) => b.score - a.score) diff --git a/packages/typescript/ai-memory/src/adapters/redis.ts b/packages/typescript/ai-memory/src/adapters/redis.ts index 676005e73..5e30f7beb 100644 --- a/packages/typescript/ai-memory/src/adapters/redis.ts +++ b/packages/typescript/ai-memory/src/adapters/redis.ts @@ -372,7 +372,12 @@ export function redisMemoryAdapter( out.push(r) } catch (err) { warnMalformedRowOnce(id, err) - /* skip malformed */ + // 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) { @@ -445,6 +450,12 @@ export function redisMemoryAdapter( 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 @@ -454,7 +465,7 @@ export function redisMemoryAdapter( const scored = candidates .map((record) => ({ record, - score: defaultScoreHit({ record, query }), + score: defaultScoreHit({ record, query, now }), })) .filter((h) => h.score >= minScore) .sort((a, b) => b.score - a.score) diff --git a/packages/typescript/ai/src/memory/helpers.ts b/packages/typescript/ai/src/memory/helpers.ts index b9390b5c0..f7ca849db 100644 --- a/packages/typescript/ai/src/memory/helpers.ts +++ b/packages/typescript/ai/src/memory/helpers.ts @@ -135,8 +135,13 @@ export function defaultRenderMemory(hits: Array): string { '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}] ${hit.record.text}`, + (hit, index) => + `${index + 1}. [${hit.record.kind}] ${JSON.stringify(hit.record.text)}`, ), ].join('\n') } diff --git a/packages/typescript/ai/src/memory/middleware.ts b/packages/typescript/ai/src/memory/middleware.ts index 8c8831c3e..241414fbb 100644 --- a/packages/typescript/ai/src/memory/middleware.ts +++ b/packages/typescript/ai/src/memory/middleware.ts @@ -25,6 +25,14 @@ interface MemoryRequestState { 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() @@ -58,6 +66,7 @@ export function memoryMiddleware( const state: MemoryRequestState = { lastUserText: '', retrievedHits: [], + pendingToolOps: [], } stateByCtx.set(ctx, state) @@ -79,7 +88,7 @@ export function memoryMiddleware( try { safeEmit('memory:retrieve:started', { scope, - query: state.lastUserText, + query: preview(state.lastUserText), topK: options.topK ?? 6, minScore: options.minScore ?? 0.15, embedderUsed: !!options.embedder, @@ -193,13 +202,13 @@ export function memoryMiddleware( adapter: options.adapter, }) if (!out) return - // Deferred tool-result persistence flows through the SAME observability - // pipeline as finish-turn persist: emits memory:persist:started / - // completed, fires events.onPersistStart / onPersistEnd, and calls - // afterPersist with the newly-added records. `runObservedPersist` - // also wraps adapter failures in memory:error + events.onError and - // (in strict mode) rejects the deferred promise. - ctx.defer(runObservedPersist(options, scope, normalizeOps(out))) + // 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. @@ -226,6 +235,10 @@ export function memoryMiddleware( 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.) @@ -238,6 +251,7 @@ export function memoryMiddleware( userEmbedding, responseText, retrievedMemoryIds, + pendingToolOps, }), ) }, @@ -434,6 +448,12 @@ async function persistTurn(args: { 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 @@ -510,6 +530,14 @@ async function persistTurn(args: { }) } + // 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, @@ -551,6 +579,15 @@ async function persistTurn(args: { } } + // 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 diff --git a/packages/typescript/ai/src/memory/types.ts b/packages/typescript/ai/src/memory/types.ts index ec0b97875..70b300664 100644 --- a/packages/typescript/ai/src/memory/types.ts +++ b/packages/typescript/ai/src/memory/types.ts @@ -508,9 +508,16 @@ export interface MemoryMiddlewareOptions { * with its arguments and result, allowing the app to persist tool output as * memory (typical `kind` is `'tool-result'`). * - * The middleware defers the resulting work via `ctx.defer` so it does not - * block the chat stream. Same return-shape conventions as `extractMemories` - * — `MemoryOp[]`, `MemoryRecord[]` shorthand, or `undefined`. + * 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, diff --git a/packages/typescript/ai/tests/memory/helpers.test.ts b/packages/typescript/ai/tests/memory/helpers.test.ts index 360512bb7..8b2ee44cb 100644 --- a/packages/typescript/ai/tests/memory/helpers.test.ts +++ b/packages/typescript/ai/tests/memory/helpers.test.ts @@ -140,7 +140,9 @@ describe('defaultRenderMemory', () => { }, ]) expect(out).toContain('Relevant memory:') - expect(out).toContain('1. [fact] User is on Windows.') + // 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."') }) }) diff --git a/packages/typescript/ai/tests/middlewares/memory.test.ts b/packages/typescript/ai/tests/middlewares/memory.test.ts index a1865250d..05a35876b 100644 --- a/packages/typescript/ai/tests/middlewares/memory.test.ts +++ b/packages/typescript/ai/tests/middlewares/memory.test.ts @@ -586,11 +586,59 @@ describe('memoryMiddleware — persistence', () => { expect(toolResults[0]?.text).toContain('echo') }) - it('onToolResult deferred persist flows through the same observability pipeline as finish-turn persist', async () => { - // Regression: previously, `onToolResult` returned ops were committed via - // `deferredApplyOps` which did NOT emit persist:started/completed, did - // NOT call events.onPersistStart/End, and did NOT call afterPersist. - // The unified pipeline (runObservedPersist) now fires for both paths. + 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: [ @@ -658,14 +706,15 @@ describe('memoryMiddleware — persistence', () => { off1() off2() } - // Tool-result persist + finish-turn persist = at least 2 starts + 2 ends. - expect(startCount.n).toBeGreaterThanOrEqual(2) - expect(endCount.n).toBeGreaterThanOrEqual(2) - expect(onPersistStart.mock.calls.length).toBeGreaterThanOrEqual(2) - expect(onPersistEnd.mock.calls.length).toBeGreaterThanOrEqual(2) - // afterPersist fires once per persist call (tool-result + finish-turn). - expect(afterPersist).toHaveBeenCalledTimes(2) - // Tool-result records visible to afterPersist. + // 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, ) From b478e8e09175a2bc7fb7568919674e9789b37f6a Mon Sep 17 00:00:00 2001 From: Alem Tuzlak Date: Sun, 10 May 2026 22:46:42 +0200 Subject: [PATCH 36/36] docs, chore: address CodeRabbit polish feedback - custom-adapter.md: fix pgvector SQL example to use partial-scope semantics (($N IS NULL OR col = $N)) instead of IS NOT DISTINCT FROM, which had the wrong matching semantics for partial scopes - custom-adapter.md: soften contract suite coverage claim - the shared suite does not exercise middleware-level extractMemories resolved-scope override - quickstart.md: drop blank line in adapter blockquote (MD028); replace placeholder skill link with direct doc + repo SKILL.md link - redis SKILL.md: add 'text' language tag to storage model fence (MD040) - ai-memory package.json: add /adapters/in-memory and /adapters/redis subpath exports per repo convention - ai-memory tsconfig.json: drop **/*.config.ts exclude so vite.config.ts (which is in include) actually gets type-checked - in-memory.test.ts: reorder imports to satisfy import/order - memory.test.ts: tighten the re-inject regression test with expect(iter1).toBeGreaterThan(0) so the assertion catches the case where injection is fully disabled in both iterations --- docs/memory/custom-adapter.md | 12 ++++++------ docs/memory/quickstart.md | 4 ++-- packages/typescript/ai-memory/package.json | 8 ++++++++ .../skills/tanstack-ai-memory-redis/SKILL.md | 2 +- .../typescript/ai-memory/tests/in-memory.test.ts | 2 +- packages/typescript/ai-memory/tsconfig.json | 2 +- .../typescript/ai/tests/middlewares/memory.test.ts | 5 +++++ 7 files changed, 24 insertions(+), 11 deletions(-) diff --git a/docs/memory/custom-adapter.md b/docs/memory/custom-adapter.md index fb11b9d9d..2648a2e51 100644 --- a/docs/memory/custom-adapter.md +++ b/docs/memory/custom-adapter.md @@ -187,11 +187,11 @@ async search(query: MemoryQuery): Promise { ELSE 0 END AS score FROM ${table} - WHERE (tenant_id IS NOT DISTINCT FROM $2) - AND (user_id IS NOT DISTINCT FROM $3) - AND (session_id IS NOT DISTINCT FROM $4) - AND (thread_id IS NOT DISTINCT FROM $5) - AND (namespace IS NOT DISTINCT FROM $6) + 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 @@ -239,7 +239,7 @@ runMemoryAdapterContract('pgvectorMemoryAdapter', async () => { }) ``` -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, colon and underscore safety, and the resolved-scope override for records returned by `extractMemories`. If your adapter passes, every contract guarantee is met. +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. diff --git a/docs/memory/quickstart.md b/docs/memory/quickstart.md index 7e74e793a..4be3a907a 100644 --- a/docs/memory/quickstart.md +++ b/docs/memory/quickstart.md @@ -29,7 +29,7 @@ pnpm add @tanstack/ai-memory ## 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. @@ -72,7 +72,7 @@ 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 [Redis adapter skill](https://github.com/TanStack/ai) for the full example. +> **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) diff --git a/packages/typescript/ai-memory/package.json b/packages/typescript/ai-memory/package.json index 689984c63..916e73e72 100644 --- a/packages/typescript/ai-memory/package.json +++ b/packages/typescript/ai-memory/package.json @@ -16,6 +16,14 @@ ".": { "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, 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 index fe31b12c2..fda27203b 100644 --- a/packages/typescript/ai-memory/skills/tanstack-ai-memory-redis/SKILL.md +++ b/packages/typescript/ai-memory/skills/tanstack-ai-memory-redis/SKILL.md @@ -62,7 +62,7 @@ The adapter accepts any client implementing the `RedisLike` shape: `get`, `set`, ## Storage model -``` +```text {prefix}:record:{memoryId} → JSON-stringified MemoryRecord {prefix}:index:{tenantId}:{userId}:{sessionId}:{threadId}:{namespace} → Set ``` diff --git a/packages/typescript/ai-memory/tests/in-memory.test.ts b/packages/typescript/ai-memory/tests/in-memory.test.ts index aef1ee00c..a3bd1191b 100644 --- a/packages/typescript/ai-memory/tests/in-memory.test.ts +++ b/packages/typescript/ai-memory/tests/in-memory.test.ts @@ -1,4 +1,4 @@ -import { runMemoryAdapterContract } from './contract' import { inMemoryMemoryAdapter } from '../src/adapters/in-memory' +import { runMemoryAdapterContract } from './contract' runMemoryAdapterContract('inMemoryMemoryAdapter', () => inMemoryMemoryAdapter()) diff --git a/packages/typescript/ai-memory/tsconfig.json b/packages/typescript/ai-memory/tsconfig.json index 31b14bdfe..377214afe 100644 --- a/packages/typescript/ai-memory/tsconfig.json +++ b/packages/typescript/ai-memory/tsconfig.json @@ -4,5 +4,5 @@ "outDir": "dist" }, "include": ["vite.config.ts", "./src", "./tests"], - "exclude": ["node_modules", "dist", "**/*.config.ts"] + "exclude": ["node_modules", "dist"] } diff --git a/packages/typescript/ai/tests/middlewares/memory.test.ts b/packages/typescript/ai/tests/middlewares/memory.test.ts index 05a35876b..5466ae12b 100644 --- a/packages/typescript/ai/tests/middlewares/memory.test.ts +++ b/packages/typescript/ai/tests/middlewares/memory.test.ts @@ -163,6 +163,11 @@ describe('memoryMiddleware — retrieval', () => { (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) })