From f4f2ef25598e9e839ff3c8fefcf2077c97114205 Mon Sep 17 00:00:00 2001 From: Alem Tuzlak Date: Sun, 10 May 2026 18:23:05 +0200 Subject: [PATCH 01/45] 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/45] 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/45] 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/45] 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/45] 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/45] 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/45] 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/45] 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/45] 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/45] 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/45] 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/45] 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/45] 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/45] 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/45] 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/45] 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/45] 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/45] 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/45] 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/45] 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/45] 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/45] 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/45] 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/45] 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/45] 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/45] 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/45] 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/45] 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/45] 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/45] 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/45] 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/45] 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/45] 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/45] 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/45] 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/45] 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) }) From 5d38c0284c62807a23b30f0d6063080a5b3cc21f Mon Sep 17 00:00:00 2001 From: Tom Beckenham <34339192+tombeckenham@users.noreply.github.com> Date: Mon, 20 Jul 2026 17:50:58 +1000 Subject: [PATCH 37/45] fix(ai, ai-memory): address memory-middleware review findings MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Portable record ids - middleware.ts mints ids via crypto.randomUUID(), which is NOT a bare global on the declared Node 18 floor (Web Crypto became unflagged only in Node 19+) and threw ReferenceError there — silently dropping the whole turn's memory in non-strict mode. Add newRecordId() (real UUID when available, portable Date.now()+Math.random() fallback via try/catch). Close silent-failure gaps in the error plumbing - Wrap the scope resolver + shouldRetrieve (onConfig), the scope resolver (onAfterToolCall, onFinish), and shouldRemember (persistTurn) so a throwing user callback emits memory:error/onError and honours strict instead of escaping the hook and breaking chat in non-strict mode. - Make emitError defensive (like safeEmit) so a throwing onError handler can't break chat or mask the original failure. Honest strict-mode docs - Persistence runs via ctx.defer; the engine awaits deferred work with Promise.allSettled and discards results, so a strict-mode rethrow on the persist path does NOT abort the run. Correct the comments that claimed otherwise; memory:error is the observable signal in both modes. Redis: stop silently deleting malformed rows - loadAllForScope routed malformed JSON through the expired-sweep, permanently deleting possibly-recoverable rows behind a one-shot console.warn. Leave the row in place and skip it; warn per-distinct-id (bounded) so ongoing corruption keeps surfacing without spamming. Skill doc updated. Docs - Fix the memory overview's broken ../advanced/observability link (page moved on main) -> ../getting-started/devtools. Tests (+4) - Cross-turn persist->retrieve round trip (the feature's headline behaviour). - Throwing scope resolver / throwing shouldRemember don't break chat + emit memory:error. - Redis malformed row is skipped on read but NOT deleted. Gates: @tanstack/ai 1186 tests, @tanstack/ai-memory 72 tests, test:types, test:eslint (0 errors), knip, sherif, test:docs all green. Co-Authored-By: Claude Opus 4.8 (1M context) --- docs/memory/overview.md | 2 +- .../skills/tanstack-ai-memory-redis/SKILL.md | 2 +- packages/ai-memory/src/adapters/redis.ts | 46 ++++-- packages/ai-memory/tests/redis.test.ts | 46 +++++- packages/ai/src/memory/middleware.ts | 156 ++++++++++++++---- packages/ai/tests/middlewares/memory.test.ts | 147 +++++++++++++++++ 6 files changed, 350 insertions(+), 49 deletions(-) diff --git a/docs/memory/overview.md b/docs/memory/overview.md index 2e49c35e7..67d0f6216 100644 --- a/docs/memory/overview.md +++ b/docs/memory/overview.md @@ -168,4 +168,4 @@ import type { - [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 +- [Devtools](../getting-started/devtools) — subscribe to `memory:*` events for tracing diff --git a/packages/ai-memory/skills/tanstack-ai-memory-redis/SKILL.md b/packages/ai-memory/skills/tanstack-ai-memory-redis/SKILL.md index fda27203b..80022c235 100644 --- a/packages/ai-memory/skills/tanstack-ai-memory-redis/SKILL.md +++ b/packages/ai-memory/skills/tanstack-ai-memory-redis/SKILL.md @@ -79,4 +79,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. -- **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. +- **Malformed JSON rows:** if the JSON in `{prefix}:record:{id}` is malformed (older schema, third-party writer, truncated/partial write), the adapter skips the row for that read and **leaves it in place** — it is never deleted, because a parse failure is not proof the data is unrecoverable. There is no exception you can catch; the observable signal is a `console.warn` emitted once per distinct malformed id (bounded, so a large corrupted store cannot spam the console). To detect drift, periodically run `list(scope)` and compare counts to your application's source of truth; to remediate, fix or delete the offending record keys directly (or `clear(scope)` the whole scope). diff --git a/packages/ai-memory/src/adapters/redis.ts b/packages/ai-memory/src/adapters/redis.ts index 86ca352fe..5134e4b7a 100644 --- a/packages/ai-memory/src/adapters/redis.ts +++ b/packages/ai-memory/src/adapters/redis.ts @@ -181,16 +181,29 @@ 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 +// Track which record ids we've already warned about so ongoing corruption of +// DIFFERENT ids keeps surfacing (a single process-global flag would let the +// first transient bad row consume the only warning and hide everything after). +// Bounded so a pathological store can't grow this set without limit; once the +// cap is hit we stop warning entirely to avoid per-read console spam. +const warnedMalformedIds = new Set() +const MALFORMED_WARN_CAP = 100 +function warnMalformedRow(id: string, err: unknown): void { + if ( + warnedMalformedIds.has(id) || + warnedMalformedIds.size >= MALFORMED_WARN_CAP + ) { + return + } + warnedMalformedIds.add(id) + const capNote = + warnedMalformedIds.size >= MALFORMED_WARN_CAP + ? ' Further malformed-row warnings will be suppressed.' + : '' console.warn( `[tanstack-ai-memory] redisMemoryAdapter: skipped malformed record JSON (id=${id}). ` + - `Subsequent malformed rows will be skipped silently. Reason: ${String(err)}`, + `The row is left in place (not deleted) in case it is recoverable.${capNote} ` + + `Reason: ${String(err)}`, ) } @@ -307,7 +320,7 @@ export function redisMemoryAdapter( try { return JSON.parse(raw) as MemoryRecord } catch (err) { - warnMalformedRowOnce(id, err) + warnMalformedRow(id, err) return undefined } } @@ -371,13 +384,14 @@ export function redisMemoryAdapter( if (!scopeMatches(r.scope, scope)) continue out.push(r) } catch (err) { - warnMalformedRowOnce(id, err) - // Sweep malformed payloads from BOTH the index bucket and the record - // key — without this, the bad row stays at recordKey(id) and the id - // stays in the index, causing every subsequent loadAllForScope to - // re-parse and re-warn forever. Reuse `markExpired` so the expired/ - // missing/malformed paths share one cleanup pass per index bucket. - markExpired(id) + // Malformed JSON is NOT swept. A parse failure is not proof the data + // is unrecoverable (a truncated read, a concurrent partial write, or a + // third-party writer using an older schema all land here), so deleting + // the row + index entry would be silent, irreversible data loss gated + // behind a single console.warn. Instead we leave the row in place and + // skip it for this read. The `warnMalformedRow` id-set keeps the warn + // from spamming on every subsequent read of the same bad id. + warnMalformedRow(id, err) } } if (expiredByIndex.size > 0) { diff --git a/packages/ai-memory/tests/redis.test.ts b/packages/ai-memory/tests/redis.test.ts index b807233ee..1e3a50ca0 100644 --- a/packages/ai-memory/tests/redis.test.ts +++ b/packages/ai-memory/tests/redis.test.ts @@ -2,7 +2,7 @@ // 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 { describe, expect, it, vi } from 'vitest' import { nodeRedisAsRedisLike, redisMemoryAdapter } from '../src/adapters/redis' import { runMemoryAdapterContract } from './contract' @@ -14,6 +14,50 @@ runMemoryAdapterContract('redisMemoryAdapter', async () => { }) }) +describe('redisMemoryAdapter malformed rows', () => { + it('skips a malformed record on read but does NOT delete it', async () => { + const prefix = `test:${crypto.randomUUID()}` + const client = new RedisMock() + const adapter = redisMemoryAdapter({ redis: client as never, prefix }) + const scope = { tenantId: 't1', userId: 'u1' } + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}) + try { + await adapter.add({ + id: 'good', + scope, + text: 'ok', + kind: 'fact', + createdAt: Date.now(), + }) + await adapter.add({ + id: 'bad', + scope, + text: 'will be corrupted', + kind: 'fact', + createdAt: Date.now(), + }) + // Corrupt the stored payload directly, simulating a truncated write or a + // third-party writer using an incompatible schema. + const badKey = `${prefix}:record:bad` + await client.set(badKey, '{ not valid json') + + // The malformed row is skipped, the good one still returned. + const listed = await adapter.list(scope) + const ids = listed.items.map((r) => r.id) + expect(ids).toContain('good') + expect(ids).not.toContain('bad') + + // Load-bearing: the malformed row is LEFT IN PLACE, not deleted — a + // parse failure is not proof the data is unrecoverable. + expect(await client.get(badKey)).toBe('{ not valid json') + // And the developer was warned about it. + expect(warn).toHaveBeenCalled() + } finally { + warn.mockRestore() + } + }) +}) + describe('nodeRedisAsRedisLike', () => { it('translates camelCase node-redis methods into lowercase RedisLike calls', async () => { const calls: Array<{ method: string; args: Array }> = [] diff --git a/packages/ai/src/memory/middleware.ts b/packages/ai/src/memory/middleware.ts index 89e3edf2f..e96abf43a 100644 --- a/packages/ai/src/memory/middleware.ts +++ b/packages/ai/src/memory/middleware.ts @@ -74,18 +74,23 @@ export function memoryMiddleware( state.lastUserText = getMessageText(lastUser) if (!state.lastUserText) return - const scope = await resolveScope(ctx, state) - - if (options.shouldRetrieve) { - const ok = await options.shouldRetrieve({ - userText: state.lastUserText, - scope, - }) - if (!ok) return - } - + // Scope resolution and the `shouldRetrieve` gate run user-supplied + // callbacks, so they live INSIDE the guarded region below. If either + // throws, the failure routes through `memory:error` + `events.onError` + // and honours `strict` — rather than escaping `onConfig` uncaught and + // breaking the chat request even in non-strict mode. const startedAt = Date.now() try { + const scope = await resolveScope(ctx, state) + + if (options.shouldRetrieve) { + const ok = await options.shouldRetrieve({ + userText: state.lastUserText, + scope, + }) + if (!ok) return + } + safeEmit('memory:retrieve:started', { scope, query: preview(state.lastUserText), @@ -136,13 +141,16 @@ export function memoryMiddleware( hits: state.retrievedHits, }) } catch (error) { + // `resolveScope` may have thrown before assigning, so fall back to the + // partially-resolved scope (or `{}`) for the error payload. + const errScope = state.resolvedScope ?? {} safeEmit('memory:error', { - scope, + scope: errScope, phase: 'retrieve', error: errorInfo(error), timestamp: Date.now(), }) - await emitError(options, scope, 'retrieve', error) + await emitError(options, errScope, 'retrieve', error) if (options.strict) throw error return } @@ -162,8 +170,12 @@ export function memoryMiddleware( if (!options.onToolResult || !info.ok) return const state = stateByCtx.get(ctx) if (!state) return - const scope = await resolveScope(ctx, state) + // `scope` is resolved INSIDE the try so a throwing scope resolver routes + // through the same plumbing as an `onToolResult` failure instead of + // escaping the hook and breaking chat in non-strict mode. + let scope: MemoryScope try { + scope = await resolveScope(ctx, state) let parsedArgs: unknown = {} try { const raw = info.toolCall.function.arguments @@ -210,15 +222,17 @@ export function memoryMiddleware( // 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. + // Errors from the scope resolver or `onToolResult` itself (synchronous + // extraction failure) — the persist phase is wrapped separately above. + // `scope` may be unassigned if `resolveScope` threw, so fall back. + const errScope = state.resolvedScope ?? {} safeEmit('memory:error', { - scope, + scope: errScope, phase: 'extract', error: errorInfo(error), timestamp: Date.now(), }) - await emitError(options, scope, 'extract', error) + await emitError(options, errScope, 'extract', error) if (options.strict) throw error } }, @@ -231,7 +245,27 @@ export function memoryMiddleware( stateByCtx.delete(ctx) return } - const scope = await resolveScope(ctx, state) + // Resolve scope defensively: a throwing scope resolver here would + // otherwise escape the terminal `onFinish` hook. Route it through the + // persist error plumbing and skip persistence for the turn instead. + let scope: MemoryScope + try { + scope = await resolveScope(ctx, state) + } catch (error) { + const errScope = state.resolvedScope ?? {} + safeEmit('memory:error', { + scope: errScope, + phase: 'persist', + error: errorInfo(error), + timestamp: Date.now(), + }) + await emitError(options, errScope, 'persist', error) + stateByCtx.delete(ctx) + // Mirror the deferred strict-mode semantics of `persistTurn`: reject a + // deferred promise (collected by the engine's `Promise.allSettled`). + if (options.strict) ctx.defer(Promise.reject(error)) + return + } const userText = state.lastUserText const userEmbedding = state.lastUserEmbedding const retrievedMemoryIds = state.retrievedHits.map((h) => h.record.id) @@ -359,9 +393,17 @@ async function applyOps( * paths — `afterPersist` and the persist devtools events fire for every * `adapter.add` commit, not just the finish-turn one. * - * Adapter failures surface via `memory:error` + `events.onError` and (in - * strict mode) re-throw so a deferred persist promise rejects rather than - * being silently swallowed by the chat engine's `Promise.allSettled`. + * Adapter failures always surface via `memory:error` + `events.onError`. + * In strict mode they additionally re-throw, which short-circuits the rest of + * this persist batch and rejects the enclosing deferred promise. + * + * NOTE: because persistence runs via `ctx.defer`, the chat engine awaits the + * deferred promise with `Promise.allSettled` and discards the settled results + * (see `activities/chat/index.ts`). A strict-mode rejection therefore does NOT + * abort the already-finished run or propagate to the `chat()` caller — the + * `memory:error` event / `events.onError` callback is the observable failure + * signal in BOTH modes. Strict mode only aborts the run on the synchronously- + * awaited paths (`onConfig` retrieval, `onAfterToolCall`). */ async function runObservedPersist( options: MemoryMiddlewareOptions, @@ -462,8 +504,11 @@ async function persistTurn(args: { 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. + // routes through the same error plumbing. In strict mode it re-throws to + // reject the deferred promise; note that the engine collects deferred + // rejections via `Promise.allSettled` and discards them, so this rejection + // does not abort the run (see the `runObservedPersist` JSDoc). The + // observable failure signal is the `memory:error` event either way. try { const now = Date.now() @@ -471,18 +516,37 @@ async function persistTurn(args: { // 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`. + // The call is wrapped so a throwing `shouldRemember` emits `memory:error` + // at the source (the outer catch assumes the event already fired). if (options.shouldRemember) { - const keep = await options.shouldRemember({ - message: { role: 'user', content: args.userText }, - responseText: args.responseText, - }) + let keep: boolean + try { + keep = await options.shouldRemember({ + message: { role: 'user', content: args.userText }, + responseText: args.responseText, + }) + } catch (error) { + // A throwing `shouldRemember` is a persist-arc failure. Emit here so + // the outer catch's "already emitted at the source" invariant holds; + // in non-strict mode skip persistence for the turn rather than + // breaking anything downstream. + safeEmit('memory:error', { + scope, + phase: 'persist', + error: errorInfo(error), + timestamp: Date.now(), + }) + await emitError(options, scope, 'persist', error) + if (options.strict) throw error + return + } if (!keep) return } const baseRecords: Array = [] if (args.userText) { baseRecords.push({ - id: crypto.randomUUID(), + id: newRecordId(), scope, text: args.userText, kind: 'message', @@ -518,7 +582,7 @@ async function persistTurn(args: { } } baseRecords.push({ - id: crypto.randomUUID(), + id: newRecordId(), scope, text: args.responseText, kind: 'message', @@ -615,6 +679,8 @@ async function persistTurn(args: { // (c) Strict-mode assistant-side embedder rethrow: the local // try/catch around the assistant embedder call above emitted // `phase: 'persist'` before rethrowing. + // (d) Strict-mode `shouldRemember` rethrow: the gate's own try/catch + // 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 @@ -629,7 +695,14 @@ async function emitError( phase: 'retrieve' | 'persist' | 'extract', error: unknown, ): Promise { - await options.events?.onError?.({ scope, phase, error }) + // Defensive like `safeEmit`: a throwing `onError` handler must never break + // chat (non-strict) or mask the original failure by replacing the in-flight + // error with its own. `onError` is telemetry — swallow anything it throws. + try { + await options.events?.onError?.({ scope, phase, error }) + } catch { + // ignored — an observability callback must not affect chat behaviour + } } /** @@ -669,6 +742,29 @@ function preview(text: string, max = 200): string { return text.length > max ? text.slice(0, max) + '…' : text } +/** + * Portable memory-record id. `crypto.randomUUID()` is NOT a bare global on the + * package's declared Node 18 floor (Web Crypto became an unflagged global only + * in Node 19+), so calling it directly would throw `ReferenceError` there — + * and because id minting happens inside the persist path, that throw would + * silently drop the whole turn's memory in non-strict mode. Prefer the real + * UUID when the global exists (Node 19+, browsers, edge runtimes) and fall + * back to the same `Date.now()`+`Math.random()` pattern used by every other id + * generator in this package. + */ +function newRecordId(): string { + // `try`/`catch` rather than `globalThis.crypto?.randomUUID?.()`: the DOM/Node + // lib types `crypto` as always-present, so optional chaining reads as dead + // code to the linter — but the whole point is that the global genuinely can + // be absent at runtime on Node 18, where the bare access throws + // `ReferenceError`. Catch it and fall back to the package's portable pattern. + try { + return crypto.randomUUID() + } catch { + return `mem-${Date.now()}-${Math.random().toString(36).slice(2, 10)}` + } +} + /** * Defensive devtools emit. Devtools events should be fire-and-forget — if the * event client throws synchronously (misconfigured global, broken transport), diff --git a/packages/ai/tests/middlewares/memory.test.ts b/packages/ai/tests/middlewares/memory.test.ts index 508025cbe..e1f67c6d9 100644 --- a/packages/ai/tests/middlewares/memory.test.ts +++ b/packages/ai/tests/middlewares/memory.test.ts @@ -285,6 +285,56 @@ describe('memoryMiddleware — persistence', () => { expect(texts).toEqual(['Ping', 'Pong.']) }) + it('round trip: a turn persisted on one chat() surfaces in retrieval on the next', async () => { + // The headline behaviour of the whole feature — memory written in one + // turn is retrieved and injected in a LATER turn — exercised end to end + // through two sequential chat() calls sharing one adapter + scope. Unlike + // the retrieval tests (which seed the adapter directly), this drives the + // real persist path in turn 1 and the real retrieval path in turn 2, so a + // mismatch between the persisted record shape and the search contract + // (scope serialization, kind, embedding handling) would fail here. + const memory = fakeAdapter() + + // Turn 1 — persist a distinctive assistant answer. + const turn1 = createMockAdapter({ + iterations: [ + [ + ev.runStarted(), + ev.textContent('Paris is the capital of France.'), + ev.runFinished('stop'), + ], + ], + }) + await collectChunks( + chat({ + adapter: turn1.adapter, + messages: [{ role: 'user', content: 'What is the capital of France?' }], + middleware: [memoryMiddleware({ adapter: memory, scope: baseScope })], + }) as AsyncIterable, + ) + // Deferred persistence has completed by the time collectChunks returns. + expect(memory.store.size).toBeGreaterThan(0) + + // Turn 2 — brand-new chat(), same adapter + scope. Memory from turn 1 + // must be retrieved and injected as a system prompt. + const turn2 = createMockAdapter({ + iterations: [ + [ev.runStarted(), ev.textContent('Sure.'), ev.runFinished('stop')], + ], + }) + await collectChunks( + chat({ + adapter: turn2.adapter, + messages: [{ role: 'user', content: 'remind me what you said' }], + middleware: [memoryMiddleware({ adapter: memory, scope: baseScope })], + }) as AsyncIterable, + ) + const injected = ( + turn2.calls[0] as { systemPrompts?: Array } + ).systemPrompts?.join('\n') + expect(injected).toContain('Paris is the capital of France.') + }) + 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 @@ -935,6 +985,103 @@ describe('memoryMiddleware — error-path observability', () => { } }) + it('a throwing scope resolver does not break chat and emits memory:error (non-strict)', async () => { + // Scope resolution runs a user-supplied callback. If it throws it must + // route through memory:error/onError and be swallowed in non-strict mode + // rather than escaping onConfig/onFinish and breaking the chat request. + const memory = fakeAdapter() + const { adapter } = createMockAdapter({ + iterations: [ + [ev.runStarted(), ev.textContent('R'), ev.runFinished('stop')], + ], + }) + 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 chunks = await collectChunks( + chat({ + adapter, + messages: [{ role: 'user', content: 'U' }], + middleware: [ + memoryMiddleware({ + adapter: memory, + scope: () => { + throw new Error('scope boom') + }, + }), + ], + }) as AsyncIterable, + ) + await new Promise((resolve) => setTimeout(resolve, 0)) + // Chat still produced output — the memory failure did not break the run. + expect(chunks.length).toBeGreaterThan(0) + // The failure surfaced on the retrieve path (onConfig). + expect(errorEvents.some((e) => e.message.includes('scope boom'))).toBe( + true, + ) + // Nothing was persisted (scope never resolved). + expect(memory.store.size).toBe(0) + } finally { + off() + } + }) + + it('a throwing shouldRemember does not break chat and emits memory:error (non-strict)', async () => { + const memory = fakeAdapter() + const { adapter } = createMockAdapter({ + iterations: [ + [ev.runStarted(), ev.textContent('R'), ev.runFinished('stop')], + ], + }) + 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 chunks = await collectChunks( + chat({ + adapter, + messages: [{ role: 'user', content: 'U' }], + middleware: [ + memoryMiddleware({ + adapter: memory, + scope: baseScope, + shouldRemember: () => { + throw new Error('remember boom') + }, + }), + ], + }) as AsyncIterable, + ) + await new Promise((resolve) => setTimeout(resolve, 0)) + expect(chunks.length).toBeGreaterThan(0) + const persistErrors = errorEvents.filter((e) => e.phase === 'persist') + expect( + persistErrors.some((e) => e.message.includes('remember boom')), + ).toBe(true) + // The gate threw before any record was committed. + expect(memory.store.size).toBe(0) + } 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 `{}`. From f86e9da123adcf69810f9b7fccc4338a0a841bf2 Mon Sep 17 00:00:00 2001 From: Jack Herrington Date: Mon, 20 Jul 2026 17:33:53 -0700 Subject: [PATCH 38/45] docs(memory): fix kiira doc-snippet type errors MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The memory docs failed `kiira check` (89 errors) — the doc code-snippet type-checker in CI's Test job. Fixes: - quickstart: inline `messages` instead of an undeclared var; self-contain the embedder example (adapter + scope) and guard the possibly-undefined embedding vector; `ignore` the ioredis swap (ioredis's `Redis` type is structurally broader than the minimal `RedisLike` contract) and the server-side scope-derivation pattern (app-defined context). - overview: drop the `MemoryScope` import that conflicted with the local type illustration; `ignore` the scope-derivation pattern block. - custom-adapter: drop the `MemoryAdapter` import that conflicted with the local interface illustration; `ignore` the pgvector scaffold, the method- body fragments, the contract-test file, and the wire-in snippet — all depend on the `pg` peer dep, elided bodies, or relative modules. No behavior change; docs-only. `test:kiira` and `test:docs` pass locally. Co-Authored-By: Claude Opus 4.8 (1M context) --- docs/memory/custom-adapter.md | 20 +++++++++++++++----- docs/memory/overview.md | 8 +++++--- docs/memory/quickstart.md | 23 ++++++++++++++++++----- 3 files changed, 38 insertions(+), 13 deletions(-) diff --git a/docs/memory/custom-adapter.md b/docs/memory/custom-adapter.md index 2648a2e51..3c33f14bd 100644 --- a/docs/memory/custom-adapter.md +++ b/docs/memory/custom-adapter.md @@ -39,7 +39,6 @@ A `MemoryAdapter` has one identifier and seven methods. The [Overview](./overvie ```ts import type { - MemoryAdapter, MemoryRecord, MemoryRecordPatch, MemoryScope, @@ -49,6 +48,7 @@ import type { MemoryListResult, } from '@tanstack/ai/memory' +// The `MemoryAdapter` contract, as exported from `@tanstack/ai/memory`: interface MemoryAdapter { name: string add(records: MemoryRecord | MemoryRecord[]): Promise @@ -73,7 +73,10 @@ The shared contract suite in `@tanstack/ai-memory/tests/contract.ts` verifies al Pick a backend and stub the eight members. Here's a pgvector skeleton you can copy as a starting point: -```ts +```ts ignore +// ignore: scaffold to copy — `pg` is a peer dependency of a real pgvector +// adapter (not a dependency of these docs), and the method bodies are elided +// stubs, so this is not a standalone-compilable module. import type { MemoryAdapter, MemoryListOptions, @@ -138,7 +141,10 @@ If your backend has native vector or full-text search (pgvector's `<->`, Postgre Implementation specifics are backend-dependent, but the shape is the same everywhere. A pgvector example for `add` and `search` makes the pattern concrete: -```ts +```ts ignore +// ignore: method-body fragments from inside the adapter object above — they +// reference `pool`, `table`, and a `rowToRecord` helper from the full adapter, +// shown to illustrate the query shape, not as a standalone module. async add(input) { const batch = Array.isArray(input) ? input : [input] const now = Date.now() @@ -225,7 +231,9 @@ The shape generalizes: every method takes a `scope`, does its backend-specific w 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 +```ts ignore +// ignore: depends on `pg` (a peer dep, not a docs dependency) and a local +// `../src/pgvector` module — an illustrative test file, not compilable here. // tests/pgvector.test.ts import { Pool } from 'pg' import { runMemoryAdapterContract } from '@tanstack/ai-memory/tests/contract' @@ -247,7 +255,9 @@ The contract module isn't re-exported from `@tanstack/ai-memory`'s public entry Once the contract suite is green, the adapter is interchangeable with the built-ins: -```ts +```ts ignore +// ignore: imports `pg` (a peer dep) and a local `./pgvector-adapter` module, and +// assumes `messages` / `scope` from your app — shown as an integration snippet. import { chat } from '@tanstack/ai' import { openaiText } from '@tanstack/ai-openai' import { memoryMiddleware } from '@tanstack/ai/memory' diff --git a/docs/memory/overview.md b/docs/memory/overview.md index 67d0f6216..5abc960c6 100644 --- a/docs/memory/overview.md +++ b/docs/memory/overview.md @@ -59,8 +59,7 @@ Custom adapters implement `MemoryAdapter` from `@tanstack/ai/memory` — see the `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' - +// The `MemoryScope` type, as exported from `@tanstack/ai/memory`: type MemoryScope = { tenantId?: string userId?: string @@ -72,7 +71,10 @@ type MemoryScope = { **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 +```ts ignore +// ignore: `adapter` and `AppCtx` (the shape of `ctx.context`) are application- +// defined — this shows the server-side scope-derivation pattern rather than +// type-checking against a concrete context type. memoryMiddleware({ adapter, scope: (ctx) => { diff --git a/docs/memory/quickstart.md b/docs/memory/quickstart.md index 506c630a2..f0e874ed2 100644 --- a/docs/memory/quickstart.md +++ b/docs/memory/quickstart.md @@ -48,7 +48,7 @@ const memory = inMemoryMemoryAdapter() const stream = chat({ adapter: openaiText('gpt-4o'), - messages, + messages: [{ role: 'user', content: 'Hello' }], middleware: [ memoryMiddleware({ adapter: memory, @@ -62,7 +62,11 @@ That's a working setup. Each turn, the middleware retrieves relevant records int When you're ready to ship, swap the adapter and keep everything else the same: -```ts +```ts ignore +// ignore: ioredis's `Redis` type is structurally broader than the adapter's +// minimal `RedisLike` contract (heavily overloaded method signatures), so it +// does not nominally match here — but it works at runtime, which is why the +// adapter accepts a BYO ioredis client directly. `scope` is from Step 5. import Redis from 'ioredis' import { redisMemoryAdapter } from '@tanstack/ai-memory' @@ -84,12 +88,14 @@ The middleware accepts an `embedder` for semantic search. **Add one when you nee ```ts import OpenAI from 'openai' import { memoryMiddleware } from '@tanstack/ai/memory' +import { inMemoryMemoryAdapter } from '@tanstack/ai-memory' const openai = new OpenAI() +const memory = inMemoryMemoryAdapter() memoryMiddleware({ adapter: memory, - scope, + scope: { tenantId: 'demo', userId: 'alice' }, embedder: { async embed(text) { // Use any embedding model — OpenAI, Cohere, a local model, etc. @@ -97,7 +103,9 @@ memoryMiddleware({ model: 'text-embedding-3-small', input: text, }) - return result.data[0].embedding + const embedding = result.data[0]?.embedding + if (!embedding) throw new Error('embedding request returned no vector') + return embedding }, }, }) @@ -109,8 +117,13 @@ The embedder is invoked on the retrieval path (to embed the query) and may be in `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 +```ts ignore +// ignore: shows deriving scope from server-validated session state. `AppCtx` +// and the shape of `ctx.context` are application-defined (attached by your auth +// layer), and `messages` / `memory` / `session` come from earlier steps — so +// this is shown as a pattern rather than type-checked against a concrete context. import { chat } from '@tanstack/ai' +import { openaiText } from '@tanstack/ai-openai' import { memoryMiddleware } from '@tanstack/ai/memory' type AppCtx = { session: { tenantId: string; userId: string; activeThreadId: string } } From e17b096149e98f7b128d652bd5b12046e662ba5b Mon Sep 17 00:00:00 2001 From: Jack Herrington Date: Tue, 21 Jul 2026 18:49:30 -0700 Subject: [PATCH 39/45] refactor(memory): recall/save adapter contract, consolidate into @tanstack/ai-memory MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace the CRUD MemoryAdapter contract with a single recall/save contract — the shape every memory backend naturally exposes — and move the middleware, contract, and helpers out of @tanstack/ai into @tanstack/ai-memory. Core no longer carries any memory code (the unreleased @tanstack/ai/memory subpath is removed). - Contract: MemoryAdapter = { id, recall(scope, query), save(scope, turn), inspect?, listFacts? } over a session-centric MemoryScope. recall returns a rendered systemPrompt plus optional fragments and LLM tools/toolGuidance; save persists a { user, assistant } turn. Extraction/ranking/rendering live in the adapter, not the middleware. - Thin memoryMiddleware: recall-on-init injects prompt + tools, deferred save-on-finish, memory:* devtools events, onRecall/onSave callbacks, and a save-only role. composeMemoryMiddleware stacks adapters. - Adapters on flat subpaths: inMemory(), redis() (BYO client, preserves the malformed-row + delimiter-escaping hardening), and vendor adapters hindsight(), mem0(), honcho() with lazily-loaded optional-peer SDKs. - Shared recall/save contract-test suite; redis hardening tests kept; new middleware unit test; a memory scenario added to the e2e middleware harness. - Docs rewritten (overview, quickstart, adapters, custom-adapter) with every option documented and an example of each; skills moved/rewritten; changeset, knip, and event payloads updated. Co-Authored-By: Claude Opus 4.8 (1M context) --- .changeset/memory-middleware.md | 45 +- docs/config.json | 14 +- docs/memory/adapters.md | 206 +++ docs/memory/custom-adapter.md | 362 ++--- docs/memory/overview.md | 225 ++-- docs/memory/quickstart.md | 108 +- packages/ai-event-client/src/index.ts | 59 +- packages/ai-memory/package.json | 41 +- .../tanstack-ai-memory-hindsight/SKILL.md | 38 + .../skills/tanstack-ai-memory-honcho/SKILL.md | 36 + .../tanstack-ai-memory-in-memory/SKILL.md | 35 +- .../skills/tanstack-ai-memory-mem0/SKILL.md | 33 + .../skills/tanstack-ai-memory-redis/SKILL.md | 74 +- .../skills/tanstack-ai-memory/SKILL.md | 94 ++ packages/ai-memory/src/adapters/in-memory.ts | 144 -- packages/ai-memory/src/adapters/redis.ts | 557 -------- packages/ai-memory/src/in-memory.ts | 59 + packages/ai-memory/src/index.ts | 34 +- packages/ai-memory/src/internal/store.ts | 368 ++++++ packages/ai-memory/src/middleware.ts | 329 +++++ .../src/providers/hindsight/index.ts | 219 +++ .../src/providers/hindsight/tools.ts | 131 ++ .../ai-memory/src/providers/honcho/index.ts | 212 +++ .../ai-memory/src/providers/mem0/index.ts | 177 +++ packages/ai-memory/src/redis.ts | 164 +++ packages/ai-memory/src/types.ts | 160 +++ packages/ai-memory/tests/contract.ts | 570 +------- packages/ai-memory/tests/in-memory.test.ts | 31 +- packages/ai-memory/tests/middleware.test.ts | 116 ++ packages/ai-memory/tests/redis.test.ts | 145 +- packages/ai-memory/vite.config.ts | 9 +- packages/ai/package.json | 4 - .../ai/skills/tanstack-ai-memory/SKILL.md | 115 -- packages/ai/src/memory/helpers.ts | 147 --- packages/ai/src/memory/index.ts | 28 - packages/ai/src/memory/middleware.ts | 802 ----------- packages/ai/src/memory/types.ts | 617 --------- packages/ai/tests/memory/helpers.test.ts | 224 ---- packages/ai/tests/middlewares/memory.test.ts | 1175 ----------------- packages/ai/vite.config.ts | 1 - pnpm-lock.yaml | 29 + testing/e2e/package.json | 1 + testing/e2e/src/lib/memory-capture.ts | 52 + testing/e2e/src/routes/api.middleware-test.ts | 89 +- testing/e2e/src/routes/middleware-test.tsx | 25 + testing/e2e/tests/middleware.spec.ts | 46 + 46 files changed, 3180 insertions(+), 4970 deletions(-) create mode 100644 docs/memory/adapters.md create mode 100644 packages/ai-memory/skills/tanstack-ai-memory-hindsight/SKILL.md create mode 100644 packages/ai-memory/skills/tanstack-ai-memory-honcho/SKILL.md create mode 100644 packages/ai-memory/skills/tanstack-ai-memory-mem0/SKILL.md create mode 100644 packages/ai-memory/skills/tanstack-ai-memory/SKILL.md delete mode 100644 packages/ai-memory/src/adapters/in-memory.ts delete mode 100644 packages/ai-memory/src/adapters/redis.ts create mode 100644 packages/ai-memory/src/in-memory.ts create mode 100644 packages/ai-memory/src/internal/store.ts create mode 100644 packages/ai-memory/src/middleware.ts create mode 100644 packages/ai-memory/src/providers/hindsight/index.ts create mode 100644 packages/ai-memory/src/providers/hindsight/tools.ts create mode 100644 packages/ai-memory/src/providers/honcho/index.ts create mode 100644 packages/ai-memory/src/providers/mem0/index.ts create mode 100644 packages/ai-memory/src/redis.ts create mode 100644 packages/ai-memory/src/types.ts create mode 100644 packages/ai-memory/tests/middleware.test.ts delete mode 100644 packages/ai/skills/tanstack-ai-memory/SKILL.md delete mode 100644 packages/ai/src/memory/helpers.ts delete mode 100644 packages/ai/src/memory/index.ts delete mode 100644 packages/ai/src/memory/middleware.ts delete mode 100644 packages/ai/src/memory/types.ts delete mode 100644 packages/ai/tests/memory/helpers.test.ts delete mode 100644 packages/ai/tests/middlewares/memory.test.ts create mode 100644 testing/e2e/src/lib/memory-capture.ts diff --git a/.changeset/memory-middleware.md b/.changeset/memory-middleware.md index 6598bfa11..0a993124d 100644 --- a/.changeset/memory-middleware.md +++ b/.changeset/memory-middleware.md @@ -4,22 +4,41 @@ '@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. +**Add server-side memory via a `recall`/`save` adapter contract in `@tanstack/ai-memory`.** + +Memory is now a single, provider-agnostic contract with two verbs — `recall` and +`save` — which is the shape every memory backend (in-process, Redis, and hosted +vendors) naturally exposes. `memoryMiddleware` recalls relevant memory into the +system prompt (and optionally injects vendor tools) before the model runs, then +defers `save` of the finished turn via `ctx.defer` so streaming is never blocked. +Extraction, ranking, and rendering live inside each adapter — the middleware is thin. + +`@tanstack/ai-memory` (new package) — everything ships here: + +- Root: `memoryMiddleware` + `composeMemoryMiddleware`, the `MemoryAdapter` contract + (`recall` / `save` / optional `inspect` / `listFacts`), and the `MemoryScope` / + `MemoryTurn` / `RecallResult` / `SaveReceipt` types. +- `@tanstack/ai-memory/in-memory` → `inMemory()` — zero-dependency adapter for dev, + tests, and single-process demos. Pass an `embedder` for semantic scoring and/or an + `extract` function to persist derived facts. +- `@tanstack/ai-memory/redis` → `redis({ redis, prefix? })` — production adapter for + plain Redis. `ioredis` wires in directly; `redis` (node-redis v4+) via the + `nodeRedisAsRedisLike(client)` wrapper. Both are optional peer dependencies. +- `@tanstack/ai-memory/hindsight` → `hindsight()`, `@tanstack/ai-memory/mem0` → + `mem0()`, `@tanstack/ai-memory/honcho` → `honcho()` — hosted-vendor adapters. Their + SDKs (`@vectorize-io/hindsight-client`, `@honcho-ai/sdk`) are optional peers loaded + lazily; mem0 talks to its server over plain HTTP (no SDK). Vendors can expose LLM + tools through `recall` (e.g. hindsight's retain/recall/reflect). +- A shared `recall`/`save` contract-test suite (`@tanstack/ai-memory/tests/contract`) + that any adapter — including third-party ones — can run. `@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. +- **Removes the (unreleased) `@tanstack/ai/memory` subpath.** The middleware, + contract, and helpers all moved to `@tanstack/ai-memory`. `@tanstack/ai-event-client`: -- Five new events on `AIDevtoolsEventMap`: `memory:retrieve:started`, `memory:retrieve:completed`, `memory:persist:started`, `memory:persist:completed`, `memory:error`. - -`@tanstack/ai-memory` (new package): - -- `inMemoryMemoryAdapter()` — zero-dep adapter for dev/tests. -- `redisMemoryAdapter({ redis, prefix? })` — production adapter for plain Redis. `ioredis` and `redis` (node-redis v4+) are both supported as optional peer dependencies. -- `nodeRedisAsRedisLike(client)` — helper for users wiring `redis` (node-redis v4+) without `legacyMode`; translates the camelCase API into the lowercase `RedisLike` shape the adapter expects. `ioredis` clients wire in directly without a wrapper. -- Both adapters pass a shared contract suite covering scope isolation, expiry, cursor pagination, kinds filtering, lexical-only ranking, semantic ranking with embeddings, and serialization round-trip (Redis). +- The five `memory:*` devtools events (`memory:retrieve:started` / `:completed`, + `memory:persist:started` / `:completed`, `memory:error`) now carry recall/save + payloads (adapter id, fragment/receipt counts, `phase: 'recall' | 'save'`). diff --git a/docs/config.json b/docs/config.json index 85c3141d9..2400e284a 100644 --- a/docs/config.json +++ b/docs/config.json @@ -427,15 +427,23 @@ "children": [ { "label": "Overview", - "to": "memory/overview" + "to": "memory/overview", + "addedAt": "2026-07-21" }, { "label": "Quickstart", - "to": "memory/quickstart" + "to": "memory/quickstart", + "addedAt": "2026-07-21" + }, + { + "label": "Adapters", + "to": "memory/adapters", + "addedAt": "2026-07-21" }, { "label": "Custom Adapter", - "to": "memory/custom-adapter" + "to": "memory/custom-adapter", + "addedAt": "2026-07-21" } ] }, diff --git a/docs/memory/adapters.md b/docs/memory/adapters.md new file mode 100644 index 000000000..e5263d853 --- /dev/null +++ b/docs/memory/adapters.md @@ -0,0 +1,206 @@ +--- +title: Adapters +id: memory-adapters +order: 3 +description: "Every built-in and vendor memory adapter in @tanstack/ai-memory, with all of their options and an example of each — inMemory, redis, hindsight, mem0, honcho." +keywords: + - tanstack ai + - memory + - adapters + - inMemory + - redis + - hindsight + - mem0 + - honcho + - options +--- + +Every adapter implements the same `recall`/`save` contract, so they're interchangeable +in `memoryMiddleware`. This page is the exhaustive option reference — each adapter's +options with an example of each. + +- [Common options](#common-options) — shared by `inMemory()` and `redis()` +- [`inMemory()`](#inmemory) · [`redis()`](#redis) · [`hindsight()`](#hindsight) · [`mem0()`](#mem0) · [`honcho()`](#honcho) + +## Common options + +`inMemory()` and `redis()` are both client-side rankers built on the same pipeline, so +they share these options. + +| Option | Type | Default | Purpose | +|--------|------|---------|---------| +| `topK` | `number` | `6` | Max hits returned by `recall`. | +| `minScore` | `number` | `0.15` | Drop hits scoring below this. | +| `kinds` | `Array` | all | Restrict recall to these record kinds (`'message'`, `'summary'`, `'fact'`, `'preference'`). | +| `embedder` | `{ embed(text): Promise }` | — | Enable semantic scoring (embeds on both `recall` and `save`). | +| `extract` | `(turn, scope) => ExtractedFact[]` | — | Persist derived facts on `save`, alongside the raw turn. | +| `render` | `(hits) => string` | built-in | Replace the prompt renderer. | + +Every option, in one adapter: + +```ts +import { inMemory } from '@tanstack/ai-memory/in-memory' + +// `embedText` stands in for your embedding client (OpenAI, Cohere, a local model). +declare function embedText(text: string): Promise> + +const memory = inMemory({ + topK: 8, // return up to 8 hits + minScore: 0.2, // ignore weak matches + kinds: ['message', 'fact', 'preference'], // skip summaries + embedder: { embed: embedText }, // semantic + lexical scoring + extract: (turn) => [ + // store a derived fact in addition to the raw turn + { text: `User said: ${turn.user}`, kind: 'fact', importance: 0.8 }, + ], + render: (hits) => + // custom prompt block instead of the default renderer + `What I remember:\n${hits.map((h) => `- ${h.record.text}`).join('\n')}`, +}) +``` + +**`extract`** returns `ExtractedFact[]` — `{ text, kind?, importance?, metadata? }`. Return +`undefined` for a no-op. It's where an LLM-based fact extractor plugs in without the +adapter taking a hard dependency on any model. + +**`embedder`** is invoked on the recall path (to embed the query) and again on save (to +embed stored text). Without it, scoring is lexical + recency only. + +## `inMemory()` + +Zero-dependency, `Map`-backed. Takes only the [common options](#common-options) above. +Records vanish on restart — use it for dev, tests, and single-process demos. + +```ts +import { inMemory } from '@tanstack/ai-memory/in-memory' + +const memory = inMemory() // all options are optional +``` + +## `redis()` + +Plain-Redis adapter. Adds two options to the [common options](#common-options), and +requires a client. + +| Option | Type | Default | Purpose | +|--------|------|---------|---------| +| `redis` | `RedisLike` | — (required) | Your Redis client (`ioredis`, or node-redis via `nodeRedisAsRedisLike`). | +| `prefix` | `string` | `'tanstack-ai:memory'` | Key namespace. | + +```ts ignore +// ignore: needs a live ioredis client. ioredis's `Redis` type is structurally broader +// than the minimal `RedisLike` the adapter needs, so it doesn't nominally match — but it +// works at runtime, which is why the adapter accepts a BYO ioredis client directly. +import Redis from 'ioredis' +import { redis } from '@tanstack/ai-memory/redis' + +const memory = redis({ + redis: new Redis(process.env.REDIS_URL), // required + prefix: 'myapp:memory', // key namespace + topK: 8, // common options apply here too + minScore: 0.2, +}) +``` + +Using **node-redis** (`redis` package) instead of `ioredis`? Its camelCase API doesn't +match `RedisLike` — wrap it with `nodeRedisAsRedisLike`: + +```ts ignore +// ignore: needs a live node-redis client. +import { createClient } from 'redis' +import { redis, nodeRedisAsRedisLike } from '@tanstack/ai-memory/redis' + +const client = createClient({ url: process.env.REDIS_URL }) +await client.connect() + +const memory = redis({ redis: nodeRedisAsRedisLike(client) }) +``` + +`ioredis` and `redis` are both optional peer dependencies — install whichever you use. + +## `hindsight()` + +Hosted adapter backed by Hindsight. Owns extraction/ranking server-side and exposes +`retain`/`recall`/`reflect` LLM tools through `recall`. `@vectorize-io/hindsight-client` +is an optional peer, loaded lazily. + +| Option | Type | Default | Purpose | +|--------|------|---------|---------| +| `user` | `string` | `scope.userId` | Durable user id used in the bank key (`{user}__{sessionId}`). | +| `baseUrl` | `string` | `HINDSIGHT_URL` / `http://localhost:8888` | Server URL. | +| `budget` | `'low' \| 'mid' \| 'high'` | `'mid'` | Recall budget. | +| `onToolRetain` | `(receipt) => void` | — | Fired when the model calls `hindsight_retain`. | +| `onToolRecall` | `(query, result) => void` | — | Fired when the model calls `hindsight_recall`. | + +```ts ignore +// ignore: requires a running Hindsight server + the @vectorize-io/hindsight-client peer. +import { hindsight } from '@tanstack/ai-memory/hindsight' + +const memory = hindsight({ + user: 'alice', // bank = alice__{sessionId} + baseUrl: 'https://hindsight.internal', // default: HINDSIGHT_URL + budget: 'high', // deeper recall + onToolRetain: (receipt) => console.log('model retained', receipt.ok), + onToolRecall: (query, result) => + console.log('model recalled', query, result.fragments?.length), +}) +``` + +## `mem0()` + +Hosted adapter backed by a mem0 server, over plain HTTP (no SDK peer). Requires a running +mem0 server. + +| Option | Type | Default | Purpose | +|--------|------|---------|---------| +| `user` | `string` | `scope.userId` / `'demo-user'` | mem0 `user_id`. | +| `baseUrl` | `string` | `MEM0_URL` / `http://localhost:8000` | Server URL. | +| `apiKey` | `string` | `MEM0_ADMIN_API_KEY` | Bearer token. | +| `rerank` | `boolean` | `true` | Ask mem0 to rerank search results. | +| `threshold` | `number` | `0.1` | Minimum search score. | + +```ts ignore +// ignore: requires a running mem0 server. +import { mem0 } from '@tanstack/ai-memory/mem0' + +const memory = mem0({ + user: 'alice', // mem0 user_id + baseUrl: 'https://mem0.internal', // default: MEM0_URL + apiKey: process.env.MEM0_ADMIN_API_KEY, // bearer token + rerank: true, // rerank results + threshold: 0.2, // stricter score floor +}) +``` + +## `honcho()` + +Hosted adapter backed by Honcho. `recall` returns a synthesized dialectic answer over the +user's representation (no discrete fragments). `@honcho-ai/sdk` is an optional peer, +loaded lazily. + +| Option | Type | Default | Purpose | +|--------|------|---------|---------| +| `user` | `string` | `scope.userId` / `'demo-user'` | User peer id. | +| `baseURL` | `string` | `HONCHO_URL` / `http://localhost:8001` | Server URL. | +| `workspaceId` | `string` | `HONCHO_APP_NAME` / `'ai-memory'` | Workspace id. | +| `apiKey` | `string` | `HONCHO_API_KEY` / `'dev-no-auth'` | API key. | +| `assistantId` | `string` | `'assistant'` | Assistant peer id. | + +```ts ignore +// ignore: requires a running Honcho server + the @honcho-ai/sdk peer. +import { honcho } from '@tanstack/ai-memory/honcho' + +const memory = honcho({ + user: 'alice', // user peer + baseURL: 'https://honcho.internal', // default: HONCHO_URL + workspaceId: 'my-app', // default: HONCHO_APP_NAME + apiKey: process.env.HONCHO_API_KEY, // default: 'dev-no-auth' + assistantId: 'support-bot', // default: 'assistant' +}) +``` + +## Where to go next + +- [Overview](./overview) — the contract, `memoryMiddleware` options, devtools events +- [Quickstart](./quickstart) — wire an adapter into a real `chat()` call +- [Custom Adapter](./custom-adapter) — implement `recall`/`save` for a backend not shipped diff --git a/docs/memory/custom-adapter.md b/docs/memory/custom-adapter.md index 3c33f14bd..83ac2c8cc 100644 --- a/docs/memory/custom-adapter.md +++ b/docs/memory/custom-adapter.md @@ -1,325 +1,173 @@ --- 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." +order: 4 +description: "Write a recall/save MemoryAdapter for a backend that isn't shipped — pgvector, MongoDB, DynamoDB, a hosted memory service. Two methods, one shared contract test." keywords: - tanstack ai - memory - custom adapter - MemoryAdapter + - recall + - save - 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. +You have a backend in mind — pgvector, MongoDB, DynamoDB, a hosted memory API — and the +built-in `inMemory()` / `redis()` adapters don't fit. A memory adapter is just an object +with two methods, `recall` and `save`, so this is a short guide. -> **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. +> **First time looking at memory?** Start with the [Overview](./overview) for what the +> contract is and how the middleware uses it. -## 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. +## The contract ```ts -import type { - MemoryRecord, - MemoryRecordPatch, - MemoryScope, - MemoryQuery, - MemorySearchResult, - MemoryListOptions, - MemoryListResult, -} from '@tanstack/ai/memory' +// The MemoryAdapter contract, from `@tanstack/ai-memory`: +import type { MemoryAdapter } from '@tanstack/ai-memory' +``` -// The `MemoryAdapter` contract, as exported from `@tanstack/ai/memory`: +```ts ignore +// ignore: the shape of the contract, shown for reference. 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 + id: string + recall(scope: MemoryScope, query: string): Promise + save(scope: MemoryScope, turn: MemoryTurn): Promise> + inspect?(scope: MemoryScope): Promise // optional (devtools) + listFacts?(scope: MemoryScope): Promise> // optional (devtools) } ``` -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. +Two rules the middleware relies on: -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. +1. **`recall` decides relevance.** Return a rendered `systemPrompt` (empty string when + there's nothing), plus optional `fragments`, `tools`, and `toolGuidance`. Ranking + strategy is entirely yours — lexical, vector, hybrid, or vendor-native. +2. **`save` owns extraction.** Turn the `{ user, assistant }` turn into whatever you + persist. Return one `SaveReceipt` per underlying write. -## Step 1 — Scaffold the adapter shape +Scope isolation is your responsibility: a `recall` for one `scope` must never surface +another scope's data. -Pick a backend and stub the eight members. Here's a pgvector skeleton you can copy as a starting point: +## Step 1 — Scaffold ```ts ignore -// ignore: scaffold to copy — `pg` is a peer dependency of a real pgvector -// adapter (not a dependency of these docs), and the method bodies are elided -// stubs, so this is not a standalone-compilable module. +// ignore: `pg` is a peer dependency of a real pgvector adapter (not of these docs), +// and the method bodies are elided — this is a scaffold to copy. import type { MemoryAdapter, - MemoryListOptions, - MemoryListResult, - MemoryQuery, - MemoryRecord, - MemoryRecordPatch, MemoryScope, - MemorySearchResult, -} from '@tanstack/ai/memory' + MemoryTurn, + RecallResult, + SaveReceipt, +} 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 - +export function pgvectorMemory(options: { pool: Pool; embed: Embed }): MemoryAdapter { + const { pool, embed } = options 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) { /* … */ }, + id: 'pgvector', + + async save(scope: MemoryScope, turn: MemoryTurn): Promise> { + const rows = [ + { role: 'user', text: turn.user }, + { role: 'assistant', text: turn.assistant }, + ] + for (const row of rows) { + const vector = await embed(row.text) + await pool.query( + `INSERT INTO memory (session_id, user_id, role, text, embedding) + VALUES ($1, $2, $3, $4, $5)`, + [scope.sessionId, scope.userId ?? null, row.role, row.text, JSON.stringify(vector)], + ) + } + return [{ ok: true }] + }, + + async recall(scope: MemoryScope, query: string): Promise { + const q = await embed(query) + const { rows } = await pool.query( + `SELECT text, 1 - (embedding <=> $1::vector) AS score + FROM memory + WHERE session_id = $2 AND ($3::text IS NULL OR user_id = $3) + ORDER BY score DESC + LIMIT 6`, + [JSON.stringify(q), scope.sessionId, scope.userId ?? null], + ) + const fragments = rows.map((r) => ({ text: r.text, source: 'pgvector' })) + const systemPrompt = fragments.length + ? `Relevant memory:\n${fragments.map((f) => `- ${f.text}`).join('\n')}` + : '' + return { systemPrompt, fragments } + }, } } ``` -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' -``` +The shape generalizes: every method takes a `scope`, does its backend-specific work, +and keeps scopes isolated. For a backend without native search, load the scope's +records and rank them yourself. -- `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. +## Step 2 — Run the contract suite -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: +`@tanstack/ai-memory/tests/contract` exports `runMemoryAdapterContract`. Point it at a +factory that returns a fresh adapter — it verifies the save→recall round-trip, scope +isolation, empty recall, receipt shape, and the optional introspection methods. ```ts ignore -// ignore: method-body fragments from inside the adapter object above — they -// reference `pool`, `table`, and a `rowToRecord` helper from the full adapter, -// shown to illustrate the query shape, not as a standalone module. -async add(input) { - const batch = Array.isArray(input) ? input : [input] - const now = Date.now() - - for (const r of batch) { - await pool.query( - `INSERT INTO ${table} (id, tenant_id, user_id, session_id, thread_id, namespace, - text, kind, role, created_at, updated_at, expires_at, - importance, embedding, metadata) - VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15) - ON CONFLICT (id) DO UPDATE SET - tenant_id = EXCLUDED.tenant_id, - user_id = EXCLUDED.user_id, - session_id = EXCLUDED.session_id, - thread_id = EXCLUDED.thread_id, - namespace = EXCLUDED.namespace, - text = EXCLUDED.text, - kind = EXCLUDED.kind, - role = EXCLUDED.role, - updated_at = EXCLUDED.updated_at, - expires_at = EXCLUDED.expires_at, - importance = EXCLUDED.importance, - embedding = EXCLUDED.embedding, - metadata = EXCLUDED.metadata`, - [ - r.id, r.scope.tenantId ?? null, r.scope.userId ?? null, - r.scope.sessionId ?? null, r.scope.threadId ?? null, r.scope.namespace ?? null, - r.text, r.kind, r.role ?? null, r.createdAt ?? now, now, - r.expiresAt ?? null, r.importance ?? null, - r.embedding ? JSON.stringify(r.embedding) : null, - r.metadata ? JSON.stringify(r.metadata) : null, - ], - ) - } -}, - -async search(query: MemoryQuery): Promise { - const topK = query.topK ?? 6 - const minScore = query.minScore ?? 0 - const offset = query.cursor ? Number.parseInt(query.cursor, 10) || 0 : 0 - - const { rows } = await pool.query( - `SELECT *, - CASE WHEN $1::vector IS NOT NULL AND embedding IS NOT NULL - THEN 1 - (embedding <=> $1::vector) - ELSE 0 - END AS score - FROM ${table} - WHERE ($2::text IS NULL OR tenant_id = $2) - AND ($3::text IS NULL OR user_id = $3) - AND ($4::text IS NULL OR session_id = $4) - AND ($5::text IS NULL OR thread_id = $5) - AND ($6::text IS NULL OR namespace = $6) - AND (expires_at IS NULL OR expires_at > $7) - AND ($8::text[] IS NULL OR kind = ANY($8)) - ORDER BY score DESC - OFFSET $9 LIMIT $10`, - [ - query.embedding ? JSON.stringify(query.embedding) : null, - query.scope.tenantId ?? null, query.scope.userId ?? null, - query.scope.sessionId ?? null, query.scope.threadId ?? null, - query.scope.namespace ?? null, - Date.now(), - query.kinds ?? null, - offset, topK + 1, - ], - ) - - const hits = rows.slice(0, topK).map((row) => ({ - record: rowToRecord(row), - score: Number(row.score), - })).filter((h) => h.score >= minScore) - - return { - hits, - nextCursor: rows.length > topK ? String(offset + topK) : undefined, - } -} -``` - -The shape generalizes: every method takes a `scope`, does its backend-specific work, and respects the three invariants. For backends without native search, fall back to "load scope-matched records, score via `defaultScoreHit`, sort, slice" — that's exactly what `inMemoryMemoryAdapter` does. - -## Step 4 — Run the contract suite - -The shared test suite in `@tanstack/ai-memory/tests/contract.ts` is the canonical verification for any adapter. Import `runMemoryAdapterContract` and point it at a factory that returns a fresh adapter: - -```ts ignore -// ignore: depends on `pg` (a peer dep, not a docs dependency) and a local -// `../src/pgvector` module — an illustrative test file, not compilable here. +// ignore: depends on `pg` (a peer dep) and a local `../src/pgvector` module. // tests/pgvector.test.ts -import { Pool } from 'pg' import { runMemoryAdapterContract } from '@tanstack/ai-memory/tests/contract' -import { pgvectorMemoryAdapter } from '../src/pgvector' +import { pgvectorMemory } 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 }) +runMemoryAdapterContract('pgvectorMemory', async () => { + const pool = makeCleanPool() // truncate between tests for a fresh adapter + return pgvectorMemory({ pool, embed }) }) ``` -The suite covers `add` (single, batch, upsert), `get`, `update`, `search` (topK, minScore, kinds filter, cursor pagination, lexical-vs-semantic ranking), `list`, `delete`, `clear`, scope isolation across every method, expiry filtering, partial-scope cascades, glob metacharacter safety, and colon and underscore safety. If your adapter passes, every adapter-level contract guarantee is met. - -The contract module isn't re-exported from `@tanstack/ai-memory`'s public entry yet — import directly from `@tanstack/ai-memory/tests/contract` until that lands. - -## Step 5 — Wire it into `memoryMiddleware` +## Step 3 — Wire it into `memoryMiddleware` -Once the contract suite is green, the adapter is interchangeable with the built-ins: +Once the suite is green, the adapter is interchangeable with the built-ins: ```ts ignore -// ignore: imports `pg` (a peer dep) and a local `./pgvector-adapter` module, and -// assumes `messages` / `scope` from your app — shown as an integration snippet. +// ignore: imports `pg` (a peer dep) and a local `./pgvector` module, and assumes +// `messages` / `scope` from your app. 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' +import { memoryMiddleware } from '@tanstack/ai-memory' +import { pgvectorMemory } from './pgvector' -const pool = new Pool({ connectionString: process.env.DATABASE_URL }) -const memory = pgvectorMemoryAdapter({ pool }) +const memory = pgvectorMemory({ pool, embed }) const stream = chat({ - adapter: openaiText('gpt-4o'), + adapter: openaiText('gpt-5.5'), 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: +The middleware never inspects the adapter's internals — `recall`/`save` is the entire +interface. -- 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. +## Exposing tools (optional) -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" - } -} -``` +`recall` can return `tools` and `toolGuidance` to give the model direct control over +memory (this is how the `hindsight()` adapter exposes retain/recall/reflect tools). The +middleware merges them into the run's tools and injects the guidance ahead of the +recalled prompt. Return `tools: []` (or omit it) when your adapter exposes none. ## 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. +- **Keep scopes isolated.** If you serialize scope into a composite key, escape your + delimiter so a `sessionId`/`userId` containing it can't collide with another scope. +- **`recall` must not throw for an empty scope.** Return `{ systemPrompt: '' }`. +- **Extraction lives in `save`.** Don't expect the middleware to derive facts — the raw + turn is handed to you; store or summarize it however you like. ## Where to go next -- [Overview](./overview) — adapter contract, hooks reference, devtools events, failure modes +- [Overview](./overview) — contract, scope, `memoryMiddleware` options, devtools events +- [Adapters](./adapters) — the built-in and vendor adapters, with every option - [Quickstart](./quickstart) — wire `memoryMiddleware` into a real `chat()` call -- [Middleware](../advanced/middleware) — the underlying `chat()` middleware lifecycle, useful when your adapter needs to coordinate with other middlewares diff --git a/docs/memory/overview.md b/docs/memory/overview.md index 5abc960c6..fe7adac07 100644 --- a/docs/memory/overview.md +++ b/docs/memory/overview.md @@ -2,7 +2,7 @@ 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." +description: "Persist and recall context across turns and sessions in TanStack AI — memoryMiddleware recalls relevant memory into the prompt through a pluggable recall/save adapter, then deferred-saves each finished turn." keywords: - tanstack ai - memory @@ -14,115 +14,164 @@ keywords: - 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. +`memoryMiddleware` plugs server-side memory into a `chat()` run. Before the model +runs it **recalls** relevant memory from a pluggable adapter into the system prompt; +after the run finishes it **saves** the turn — asynchronously, so streaming is never +blocked. It's the right tool when you need recall **across turns or across sessions**, +not for keeping recent messages in the same request. -> **Want a copy-paste setup before reading the contract?** See the [Memory Quickstart](./quickstart) guide. **Building an adapter for a backend that isn't shipped?** See the [Custom Adapter](./custom-adapter) guide. +Everything lives in `@tanstack/ai-memory`: the middleware, the adapter contract, and +the built-in and vendor adapters. + +> **Want a copy-paste setup?** See the [Quickstart](./quickstart). **Building an +> adapter for a backend that isn't shipped?** See the [Custom Adapter](./custom-adapter) guide. ## When to reach for it | Need | Use this | |------|----------| -| "Remember what the user told me last week" | Memory middleware + persistent adapter | -| "Each tenant or user has its own context" | Memory middleware with scoped adapter calls | -| "Cache expensive tool results across requests" | Memory middleware with `onToolResult` + `kind: 'tool-result'` | +| "Remember what the user told me last week" | Memory middleware + a persistent adapter | +| "Each user has their own context" | Memory middleware with a scoped adapter | +| "Use a hosted memory service (mem0, Honcho, Hindsight)" | The matching vendor adapter | | 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 +## The contract: `recall` + `save` -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: +A memory adapter has one identifier and two verbs. Everything else — extraction, +ranking, rendering, storage — is the adapter's job. The middleware never inspects +records. -| Method | Purpose | +| Member | 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. | +| `id` | Stable identifier used in logs and devtools. | +| `recall(scope, query)` | Return what's relevant to `query` within `scope`: a rendered `systemPrompt`, optional `fragments`, and optional LLM `tools` + `toolGuidance`. | +| `save(scope, turn)` | Persist a completed `{ user, assistant }` turn. Extraction happens here. Returns one `SaveReceipt` per underlying write. | +| `inspect(scope)?` | Optional — a full snapshot for a devtools panel. | +| `listFacts(scope)?` | Optional — a flat fact list for a devtools panel. | -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. +```ts +// The MemoryAdapter contract, from `@tanstack/ai-memory`: +import type { MemoryAdapter } from '@tanstack/ai-memory' +``` -Built-in adapters live in `@tanstack/ai-memory`: +Built-in adapters (each a tree-shakeable subpath): ```ts -import { inMemoryMemoryAdapter, redisMemoryAdapter } from '@tanstack/ai-memory' +import { inMemory } from '@tanstack/ai-memory/in-memory' +import { redis } from '@tanstack/ai-memory/redis' ``` -Custom adapters implement `MemoryAdapter` from `@tanstack/ai/memory` — see the [Custom Adapter](./custom-adapter) guide for a complete walkthrough. +Vendor adapters: + +```ts +import { hindsight } from '@tanstack/ai-memory/hindsight' +import { mem0 } from '@tanstack/ai-memory/mem0' +import { honcho } from '@tanstack/ai-memory/honcho' +``` ## Scope and security -`MemoryScope` is the isolation boundary. Every key is optional and orthogonal — the adapter rejects cross-scope reads and writes: +`MemoryScope` is the isolation boundary — session-centric, with an optional durable +user id: ```ts -// The `MemoryScope` type, as exported from `@tanstack/ai/memory`: +// The MemoryScope type, from `@tanstack/ai-memory`: type MemoryScope = { - tenantId?: string + sessionId: 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: +**Always derive scope server-side from trusted state.** Accepting `userId` from the +request body is how one user reads another user's memory. The function form on `scope` +runs per request and only sees what your server attached to the chat context: ```ts ignore -// ignore: `adapter` and `AppCtx` (the shape of `ctx.context`) are application- -// defined — this shows the server-side scope-derivation pattern rather than -// type-checking against a concrete context type. +// ignore: `adapter` and `getSession` are application-defined — this shows the +// server-side scope-derivation pattern. memoryMiddleware({ adapter, scope: (ctx) => { - const session = (ctx.context as AppCtx).session // server-validated - return { - tenantId: session.tenantId, - userId: session.userId, - threadId: session.activeThreadId, - } + const session = getSession(ctx) // your server-validated session + return { sessionId: session.threadId, userId: session.userId } }, }) ``` -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. +## Recall flow (read side) -## Retrieval flow +Runs once per `chat()` invocation, during the `init` phase: -Retrieval runs once per `chat()` invocation, during the `init` phase: +1. `adapter.recall({ sessionId, userId }, userText)` — the adapter decides how to + rank (lexical, semantic, hybrid, or vendor-native). +2. The middleware injects `result.toolGuidance` and `result.systemPrompt` into the + system prompts, and merges `result.tools` into the run's tools. -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`. +Set `role: 'save-only'` to skip recall entirely (persist without reading). -An `embedder` is **optional**. Adapters that support semantic search (Redis with vector ops, hosted vector DBs) need one; lexical-only setups don't. +## Save flow (write side) -## Persistence flow +Deferred via `ctx.defer` — runs after the stream finishes and never blocks the response: -Persistence is **deferred** via `ctx.defer` — it runs after the chat stream finishes and never blocks the response: +1. The middleware captures the `{ user, assistant }` turn. +2. `adapter.save(scope, turn)` persists it. Extraction (turn → stored facts) is the + adapter's responsibility — the built-in adapters store the raw turn by default and + accept an `extract` option; vendors extract server-side. -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). +## `memoryMiddleware` options -## Extension hooks +| Option | Type | Default | Purpose | +|--------|------|---------|---------| +| `adapter` | `MemoryAdapter` | — (required) | The backend to `recall` from / `save` to. | +| `scope` | `MemoryScope \| (ctx) => MemoryScope` | — (required) | Isolation scope, static or derived per request. | +| `role` | `'recall+save' \| 'save-only'` | `'recall+save'` | `'save-only'` persists turns without recalling/injecting. | +| `onRecall` | `({ scope, query, result }) => void` | — | App telemetry after each `recall`. | +| `onSave` | `({ scope, turn, receipts }) => void` | — | App telemetry after each deferred `save`. | -| 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 | +Every option in one place: -`extractMemories` and `onToolResult` may return `MemoryRecord[]` (shorthand: all-add) or `MemoryOp[]` (mixed `add` / `update` / `delete`). +```ts +import { memoryMiddleware } from '@tanstack/ai-memory' +import { inMemory } from '@tanstack/ai-memory/in-memory' + +const mw = memoryMiddleware({ + adapter: inMemory(), + // Function form derives scope per request. `ctx.threadId` is the stable + // per-conversation id; add `userId` from your server-validated session. + scope: (ctx) => ({ sessionId: ctx.threadId }), + // Static form is fine for fixtures: scope: { sessionId: 'demo', userId: 'alice' } + role: 'recall+save', // or 'save-only' to persist without injecting + onRecall: ({ query, result }) => { + console.log('recalled', result.fragments?.length ?? 0, 'hits for', query) + }, + onSave: ({ receipts }) => { + console.log('saved', receipts.filter((r) => r.ok).length, 'records') + }, +}) +``` + +See the [Adapters](./adapters) page for every adapter's own options. + +## Stacking adapters + +`composeMemoryMiddleware` runs several memory middlewares as one — e.g. save to two +backends, or recall from one while saving to another: + +```ts ignore +// ignore: `scope` and `client` come from your app. +import { + memoryMiddleware, + composeMemoryMiddleware, +} from '@tanstack/ai-memory' +import { inMemory } from '@tanstack/ai-memory/in-memory' +import { redis } from '@tanstack/ai-memory/redis' + +const memory = composeMemoryMiddleware([ + memoryMiddleware({ adapter: redis({ redis: client }), scope }), + // second adapter only writes (no recall injection) + memoryMiddleware({ adapter: inMemory(), scope, role: 'save-only' }), +]) +``` ## Devtools events @@ -130,44 +179,24 @@ The middleware emits five events on `aiEventClient` (from `@tanstack/ai-event-cl | 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 | +| `memory:retrieve:started` | Recall begins | +| `memory:retrieve:completed` | Recall returns (fragment count, whether tools were injected) | +| `memory:persist:started` | A deferred save begins | +| `memory:persist:completed` | A save completes (receipt count) | +| `memory:error` | A `recall` or `save` threw (`phase: 'recall' \| 'save'`) | -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`). +For app telemetry that shouldn't depend on devtools, use the `onRecall` / `onSave` +callbacks on `memoryMiddleware`. ## 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' -``` +Memory failures are **non-fatal**: a throwing `recall` or `save` emits `memory:error` +and the chat run continues with degraded memory. Streaming is never blocked, and a +failed save never fails the turn. ## Next steps -- [Memory Quickstart](./quickstart) — wire the middleware into a real `chat()` call in five steps -- [Custom Adapter](./custom-adapter) — implement `MemoryAdapter` for an unsupported backend -- [Middleware](../advanced/middleware) — the underlying `chat()` middleware lifecycle and hooks -- [Devtools](../getting-started/devtools) — subscribe to `memory:*` events for tracing +- [Quickstart](./quickstart) — wire `memoryMiddleware` into a real `chat()` call +- [Adapters](./adapters) — every adapter's options, with an example of each +- [Custom Adapter](./custom-adapter) — implement `recall`/`save` for an unsupported backend +- [Middleware](../advanced/middleware) — the underlying `chat()` middleware lifecycle diff --git a/docs/memory/quickstart.md b/docs/memory/quickstart.md index f0e874ed2..26c7b7883 100644 --- a/docs/memory/quickstart.md +++ b/docs/memory/quickstart.md @@ -2,7 +2,7 @@ title: Quickstart id: memory-quickstart order: 2 -description: "Add cross-session memory to a TanStack AI chat() call in five steps — install the package, pick an adapter, wire memoryMiddleware, optionally add an embedder, and derive scope server-side." +description: "Add cross-session memory to a TanStack AI chat() call — install the package, pick a recall/save adapter, wire memoryMiddleware, and derive scope server-side." keywords: - tanstack ai - memory @@ -12,93 +12,95 @@ keywords: - 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. +You have a working `chat()` call and you want it to remember context across turns or +sessions. By the end of this guide, `memoryMiddleware` will recall relevant memory into +the prompt and save each finished turn through a real adapter, scoped safely from your +server-validated session. -> **Want the full contract first?** See the [Overview](./overview) page for the adapter interface, hooks, and devtools events. +> **Want the full contract first?** See the [Overview](./overview). ## 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. +`@tanstack/ai-memory` ships `memoryMiddleware`, the `MemoryAdapter` contract, and the +built-in and vendor adapters (each on its own subpath). ## 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. +> **In-memory** — `inMemory()` is zero-dependency and stores records in a `Map`. Use +> it for local development, tests, and single-process demos. Records vanish on restart. +> +> **Redis** — `redis({ redis })` persists across restarts and shares state across +> processes. Bring your own client (`ioredis`, or `redis` via `nodeRedisAsRedisLike`). > -> **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. +> **Vendors** — `hindsight()`, `mem0()`, `honcho()` delegate to a hosted memory service. -Custom adapters implement the `MemoryAdapter` interface from `@tanstack/ai/memory`. See [Custom Adapter](./custom-adapter) for the full authoring journey. +Custom adapters implement the `recall`/`save` contract — see [Custom Adapter](./custom-adapter). ## Step 3 — Wire `memoryMiddleware` into `chat()` -Start with the in-memory adapter — it's the fastest path to a working setup: +Start with the in-memory adapter — 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' +import { memoryMiddleware } from '@tanstack/ai-memory' +import { inMemory } from '@tanstack/ai-memory/in-memory' -const memory = inMemoryMemoryAdapter() +const memory = inMemory() const stream = chat({ - adapter: openaiText('gpt-4o'), + adapter: openaiText('gpt-5.5'), messages: [{ role: 'user', content: 'Hello' }], middleware: [ memoryMiddleware({ adapter: memory, - scope: { tenantId: 'demo', userId: 'alice' }, + scope: { sessionId: 'demo-thread', 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. +Each turn, the middleware recalls relevant memory into the system prompt (lexical +scoring by default), then deferred-saves the user + assistant turn after the stream +finishes. When you're ready to ship, swap the adapter and keep everything else the same: ```ts ignore -// ignore: ioredis's `Redis` type is structurally broader than the adapter's -// minimal `RedisLike` contract (heavily overloaded method signatures), so it -// does not nominally match here — but it works at runtime, which is why the -// adapter accepts a BYO ioredis client directly. `scope` is from Step 5. +// ignore: ioredis's `Redis` type is structurally broader than the adapter's minimal +// `RedisLike` contract, so it does not nominally match here — but it works at runtime, +// which is why the adapter accepts a BYO ioredis client directly. `scope` is from Step 5. import Redis from 'ioredis' -import { redisMemoryAdapter } from '@tanstack/ai-memory' +import { redis } from '@tanstack/ai-memory/redis' -const redis = new Redis(process.env.REDIS_URL!) -const memory = redisMemoryAdapter({ redis }) +const client = new Redis(process.env.REDIS_URL) +const memory = redis({ redis: client }) memoryMiddleware({ adapter: memory, scope }) ``` -> **Using `redis` (node-redis v4+) instead of `ioredis`?** node-redis exposes a camelCase API by default (`sAdd`, `mGet`, …) which does not match the adapter's lowercase `RedisLike` contract. Wrap the client with `nodeRedisAsRedisLike` from `@tanstack/ai-memory` before passing it in. See the [Custom Adapter](./custom-adapter) guide and the [`tanstack-ai-memory-redis` skill](https://github.com/TanStack/ai/blob/main/packages/ai-memory/skills/tanstack-ai-memory-redis/SKILL.md) for the full example. - -## Step 4 — Add an embedder (optional) +> **Using a hosted service?** Swap `inMemory()` for `hindsight({ user })`, +> `mem0({ user })`, or `honcho({ user })`. The middleware wiring is identical — the +> adapter maps `recall`/`save` onto the vendor API. -The middleware accepts an `embedder` for semantic search. **Add one when you need it; skip it when you don't:** +## Step 4 — Semantic scoring (optional) -- **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). +The built-in adapters score lexically by default. Pass an `embedder` for semantic +recall when scopes grow large or queries don't share keywords with stored text: ```ts import OpenAI from 'openai' -import { memoryMiddleware } from '@tanstack/ai/memory' -import { inMemoryMemoryAdapter } from '@tanstack/ai-memory' +import { inMemory } from '@tanstack/ai-memory/in-memory' const openai = new OpenAI() -const memory = inMemoryMemoryAdapter() -memoryMiddleware({ - adapter: memory, - scope: { tenantId: 'demo', userId: 'alice' }, +const memory = inMemory({ embedder: { async embed(text) { - // Use any embedding model — OpenAI, Cohere, a local model, etc. const result = await openai.embeddings.create({ model: 'text-embedding-3-small', input: text, @@ -111,46 +113,40 @@ memoryMiddleware({ }) ``` -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. +`scope` is the isolation boundary. Static scopes are fine for fixtures, but in any real +app derive scope per request from server-validated session data — never from the +request body. ```ts ignore -// ignore: shows deriving scope from server-validated session state. `AppCtx` -// and the shape of `ctx.context` are application-defined (attached by your auth -// layer), and `messages` / `memory` / `session` come from earlier steps — so -// this is shown as a pattern rather than type-checked against a concrete context. +// ignore: `getSession` and `memory` come from earlier steps / your auth layer — this +// shows the pattern rather than type-checking against a concrete context type. import { chat } from '@tanstack/ai' import { openaiText } from '@tanstack/ai-openai' -import { memoryMiddleware } from '@tanstack/ai/memory' - -type AppCtx = { session: { tenantId: string; userId: string; activeThreadId: string } } +import { memoryMiddleware } from '@tanstack/ai-memory' const stream = chat({ - adapter: openaiText('gpt-4o'), + adapter: openaiText('gpt-5.5'), 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, - } + const session = getSession(ctx) + return { sessionId: session.threadId, userId: session.userId } }, }), ], }) ``` -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. +On the client, nothing changes — `useChat` (or your connection adapter) consumes the +stream exactly as before. Memory is entirely server-side. ## Where to go next -- [Overview](./overview) — adapter contract, hooks reference, devtools events, failure modes -- [Custom Adapter](./custom-adapter) — implement `MemoryAdapter` for a backend not shipped (pgvector, MongoDB, Pinecone, …) +- [Overview](./overview) — the `recall`/`save` contract, scope, `memoryMiddleware` options +- [Adapters](./adapters) — every adapter's options, with an example of each +- [Custom Adapter](./custom-adapter) — implement `recall`/`save` for a backend not shipped diff --git a/packages/ai-event-client/src/index.ts b/packages/ai-event-client/src/index.ts index 6439ba6e7..7f60e2bbe 100644 --- a/packages/ai-event-client/src/index.ts +++ b/packages/ai-event-client/src/index.ts @@ -824,61 +824,60 @@ export interface VideoUsageEvent extends BaseEventContext { // Memory events // --------------------------------------------------------------------------- +/** + * Lite scope for devtools payloads. Mirrors the `MemoryScope` contract in + * `@tanstack/ai-memory` (session-centric); kept structurally minimal so the + * event client stays decoupled from the memory package. + */ export type MemoryScopeLite = { - tenantId?: string - userId?: string sessionId?: string - threadId?: string - namespace?: string + userId?: string } -export type MemoryKindLite = - | 'message' - | 'summary' - | 'fact' - | 'preference' - | 'tool-result' - -export type MemoryRoleLite = 'user' | 'assistant' | 'system' | 'tool' - +/** Emitted when the middleware begins a `recall` for the current turn. */ export interface MemoryRetrieveStartedEvent extends BaseEventContext { scope: MemoryScopeLite + /** Adapter id (e.g. 'in-memory', 'hindsight'). */ + adapter: string + /** Recall query text (typically the last user message). */ query: string - topK: number - minScore: number - embedderUsed: boolean } +/** Emitted when `recall` returns, before the result is injected into the prompt. */ export interface MemoryRetrieveCompletedEvent extends BaseEventContext { scope: MemoryScopeLite - hits: Array<{ - id: string - kind: MemoryKindLite - score: number - preview: string - }> + adapter: string + /** Number of discrete fragments returned (0 when the adapter synthesizes). */ + fragmentCount: number + /** Whether the recall result injected any tools this turn. */ + hasTools: boolean + /** Length (chars) of the rendered system-prompt block. */ + systemPromptChars: number durationMs: number } +/** Emitted when the middleware begins a deferred `save` for the finished turn. */ export interface MemoryPersistStartedEvent extends BaseEventContext { scope: MemoryScopeLite - records: Array<{ - id: string - kind: MemoryKindLite - role?: MemoryRoleLite - preview: string - }> + adapter: string } +/** Emitted when a deferred `save` completes. */ export interface MemoryPersistCompletedEvent extends BaseEventContext { scope: MemoryScopeLite - recordIds: Array + adapter: string + /** Total receipts returned by `save`. */ + receiptCount: number + /** Receipts with `ok: true`. */ + okCount: number durationMs: number } +/** Emitted when a `recall` or `save` throws. Memory failures are non-fatal. */ export interface MemoryErrorEvent extends BaseEventContext { scope: MemoryScopeLite - phase: 'retrieve' | 'persist' | 'extract' + adapter: string + phase: 'recall' | 'save' error: { name: string; message: string } } diff --git a/packages/ai-memory/package.json b/packages/ai-memory/package.json index f6874e4fc..9dcb8fb2a 100644 --- a/packages/ai-memory/package.json +++ b/packages/ai-memory/package.json @@ -17,13 +17,25 @@ "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" + "./in-memory": { + "types": "./dist/esm/in-memory.d.ts", + "import": "./dist/esm/in-memory.js" }, - "./adapters/redis": { - "types": "./dist/esm/adapters/redis.d.ts", - "import": "./dist/esm/adapters/redis.js" + "./redis": { + "types": "./dist/esm/redis.d.ts", + "import": "./dist/esm/redis.js" + }, + "./hindsight": { + "types": "./dist/esm/providers/hindsight/index.d.ts", + "import": "./dist/esm/providers/hindsight/index.js" + }, + "./mem0": { + "types": "./dist/esm/providers/mem0/index.d.ts", + "import": "./dist/esm/providers/mem0/index.js" + }, + "./honcho": { + "types": "./dist/esm/providers/honcho/index.d.ts", + "import": "./dist/esm/providers/honcho/index.js" } }, "sideEffects": false, @@ -35,9 +47,9 @@ "scripts": { "build": "vite build", "clean": "premove ./build ./dist", - "lint:fix": "eslint ./src --fix", + "lint:fix": "oxlint src --type-aware --fix", "test:build": "publint --strict", - "test:eslint": "eslint ./src", + "test:oxlint": "oxlint src --type-aware", "test:lib": "vitest --passWithNoTests", "test:lib:dev": "pnpm test:lib --watch", "test:types": "tsc" @@ -49,8 +61,13 @@ "redis", "rag" ], + "dependencies": { + "@tanstack/ai-event-client": "workspace:*" + }, "peerDependencies": { + "@honcho-ai/sdk": ">=2.0.0", "@tanstack/ai": "workspace:^", + "@vectorize-io/hindsight-client": ">=0.6.0", "ioredis": ">=5.0.0", "redis": ">=4.0.0" }, @@ -60,10 +77,18 @@ }, "redis": { "optional": true + }, + "@vectorize-io/hindsight-client": { + "optional": true + }, + "@honcho-ai/sdk": { + "optional": true } }, "devDependencies": { + "@honcho-ai/sdk": "^2.1.1", "@tanstack/ai": "workspace:*", + "@vectorize-io/hindsight-client": "^0.6.1", "@vitest/coverage-v8": "4.0.14", "ioredis-mock": "^8.9.0", "redis": "^4.7.0" diff --git a/packages/ai-memory/skills/tanstack-ai-memory-hindsight/SKILL.md b/packages/ai-memory/skills/tanstack-ai-memory-hindsight/SKILL.md new file mode 100644 index 000000000..fa12efaa9 --- /dev/null +++ b/packages/ai-memory/skills/tanstack-ai-memory-hindsight/SKILL.md @@ -0,0 +1,38 @@ +--- +name: tanstack-ai-memory-hindsight +description: Use when wiring hindsight() from @tanstack/ai-memory/hindsight — a hosted memory adapter that buckets memory per conversation and exposes retain/recall/reflect tools to the model. Requires the optional @vectorize-io/hindsight-client peer. +--- + +# Hindsight Memory Adapter + +Hosted `recall`/`save` adapter backed by Hindsight. Hindsight owns extraction and +ranking server-side, buckets memory into per-conversation "banks" +(`{userId}__{sessionId}`), and — uniquely — exposes LLM **tools** through `recall` so the +model can retain/recall/reflect directly. + +## Setup + +```ts +import { memoryMiddleware } from '@tanstack/ai-memory' +import { hindsight } from '@tanstack/ai-memory/hindsight' + +const memory = hindsight({ user: currentUserId }) // baseUrl defaults to HINDSIGHT_URL + +memoryMiddleware({ adapter: memory, scope }) +``` + +`@vectorize-io/hindsight-client` is an **optional peer dependency**, loaded lazily on +first use — install it where you use `hindsight()`. + +## Options + +- `user` — durable user id for the bank key (falls back to `scope.userId`). +- `baseUrl` — Hindsight server URL (default `HINDSIGHT_URL` or `http://localhost:8888`). +- `budget` — recall budget: `'low' | 'mid' | 'high'` (default `'mid'`). +- `onToolRetain` / `onToolRecall` — callbacks fired when the model uses the memory tools. + +## Tools + +`recall` returns `hindsight_retain`, `hindsight_recall`, and `hindsight_reflect` in its +`tools` plus a `toolGuidance` block. `memoryMiddleware` merges them into the run so the +model can manage long-term memory itself. diff --git a/packages/ai-memory/skills/tanstack-ai-memory-honcho/SKILL.md b/packages/ai-memory/skills/tanstack-ai-memory-honcho/SKILL.md new file mode 100644 index 000000000..a41b59790 --- /dev/null +++ b/packages/ai-memory/skills/tanstack-ai-memory-honcho/SKILL.md @@ -0,0 +1,36 @@ +--- +name: tanstack-ai-memory-honcho +description: Use when wiring honcho() from @tanstack/ai-memory/honcho — a hosted memory adapter where recall is a dialectic answer over the user's representation (no discrete fragments). Requires the optional @honcho-ai/sdk peer. +--- + +# Honcho Memory Adapter + +Hosted `recall`/`save` adapter backed by Honcho. Honcho models memory as peers +exchanging messages in a session; `recall` returns a **synthesized dialectic answer** +over the user peer's representation (so there are no discrete fragments), and `save` +appends the turn's messages to the session. + +## Setup + +```ts +import { memoryMiddleware } from '@tanstack/ai-memory' +import { honcho } from '@tanstack/ai-memory/honcho' + +const memory = honcho({ user: currentUserId }) // baseURL defaults to HONCHO_URL + +memoryMiddleware({ adapter: memory, scope }) +``` + +`@honcho-ai/sdk` is an **optional peer dependency**, loaded lazily on first use — install +it where you use `honcho()`. + +## Options + +- `user` — user peer id (falls back to `scope.userId`, then `'demo-user'`). +- `baseURL` — Honcho server URL (default `HONCHO_URL` or `http://localhost:8001`). +- `workspaceId` — default `HONCHO_APP_NAME` or `'ai-memory'`. +- `apiKey` — default `HONCHO_API_KEY`. +- `assistantId` — assistant peer id (default `'assistant'`). + +`recall` calls the user peer's dialectic `chat()` and injects the answer as the system +prompt; Honcho exposes no LLM tools. diff --git a/packages/ai-memory/skills/tanstack-ai-memory-in-memory/SKILL.md b/packages/ai-memory/skills/tanstack-ai-memory-in-memory/SKILL.md index d5e558fb7..03f62719e 100644 --- a/packages/ai-memory/skills/tanstack-ai-memory-in-memory/SKILL.md +++ b/packages/ai-memory/skills/tanstack-ai-memory-in-memory/SKILL.md @@ -1,11 +1,12 @@ --- 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). +description: Use when wiring inMemory() from @tanstack/ai-memory/in-memory — explains setup, options (embedder, extract, topK/minScore), when to pick it (dev/tests/single-process demos), and what NOT to use it for (multi-process or persistent). --- # In-Memory Memory Adapter -Zero-dependency `MemoryAdapter` backed by a `Map`. Records vanish on process restart. +Zero-dependency `recall`/`save` adapter backed by a `Map`. Records vanish on process +restart. ## When to use it @@ -15,28 +16,36 @@ Zero-dependency `MemoryAdapter` backed by a `Map`. Records vanish on process res ## 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. +- Production multi-process deployments — every worker has its own `Map`; users get + inconsistent memory. +- Anything that needs survival across restarts. -For production, use `redisMemoryAdapter` (see `tanstack-ai-memory-redis` skill). +For production, use `redis()` (see the `tanstack-ai-memory-redis` skill). ## Setup ```ts -import { memoryMiddleware } from '@tanstack/ai/memory' -import { inMemoryMemoryAdapter } from '@tanstack/ai-memory' +import { memoryMiddleware } from '@tanstack/ai-memory' +import { inMemory } from '@tanstack/ai-memory/in-memory' -const memory = inMemoryMemoryAdapter() +const memory = inMemory() memoryMiddleware({ adapter: memory, scope }) ``` -That's the entire setup — there are no options and no peer dependencies. +## Options -## Capacity +`inMemory(options?)` accepts: -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. +- `topK` (default 6), `minScore` (default 0.15), `kinds` — recall tuning. +- `embedder: { embed(text): Promise }` — enable semantic scoring (both + `recall` and `save` embed through it). +- `extract(turn, scope)` — return derived facts to persist alongside the raw turn + (e.g. call an LLM to pull out preferences). Without it, `save` stores the raw + user/assistant messages and `recall` scores them lexically + by recency. +- `render(hits)` — replace the built-in prompt renderer. -## Expiry +## Capacity -`MemoryRecord.expiresAt` is honored — expired records are filtered from `search`/`list`/`get` and opportunistically swept on `add`. +The adapter scans every record in a scope per `recall`. Fine up to ~100k records; beyond +that, switch to Redis. diff --git a/packages/ai-memory/skills/tanstack-ai-memory-mem0/SKILL.md b/packages/ai-memory/skills/tanstack-ai-memory-mem0/SKILL.md new file mode 100644 index 000000000..7f442f340 --- /dev/null +++ b/packages/ai-memory/skills/tanstack-ai-memory-mem0/SKILL.md @@ -0,0 +1,33 @@ +--- +name: tanstack-ai-memory-mem0 +description: Use when wiring mem0() from @tanstack/ai-memory/mem0 — a hosted memory adapter that talks to a mem0 server over plain HTTP (no SDK peer). Requires a running mem0 server. +--- + +# mem0 Memory Adapter + +Hosted `recall`/`save` adapter backed by a mem0 server. mem0 owns extraction and ranking +server-side. Talks to the server over plain HTTP — **no SDK peer dependency**. + +## Setup + +```ts +import { memoryMiddleware } from '@tanstack/ai-memory' +import { mem0 } from '@tanstack/ai-memory/mem0' + +const memory = mem0({ user: currentUserId }) // baseUrl defaults to MEM0_URL + +memoryMiddleware({ adapter: memory, scope }) +``` + +Requires a running mem0 server (self-hosted or hosted). Point it via `baseUrl` (or +`MEM0_URL`); pass `apiKey` (or `MEM0_ADMIN_API_KEY`) when secured. + +## Options + +- `user` — mem0 `user_id` (falls back to `scope.userId`, then `'demo-user'`). +- `baseUrl` — mem0 server URL (default `MEM0_URL` or `http://localhost:8000`). +- `apiKey` — bearer token (default `MEM0_ADMIN_API_KEY`). +- `rerank` (default `true`), `threshold` (default `0.1`) — search tuning. + +`save` posts the `{ user, assistant }` turn to `/memories`; `recall` queries `/search` +and renders the results into the system prompt. mem0 exposes no LLM tools. diff --git a/packages/ai-memory/skills/tanstack-ai-memory-redis/SKILL.md b/packages/ai-memory/skills/tanstack-ai-memory-redis/SKILL.md index 80022c235..d410eb7f8 100644 --- a/packages/ai-memory/skills/tanstack-ai-memory-redis/SKILL.md +++ b/packages/ai-memory/skills/tanstack-ai-memory-redis/SKILL.md @@ -1,82 +1,74 @@ --- 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. +description: Use when wiring redis() from @tanstack/ai-memory/redis in production — covers client setup (ioredis or node-redis via nodeRedisAsRedisLike), the storage model, client-side ranking limits, and troubleshooting. --- # Redis Memory Adapter -Production-grade `MemoryAdapter` backed by plain Redis (no vector index required). +Production-grade `recall`/`save` adapter backed by plain Redis (no vector index +required). Ranks client-side (lexical + optional cosine + recency + importance). ## 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. +Bring your own Redis client. `ioredis` wires in directly; `redis` (node-redis v4+) needs +a small wrapper. -### Option A: `ioredis` (direct wiring) - -```bash -pnpm add ioredis -``` +### Option A: `ioredis` ```ts import Redis from 'ioredis' -import { memoryMiddleware } from '@tanstack/ai/memory' -import { redisMemoryAdapter } from '@tanstack/ai-memory' +import { memoryMiddleware } from '@tanstack/ai-memory' +import { redis } from '@tanstack/ai-memory/redis' -const redis = new Redis(process.env.REDIS_URL!) -const memory = redisMemoryAdapter({ redis, prefix: 'myapp:memory' }) +const client = new Redis(process.env.REDIS_URL) +const memory = redis({ redis: client, prefix: 'myapp:memory' }) memoryMiddleware({ adapter: memory, scope }) ``` -`ioredis` exposes lowercase method names (`sadd`, `mget`, `scan(cursor, 'MATCH', ...)`) directly, which matches the adapter's `RedisLike` contract — no wrapper needed. - -### Option B: `redis` (node-redis v4+) — wrap with `nodeRedisAsRedisLike` - -```bash -pnpm add redis -``` +### Option B: `redis` (node-redis v4+) ```ts import { createClient } from 'redis' -import { memoryMiddleware } from '@tanstack/ai/memory' -import { redisMemoryAdapter, nodeRedisAsRedisLike } from '@tanstack/ai-memory' +import { memoryMiddleware } from '@tanstack/ai-memory' +import { redis, nodeRedisAsRedisLike } from '@tanstack/ai-memory/redis' const client = createClient({ url: process.env.REDIS_URL }) await client.connect() -const memory = redisMemoryAdapter({ - redis: nodeRedisAsRedisLike(client), - prefix: 'myapp:memory', -}) +const memory = redis({ redis: nodeRedisAsRedisLike(client), prefix: 'myapp:memory' }) memoryMiddleware({ adapter: memory, scope }) ``` -node-redis v4+ uses a camelCase API by default (`sAdd`, `mGet`, `scan(cursor, { MATCH, COUNT })`); `nodeRedisAsRedisLike` translates between the two shapes. Passing a raw node-redis v4+ client without the wrapper will throw `client.sadd is not a function` at runtime. - -(You can also use `createClient({ legacyMode: true })` and skip the wrapper, but the wrapper is the cleaner choice for new code — `legacyMode` is deprecated upstream.) +node-redis exposes a camelCase API (`sAdd`, `mGet`); `nodeRedisAsRedisLike` translates it +to the lowercase `RedisLike` shape. Passing a raw node-redis client without the wrapper +throws `client.sadd is not a function`. -### `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. +`redis()` accepts the same `topK` / `minScore` / `kinds` / `embedder` / `extract` options +as `inMemory()`. ## Storage model ```text -{prefix}:record:{memoryId} → JSON-stringified MemoryRecord -{prefix}:index:{tenantId}:{userId}:{sessionId}:{threadId}:{namespace} → Set +{prefix}:record:{id} -> JSON record +{prefix}:index:{userId or _}:{sessionId} -> 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 +`save` writes the record and adds it to the scope's index set; `recall` loads the set, +scores, and renders. Scope values are escaped so a `:` in a `userId`/`sessionId` can't +collide two scopes. -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. +## Ranking limits -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`. +Ranking is client-side: `recall` loads every record for the scope into Node and scores +it. Fine up to ~10k records per scope. Beyond that, write a vector-index-aware adapter +against the same `recall`/`save` contract. ## Troubleshooting -- **Records not visible across processes:** check that all processes use the same `REDIS_URL` and `prefix`. The adapter does not auto-namespace by host. -- **Records expiring unexpectedly:** check whether your records carry `expiresAt`; the adapter sweeps these on read. If you do not want expiry, leave `expiresAt` undefined. -- **Malformed JSON rows:** if the JSON in `{prefix}:record:{id}` is malformed (older schema, third-party writer, truncated/partial write), the adapter skips the row for that read and **leaves it in place** — it is never deleted, because a parse failure is not proof the data is unrecoverable. There is no exception you can catch; the observable signal is a `console.warn` emitted once per distinct malformed id (bounded, so a large corrupted store cannot spam the console). To detect drift, periodically run `list(scope)` and compare counts to your application's source of truth; to remediate, fix or delete the offending record keys directly (or `clear(scope)` the whole scope). +- **Records not visible across processes:** ensure every process uses the same + `REDIS_URL` and `prefix`. +- **Malformed JSON rows:** a row whose JSON won't parse is skipped on read and **left in + place** (never deleted) — the signal is a one-time `console.warn` per bad id. Fix or + delete the offending `{prefix}:record:{id}` key to remediate. diff --git a/packages/ai-memory/skills/tanstack-ai-memory/SKILL.md b/packages/ai-memory/skills/tanstack-ai-memory/SKILL.md new file mode 100644 index 000000000..0fd56b904 --- /dev/null +++ b/packages/ai-memory/skills/tanstack-ai-memory/SKILL.md @@ -0,0 +1,94 @@ +--- +name: tanstack-ai-memory +description: Use when wiring memoryMiddleware from @tanstack/ai-memory into a chat() call — covers the recall/save adapter contract, scope shape and server-side scope security, the recall-inject / deferred-save lifecycle, choosing an adapter (inMemory, redis, hindsight, mem0, honcho), and devtools events. +--- + +# TanStack AI Memory Middleware + +Use this when adding **server-side memory** to a `chat()` call. Everything lives in +`@tanstack/ai-memory`. A memory adapter is a single contract with two verbs — `recall` +and `save` — and the middleware is thin: it recalls into the system prompt before the +model runs and defers `save` after the turn finishes. + +## When to reach for it + +- A user expects "remember what I told you last time." +- Per-user or per-thread context that must survive across sessions. +- A hosted memory service (mem0, Honcho, Hindsight). + +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 { inMemory } from '@tanstack/ai-memory/in-memory' + +const memory = inMemory() // dev/tests only — see the in-memory skill + +const stream = chat({ + adapter: openaiText('gpt-5.5'), + messages, + context: { session }, // attached by your auth middleware + middleware: [ + memoryMiddleware({ + adapter: memory, + // Derive scope server-side from trusted session state. + scope: (ctx) => { + const session = getSession(ctx) + return { sessionId: session.threadId, userId: session.userId } + }, + }), + ], +}) +``` + +`memoryMiddleware` options: `adapter`, `scope` (static or a function of `ctx`), +`role` (`'recall+save'` default, or `'save-only'`), and `onRecall` / `onSave` telemetry +callbacks. + +## The contract + +```ts +interface MemoryAdapter { + id: string + recall(scope, query): Promise // { systemPrompt, fragments?, tools?, toolGuidance? } + save(scope, turn): Promise> // turn = { user, assistant }; extraction lives HERE + inspect?(scope): Promise // optional (devtools) + listFacts?(scope): Promise> // optional (devtools) +} +``` + +- `recall` decides relevance and renders a `systemPrompt`; it may also return `tools` + + `toolGuidance` to hand the model direct control of memory (hindsight does this). +- `save` owns extraction — turning the raw turn into whatever gets persisted. + +## Scope security + +`MemoryScope` is `{ sessionId, userId? }` and is the isolation boundary. **Never trust a +client-supplied `userId`/`sessionId`.** Resolve scope server-side from session/auth and +pass the validated session through `chat({ context: { session } })`. If you accept a +thread id from the request body, validate it belongs to the session user BEFORE using it. + +## Adapters + +- `inMemory()` from `@tanstack/ai-memory/in-memory` — dev, tests, single-process demos. +- `redis({ redis })` from `@tanstack/ai-memory/redis` — production, plain Redis. +- `hindsight()` / `mem0()` / `honcho()` — hosted memory services (optional peer SDKs). +- Custom — implement `recall`/`save` and run `@tanstack/ai-memory/tests/contract`. + +## Failure modes + +Memory failures are non-fatal: a throwing `recall` or `save` emits `memory:error` and +the run continues with degraded memory. Streaming is never blocked; a failed save never +fails the turn. + +## Devtools + +Five events on `aiEventClient` (from `@tanstack/ai-event-client`): +`memory:retrieve:started` / `:completed`, `memory:persist:started` / `:completed`, +`memory:error` (`phase: 'recall' | 'save'`). Payloads carry the adapter id and +fragment/receipt counts, not full memory text. diff --git a/packages/ai-memory/src/adapters/in-memory.ts b/packages/ai-memory/src/adapters/in-memory.ts deleted file mode 100644 index 1d25b36d2..000000000 --- a/packages/ai-memory/src/adapters/in-memory.ts +++ /dev/null @@ -1,144 +0,0 @@ -import { defaultScoreHit, isExpired, scopeMatches } from '@tanstack/ai/memory' -import type { - MemoryAdapter, - MemoryListOptions, - MemoryListResult, - MemoryQuery, - MemoryRecord, - MemoryScope, - MemorySearchResult, -} from '@tanstack/ai/memory' - -export function inMemoryMemoryAdapter(): MemoryAdapter { - const records = new Map() - - function liveRecords(): Array { - const now = Date.now() - const out: Array = [] - for (const r of records.values()) { - if (isExpired(r, now)) records.delete(r.id) - else out.push(r) - } - return out - } - - function scopedLive(scope: MemoryScope): Array { - return liveRecords().filter((r) => scopeMatches(r.scope, scope)) - } - - return { - name: 'in-memory', - - async add(input) { - const batch = Array.isArray(input) ? input : [input] - const now = Date.now() - for (const r of batch) { - records.set(r.id, { ...r, updatedAt: now }) - } - // Opportunistic sweep — cheap on a single Map. - liveRecords() - }, - - async get(id, scope) { - const r = records.get(id) - if (!r) return undefined - if (isExpired(r)) { - records.delete(id) - return undefined - } - if (!scopeMatches(r.scope, scope)) return undefined - return r - }, - - async update(id, scope, patch) { - const existing = records.get(id) - if (!existing) return undefined - if (isExpired(existing)) { - records.delete(id) - return undefined - } - if (!scopeMatches(existing.scope, scope)) return undefined - const next: MemoryRecord = { - ...existing, - ...patch, - id: existing.id, - scope: existing.scope, - createdAt: existing.createdAt, - updatedAt: Date.now(), - } - records.set(id, next) - return next - }, - - async search(query: MemoryQuery): Promise { - // Snapshot `now` once so every candidate in this pass shares the same - // recency reference time (mirrors redisMemoryAdapter.search). - const now = Date.now() - const candidates = scopedLive(query.scope).filter((r) => { - if (query.kinds?.length && !query.kinds.includes(r.kind)) return false - return true - }) - const minScore = query.minScore ?? 0 - const topK = query.topK ?? 6 - const scored = candidates - .map((record) => ({ - record, - score: defaultScoreHit({ record, query, now }), - })) - .filter((h) => h.score >= minScore) - .sort((a, b) => b.score - a.score) - - // Cursor support: encode an integer offset; nextCursor undefined when exhausted. - const offset = query.cursor ? Number.parseInt(query.cursor, 10) || 0 : 0 - const page = scored.slice(offset, offset + topK) - const nextCursor = - offset + topK < scored.length ? String(offset + topK) : undefined - return { hits: page, nextCursor } - }, - - async list( - scope, - options: MemoryListOptions = {}, - ): Promise { - let items = scopedLive(scope) - if (options.kinds?.length) { - const kinds = options.kinds - items = items.filter((r) => kinds.includes(r.kind)) - } - const order = options.order ?? 'createdAt:desc' - items = [...items].sort((a, b) => { - switch (order) { - case 'createdAt:asc': - return a.createdAt - b.createdAt - case 'updatedAt:desc': - return (b.updatedAt ?? b.createdAt) - (a.updatedAt ?? a.createdAt) - case 'createdAt:desc': - 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/ai-memory/src/adapters/redis.ts b/packages/ai-memory/src/adapters/redis.ts deleted file mode 100644 index 5134e4b7a..000000000 --- a/packages/ai-memory/src/adapters/redis.ts +++ /dev/null @@ -1,557 +0,0 @@ -import { defaultScoreHit, isExpired, scopeMatches } from '@tanstack/ai/memory' -import type { - MemoryAdapter, - MemoryListOptions, - MemoryListResult, - MemoryQuery, - MemoryRecord, - MemoryRecordPatch, - MemoryScope, - MemorySearchResult, -} from '@tanstack/ai/memory' - -/** - * Minimal subset of the Redis client API this adapter uses. Shaped to match - * `ioredis` (and node-redis with `legacyMode: true`) directly — lowercase - * method names plus the variadic `scan(cursor, 'MATCH', pattern, 'COUNT', n)` - * form returning `[nextCursor, matchedKeys]`. - * - * For node-redis v4+'s default camelCase API (`sAdd`, `sRem`, `sMembers`, - * `mGet`, `scan(cursor, { MATCH, COUNT })`), wrap the client with - * {@link nodeRedisAsRedisLike} before passing it in. ioredis clients do not - * need a wrapper. - */ -export interface RedisLike { - set: (key: string, value: string) => Promise - get: (key: string) => Promise - del: (...keys: Array) => Promise - sadd: (key: string, ...members: Array) => Promise - srem: (key: string, ...members: Array) => Promise - smembers: (key: string) => Promise> - mget: (...keys: Array) => Promise> - scan: ( - cursor: string | number, - ...args: Array - ) => Promise<[string, Array]> -} - -export interface RedisMemoryAdapterOptions { - redis: RedisLike - /** Default 'tanstack-ai:memory'. */ - prefix?: string -} - -/** - * Minimal node-redis v4+ default-mode (camelCase) surface used by - * {@link nodeRedisAsRedisLike}. Real node-redis clients are structurally - * compatible with this shape — you do not need to construct one manually. - */ -export interface NodeRedisLike { - get: (key: string) => Promise - set: (key: string, value: string) => Promise - del: (keys: Array | string) => Promise - sAdd: (key: string, members: string | Array) => Promise - sRem: (key: string, members: string | Array) => Promise - sMembers: (key: string) => Promise> - mGet: (keys: Array) => Promise> - /** - * node-redis v4 accepts/returns `cursor: number`; node-redis v5 accepts - * and returns `cursor: string`. We widen both ends to `number | string` - * so the wrapper can thread either client's cursor through without - * lossy coercion (string cursors past `Number.MAX_SAFE_INTEGER` lose - * precision when round-tripped through `Number()`). - */ - scan: ( - cursor: number | string, - options?: { MATCH?: string; COUNT?: number }, - ) => Promise<{ cursor: number | string; keys: Array }> -} - -/** - * Adapter helper: wraps a node-redis v4+ default-mode client (camelCase API) - * into the lowercase {@link RedisLike} shape this adapter expects. Use when - * you have a `redis` package client and don't want to enable `legacyMode`. - * - * Pass the result into `redisMemoryAdapter({ redis: nodeRedisAsRedisLike(client) })`. - * - * For `ioredis`, no wrapper is needed — `redisMemoryAdapter({ redis: client })` - * works directly because ioredis already exposes lowercase method names. - * - * The wrapper translates the ioredis-style variadic `scan(cursor, 'MATCH', - * pattern, 'COUNT', n)` form this adapter uses into node-redis v4's - * options-object form, and unwraps the `{ cursor, keys }` reply back into - * the `[nextCursor, matchedKeys]` tuple ioredis returns. - */ -export function nodeRedisAsRedisLike(client: NodeRedisLike): RedisLike { - return { - get: (key) => client.get(key), - set: (key, value) => client.set(key, value), - del: (...keys) => client.del(keys).then((n) => n), - sadd: (key, ...members) => client.sAdd(key, members), - srem: (key, ...members) => client.sRem(key, members), - smembers: (key) => client.sMembers(key), - mget: (...keys) => client.mGet(keys), - scan: async (cursor, ...args) => { - // Translate variadic (cursor, 'MATCH', pattern, 'COUNT', count) into - // node-redis v4/v5's options-object form. Pairs are read positionally; - // unknown tokens are ignored rather than rejected so future extensions - // (e.g. TYPE) degrade gracefully if a caller passes them through. - let match: string | undefined - let count: number | undefined - for (let i = 0; i < args.length; i += 2) { - const key = String(args[i] ?? '').toUpperCase() - const value = args[i + 1] - if (key === 'MATCH' && typeof value === 'string') match = value - else if (key === 'COUNT' && value !== undefined) { - const n = Number(value) - // Redis rejects COUNT <= 0. Drop silently rather than throwing so - // a malformed caller-supplied COUNT degrades to "use server default" - // instead of breaking SCAN entirely. - if (Number.isFinite(n) && n > 0) count = n - } - } - // Pass the cursor through as-is. node-redis v4 typed `cursor: number`, - // v5 typed `cursor: string`; `NodeRedisLike.scan` widens both ends to - // `number | string` so either client's cursor threads through without a - // lossy `Number(cursor)` coercion (which would drop precision for v5 - // cursors larger than `Number.MAX_SAFE_INTEGER`). - const result = await client.scan(cursor, { - ...(match !== undefined ? { MATCH: match } : {}), - ...(count !== undefined ? { COUNT: count } : {}), - }) - return [String(result.cursor), result.keys] - }, - } -} - -/** - * Escape Redis glob metacharacters so a scope value can be safely interpolated - * into a `SCAN MATCH` pattern. Redis SCAN's MATCH glob recognises `*`, `?`, - * `[`, `]`, and `\` as metacharacters; the backslash is also the glob's escape - * character. Without this, a scope value like `tenantId: 't*'` would cause the - * SCAN pattern to match every other tenant's index bucket — a cross-tenant - * leak through the documented isolation boundary. - */ -function escapeGlob(value: string): string { - return value.replace(/[\\*?[\]]/g, '\\$&') -} - -/** - * Escape the `:` segment delimiter (and the `\` escape character itself) in a - * scope value before composing the colon-joined `scopeKey` tuple. Without this, - * a scope value containing `:` would shift the segment positions and a single- - * key scope `{ tenantId: 'a:b' }` would collide with a multi-key scope - * `{ tenantId: 'a', userId: 'b' }` — both would otherwise serialize to - * `a:b:_:_:_:_` and silently merge two different tenants' index buckets. - * - * This is the EXACT-MATCH counterpart to `escapeGlob`'s SCAN MATCH defence: - * together they close both sides of the cross-tenant leak through the documented - * isolation boundary. - */ -function escapeScopeValue(value: string): string { - // Escape : (our delimiter), \ (the escape character itself), and _ (the - // unset-key placeholder). Without escaping _, a user-supplied scope value - // of literal '_' would collide with the placeholder for an unset key — e.g. - // {tenantId:'t1', userId:'_'} would build the same index key as - // {tenantId:'t1'} (userId unset), allowing cross-leak via clear(). - return value.replace(/[\\:_]/g, '\\$&') -} - -const SCOPE_KEYS = [ - 'tenantId', - 'userId', - 'sessionId', - 'threadId', - 'namespace', -] as const - -/** - * Empty-string scope values are treated as undefined (mirrors `scopeMatches`). - * A scope value MUST be a non-empty string to be meaningful — otherwise it - * would be written as a literal empty segment (e.g. `:_:_:_:_`) that no - * partial-scope query could ever reach. - */ -function hasAnyScopeKey(scope: MemoryScope): boolean { - for (const key of SCOPE_KEYS) { - const v = scope[key] - if (v == null) continue - if (typeof v === 'string' && v.length === 0) continue - return true - } - return false -} - -// Track which record ids we've already warned about so ongoing corruption of -// DIFFERENT ids keeps surfacing (a single process-global flag would let the -// first transient bad row consume the only warning and hide everything after). -// Bounded so a pathological store can't grow this set without limit; once the -// cap is hit we stop warning entirely to avoid per-read console spam. -const warnedMalformedIds = new Set() -const MALFORMED_WARN_CAP = 100 -function warnMalformedRow(id: string, err: unknown): void { - if ( - warnedMalformedIds.has(id) || - warnedMalformedIds.size >= MALFORMED_WARN_CAP - ) { - return - } - warnedMalformedIds.add(id) - const capNote = - warnedMalformedIds.size >= MALFORMED_WARN_CAP - ? ' Further malformed-row warnings will be suppressed.' - : '' - console.warn( - `[tanstack-ai-memory] redisMemoryAdapter: skipped malformed record JSON (id=${id}). ` + - `The row is left in place (not deleted) in case it is recoverable.${capNote} ` + - `Reason: ${String(err)}`, - ) -} - -export function redisMemoryAdapter( - options: RedisMemoryAdapterOptions, -): MemoryAdapter { - const prefix = options.prefix ?? 'tanstack-ai:memory' - const redis = options.redis - - function scopeKey(scope: MemoryScope): string { - // Escape `:` and `\` in scope values so a value containing the delimiter - // (e.g. `{ tenantId: 'a:b' }`) cannot collide with a multi-key scope - // (e.g. `{ tenantId: 'a', userId: 'b' }`) that would otherwise serialize - // to the same `a:b:_:_:_:_` tuple. Empty-string scope values are - // normalised to the `_` placeholder per the same rule applied in - // `scopeMatches` and `hasAnyScopeKey`. - return SCOPE_KEYS.map((k) => { - const v = scope[k] - if (v == null) return '_' - const str = String(v) - if (str.length === 0) return '_' - return escapeScopeValue(str) - }).join(':') - } - function indexKey(scope: MemoryScope): string { - return `${prefix}:index:${scopeKey(scope)}` - } - function recordKey(id: string): string { - return `${prefix}:record:${id}` - } - - /** - * Scope-key equality across all five SCOPE_KEYS. Used by `add` to detect - * an upsert whose scope changed from the previously-stored record, so we - * can srem the id from the old scope's index before sadding to the new - * one. A simple per-key comparison is sufficient — `MemoryScope` values - * are plain strings. - */ - function scopesEqual(a: MemoryScope, b: MemoryScope): boolean { - for (const key of SCOPE_KEYS) { - if ((a[key] ?? null) !== (b[key] ?? null)) return false - } - return true - } - - /** - * Find every index bucket whose scope tuple is consistent with `scope`. - * - * The adapter stores records under an EXACT scope tuple - * `${tenantId or _}:${userId or _}:${sessionId or _}:${threadId or _}:${namespace or _}`. - * A partial query scope (e.g. `{ tenantId: 't1' }`) must therefore - * enumerate every bucket whose tuple positions match the defined keys — - * the rest can be anything, so we glob them with `*` and SCAN. - * - * Returns `[]` when `scope` has no defined keys: per the strict - * empty-scope semantics in `scopeMatches`, an empty scope matches - * nothing and so resolves to zero buckets. - * - * Two escape passes are applied to literal scope values, IN ORDER: - * 1. `escapeScopeValue` — escape `:` (the segment delimiter) so a scope - * value containing a colon does not shift segment positions in the - * SCAN pattern. This must run FIRST so the segment grid stays aligned - * with the EXACT-MATCH `scopeKey` form. - * 2. `escapeGlob` — escape `*`, `?`, `[`, `]`, and `\` so a scope value - * cannot glob-match other tenants' index buckets. - * - * Order matters: if `escapeGlob` ran first it would emit `\*` for a literal - * `*`, and `escapeScopeValue` would then re-escape that backslash as - * `\\\*`, producing a stray escape pair that does not match what `scopeKey` - * wrote. Running `escapeScopeValue` first leaves the glob characters - * untouched, then `escapeGlob` escapes them along with the backslashes - * `escapeScopeValue` introduced — yielding a pattern whose literal segments - * exactly match the `scopeKey` form. - * - * The `*` we substitute for unset scope keys is left unescaped because it - * is the SCAN wildcard we actually want. - */ - async function findIndexKeysForScope( - scope: MemoryScope, - ): Promise> { - if (!hasAnyScopeKey(scope)) return [] - const pattern = `${prefix}:index:${SCOPE_KEYS.map((k) => { - const v = scope[k] - if (v == null) return '*' - const str = String(v) - // Empty-string values are not "defined" per `hasAnyScopeKey`; if all - // were empty we'd have returned above. A single empty value among - // others should still glob ('*') so a partial-scope query that mixes - // a meaningful key with an empty-string fallback is interpreted the - // same as omitting the empty one entirely. - if (str.length === 0) return '*' - // Escape : FIRST (segment delimiter), THEN glob metacharacters. - return escapeGlob(escapeScopeValue(str)) - }).join(':')}` - const seen = new Set() - let cursor = '0' - do { - const [next, batch] = await redis.scan( - cursor, - 'MATCH', - pattern, - 'COUNT', - '100', - ) - for (const k of batch) seen.add(k) - cursor = next - } while (cursor !== '0') - return Array.from(seen) - } - - async function loadRecord(id: string): Promise { - const raw = await redis.get(recordKey(id)) - if (!raw) return undefined - try { - return JSON.parse(raw) as MemoryRecord - } catch (err) { - warnMalformedRow(id, err) - return undefined - } - } - - /** - * Load and scope-filter every record reachable from `scope`. - * - * Iterates ALL index buckets whose scope tuple is consistent with the - * query scope (via `findIndexKeysForScope`), mGets the records, filters - * via `scopeMatches` (defensive — sub-bucket records that wouldn't - * satisfy a mid-tuple constraint must still be dropped), and sweeps - * expired/missing rows from each bucket they appeared in. - */ - async function loadAllForScope( - scope: MemoryScope, - ): Promise> { - if (!hasAnyScopeKey(scope)) return [] - const indexKeys = await findIndexKeysForScope(scope) - if (indexKeys.length === 0) return [] - - // Maintain id -> originating index key so srem of expired/missing rows - // targets the bucket the id actually lives in. - const idToIndexKey = new Map() - for (const idx of indexKeys) { - const members = await redis.smembers(idx) - for (const m of members) { - // First-write-wins is fine: each record only lives in exactly one - // index bucket in steady state, so duplicates here would only be a - // transient state we're about to clean up anyway. - if (!idToIndexKey.has(m)) idToIndexKey.set(m, idx) - } - } - if (idToIndexKey.size === 0) return [] - - const ids = Array.from(idToIndexKey.keys()) - const raws = await redis.mget(...ids.map(recordKey)) - const out: Array = [] - // Group expired/missing ids by their originating index key so we can - // srem them in a single call per bucket. - const expiredByIndex = new Map>() - function markExpired(id: string) { - const idx = idToIndexKey.get(id) - if (!idx) return - const arr = expiredByIndex.get(idx) ?? [] - arr.push(id) - expiredByIndex.set(idx, arr) - } - for (let i = 0; i < raws.length; i++) { - const raw = raws[i] as string | null - const id = ids[i] as string - if (!raw) { - markExpired(id) - continue - } - try { - const r = JSON.parse(raw) as MemoryRecord - if (isExpired(r)) { - markExpired(r.id) - continue - } - if (!scopeMatches(r.scope, scope)) continue - out.push(r) - } catch (err) { - // Malformed JSON is NOT swept. A parse failure is not proof the data - // is unrecoverable (a truncated read, a concurrent partial write, or a - // third-party writer using an older schema all land here), so deleting - // the row + index entry would be silent, irreversible data loss gated - // behind a single console.warn. Instead we leave the row in place and - // skip it for this read. The `warnMalformedRow` id-set keeps the warn - // from spamming on every subsequent read of the same bad id. - warnMalformedRow(id, err) - } - } - if (expiredByIndex.size > 0) { - const recordKeysToDelete: Array = [] - for (const [idx, ids2] of expiredByIndex) { - if (ids2.length === 0) continue - await redis.srem(idx, ...ids2) - for (const id of ids2) recordKeysToDelete.push(recordKey(id)) - } - if (recordKeysToDelete.length > 0) { - await redis.del(...recordKeysToDelete) - } - } - return out - } - - return { - name: 'redis', - - async add(input) { - const batch = Array.isArray(input) ? input : [input] - const now = Date.now() - for (const r of batch) { - // If this id already exists under a DIFFERENT scope, remove it - // from the old scope's index before we sadd to the new one. - // Without this the id would be reachable from the old bucket and - // surface in partial-scope traversals that happen to include it. - const prev = await loadRecord(r.id) - if (prev && !scopesEqual(prev.scope, r.scope)) { - await redis.srem(indexKey(prev.scope), r.id) - } - const next: MemoryRecord = { ...r, updatedAt: now } - await redis.set(recordKey(r.id), JSON.stringify(next)) - await redis.sadd(indexKey(r.scope), r.id) - } - }, - - async get(id, scope) { - const r = await loadRecord(id) - if (!r) return undefined - if (isExpired(r)) { - await redis.del(recordKey(id)) - await redis.srem(indexKey(r.scope), id) - return undefined - } - if (!scopeMatches(r.scope, scope)) return undefined - return r - }, - - async update(id, scope, patch: MemoryRecordPatch) { - const r = await loadRecord(id) - if (!r) return undefined - if (isExpired(r)) { - await redis.del(recordKey(id)) - await redis.srem(indexKey(r.scope), id) - return undefined - } - if (!scopeMatches(r.scope, scope)) return undefined - const next: MemoryRecord = { - ...r, - ...patch, - id: r.id, - scope: r.scope, - createdAt: r.createdAt, - updatedAt: Date.now(), - } - await redis.set(recordKey(id), JSON.stringify(next)) - return next - }, - - async search(query: MemoryQuery): Promise { - const records = await loadAllForScope(query.scope) - // Snapshot `now` once so every candidate in this pass is scored - // against the SAME reference time. Without this, `defaultScoreHit` - // calls `Date.now()` per record and later candidates in the same - // search get a slightly tinier recency contribution than earlier - // ones, perturbing the relative ranking of equally-recent records. - const now = Date.now() - const candidates = records.filter((r) => { - if (query.kinds?.length && !query.kinds.includes(r.kind)) return false - return true - }) - const minScore = query.minScore ?? 0 - const topK = query.topK ?? 6 - const scored = candidates - .map((record) => ({ - record, - score: defaultScoreHit({ record, query, now }), - })) - .filter((h) => h.score >= minScore) - .sort((a, b) => b.score - a.score) - const offset = query.cursor ? Number.parseInt(query.cursor, 10) || 0 : 0 - const page = scored.slice(offset, offset + topK) - const nextCursor = - offset + topK < scored.length ? String(offset + topK) : undefined - return { hits: page, nextCursor } - }, - - async list( - scope, - options: MemoryListOptions = {}, - ): Promise { - let items = await loadAllForScope(scope) - if (options.kinds?.length) { - const kinds = options.kinds - items = items.filter((r) => kinds.includes(r.kind)) - } - const order = options.order ?? 'createdAt:desc' - items = [...items].sort((a, b) => { - switch (order) { - case 'createdAt:asc': - return a.createdAt - b.createdAt - case 'updatedAt:desc': - return (b.updatedAt ?? b.createdAt) - (a.updatedAt ?? a.createdAt) - case 'createdAt:desc': - return b.createdAt - a.createdAt - } - }) - const limit = options.limit ?? items.length - const offset = options.cursor - ? Number.parseInt(options.cursor, 10) || 0 - : 0 - const page = items.slice(offset, offset + limit) - const nextCursor = - offset + limit < items.length ? String(offset + limit) : undefined - return { items: page, nextCursor } - }, - - async delete(ids, scope) { - for (const id of ids) { - const r = await loadRecord(id) - if (!r) continue - if (!scopeMatches(r.scope, scope)) continue - await redis.del(recordKey(id)) - // srem against the RECORD'S actual scope, not the caller's scope. - // A partial-scope caller (e.g. `{ tenantId: 't1' }`) would otherwise - // try to srem from `t1:_:_:_:_` while the id actually lives in - // `t1:u1:_:_:_`, leaving a dangling index entry. - await redis.srem(indexKey(r.scope), id) - } - }, - - async clear(scope) { - // Empty-scope safety: refuse to wipe everything. The shared - // `scopeMatches` helper treats `{}` as "match nothing"; mirror that - // behaviour here so `clear({})` is a no-op rather than a tenant-wide - // wipe (the index key for an all-blank scope would otherwise enumerate - // a real bucket of records). - if (!hasAnyScopeKey(scope)) return - const indexKeys = await findIndexKeysForScope(scope) - if (indexKeys.length === 0) return - const idsToDelete = new Set() - for (const idx of indexKeys) { - const members = await redis.smembers(idx) - for (const m of members) idsToDelete.add(m) - } - if (idsToDelete.size > 0) { - await redis.del(...Array.from(idsToDelete).map(recordKey)) - } - await redis.del(...indexKeys) - }, - } -} diff --git a/packages/ai-memory/src/in-memory.ts b/packages/ai-memory/src/in-memory.ts new file mode 100644 index 000000000..9984f5f78 --- /dev/null +++ b/packages/ai-memory/src/in-memory.ts @@ -0,0 +1,59 @@ +import { + inspectRecords, + isExpired, + listRecordFacts, + recallRecords, + sameScope, + saveTurn, +} from './internal/store' +import type { BuiltinOptions, MemoryRecord, RecordStore } from './internal/store' +import type { MemoryAdapter, MemoryScope } from './types' + +/** + * Options for {@link inMemory}. Retrieval/extraction knobs that used to live on + * the middleware are adapter options here. + */ +export interface InMemoryOptions extends BuiltinOptions {} + +/** + * Zero-dependency memory adapter backed by a `Map`. Records vanish on process + * restart, so this is for local development, tests, and single-process demos — + * not multi-process production (each worker gets its own Map). For production, + * use {@link redis} from `@tanstack/ai-memory/redis`. + * + * By default `save` stores the raw user/assistant turn and `recall` scores it + * lexically + by recency. Pass an `embedder` for semantic scoring and/or an + * `extract` function to persist derived facts. + */ +export function inMemory(options: InMemoryOptions = {}): MemoryAdapter { + const records = new Map() + + function sweep(): Array { + const now = Date.now() + const live: Array = [] + for (const r of records.values()) { + if (isExpired(r, now)) records.delete(r.id) + else live.push(r) + } + return live + } + + const store: RecordStore = { + async add(batch) { + const now = Date.now() + for (const r of batch) records.set(r.id, { ...r, updatedAt: now }) + sweep() + }, + async loadScope(scope: MemoryScope) { + return sweep().filter((r) => sameScope(r.scope, scope)) + }, + } + + return { + id: 'in-memory', + recall: (scope, query) => recallRecords(store, scope, query, options), + save: (scope, turn) => saveTurn(store, scope, turn, options), + inspect: (scope) => inspectRecords(store, scope), + listFacts: (scope) => listRecordFacts(store, scope), + } +} diff --git a/packages/ai-memory/src/index.ts b/packages/ai-memory/src/index.ts index 4c2a4945c..4a724fd7d 100644 --- a/packages/ai-memory/src/index.ts +++ b/packages/ai-memory/src/index.ts @@ -1,25 +1,19 @@ -export { inMemoryMemoryAdapter } from './adapters/in-memory' - export { - redisMemoryAdapter, - nodeRedisAsRedisLike, - type RedisMemoryAdapterOptions, - type RedisLike, - type NodeRedisLike, -} from './adapters/redis' + memoryMiddleware, + composeMemoryMiddleware, + type MemoryMiddlewareOptions, + type MemoryMiddlewareRole, + type MemoryRecallInfo, + type MemorySaveInfo, +} from './middleware' export type { MemoryAdapter, - MemoryRecord, - MemoryRecordPatch, MemoryScope, - MemoryQuery, - MemoryHit, - MemoryKind, - MemoryRole, - MemoryEmbedder, - MemoryOp, - MemorySearchResult, - MemoryListOptions, - MemoryListResult, -} from '@tanstack/ai/memory' + MemoryTurn, + MemoryFragment, + RecallResult, + SaveReceipt, + MemorySnapshot, + MemoryFact, +} from './types' diff --git a/packages/ai-memory/src/internal/store.ts b/packages/ai-memory/src/internal/store.ts new file mode 100644 index 000000000..013e4cb06 --- /dev/null +++ b/packages/ai-memory/src/internal/store.ts @@ -0,0 +1,368 @@ +/** + * Shared internals for the built-in `inMemory()` and `redis()` adapters. + * + * NOT part of the public contract — nothing here is exported from the package + * root. Both built-in adapters keep a set of scored, optionally-embedded + * `MemoryRecord`s and expose only `recall`/`save`; this module holds the record + * model, the scoring/rendering helpers, and the extract→store→score→render + * pipeline they share. The only thing an adapter supplies is a {@link RecordStore} + * (a Map for in-memory, Redis keys for redis). + */ + +import type { + MemoryFact, + MemoryFragment, + MemoryScope, + MemorySnapshot, + MemoryTurn, + RecallResult, + SaveReceipt, +} from '../types' + +export type MemoryKind = 'message' | 'summary' | 'fact' | 'preference' +export type MemoryRole = 'user' | 'assistant' + +/** Internal stored record. Never crosses the public boundary. */ +export interface MemoryRecord { + id: string + scope: MemoryScope + text: string + kind: MemoryKind + role?: MemoryRole + createdAt: number + updatedAt?: number + expiresAt?: number + importance?: number + embedding?: Array + metadata?: Record +} + +/** Pluggable extractor: turn a completed turn into extra records to persist. */ +export type ExtractFn = ( + turn: MemoryTurn, + scope: MemoryScope, +) => + | Promise | undefined> + | Array + | undefined + +export interface ExtractedFact { + text: string + kind?: MemoryKind + importance?: number + metadata?: Record +} + +export interface Embedder { + embed: (text: string) => Promise> +} + +/** Options common to the built-in adapters. */ +export interface BuiltinOptions { + /** Max hits returned by recall. Defaults to 6. */ + topK?: number + /** Drop hits scoring below this. Defaults to 0.15. */ + minScore?: number + /** Restrict recall to these kinds. Defaults to all. */ + kinds?: Array + /** Optional embedder for semantic scoring on both save and recall. */ + embedder?: Embedder + /** Optional extractor run on `save` to persist derived facts/preferences. */ + extract?: ExtractFn + /** Replace the built-in prompt renderer. */ + render?: (hits: Array) => string +} + +export interface MemoryHit { + record: MemoryRecord + score: number +} + +/** + * Minimal storage backend the built-in adapters run on. `add` upserts by id; + * `loadScope` returns the live (non-expired) records for exactly this scope. + */ +export interface RecordStore { + add: (records: Array) => Promise + loadScope: (scope: MemoryScope) => Promise> +} + +// =========================== +// Scope +// =========================== + +/** + * Exact scope match. In the recall/save model the scope is always fully + * specified at both write and read (same middleware, same resolver), so a + * record is in-scope iff its `sessionId` matches and — when the query carries a + * `userId` — its `userId` matches too. + */ +export function sameScope(record: MemoryScope, query: MemoryScope): boolean { + if (record.sessionId !== query.sessionId) return false + if (query.userId != null && query.userId !== '') { + return record.userId === query.userId + } + return true +} + +// =========================== +// Scoring helpers +// =========================== + +const DEFAULT_HALF_LIFE_MS = 1000 * 60 * 60 * 24 * 30 // 30 days + +export function cosine(a?: Array, b?: Array): number { + if (!a || !b || a.length !== b.length || a.length === 0) return 0 + let dot = 0 + let aMag = 0 + let bMag = 0 + for (let i = 0; i < a.length; i++) { + const av = a[i] as number + const bv = b[i] as number + dot += av * bv + aMag += av ** 2 + bMag += bv ** 2 + } + if (aMag === 0 || bMag === 0) return 0 + return dot / (Math.sqrt(aMag) * Math.sqrt(bMag)) +} + +export function lexicalOverlap(query: string, text: string): number { + const queryTokens = new Set(query.toLowerCase().split(/\W+/).filter(Boolean)) + if (queryTokens.size === 0) return 0 + const textTokens = new Set(text.toLowerCase().split(/\W+/).filter(Boolean)) + let overlap = 0 + for (const token of queryTokens) { + if (textTokens.has(token)) overlap++ + } + return overlap / queryTokens.size +} + +export function recencyScore( + createdAt: number, + halfLifeMs: number = DEFAULT_HALF_LIFE_MS, + now: number = Date.now(), +): number { + const age = Math.max(0, now - createdAt) + return Math.pow(0.5, age / halfLifeMs) +} + +export function isExpired( + record: MemoryRecord, + now: number = Date.now(), +): boolean { + return record.expiresAt !== undefined && record.expiresAt < now +} + +/** + * Reference ranking: weighted sum of semantic (0.55), lexical (0.20), recency + * (0.15), and importance (0.10). Unset importance contributes 0 — no mid-range + * fallback, so recent records don't automatically clear the `minScore` floor. + */ +export function defaultScoreHit(args: { + record: MemoryRecord + queryText: string + queryEmbedding?: Array + now?: number +}): number { + const { record, queryText, queryEmbedding, now } = args + const semantic = cosine(queryEmbedding, record.embedding) + const lexical = lexicalOverlap(queryText, record.text) + const recency = recencyScore(record.createdAt, undefined, now) + const importance = record.importance ?? 0 + return semantic * 0.55 + lexical * 0.2 + recency * 0.15 + importance * 0.1 +} + +export function defaultRenderMemory(hits: Array): string { + if (hits.length === 0) return '' + return [ + 'Relevant memory:', + 'Use this information only when it is relevant to the current user request.', + 'Do not mention memory directly unless the user asks about it.', + 'If current conversation context contradicts memory, prefer the current conversation.', + '', + // JSON.stringify the text so persisted content with newlines or + // instruction-shaped text can't break out of the list and steer the turn. + ...hits.map( + (hit, index) => + `${index + 1}. [${hit.record.kind}] ${JSON.stringify(hit.record.text)}`, + ), + ].join('\n') +} + +// =========================== +// Shared recall / save pipeline +// =========================== + +/** Portable record id — real UUID where available, deterministic fallback otherwise. */ +export function newRecordId(): string { + try { + return crypto.randomUUID() + } catch { + return `mem-${Date.now()}-${Math.random().toString(36).slice(2, 10)}` + } +} + +/** + * Build the records for a completed turn: the raw user/assistant messages + * (importance 0.4) plus anything the optional extractor returns, embedding each + * when an embedder is configured. + */ +export async function buildTurnRecords( + scope: MemoryScope, + turn: MemoryTurn, + options: BuiltinOptions, +): Promise> { + const now = Date.now() + const records: Array = [] + + async function embed(text: string): Promise | undefined> { + if (!options.embedder) return undefined + return options.embedder.embed(text) + } + + if (turn.user) { + records.push({ + id: newRecordId(), + scope, + text: turn.user, + kind: 'message', + role: 'user', + createdAt: now, + importance: 0.4, + embedding: await embed(turn.user), + }) + } + if (turn.assistant) { + records.push({ + id: newRecordId(), + scope, + text: turn.assistant, + kind: 'message', + role: 'assistant', + createdAt: now, + importance: 0.4, + embedding: await embed(turn.assistant), + }) + } + + const extracted = await options.extract?.(turn, scope) + if (extracted) { + for (const fact of extracted) { + records.push({ + id: newRecordId(), + scope, + text: fact.text, + kind: fact.kind ?? 'fact', + createdAt: now, + importance: fact.importance, + embedding: await embed(fact.text), + metadata: fact.metadata, + }) + } + } + return records +} + +/** Persist a turn to the store and return one receipt for the batch. */ +export async function saveTurn( + store: RecordStore, + scope: MemoryScope, + turn: MemoryTurn, + options: BuiltinOptions, +): Promise> { + const startedAt = Date.now() + try { + const records = await buildTurnRecords(scope, turn, options) + if (records.length > 0) await store.add(records) + return [ + { + ok: true, + latencyMs: Date.now() - startedAt, + raw: { addedIds: records.map((r) => r.id) }, + }, + ] + } catch (error) { + return [ + { + ok: false, + latencyMs: Date.now() - startedAt, + error: error instanceof Error ? error.message : String(error), + }, + ] + } +} + +/** Score the scoped records against the query and render a recall result. */ +export async function recallRecords( + store: RecordStore, + scope: MemoryScope, + query: string, + options: BuiltinOptions, +): Promise { + const topK = options.topK ?? 6 + const minScore = options.minScore ?? 0.15 + const now = Date.now() + + const queryEmbedding = options.embedder + ? await options.embedder.embed(query) + : undefined + + const records = await store.loadScope(scope) + const kinds = options.kinds + const candidates = + kinds && kinds.length > 0 + ? records.filter((r) => kinds.includes(r.kind)) + : records + + const hits = candidates + .map((record) => ({ + record, + score: defaultScoreHit({ record, queryText: query, queryEmbedding, now }), + })) + .filter((h) => h.score >= minScore) + .sort((a, b) => b.score - a.score) + .slice(0, topK) + + const systemPrompt = (options.render ?? defaultRenderMemory)(hits) + const fragments: Array = hits.map((h) => ({ + text: h.record.text, + source: h.record.id, + })) + return { systemPrompt, fragments } +} + +/** Devtools inspect over a scope's live records. */ +export async function inspectRecords( + store: RecordStore, + scope: MemoryScope, +): Promise { + const records = await store.loadScope(scope) + return { + takenAt: new Date().toISOString(), + data: { + records: records.map((r) => ({ + id: r.id, + text: r.text, + kind: r.kind, + role: r.role, + createdAt: r.createdAt, + importance: r.importance, + })), + }, + } +} + +/** Devtools flat fact list over a scope's live records. */ +export async function listRecordFacts( + store: RecordStore, + scope: MemoryScope, +): Promise> { + const records = await store.loadScope(scope) + return records.map((r) => ({ + id: r.id, + text: r.text, + source: r.role ?? r.kind, + createdAt: new Date(r.createdAt).toISOString(), + })) +} diff --git a/packages/ai-memory/src/middleware.ts b/packages/ai-memory/src/middleware.ts new file mode 100644 index 000000000..7c902ea92 --- /dev/null +++ b/packages/ai-memory/src/middleware.ts @@ -0,0 +1,329 @@ +import { aiEventClient } from '@tanstack/ai-event-client' +import type { + ChatMiddleware, + ChatMiddlewareConfig, + ChatMiddlewareContext, + ModelMessage, + StreamChunk, +} from '@tanstack/ai' +import type { + MemoryAdapter, + MemoryScope, + MemoryTurn, + RecallResult, + SaveReceipt, +} from './types' + +/** + * How the middleware participates in the run: + * - `'recall+save'` (default): recall on init (inject prompt + tools), save on finish. + * - `'save-only'`: skip recall entirely — persist the turn but never read/inject. + */ +export type MemoryMiddlewareRole = 'recall+save' | 'save-only' + +export interface MemoryRecallInfo { + scope: MemoryScope + query: string + result: RecallResult +} + +export interface MemorySaveInfo { + scope: MemoryScope + turn: MemoryTurn + receipts: Array +} + +export interface MemoryMiddlewareOptions { + /** The memory backend to recall from / save to. */ + adapter: MemoryAdapter + /** + * Scope for every adapter call. The function form is the safer default for + * multi-tenant apps: derive scope per request from trusted, server-validated + * chat context — never from client input. + */ + scope: + | MemoryScope + | ((ctx: ChatMiddlewareContext) => MemoryScope | Promise) + /** Participation role. Defaults to `'recall+save'`. */ + role?: MemoryMiddlewareRole + /** Fired after `recall` completes (post-injection), for app telemetry. */ + onRecall?: (info: MemoryRecallInfo) => void | Promise + /** Fired after the deferred `save` completes, for app telemetry. */ + onSave?: (info: MemorySaveInfo) => void | Promise +} + +/** Per-request scratch state, keyed by context in a module-level WeakMap so the + * same middleware instance is safe across concurrent `chat()` calls. */ +interface MemoryRequestState { + resolvedScope?: MemoryScope + lastUserText: string +} + +const stateByCtx = new WeakMap() + +/** + * Server-side memory middleware. Recalls relevant memory into the prompt before + * the model runs, then defers `save` of the completed turn after it finishes. + * All extraction/ranking/rendering lives in the adapter — this middleware only + * wires `recall`/`save` into the chat lifecycle and emits devtools events. + */ +export function memoryMiddleware( + options: MemoryMiddlewareOptions, +): ChatMiddleware { + const role = options.role ?? 'recall+save' + + async function resolveScope( + ctx: ChatMiddlewareContext, + state: MemoryRequestState, + ): Promise { + if (state.resolvedScope) return state.resolvedScope + state.resolvedScope = + typeof options.scope === 'function' + ? await options.scope(ctx) + : options.scope + return state.resolvedScope + } + + return { + name: `memory:${options.adapter.id}`, + + async onConfig(ctx, config) { + if (ctx.phase !== 'init') return + + const state: MemoryRequestState = { lastUserText: '' } + stateByCtx.set(ctx, state) + + state.lastUserText = getMessageText(findLastUserMessage(config.messages)) + if (!state.lastUserText || role === 'save-only') return + + const startedAt = Date.now() + let scope: MemoryScope + let result: RecallResult + try { + scope = await resolveScope(ctx, state) + safeEmit('memory:retrieve:started', { + scope, + adapter: options.adapter.id, + query: state.lastUserText, + timestamp: startedAt, + }) + result = await options.adapter.recall(scope, state.lastUserText) + } catch (error) { + const errScope = state.resolvedScope ?? emptyScope() + safeEmit('memory:error', { + scope: errScope, + adapter: options.adapter.id, + phase: 'recall', + error: errorInfo(error), + timestamp: Date.now(), + }) + return + } + + const tools = result.tools ?? [] + safeEmit('memory:retrieve:completed', { + scope, + adapter: options.adapter.id, + fragmentCount: result.fragments?.length ?? 0, + hasTools: tools.length > 0, + systemPromptChars: result.systemPrompt.length, + durationMs: Date.now() - startedAt, + timestamp: Date.now(), + }) + await options.onRecall?.({ scope, query: state.lastUserText, result }) + + const memoryPrompts = [result.toolGuidance ?? '', result.systemPrompt] + const additions = memoryPrompts.filter((p) => p.length > 0) + if (additions.length === 0 && tools.length === 0) return + + return { + systemPrompts: [...config.systemPrompts, ...additions], + tools: [...config.tools, ...tools], + } satisfies Partial + }, + + onFinish(ctx, info) { + const state = stateByCtx.get(ctx) + stateByCtx.delete(ctx) + const userText = state?.lastUserText || getMessageText(findLastUserMessage(ctx.messages)) + const assistant = info.content + if (!userText || !assistant) return + const scope = state?.resolvedScope + + ctx.defer( + (async () => { + // Resolve scope defensively — a throwing resolver must not escape the + // terminal hook. Memory failures are always non-fatal + observable. + let resolved: MemoryScope + try { + resolved = scope ?? (await resolveScope(ctx, { lastUserText: userText })) + } catch (error) { + safeEmit('memory:error', { + scope: emptyScope(), + adapter: options.adapter.id, + phase: 'save', + error: errorInfo(error), + timestamp: Date.now(), + }) + return + } + + const turn: MemoryTurn = { user: userText, assistant } + const startedAt = Date.now() + safeEmit('memory:persist:started', { + scope: resolved, + adapter: options.adapter.id, + timestamp: startedAt, + }) + let receipts: Array + try { + receipts = await options.adapter.save(resolved, turn) + } catch (error) { + receipts = [{ ok: false, error: String(error) }] + safeEmit('memory:error', { + scope: resolved, + adapter: options.adapter.id, + phase: 'save', + error: errorInfo(error), + timestamp: Date.now(), + }) + } + safeEmit('memory:persist:completed', { + scope: resolved, + adapter: options.adapter.id, + receiptCount: receipts.length, + okCount: receipts.filter((r) => r.ok).length, + durationMs: Date.now() - startedAt, + timestamp: Date.now(), + }) + await options.onSave?.({ scope: resolved, turn, receipts }) + })(), + ) + }, + } +} + +/** + * Compose multiple memory middlewares into one — useful for saving to (or + * recalling from) more than one backend in a single run. `onConfig` results are + * merged in order; every other hook fans out to each middleware. + */ +export function composeMemoryMiddleware( + middlewares: Array, +): ChatMiddleware { + return { + name: 'memory:compose', + + async onConfig(ctx, config) { + let current: ChatMiddlewareConfig = config + let changed = false + for (const middleware of middlewares) { + const result = await middleware.onConfig?.(ctx, current) + if (result != null) { + current = { ...current, ...result } + changed = true + } + } + return changed ? current : undefined + }, + + async onChunk(ctx, chunk) { + let chunks: Array = [chunk] + for (const middleware of middlewares) { + if (!middleware.onChunk) continue + const next: Array = [] + for (const item of chunks) { + const result = await middleware.onChunk(ctx, item) + if (result === null) continue + if (result === undefined) next.push(item) + else if (Array.isArray(result)) next.push(...result) + else next.push(result) + } + chunks = next + } + if (chunks.length === 0) return null + if (chunks.length === 1) return chunks[0] + return chunks + }, + + async onStart(ctx) { + for (const m of middlewares) await m.onStart?.(ctx) + }, + async onFinish(ctx, info) { + for (const m of middlewares) await m.onFinish?.(ctx, info) + }, + async onAbort(ctx, info) { + for (const m of middlewares) await m.onAbort?.(ctx, info) + }, + async onError(ctx, info) { + for (const m of middlewares) await m.onError?.(ctx, info) + }, + } +} + +// =========================== +// Internals +// =========================== + +function emptyScope(): MemoryScope { + return { sessionId: '' } +} + +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 +} + +/** + * Extract plain text from a `ModelMessage`. Text lives on `part.content` for + * `TextPart`; bare strings in the content array are tolerated. All other + * content kinds (tool-call, image, …) yield '' so they don't pollute the + * recall query. + */ +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.type === 'text' && typeof part.content === 'string') { + return part.content + } + return '' + }) + .filter(Boolean) + .join('\n') + } + return '' +} + +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.name === 'string' + ) { + return { + name: error.name, + message: String((error as { message?: unknown }).message ?? error), + } + } + return { name: 'Error', message: String(error) } +} + +/** Fire-and-forget devtools emit — telemetry failures must never affect chat. */ +function safeEmit(...args: Parameters): void { + try { + aiEventClient.emit(...args) + } catch { + // ignored — telemetry must not affect chat behaviour + } +} diff --git a/packages/ai-memory/src/providers/hindsight/index.ts b/packages/ai-memory/src/providers/hindsight/index.ts new file mode 100644 index 000000000..60034b01b --- /dev/null +++ b/packages/ai-memory/src/providers/hindsight/index.ts @@ -0,0 +1,219 @@ +/** + * Hindsight memory adapter. Hindsight owns extraction/ranking server-side and + * buckets memory into per-conversation "banks" (`{userId}__{sessionId}`). Recall + * returns a rendered prompt block AND a set of LLM tools (retain/recall/reflect) + * that let the model take direct control of memory. + * + * `@vectorize-io/hindsight-client` is an OPTIONAL peer dependency, loaded lazily. + */ + +import { makeHindsightTools } from './tools' +import type { + MemoryAdapter, + MemoryFact, + MemoryFragment, + MemoryScope, + MemorySnapshot, + MemoryTurn, + RecallResult, + SaveReceipt, +} from '../../types' + +/** Recall payload shape (the subset this adapter reads). */ +export interface HindsightRecallResponse { + results?: Array<{ text: string; type?: string; id: string }> +} + +/** + * Structural view of the hindsight client — only the methods this adapter uses. + * Decouples the adapter from the SDK's exact type surface. + */ +export interface HindsightClientLike { + retain: ( + bankId: string, + text: string, + opts: { context: string; timestamp: Date }, + ) => Promise + recall: ( + bankId: string, + query: string, + opts: { budget: string }, + ) => Promise + reflect: (bankId: string, query: string) => Promise<{ text?: string }> + listMemories: ( + bankId: string, + opts: { limit: number }, + ) => Promise<{ items?: Array> }> + getBankProfile: (bankId: string) => Promise + deleteBank: (bankId: string) => Promise +} + +export interface HindsightRuntime { + client: HindsightClientLike + recallToPrompt: (data: unknown) => string +} + +export interface HindsightOptions { + /** Durable user id used in the bank key. Falls back to `scope.userId`, then `'demo-user'`. */ + user?: string + /** Hindsight server URL. Defaults to `HINDSIGHT_URL` or `http://localhost:8888`. */ + baseUrl?: string + /** Recall budget. Defaults to `'mid'`. */ + budget?: 'low' | 'mid' | 'high' + /** Fired when a `hindsight_retain` tool call completes. */ + onToolRetain?: (receipt: SaveReceipt) => void + /** Fired when a `hindsight_recall` tool call completes. */ + onToolRecall?: (query: string, result: RecallResult) => void +} + +const TOOL_GUIDANCE = `You have access to persistent long-term memory that survives across sessions. + +Relevant memories for this turn have already been recalled and included in +your context. You also have three tools for direct control over memory: + +- hindsight_retain(content): explicitly store a fact, decision, or piece of + context you want to ensure is remembered in future sessions. + +- hindsight_recall(query): query memory directly with a specific question, + to look up a different topic than the user's last message. + +- hindsight_reflect(question): synthesize across many memories to answer + questions that require reasoning over accumulated knowledge. + +Prefer to use these tools when they would meaningfully improve your response. +You do not need to call them on every turn.` + +export function hindsight(options: HindsightOptions = {}): MemoryAdapter { + const budget = options.budget ?? 'mid' + let runtimePromise: Promise | null = null + + function getRuntime(): Promise { + if (!runtimePromise) { + runtimePromise = (async () => { + const mod = await import('@vectorize-io/hindsight-client') + const baseUrl = + options.baseUrl ?? process.env.HINDSIGHT_URL ?? 'http://localhost:8888' + // oxlint-disable-next-line eslint-js/no-restricted-syntax -- intentionally decoupled from the SDK's exact client type; the adapter only uses the HindsightClientLike subset + const client = new mod.HindsightClient({ baseUrl }) as unknown as HindsightClientLike + const recallToPrompt = mod.recallResponseToPromptString as ( + data: unknown, + ) => string + return { client, recallToPrompt } + })().catch((err) => { + runtimePromise = null + throw err + }) + } + return runtimePromise + } + + function bankId(scope: MemoryScope): string { + const user = options.user ?? scope.userId ?? 'demo-user' + return `${user}__${scope.sessionId}` + } + + return { + id: 'hindsight', + + async save(scope, turn: MemoryTurn): Promise> { + const bank = bankId(scope) + const timestamp = new Date() + async function retain(text: string, context: string): Promise { + const start = Date.now() + try { + const { client } = await getRuntime() + const data = await client.retain(bank, text, { context, timestamp }) + return { ok: true, latencyMs: Date.now() - start, raw: data } + } catch (err) { + return { + ok: false, + latencyMs: Date.now() - start, + error: err instanceof Error ? err.message : String(err), + } + } + } + return Promise.all([ + retain(turn.user, 'chat:user'), + retain(turn.assistant, 'chat:assistant'), + ]) + }, + + async recall(scope, query): Promise { + const bank = bankId(scope) + const tools = makeHindsightTools({ + getRuntime, + bankId: bank, + budget, + onToolRetain: options.onToolRetain, + onToolRecall: options.onToolRecall, + }) + try { + const { client, recallToPrompt } = await getRuntime() + const data = await client.recall(bank, query, { budget }) + const fragments: Array = (data.results ?? []).map((r) => ({ + text: r.text, + source: r.type ?? r.id, + })) + return { + systemPrompt: recallToPrompt(data), + fragments, + tools, + toolGuidance: TOOL_GUIDANCE, + raw: data, + } + } catch (err) { + return { + systemPrompt: '', + fragments: [], + tools, + toolGuidance: TOOL_GUIDANCE, + raw: { error: err instanceof Error ? err.message : String(err) }, + } + } + }, + + async inspect(scope): Promise { + const bank = bankId(scope) + try { + const { client } = await getRuntime() + const [memories, profile] = await Promise.all([ + client.listMemories(bank, { limit: 200 }), + client.getBankProfile(bank), + ]) + return { takenAt: new Date().toISOString(), data: { memories, profile } } + } catch (err) { + return { + takenAt: new Date().toISOString(), + data: { error: err instanceof Error ? err.message : String(err) }, + } + } + }, + + async listFacts(scope): Promise> { + const bank = bankId(scope) + try { + const { client } = await getRuntime() + const res = await client.listMemories(bank, { limit: 200 }) + const items = res.items ?? [] + return items + .map((m, i): MemoryFact | null => { + const text = + (typeof m.text === 'string' ? m.text : undefined) ?? + (typeof m.content === 'string' ? m.content : undefined) + if (!text) return null + return { + id: typeof m.id === 'string' ? m.id : `hindsight-${i}`, + text, + source: typeof m.context === 'string' ? m.context : 'memory', + createdAt: typeof m.created_at === 'string' ? m.created_at : undefined, + } + }) + .filter((f): f is MemoryFact => f !== null) + } catch { + return [] + } + }, + } +} + +export { makeHindsightTools } from './tools' diff --git a/packages/ai-memory/src/providers/hindsight/tools.ts b/packages/ai-memory/src/providers/hindsight/tools.ts new file mode 100644 index 000000000..ac2b75d95 --- /dev/null +++ b/packages/ai-memory/src/providers/hindsight/tools.ts @@ -0,0 +1,131 @@ +import type { Tool } from '@tanstack/ai' +import type { MemoryFragment, RecallResult, SaveReceipt } from '../../types' +import type { HindsightRuntime } from './index' + +export interface HindsightToolDeps { + getRuntime: () => Promise + bankId: string + budget: string + onToolRetain?: (receipt: SaveReceipt) => void + onToolRecall?: (query: string, result: RecallResult) => void +} + +function stringField(args: unknown, key: string): string { + if (args && typeof args === 'object' && key in args) { + const value = (args as Record)[key] + if (typeof value === 'string') return value + } + return '' +} + +/** + * Build the hindsight LLM tools (retain / recall / reflect). These let the model + * take direct control of long-term memory beyond the automatic recall/save the + * middleware performs. Returned in `RecallResult.tools` and merged into the run. + */ +export function makeHindsightTools(deps: HindsightToolDeps): Array { + const retainTool: Tool = { + name: 'hindsight_retain', + description: + 'Explicitly store a fact, decision, or piece of context to remember in future sessions. Call this when the user shares something important about themselves, their preferences, their work, or any detail that should persist beyond this conversation.', + inputSchema: { + type: 'object', + properties: { + content: { + type: 'string', + description: + 'The exact fact, decision, or piece of context to store. Write it as a self-contained statement that will still make sense out of conversation context.', + }, + }, + required: ['content'], + additionalProperties: false, + }, + async execute(args) { + const content = stringField(args, 'content') + const start = Date.now() + try { + const { client } = await deps.getRuntime() + const data = await client.retain(deps.bankId, content, { + context: 'chat:tool', + timestamp: new Date(), + }) + deps.onToolRetain?.({ ok: true, latencyMs: Date.now() - start, raw: data }) + return { ok: true } + } catch (err) { + const error = err instanceof Error ? err.message : String(err) + deps.onToolRetain?.({ ok: false, latencyMs: Date.now() - start, error }) + return { ok: false, error } + } + }, + } + + const recallTool: Tool = { + name: 'hindsight_recall', + description: + "Query memory directly with a specific question. Use this when you need context that may not have surfaced in the automatic recall — for example, to look up a different topic than the user's last message, or to find facts about an entity mentioned in passing.", + inputSchema: { + type: 'object', + properties: { + query: { + type: 'string', + description: 'Natural-language question or topic to look up.', + }, + }, + required: ['query'], + additionalProperties: false, + }, + async execute(args) { + const query = stringField(args, 'query') + const start = Date.now() + try { + const { client, recallToPrompt } = await deps.getRuntime() + const data = await client.recall(deps.bankId, query, { budget: deps.budget }) + const systemPrompt = recallToPrompt(data) + const fragments: Array = (data.results ?? []).map((r) => ({ + text: r.text, + source: r.type ?? r.id, + })) + deps.onToolRecall?.(query, { + systemPrompt, + fragments, + latencyMs: Date.now() - start, + raw: data, + } as RecallResult) + return systemPrompt || '(no relevant memories found)' + } catch (err) { + const error = err instanceof Error ? err.message : String(err) + return `(no memory available: ${error})` + } + }, + } + + const reflectTool: Tool = { + name: 'hindsight_reflect', + description: + 'Synthesize across many memories to answer questions that require reasoning over accumulated knowledge, rather than retrieving specific facts. Use this for questions like "what do I know about this user\'s stack?" or "what has the user been working on lately?"', + inputSchema: { + type: 'object', + properties: { + query: { + type: 'string', + description: + "The synthesis question to reflect on, e.g. \"what do I know about the user's preferences?\"", + }, + }, + required: ['query'], + additionalProperties: false, + }, + async execute(args) { + const query = stringField(args, 'query') + try { + const { client } = await deps.getRuntime() + const data = await client.reflect(deps.bankId, query) + return data.text ?? '(no reflection)' + } catch (err) { + return `(reflection failed: ${err instanceof Error ? err.message : String(err)})` + } + }, + } + + return [retainTool, recallTool, reflectTool] +} diff --git a/packages/ai-memory/src/providers/honcho/index.ts b/packages/ai-memory/src/providers/honcho/index.ts new file mode 100644 index 000000000..72ebe34d6 --- /dev/null +++ b/packages/ai-memory/src/providers/honcho/index.ts @@ -0,0 +1,212 @@ +/** + * Honcho memory adapter. Honcho models memory as peers exchanging messages in a + * session and answers recall via a "dialectic" query over the user peer's + * representation — so `recall` returns a synthesized answer (no discrete + * fragments) and `save` appends the turn's messages to the session. + * + * `@honcho-ai/sdk` is an OPTIONAL peer dependency, loaded lazily on first use. + */ + +import type { Peer, Session } from '@honcho-ai/sdk' +import type { + MemoryAdapter, + MemoryFact, + MemoryScope, + MemorySnapshot, + MemoryTurn, + RecallResult, + SaveReceipt, +} from '../../types' + +export interface HonchoOptions { + /** Durable user id. Falls back to `scope.userId`, then `'demo-user'`. */ + user?: string + /** Honcho server URL. Defaults to `HONCHO_URL` or `http://localhost:8001`. */ + baseURL?: string + /** Workspace id. Defaults to `HONCHO_APP_NAME` or `'ai-memory'`. */ + workspaceId?: string + /** API key. Defaults to `HONCHO_API_KEY` (or `'dev-no-auth'`). */ + apiKey?: string + /** Assistant peer id. Defaults to `'assistant'`. */ + assistantId?: string +} + +type Timed = + | { ok: true; latencyMs: number; data: T } + | { ok: false; latencyMs: number; error: string } + +async function timed(fn: () => Promise): Promise> { + const start = Date.now() + try { + const data = await fn() + return { ok: true, latencyMs: Date.now() - start, data } + } catch (err) { + return { + ok: false, + latencyMs: Date.now() - start, + error: err instanceof Error ? err.message : String(err), + } + } +} + +const HONCHO_LINE_RE = /^\[(?[^\]]+)\]\s+(?.+)$/ + +/** Parse a Honcho `peer.representation()` text blob into flat fact rows. */ +export function parseHonchoRepresentation(raw: string): Array { + return raw + .split('\n') + .map((line) => line.trim()) + .filter( + (line) => + line.length > 0 && + !line.startsWith('##') && + !line.startsWith('Explicit Observations'), + ) + .map((line, i): MemoryFact => { + const m = line.match(HONCHO_LINE_RE) + if (m?.groups?.ts && m.groups.text) { + return { + id: `honcho-${m.groups.ts}-${i}`, + text: m.groups.text, + source: 'observation', + createdAt: m.groups.ts, + } + } + return { id: `honcho-${i}`, text: line, source: 'representation' } + }) +} + +export function honcho(options: HonchoOptions = {}): MemoryAdapter { + const assistantId = options.assistantId ?? 'assistant' + + // Client + entity caches live in this factory's closure — each honcho() + // instance owns its own. + type Client = Awaited> + let clientPromise: Promise | null = null + const sessionCache = new Map>() + const userPeerCache = new Map>() + let assistantPeerPromise: Promise | null = null + + async function loadClient() { + const mod = await import('@honcho-ai/sdk') + return new mod.Honcho({ + baseURL: options.baseURL ?? process.env.HONCHO_URL ?? 'http://localhost:8001', + workspaceId: options.workspaceId ?? process.env.HONCHO_APP_NAME ?? 'ai-memory', + apiKey: options.apiKey ?? process.env.HONCHO_API_KEY ?? 'dev-no-auth', + }) + } + + function getClient(): Promise { + if (!clientPromise) clientPromise = loadClient() + return clientPromise + } + + function cached( + cache: Map>, + key: string, + create: () => Promise, + ): Promise { + const existing = cache.get(key) + if (existing) return existing + const created = create().catch((err) => { + if (cache.get(key) === created) cache.delete(key) + throw err + }) + cache.set(key, created) + return created + } + + function getUserPeer(userId: string): Promise { + return cached(userPeerCache, userId, async () => (await getClient()).peer(userId)) + } + function getAssistantPeer(): Promise { + if (!assistantPeerPromise) { + assistantPeerPromise = (async () => (await getClient()).peer(assistantId))().catch( + (err) => { + assistantPeerPromise = null + throw err + }, + ) + } + return assistantPeerPromise + } + function getSession(sessionId: string): Promise { + return cached(sessionCache, sessionId, async () => (await getClient()).session(sessionId)) + } + + function userIdFor(scope: MemoryScope): string { + return options.user ?? scope.userId ?? 'demo-user' + } + + return { + id: 'honcho', + + async save(scope, turn: MemoryTurn): Promise> { + const result = await timed(async () => { + const [userPeer, assistantPeer, session] = await Promise.all([ + getUserPeer(userIdFor(scope)), + getAssistantPeer(), + getSession(scope.sessionId), + ]) + return session.addMessages([ + userPeer.message(turn.user), + assistantPeer.message(turn.assistant), + ]) + }) + return [ + { + ok: result.ok, + latencyMs: result.latencyMs, + raw: result.ok ? result.data : undefined, + error: result.ok ? undefined : result.error, + }, + ] + }, + + async recall(scope, query): Promise { + const result = await timed(async () => { + const [userPeer, session] = await Promise.all([ + getUserPeer(userIdFor(scope)), + getSession(scope.sessionId), + ]) + return userPeer.chat(query, { session }) + }) + if (!result.ok) { + return { systemPrompt: '', raw: { error: result.error } } + } + const text = typeof result.data === 'string' ? result.data : '' + return { systemPrompt: text, raw: { dialectic: text } } + }, + + async inspect(scope): Promise { + const session = await getSession(scope.sessionId).catch(() => null) + if (!session) { + return { takenAt: new Date().toISOString(), data: { error: 'failed to get session' } } + } + const [messages, summaries] = await Promise.all([ + timed(() => session.messages({ size: 50 })), + timed(() => session.summaries()), + ]) + return { + takenAt: new Date().toISOString(), + data: { + messages: messages.ok ? messages.data : { error: messages.error }, + summaries: summaries.ok ? summaries.data : { error: summaries.error }, + }, + } + }, + + async listFacts(scope): Promise> { + const result = await timed(async () => { + const userPeer = await getUserPeer(userIdFor(scope)) + return userPeer.representation() + }) + if (!result.ok) return [] + const raw = + typeof result.data === 'string' + ? result.data + : String((result.data as { representation?: unknown }).representation ?? '') + return parseHonchoRepresentation(raw) + }, + } +} diff --git a/packages/ai-memory/src/providers/mem0/index.ts b/packages/ai-memory/src/providers/mem0/index.ts new file mode 100644 index 000000000..92b82fc52 --- /dev/null +++ b/packages/ai-memory/src/providers/mem0/index.ts @@ -0,0 +1,177 @@ +/** + * mem0 memory adapter — talks to a mem0 server over plain HTTP (no SDK, so no + * peer dependency). mem0 owns extraction and ranking server-side; this adapter + * maps the `recall`/`save` contract onto its `/memories` and `/search` endpoints. + * + * Requires a running mem0 server. Point it at one via `baseUrl` (or the + * `MEM0_URL` env var); pass `apiKey` (or `MEM0_ADMIN_API_KEY`) when it's secured. + */ + +import type { + MemoryAdapter, + MemoryFact, + MemoryFragment, + MemoryScope, + MemorySnapshot, + MemoryTurn, + RecallResult, + SaveReceipt, +} from '../../types' + +export interface Mem0Options { + /** Durable user id. Falls back to `scope.userId`, then `'demo-user'`. */ + user?: string + /** mem0 server URL. Defaults to `MEM0_URL` or `http://localhost:8000`. */ + baseUrl?: string + /** Bearer token. Defaults to `MEM0_ADMIN_API_KEY`. */ + apiKey?: string + /** Ask mem0 to rerank search results. Defaults to `true`. */ + rerank?: boolean + /** Minimum search score. Defaults to `0.1`. */ + threshold?: number +} + +type JsonResult = + | { ok: true; latencyMs: number; data: unknown } + | { ok: false; latencyMs: number; error: string } + +function asRecord(value: unknown): Record | undefined { + return value && typeof value === 'object' ? (value as Record) : undefined +} + +function asString(value: unknown): string | undefined { + return typeof value === 'string' ? value : undefined +} + +/** Pull the array of items out of a mem0 response (`{results: []}` or a bare array). */ +function itemsOf(data: unknown): Array> { + const rec = asRecord(data) + const candidate = rec && 'results' in rec ? rec.results : data + if (!Array.isArray(candidate)) return [] + return candidate.filter( + (m): m is Record => !!m && typeof m === 'object', + ) +} + +export function mem0(options: Mem0Options = {}): MemoryAdapter { + const baseUrl = + options.baseUrl ?? process.env.MEM0_URL ?? 'http://localhost:8000' + const apiKey = options.apiKey ?? process.env.MEM0_ADMIN_API_KEY ?? '' + const rerank = options.rerank ?? true + const threshold = options.threshold ?? 0.1 + + function headers(): Record { + const h: Record = { 'Content-Type': 'application/json' } + if (apiKey) h.Authorization = `Bearer ${apiKey}` + return h + } + + function userId(scope: MemoryScope): string { + return options.user ?? scope.userId ?? 'demo-user' + } + + async function safeJson(fn: () => Promise): Promise { + const start = Date.now() + try { + const res = await fn() + const latencyMs = Date.now() - start + if (!res.ok) { + const text = await res.text().catch(() => '') + return { ok: false, latencyMs, error: `HTTP ${res.status}: ${text.slice(0, 300)}` } + } + const data = await res.json().catch(() => null) + return { ok: true, latencyMs, data } + } catch (err) { + return { + ok: false, + latencyMs: Date.now() - start, + error: err instanceof Error ? err.message : String(err), + } + } + } + + async function loadMemories(scope: MemoryScope): Promise { + const url = `${baseUrl}/memories?user_id=${encodeURIComponent(userId(scope))}` + return safeJson(() => fetch(url, { method: 'GET', headers: headers() })) + } + + return { + id: 'mem0', + + async save(scope, turn: MemoryTurn): Promise> { + const result = await safeJson(() => + fetch(`${baseUrl}/memories`, { + method: 'POST', + headers: headers(), + body: JSON.stringify({ + messages: [ + { role: 'user', content: turn.user }, + { role: 'assistant', content: turn.assistant }, + ], + user_id: userId(scope), + }), + }), + ) + return [ + { + ok: result.ok, + latencyMs: result.latencyMs, + raw: result.ok ? result.data : undefined, + error: result.ok ? undefined : result.error, + }, + ] + }, + + async recall(scope, query): Promise { + const result = await safeJson(() => + fetch(`${baseUrl}/search`, { + method: 'POST', + headers: headers(), + body: JSON.stringify({ + query, + user_id: userId(scope), + rerank, + threshold, + }), + }), + ) + if (!result.ok) { + return { systemPrompt: '', fragments: [], raw: { error: result.error } } + } + const fragments: Array = itemsOf(result.data).map((m) => ({ + text: asString(m.memory) ?? asString(m.text) ?? JSON.stringify(m), + source: asString(m.id) ?? 'mem0', + })) + const systemPrompt = + fragments.length === 0 + ? '' + : `Recalled memory:\n${fragments.map((f) => `- (${f.source}) ${f.text}`).join('\n')}` + return { systemPrompt, fragments, raw: result.data } + }, + + async inspect(scope): Promise { + const result = await loadMemories(scope) + return { + takenAt: new Date().toISOString(), + data: result.ok ? result.data : { error: result.error }, + } + }, + + async listFacts(scope): Promise> { + const result = await loadMemories(scope) + if (!result.ok) return [] + return itemsOf(result.data) + .map((m): MemoryFact | null => { + const text = asString(m.memory) + if (!text) return null + return { + id: asString(m.id) ?? 'mem0', + text, + source: 'memory', + createdAt: asString(m.updated_at) ?? asString(m.created_at), + } + }) + .filter((f): f is MemoryFact => f !== null) + }, + } +} diff --git a/packages/ai-memory/src/redis.ts b/packages/ai-memory/src/redis.ts new file mode 100644 index 000000000..d9580afea --- /dev/null +++ b/packages/ai-memory/src/redis.ts @@ -0,0 +1,164 @@ +import { + inspectRecords, + isExpired, + listRecordFacts, + recallRecords, + saveTurn, +} from './internal/store' +import type { BuiltinOptions, MemoryRecord, RecordStore } from './internal/store' +import type { MemoryAdapter, MemoryScope } from './types' + +/** + * Minimal subset of the Redis client API the adapter uses. Shaped to match + * `ioredis` directly (lowercase method names). For node-redis v4+'s camelCase + * API, wrap the client with {@link nodeRedisAsRedisLike}. + */ +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> +} + +/** node-redis v4+ default-mode (camelCase) surface used by {@link nodeRedisAsRedisLike}. */ +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> +} + +/** + * Wrap a node-redis v4+ default-mode client (camelCase API) into the lowercase + * {@link RedisLike} shape this adapter expects. For `ioredis`, no wrapper is + * needed — pass the client directly. + */ +export function nodeRedisAsRedisLike(client: NodeRedisLike): RedisLike { + return { + get: (key) => client.get(key), + set: (key, value) => client.set(key, value), + del: (...keys) => client.del(keys), + sadd: (key, ...members) => client.sAdd(key, members), + srem: (key, ...members) => client.sRem(key, members), + smembers: (key) => client.sMembers(key), + mget: (...keys) => client.mGet(keys), + } +} + +export interface RedisOptions extends BuiltinOptions { + /** A Redis client implementing {@link RedisLike} (ioredis, or wrapped node-redis). */ + redis: RedisLike + /** Key prefix. Defaults to `'tanstack-ai:memory'`. */ + prefix?: string +} + +/** + * Escape the `:` scope-key delimiter (and the `\` escape character itself) in a + * scope value before composing the colon-joined key. Without this, a scope + * value containing `:` could shift segment positions and collide two different + * scopes' index buckets. `_` is escaped too so a literal `_` value can't collide + * with the unset-key placeholder. + */ +function escapeScopeValue(value: string): string { + return value.replace(/[\\:_]/g, '\\$&') +} + +// Track ids we've warned about so ongoing corruption of DIFFERENT ids keeps +// surfacing, bounded so a pathological store can't spam the console forever. +const warnedMalformedIds = new Set() +const MALFORMED_WARN_CAP = 100 +function warnMalformedRow(id: string, err: unknown): void { + if (warnedMalformedIds.has(id) || warnedMalformedIds.size >= MALFORMED_WARN_CAP) { + return + } + warnedMalformedIds.add(id) + console.warn( + `[tanstack-ai-memory] redis: skipped malformed record JSON (id=${id}). ` + + `The row is left in place (not deleted) in case it is recoverable. ` + + `Reason: ${String(err)}`, + ) +} + +/** + * Production memory adapter backed by plain Redis (no vector index required). + * Ranks client-side (lexical + optional cosine + recency + importance), so it's + * suited to up to ~10k records per scope. Bring your own client (`ioredis`, or + * node-redis wrapped with {@link nodeRedisAsRedisLike}). + * + * Storage model: + * ```text + * {prefix}:record:{id} -> JSON MemoryRecord + * {prefix}:index:{userId or _}:{sessionId} -> Set + * ``` + */ +export function redis(options: RedisOptions): MemoryAdapter { + const client = options.redis + const prefix = options.prefix ?? 'tanstack-ai:memory' + + const scopeKey = (scope: MemoryScope): string => + `${escapeScopeValue(scope.userId != null && scope.userId !== '' ? scope.userId : '_')}:${escapeScopeValue(scope.sessionId)}` + const indexKey = (scope: MemoryScope): string => + `${prefix}:index:${scopeKey(scope)}` + const recordKey = (id: string): string => `${prefix}:record:${id}` + + const store: RecordStore = { + async add(batch) { + const now = Date.now() + for (const r of batch) { + const next: MemoryRecord = { ...r, updatedAt: now } + await client.set(recordKey(r.id), JSON.stringify(next)) + await client.sadd(indexKey(r.scope), r.id) + } + }, + + async loadScope(scope: MemoryScope) { + const idx = indexKey(scope) + const ids = await client.smembers(idx) + if (ids.length === 0) return [] + const raws = await client.mget(...ids.map(recordKey)) + const out: Array = [] + const stale: Array = [] + for (let i = 0; i < raws.length; i++) { + const raw = raws[i] as string | null + const id = ids[i] as string + if (!raw) { + stale.push(id) + continue + } + let record: MemoryRecord + try { + record = JSON.parse(raw) as MemoryRecord + } catch (err) { + // Malformed JSON is skipped, NOT swept — a parse failure isn't proof + // the data is unrecoverable (truncated read, older schema, etc.). + warnMalformedRow(id, err) + continue + } + if (isExpired(record)) { + stale.push(id) + continue + } + out.push(record) + } + if (stale.length > 0) { + await client.srem(idx, ...stale) + await client.del(...stale.map(recordKey)) + } + return out + }, + } + + return { + id: 'redis', + recall: (scope, query) => recallRecords(store, scope, query, options), + save: (scope, turn) => saveTurn(store, scope, turn, options), + inspect: (scope) => inspectRecords(store, scope), + listFacts: (scope) => listRecordFacts(store, scope), + } +} diff --git a/packages/ai-memory/src/types.ts b/packages/ai-memory/src/types.ts new file mode 100644 index 000000000..39afd4002 --- /dev/null +++ b/packages/ai-memory/src/types.ts @@ -0,0 +1,160 @@ +/** + * Public contract for the TanStack AI memory subsystem. + * + * A memory backend implements ONE contract with two verbs: {@link MemoryAdapter.recall} + * and {@link MemoryAdapter.save}. This is deliberately the shape every real memory + * provider (mem0, honcho, hindsight, …) already exposes — "what's relevant for this + * query?" and "remember this turn". The middleware ({@link memoryMiddleware}) is thin: + * it calls `recall` before the model runs and defers `save` after the turn finishes. + * + * Adapters own everything else. Extraction (turning a turn into stored facts), + * ranking, rendering into a prompt, scope isolation, and expiry are all the + * adapter's responsibility — the middleware never inspects records. The built-in + * `inMemory()` / `redis()` adapters keep their store/scoring internals private + * behind `recall`/`save`; vendor adapters map these two verbs onto their APIs. + */ + +import type { Tool } from '@tanstack/ai' + +// =========================== +// Scope & turn primitives +// =========================== + +/** + * Isolation scope for memory reads and writes. Opaque to the middleware — + * each adapter interprets it (vendors map it to bank/user ids; the built-in + * stores key their internal record space by it). + * + * Derive scope server-side from trusted session state — never from client + * input, or one user's request can read or write another user's memory. + */ +export interface MemoryScope { + /** Conversation/session identifier. Required — the minimal isolation key. */ + sessionId: string + /** Optional durable end-user identity, for cross-session recall. */ + userId?: string +} + +/** A completed conversation turn handed to {@link MemoryAdapter.save}. */ +export interface MemoryTurn { + user: string + assistant: string +} + +// =========================== +// Recall +// =========================== + +/** A discrete recalled item, when the adapter produces them. */ +export interface MemoryFragment { + /** The recalled text. */ + text: string + /** Provenance hint (record id, vendor result type, etc.). */ + source: string +} + +/** + * Result of {@link MemoryAdapter.recall}. Everything the middleware needs to + * augment the run: a pre-rendered prompt block, optional discrete fragments, + * and optional tools the adapter wants exposed to the model this turn. + */ +export interface RecallResult { + /** + * Pre-rendered block to inject into the system prompt. An empty string means + * "nothing to inject" — the middleware skips it. + */ + systemPrompt: string + /** + * Discrete recalled items, when the adapter produces them. Omitted for + * engines that return synthesized output (e.g. honcho's dialectic answer). + */ + fragments?: Array + /** + * Tools the adapter wants exposed to the model for this turn (e.g. hindsight's + * retain/recall/reflect tools). Merged into the run's tool set by the + * middleware. Omit or `[]` when the adapter exposes no tools. + */ + tools?: Array + /** + * System-prompt text explaining when/how to use {@link RecallResult.tools}. + * Injected ahead of `systemPrompt`. Omit or `''` when there are no tools. + */ + toolGuidance?: string + /** Raw vendor payload, surfaced for devtools/inspection. */ + raw?: unknown +} + +// =========================== +// Save +// =========================== + +/** + * Receipt for a single underlying write performed by {@link MemoryAdapter.save}. + * One turn can produce several receipts (e.g. hindsight writes the user and + * assistant utterances separately), so `save` returns an array. + */ +export interface SaveReceipt { + ok: boolean + /** Optional adapter-reported write latency (ms), for devtools. */ + latencyMs?: number + /** Present when `ok` is `false`. */ + error?: string + /** Raw vendor payload, surfaced for devtools/inspection. */ + raw?: unknown +} + +// =========================== +// Optional introspection (devtools / admin panels) +// =========================== + +/** Full snapshot returned by the optional {@link MemoryAdapter.inspect}. */ +export interface MemorySnapshot { + /** ISO timestamp when the snapshot was taken. */ + takenAt: string + /** Adapter-defined snapshot payload. */ + data: unknown +} + +/** A flat fact row returned by the optional {@link MemoryAdapter.listFacts}. */ +export interface MemoryFact { + id: string + text: string + source?: string + /** ISO timestamp, when the adapter tracks creation time. */ + createdAt?: string +} + +// =========================== +// Adapter contract +// =========================== + +/** + * The single memory adapter contract. All backends — the built-in `inMemory()` + * and `redis()` adapters as well as vendor adapters (`hindsight()`, `mem0()`, + * `honcho()`) — implement `recall` + `save`. `inspect`/`listFacts` are optional + * and exist only for devtools/admin surfaces. + */ +export interface MemoryAdapter { + /** Stable id used in logs, devtools, and event payloads (e.g. 'in-memory', 'hindsight'). */ + readonly id: string + /** Optional human-readable label; defaults to {@link MemoryAdapter.id} in logs. */ + readonly name?: string + + /** + * Read side — retrieve what's relevant to `query` within `scope`. The ranking + * strategy (lexical, semantic, hybrid, vendor-native) is entirely the + * adapter's concern. + */ + recall: (scope: MemoryScope, query: string) => Promise + + /** + * Write side — persist a completed turn. Extraction (turn → stored facts) + * happens HERE, inside the adapter. Returns one receipt per underlying write. + */ + save: (scope: MemoryScope, turn: MemoryTurn) => Promise> + + /** Optional — full snapshot for a devtools inspection panel. */ + inspect?: (scope: MemoryScope) => Promise + /** Optional — flat fact list for a devtools panel. */ + listFacts?: (scope: MemoryScope) => Promise> +} diff --git a/packages/ai-memory/tests/contract.ts b/packages/ai-memory/tests/contract.ts index 83fdd56f9..232582d1c 100644 --- a/packages/ai-memory/tests/contract.ts +++ b/packages/ai-memory/tests/contract.ts @@ -1,548 +1,82 @@ -// packages/ai-memory/tests/contract.ts import { beforeEach, describe, expect, it } from 'vitest' -import type { - MemoryAdapter, - MemoryRecord, - MemoryScope, -} from '@tanstack/ai/memory' - +import type { MemoryAdapter, MemoryScope } from '../src' + +/** + * Shared contract suite for any `recall`/`save` {@link MemoryAdapter}. Point it + * at a factory that returns a fresh adapter and it verifies the round-trip, + * scope isolation, empty recall, receipt shape, and the optional introspection + * methods. If your adapter passes, the middleware works. + */ 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' } + const scopeA: MemoryScope = { sessionId: 's1', userId: 'u1' } + const scopeB: MemoryScope = { sessionId: 's2', userId: 'u2' } beforeEach(async () => { adapter = await factory() }) - function rec(over: Partial = {}): MemoryRecord { - return { - id: over.id ?? crypto.randomUUID(), - scope: over.scope ?? scopeA, - text: over.text ?? 'hello world', - kind: over.kind ?? 'fact', - createdAt: over.createdAt ?? Date.now(), - ...over, - } - } - - describe('add', () => { - it('inserts a single record', async () => { - const r = rec() - await adapter.add(r) - expect(await adapter.get(r.id, scopeA)).toMatchObject({ id: r.id }) - }) - - it('inserts an array of records in one call', async () => { - const a = rec({ id: 'a' }) - const b = rec({ id: 'b' }) - await adapter.add([a, b]) - expect(await adapter.get('a', scopeA)).toBeDefined() - expect(await adapter.get('b', scopeA)).toBeDefined() - }) - - it('upserts by id (replays the same id replace)', async () => { - const r = rec({ id: 'x', text: 'first' }) - await adapter.add(r) - const after1 = await adapter.get('x', scopeA) - expect(after1?.text).toBe('first') - expect(after1?.updatedAt).toBeGreaterThanOrEqual(after1!.createdAt) - - // Yield to the event loop so Date.now() can advance — without this, - // a tight double-add can land in the same millisecond and the - // strictly-greater assertion below would be flaky on fast machines. - await new Promise((resolve) => setTimeout(resolve, 2)) - - await adapter.add({ ...r, text: 'second' }) - const after2 = await adapter.get('x', scopeA) - expect(after2?.text).toBe('second') - expect(after2?.updatedAt).toBeGreaterThanOrEqual(after2!.createdAt) - // Load-bearing assertion: the second add MUST bump updatedAt. - // Without this, an adapter that sets updatedAt = createdAt once - // and never touches it again would silently pass the upsert - // contract test. - expect(after2!.updatedAt).toBeGreaterThan(after1!.updatedAt!) - }) - }) - - describe('get', () => { - it('returns undefined for unknown id', async () => { - expect(await adapter.get('nope', scopeA)).toBeUndefined() - }) - it('returns undefined when scope mismatches', async () => { - const r = rec({ id: 'q', scope: scopeA }) - await adapter.add(r) - expect(await adapter.get('q', scopeB)).toBeUndefined() - }) - it('returns undefined when record is expired', async () => { - const r = rec({ id: 'e', expiresAt: Date.now() - 1 }) - await adapter.add(r) - expect(await adapter.get('e', scopeA)).toBeUndefined() - }) - }) - - describe('update', () => { - it('patches text and bumps updatedAt, preserves createdAt', async () => { - const r = rec({ id: 'u', text: 'old', createdAt: 1000 }) - await adapter.add(r) - const before = Date.now() - const out = await adapter.update('u', scopeA, { text: 'new' }) - expect(out?.text).toBe('new') - expect(out?.createdAt).toBe(1000) - expect(out?.updatedAt ?? 0).toBeGreaterThanOrEqual(before) - }) - it('returns undefined for unknown id or wrong scope', async () => { - await adapter.add(rec({ id: 'u', scope: scopeA })) - expect(await adapter.update('u', scopeB, { text: 'x' })).toBeUndefined() - expect( - await adapter.update('nope', scopeA, { text: 'x' }), - ).toBeUndefined() - }) - }) - - describe('search', () => { - it('respects topK', async () => { - for (let i = 0; i < 10; i++) { - await adapter.add(rec({ id: `r${i}`, text: `word${i} same` })) - } - const out = await adapter.search({ - scope: scopeA, - text: 'same', - topK: 3, - }) - expect(out.hits.length).toBeLessThanOrEqual(3) - }) - - it('isolates scope', async () => { - await adapter.add(rec({ id: 'a', scope: scopeA, text: 'apples' })) - await adapter.add(rec({ id: 'b', scope: scopeB, text: 'apples' })) - const out = await adapter.search({ scope: scopeA, text: 'apples' }) - // Non-empty guard: `every` on [] is vacuously true and would mask - // an adapter that returned zero hits. - expect(out.hits.length).toBeGreaterThan(0) - expect(out.hits.every((h) => h.record.scope.userId === 'u1')).toBe(true) - }) - - it('filters by kinds', async () => { - await adapter.add(rec({ id: 'a', text: 'foo', kind: 'fact' })) - await adapter.add(rec({ id: 'b', text: 'foo', kind: 'preference' })) - const out = await adapter.search({ - scope: scopeA, - text: 'foo', - kinds: ['fact'], + describe('save', () => { + it('returns a non-empty array of ok receipts', async () => { + const receipts = await adapter.save(scopeA, { + user: 'I love hiking in the mountains', + assistant: 'Noted — hiking it is.', }) - // Non-empty guard: `every` on [] is vacuously true. - expect(out.hits.length).toBeGreaterThan(0) - expect(out.hits.every((h) => h.record.kind === 'fact')).toBe(true) - }) - - it('does not return expired records', async () => { - await adapter.add( - rec({ id: 'e', text: 'orange', expiresAt: Date.now() - 1 }), - ) - await adapter.add(rec({ id: 'f', text: 'orange' })) - const out = await adapter.search({ scope: scopeA, text: 'orange' }) - expect(out.hits.find((h) => h.record.id === 'e')).toBeUndefined() - expect(out.hits.find((h) => h.record.id === 'f')).toBeDefined() - }) - - it('paginates with cursor and terminates', async () => { - for (let i = 0; i < 12; i++) { - await adapter.add(rec({ id: `p${i}`, text: `pagework${i}` })) - } - let cursor: string | undefined - const seen = new Set() - let pages = 0 - do { - const out = await adapter.search({ - scope: scopeA, - text: 'pagework', - topK: 4, - cursor, - }) - for (const h of out.hits) seen.add(h.record.id) - cursor = out.nextCursor - pages++ - if (pages > 10) throw new Error('cursor did not terminate') - } while (cursor) - // Load-bearing: every record must be visible exactly once across - // pages. Catches adapters that drop records between pages or - // return the same page repeatedly with a terminating cursor. - // Adapters MAY return all in one page (no nextCursor) OR paginate; - // either is fine, but the union of pages must cover all 12 ids. - expect(seen.size).toBe(12) - expect(pages).toBeGreaterThanOrEqual(1) + expect(Array.isArray(receipts)).toBe(true) + expect(receipts.length).toBeGreaterThan(0) + expect(receipts.every((r) => typeof r.ok === 'boolean')).toBe(true) + expect(receipts.some((r) => r.ok)).toBe(true) }) }) - describe('list', () => { - it('returns scoped records', async () => { - await adapter.add(rec({ id: 'a', scope: scopeA })) - await adapter.add(rec({ id: 'b', scope: scopeB })) - const out = await adapter.list(scopeA) - // Non-empty guard: `every` on [] is vacuously true. - expect(out.items.length).toBeGreaterThan(0) - expect(out.items.every((r) => r.scope.userId === 'u1')).toBe(true) - }) - it('respects limit', async () => { - for (let i = 0; i < 6; i++) await adapter.add(rec({ id: `l${i}` })) - const out = await adapter.list(scopeA, { limit: 2 }) - expect(out.items.length).toBeLessThanOrEqual(2) - }) - it('filters by kinds', async () => { - await adapter.add(rec({ id: 'a', kind: 'fact' })) - await adapter.add(rec({ id: 'b', kind: 'preference' })) - const out = await adapter.list(scopeA, { kinds: ['preference'] }) - // Non-empty guard: `every` on [] is vacuously true. - expect(out.items.length).toBeGreaterThan(0) - expect(out.items.every((r) => r.kind === 'preference')).toBe(true) - }) - }) - - describe('delete', () => { - it('removes records by id within scope', async () => { - await adapter.add(rec({ id: 'd' })) - await adapter.delete(['d'], scopeA) - expect(await adapter.get('d', scopeA)).toBeUndefined() - }) - it('does not remove records from another scope', async () => { - await adapter.add(rec({ id: 'd', scope: scopeA })) - await adapter.delete(['d'], scopeB) - expect(await adapter.get('d', scopeA)).toBeDefined() - }) - }) - - describe('clear', () => { - it('removes all records for a scope', async () => { - await adapter.add(rec({ id: 'c1', scope: scopeA })) - await adapter.add(rec({ id: 'c2', scope: scopeB })) - await adapter.clear(scopeA) - expect(await adapter.get('c1', scopeA)).toBeUndefined() - expect(await adapter.get('c2', scopeB)).toBeDefined() - }) - }) - - describe('empty scope safety', () => { - // Cross-tenant safety guard: an empty scope object MUST NOT match any - // record. See `scopeMatches` JSDoc — `clear({})` and `search({ scope: {} })` - // would otherwise wipe / leak every tenant's records. - it('search with empty scope returns no hits', async () => { - await adapter.add(rec({ id: 'a', scope: scopeA, text: 'apples' })) - await adapter.add(rec({ id: 'b', scope: scopeB, text: 'apples' })) - const out = await adapter.search({ scope: {}, text: 'apples' }) - expect(out.hits.length).toBe(0) - }) - - it('list with empty scope returns no items', async () => { - await adapter.add(rec({ id: 'a', scope: scopeA })) - await adapter.add(rec({ id: 'b', scope: scopeB })) - const out = await adapter.list({}) - expect(out.items.length).toBe(0) - }) - - it('clear with empty scope wipes nothing', async () => { - await adapter.add(rec({ id: 'a', scope: scopeA })) - await adapter.add(rec({ id: 'b', scope: scopeB })) - await adapter.clear({}) - expect(await adapter.get('a', scopeA)).toBeDefined() - expect(await adapter.get('b', scopeB)).toBeDefined() - }) - }) - - describe('partial scope semantics', () => { - it('search with a partial scope finds records added under sub-scopes', async () => { - const sub1: MemoryScope = { tenantId: 't1', userId: 'u1' } - const sub2: MemoryScope = { tenantId: 't1', userId: 'u2' } - const other: MemoryScope = { tenantId: 't2', userId: 'u1' } - await adapter.add(rec({ id: 'a', scope: sub1, text: 'apple' })) - await adapter.add(rec({ id: 'b', scope: sub2, text: 'apple' })) - await adapter.add(rec({ id: 'c', scope: other, text: 'apple' })) - - const out = await adapter.search({ - scope: { tenantId: 't1' }, - text: 'apple', + describe('recall', () => { + it('round-trips: a saved turn surfaces in a later recall', async () => { + await adapter.save(scopeA, { + user: 'My favorite programming language is TypeScript', + assistant: 'Great choice.', }) - 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) + const result = await adapter.recall(scopeA, 'programming language') + expect(result.systemPrompt.toLowerCase()).toContain('typescript') }) - 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('returns an empty result for a scope with nothing saved', async () => { + const result = await adapter.recall(scopeA, 'anything at all') + expect(result.systemPrompt).toBe('') + expect(result.fragments ?? []).toHaveLength(0) }) - it('clear with a partial scope wipes records from sub-scopes', async () => { - const sub1: MemoryScope = { tenantId: 't1', userId: 'u1' } - const sub2: MemoryScope = { tenantId: 't1', userId: 'u2' } - const other: MemoryScope = { tenantId: 't2', userId: 'u1' } - await adapter.add(rec({ id: 'a', scope: sub1 })) - await adapter.add(rec({ id: 'b', scope: sub2 })) - await adapter.add(rec({ id: 'c', scope: other })) - await adapter.clear({ tenantId: 't1' }) - expect(await adapter.get('a', sub1)).toBeUndefined() - expect(await adapter.get('b', sub2)).toBeUndefined() - expect(await adapter.get('c', other)).toBeDefined() - }) - - it('delete by id keeps the record findable via the actual scope after the call', async () => { - // NOT a partial-scope test, but it pins the srem-uses-record-scope fix. - const subScope: MemoryScope = { tenantId: 't1', userId: 'u1' } - await adapter.add(rec({ id: 'd', scope: subScope })) - await adapter.delete(['d'], { tenantId: 't1' }) // wider than record scope - expect(await adapter.get('d', subScope)).toBeUndefined() - // After the delete, list({tenantId:'t1'}) should also not return it - const listed = await adapter.list({ tenantId: 't1' }) - expect(listed.items.find((r) => r.id === 'd')).toBeUndefined() - }) - - it('add upsert with changed scope removes id from old scope index', async () => { - const oldScope: MemoryScope = { tenantId: 't1', userId: 'u1' } - const newScope: MemoryScope = { tenantId: 't1', userId: 'u2' } - await adapter.add(rec({ id: 'm', scope: oldScope, text: 'original' })) - await adapter.add(rec({ id: 'm', scope: newScope, text: 'rescoped' })) - // Record is no longer findable via old scope - expect(await adapter.get('m', oldScope)).toBeUndefined() - expect(await adapter.get('m', newScope)).toBeDefined() - // list under old scope shouldn't return it - const oldList = await adapter.list(oldScope) - expect(oldList.items.find((r) => r.id === 'm')).toBeUndefined() - // list under new scope should - const newList = await adapter.list(newScope) - expect(newList.items.find((r) => r.id === 'm')).toBeDefined() - }) - }) - - describe('scope value safety', () => { - // Defense-in-depth: scope values that happen to contain glob - // metacharacters (*, ?, [, ], \) MUST NOT cross-match other tenants' - // index buckets. The in-memory adapter doesn't use globs so this is - // a no-op there; for the redis adapter it pins the escapeGlob fix on - // findIndexKeysForScope's SCAN MATCH pattern. Without escaping, a - // scope value like `tenantId: 't*'` would cause the SCAN to glob - // every other tenant's index key and surface their records. - it('does not cross-match scope values that contain glob metacharacters', async () => { - const realTenant: MemoryScope = { tenantId: 'real-tenant' } - const otherTenant: MemoryScope = { tenantId: 'tenant-x' } - const attacker: MemoryScope = { tenantId: 't*' } - await adapter.add( - rec({ id: 'real', scope: realTenant, text: 'tenant data' }), - ) - await adapter.add( - rec({ id: 'other', scope: otherTenant, text: 'tenant data' }), - ) - const out = await adapter.search({ - scope: attacker, - text: 'tenant data', + it('isolates scopes — recall never crosses into another scope', async () => { + await adapter.save(scopeA, { + user: 'The secret code is alpha-bravo', + assistant: 'Understood.', }) - // Neither tenant's records are leaked — the attacker's literal - // `t*` scope must not glob-match `real-tenant` or `tenant-x`. - expect(out.hits.find((h) => h.record.id === 'real')).toBeUndefined() - expect(out.hits.find((h) => h.record.id === 'other')).toBeUndefined() - }) - - // EXACT-MATCH counterpart to the SCAN MATCH glob-escape test above. The - // redis adapter's `scopeKey` joins scope values with `:`. Without - // escaping, `{ tenantId: 'a:b' }` and `{ tenantId: 'a', userId: 'b' }` - // would both serialize to `a:b:_:_:_:_` and silently merge two - // different tenants' index buckets. The in-memory adapter is unaffected - // because it does not serialize scope to strings — it uses - // `scopeMatches` against the raw scope object — but the test still - // pins the same isolation guarantee. - // - // We assert ONLY the isolation property (no cross-leak), not the - // own-record retrieval, because ioredis-mock does not implement the - // SCAN MATCH backslash-escape mechanism Redis uses. In a real Redis - // deployment the escaped pattern correctly matches the literal key; - // here we verify the security-critical half — that buckets do not - // merge — and rely on the in-memory contract run for the - // own-record-reachability half. - it('does not cross-leak scope values that contain the segment delimiter', async () => { - const colonTenant: MemoryScope = { tenantId: 'a:b' } - const splitScope: MemoryScope = { tenantId: 'a', userId: 'b' } - await adapter.add( - rec({ id: 'colon', scope: colonTenant, text: 'colon data' }), - ) - await adapter.add( - rec({ id: 'split', scope: splitScope, text: 'split data' }), - ) - // Querying the split scope must NOT surface the colon-scope record — - // the previously-colliding bucket layout is now isolated. - const splitOut = await adapter.search({ - scope: splitScope, - text: 'data', - }) - expect( - splitOut.hits.find((h) => h.record.id === 'colon'), - ).toBeUndefined() - expect(splitOut.hits.find((h) => h.record.id === 'split')).toBeDefined() - // get() uses an id+scope check via scopeMatches against the raw - // scope object, so the own-record reachability half is also testable - // here without relying on SCAN MATCH escape semantics. - expect(await adapter.get('colon', colonTenant)).toBeDefined() - expect(await adapter.get('split', splitScope)).toBeDefined() - // And the cross-scope get must not leak either way. - expect(await adapter.get('colon', splitScope)).toBeUndefined() - expect(await adapter.get('split', colonTenant)).toBeUndefined() - }) - - it('does not cross-leak scope values that contain the escape character', async () => { - // Backslash is the escape character used by both `escapeScopeValue` - // (for `:`/`\`) and `escapeGlob` (for glob metacharacters). A naive - // escape that didn't escape `\` itself would let - // `tenantId: 'a\\backslash'` collide with another scope after - // unescaping. Same isolation-only assertion shape as the colon test. - const backslashTenant: MemoryScope = { tenantId: 'has\\backslash' } - const otherTenant: MemoryScope = { tenantId: 'has' } - await adapter.add( - rec({ id: 'bs', scope: backslashTenant, text: 'bs data' }), - ) - await adapter.add( - rec({ id: 'plain', scope: otherTenant, text: 'plain data' }), - ) - const out = await adapter.search({ - scope: otherTenant, - text: 'data', - }) - expect(out.hits.find((h) => h.record.id === 'plain')).toBeDefined() - expect(out.hits.find((h) => h.record.id === 'bs')).toBeUndefined() - // Own-record reachability via id+scope is testable without SCAN. - expect(await adapter.get('bs', backslashTenant)).toBeDefined() - expect(await adapter.get('plain', otherTenant)).toBeDefined() + const other = await adapter.recall(scopeB, 'secret code') + expect(other.systemPrompt).toBe('') + expect(other.fragments ?? []).toHaveLength(0) }) + }) - // 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() + describe('optional introspection', () => { + it('inspect (when present) returns a well-formed snapshot after a save', async () => { + if (!adapter.inspect) return + await adapter.save(scopeA, { user: 'hello world', assistant: 'hi' }) + const snap = await adapter.inspect(scopeA) + expect(typeof snap.takenAt).toBe('string') + expect(snap.data).toBeDefined() }) - 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() + it('listFacts (when present) returns rows after a save', async () => { + if (!adapter.listFacts) return + await adapter.save(scopeA, { user: 'hello world', assistant: 'hi' }) + const facts = await adapter.listFacts(scopeA) + expect(Array.isArray(facts)).toBe(true) expect( - out.hits.find((h) => h.record.id === 'real-user'), - ).toBeUndefined() - // Own-record reachability via id+scope is testable without SCAN. - expect(await adapter.get('no-user', noUserScope)).toBeDefined() - expect(await adapter.get('us', underscoreUserScope)).toBeDefined() - expect(await adapter.get('real-user', realUserScope)).toBeDefined() - // The narrower (underscore-user) query against the broader (no-user) - // record must NOT match — per `scopeMatches`, the query's defined - // `userId: '_'` does not match a missing `userId`. This is the - // partial-scope asymmetry; the converse (broader query, narrower - // record) is the legitimate partial-scope cascade and is not - // asserted here. - expect( - await adapter.get('no-user', underscoreUserScope), - ).toBeUndefined() - }) - - it('treats empty-string scope values as undefined (not as a distinct bucket)', async () => { - // A scope value of `''` is equivalent to the key being unset — see - // `scopeMatches` JSDoc. A record written with `{ tenantId: '' }` - // would otherwise produce a degenerate "blank-tenant" bucket that - // no normal query could reach. The empty-scope safety guard kicks - // in for `{ tenantId: '' }` (since the only defined key is empty) - // and turns clear/search/list into no-ops. - await adapter.add(rec({ id: 'a', scope: scopeA, text: 'apples' })) - await adapter.add(rec({ id: 'b', scope: scopeB, text: 'apples' })) - const out = await adapter.search({ - scope: { tenantId: '' }, - text: 'apples', - }) - expect(out.hits.length).toBe(0) - const listed = await adapter.list({ tenantId: '' }) - expect(listed.items.length).toBe(0) - // `clear({ tenantId: '' })` must NOT wipe real tenants. - await adapter.clear({ tenantId: '' }) - expect(await adapter.get('a', scopeA)).toBeDefined() - expect(await adapter.get('b', scopeB)).toBeDefined() - }) - }) - - describe('semantic vs lexical ranking', () => { - it('lexical-only when no embeddings', async () => { - await adapter.add(rec({ id: 'a', text: 'apple banana' })) - await adapter.add(rec({ id: 'b', text: 'totally unrelated' })) - const out = await adapter.search({ scope: scopeA, text: 'apple' }) - expect(out.hits[0]?.record.id).toBe('a') - }) - it('semantic match outranks lexical-only when embeddings present', async () => { - await adapter.add(rec({ id: 'lex', text: 'apple', embedding: [0, 1] })) - await adapter.add(rec({ id: 'sem', text: 'fruit', embedding: [1, 0] })) - const out = await adapter.search({ - scope: scopeA, - text: 'apple', - embedding: [1, 0], - }) - expect(out.hits[0]?.record.id).toBe('sem') + facts.every((f) => typeof f.id === 'string' && typeof f.text === 'string'), + ).toBe(true) }) }) }) diff --git a/packages/ai-memory/tests/in-memory.test.ts b/packages/ai-memory/tests/in-memory.test.ts index a3bd1191b..e37fc1241 100644 --- a/packages/ai-memory/tests/in-memory.test.ts +++ b/packages/ai-memory/tests/in-memory.test.ts @@ -1,4 +1,31 @@ -import { inMemoryMemoryAdapter } from '../src/adapters/in-memory' +import { describe, expect, it } from 'vitest' +import { inMemory } from '../src/in-memory' import { runMemoryAdapterContract } from './contract' -runMemoryAdapterContract('inMemoryMemoryAdapter', () => inMemoryMemoryAdapter()) +runMemoryAdapterContract('inMemory', () => inMemory()) + +describe('inMemory options', () => { + it('runs an extractor on save and surfaces extracted facts on recall', async () => { + const adapter = inMemory({ + extract: (turn) => [{ text: `fact: ${turn.user}`, kind: 'fact', importance: 1 }], + }) + const scope = { sessionId: 's1', userId: 'u1' } + await adapter.save(scope, { user: 'I live in Berlin', assistant: 'ok' }) + const result = await adapter.recall(scope, 'Berlin') + expect(result.systemPrompt).toContain('fact:') + expect(result.systemPrompt.toLowerCase()).toContain('berlin') + }) + + it('respects the userId dimension of scope', async () => { + const adapter = inMemory() + await adapter.save({ sessionId: 's', userId: 'a' }, { + user: 'apples are red', + assistant: 'ok', + }) + const sameSessionOtherUser = await adapter.recall( + { sessionId: 's', userId: 'b' }, + 'apples', + ) + expect(sameSessionOtherUser.systemPrompt).toBe('') + }) +}) diff --git a/packages/ai-memory/tests/middleware.test.ts b/packages/ai-memory/tests/middleware.test.ts new file mode 100644 index 000000000..4e65c4d05 --- /dev/null +++ b/packages/ai-memory/tests/middleware.test.ts @@ -0,0 +1,116 @@ +import { describe, expect, it, vi } from 'vitest' +import { memoryMiddleware } from '../src' +import type { + ChatMiddlewareConfig, + ChatMiddlewareContext, + FinishInfo, + Tool, +} from '@tanstack/ai' +import type { MemoryAdapter, MemoryScope, MemoryTurn } from '../src' + +const catTool: Tool = { + name: 'cat_tool', + description: 'A tool about cats.', + inputSchema: { type: 'object', properties: {}, additionalProperties: false }, +} + +function makeConfig(userText: string): ChatMiddlewareConfig { + return { + messages: [{ role: 'user', content: userText }], + systemPrompts: ['base prompt'], + tools: [], + } +} + +function makeCtx( + config: ChatMiddlewareConfig, + deferred: Array>, +): ChatMiddlewareContext { + return { + phase: 'init', + messages: config.messages, + defer: (p: Promise) => deferred.push(p), + } as unknown as ChatMiddlewareContext +} + +function fakeAdapter( + saved: Array<{ scope: MemoryScope; turn: MemoryTurn }>, +): MemoryAdapter { + return { + id: 'fake', + recall: async () => ({ + systemPrompt: 'MEMORY: the user likes cats', + fragments: [{ text: 'the user likes cats', source: 'f1' }], + tools: [catTool], + toolGuidance: 'Use cat_tool when relevant.', + }), + save: async (scope, turn) => { + saved.push({ scope, turn }) + return [{ ok: true }] + }, + } +} + +const scope: MemoryScope = { sessionId: 's1', userId: 'u1' } + +describe('memoryMiddleware', () => { + it('injects recalled systemPrompt + toolGuidance + tools at init', async () => { + const mw = memoryMiddleware({ adapter: fakeAdapter([]), scope }) + const config = makeConfig('tell me about my pets') + const result = await mw.onConfig?.(makeCtx(config, []), config) + + expect(result).toBeTruthy() + const patch = result as Partial + expect(patch.systemPrompts).toEqual([ + 'base prompt', + 'Use cat_tool when relevant.', + 'MEMORY: the user likes cats', + ]) + expect(patch.tools?.map((t) => t.name)).toEqual(['cat_tool']) + }) + + it('save-only role skips recall entirely', async () => { + const mw = memoryMiddleware({ adapter: fakeAdapter([]), scope, role: 'save-only' }) + const config = makeConfig('hello') + const result = await mw.onConfig?.(makeCtx(config, []), config) + expect(result).toBeUndefined() + }) + + it('defers save of the finished turn and reports receipts', async () => { + const saved: Array<{ scope: MemoryScope; turn: MemoryTurn }> = [] + const onSave = vi.fn() + const mw = memoryMiddleware({ adapter: fakeAdapter(saved), scope, onSave }) + + const deferred: Array> = [] + const config = makeConfig('remember I like cats') + const ctx = makeCtx(config, deferred) + // Prime per-request state (captures lastUserText) via onConfig. + await mw.onConfig?.(ctx, config) + + const info: FinishInfo = { + finishReason: 'stop', + duration: 1, + content: 'You like cats!', + } + mw.onFinish?.(ctx, info) + await Promise.all(deferred) + + expect(saved).toHaveLength(1) + expect(saved[0]?.turn).toEqual({ user: 'remember I like cats', assistant: 'You like cats!' }) + expect(onSave).toHaveBeenCalledOnce() + }) + + it('recall failures are non-fatal (no throw, no injection)', async () => { + const failing: MemoryAdapter = { + id: 'boom', + recall: async () => { + throw new Error('recall exploded') + }, + save: async () => [{ ok: true }], + } + const mw = memoryMiddleware({ adapter: failing, scope }) + const config = makeConfig('hi') + const result = await mw.onConfig?.(makeCtx(config, []), config) + expect(result).toBeUndefined() + }) +}) diff --git a/packages/ai-memory/tests/redis.test.ts b/packages/ai-memory/tests/redis.test.ts index 1e3a50ca0..7be5a7885 100644 --- a/packages/ai-memory/tests/redis.test.ts +++ b/packages/ai-memory/tests/redis.test.ts @@ -1,56 +1,45 @@ -// @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). +// @ts-expect-error -- ioredis-mock has no bundled types; the adapter only uses +// the lowercase RedisLike subset ioredis-mock implements (cast below). import RedisMock from 'ioredis-mock' import { describe, expect, it, vi } from 'vitest' -import { nodeRedisAsRedisLike, redisMemoryAdapter } from '../src/adapters/redis' +import { nodeRedisAsRedisLike, redis } from '../src/redis' +import type { RedisLike } from '../src/redis' import { runMemoryAdapterContract } from './contract' -runMemoryAdapterContract('redisMemoryAdapter', async () => { - const client = new RedisMock() - return redisMemoryAdapter({ - redis: client as never, - prefix: `test:${crypto.randomUUID()}`, - }) -}) +function mockClient(): RedisLike { + return new RedisMock() as unknown as RedisLike +} + +runMemoryAdapterContract('redis', () => + redis({ redis: mockClient(), prefix: `test:${crypto.randomUUID()}` }), +) -describe('redisMemoryAdapter malformed rows', () => { +describe('redis malformed rows', () => { it('skips a malformed record on read but does NOT delete it', async () => { const prefix = `test:${crypto.randomUUID()}` - const client = new RedisMock() - const adapter = redisMemoryAdapter({ redis: client as never, prefix }) - const scope = { tenantId: 't1', userId: 'u1' } + const client = mockClient() + const adapter = redis({ redis: client, prefix }) + const scope = { sessionId: 's', userId: 'u' } const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}) try { - await adapter.add({ - id: 'good', - scope, - text: 'ok', - kind: 'fact', - createdAt: Date.now(), + await adapter.save(scope, { + user: 'good memory about penguins', + assistant: 'noted', }) - await adapter.add({ - id: 'bad', - scope, - text: 'will be corrupted', - kind: 'fact', - createdAt: Date.now(), - }) - // Corrupt the stored payload directly, simulating a truncated write or a - // third-party writer using an incompatible schema. - const badKey = `${prefix}:record:bad` + // Find the stored record ids from the scope index and corrupt one. + const indexKey = `${prefix}:index:u:s` + const ids = await client.smembers(indexKey) + expect(ids.length).toBeGreaterThan(0) + const badId = ids[0] as string + const badKey = `${prefix}:record:${badId}` await client.set(badKey, '{ not valid json') - // The malformed row is skipped, the good one still returned. - const listed = await adapter.list(scope) - const ids = listed.items.map((r) => r.id) - expect(ids).toContain('good') - expect(ids).not.toContain('bad') + // recall skips the corrupted row without throwing. + const result = await adapter.recall(scope, 'penguins') + expect(result.fragments?.some((f) => f.source === badId)).toBeFalsy() - // Load-bearing: the malformed row is LEFT IN PLACE, not deleted — a - // parse failure is not proof the data is unrecoverable. + // Load-bearing: the malformed row is LEFT IN PLACE, not deleted. expect(await client.get(badKey)).toBe('{ not valid json') - // And the developer was warned about it. expect(warn).toHaveBeenCalled() } finally { warn.mockRestore() @@ -58,6 +47,24 @@ describe('redisMemoryAdapter malformed rows', () => { }) }) +describe('redis scope-key hardening', () => { + it('escapes the delimiter so scope values containing ":" cannot collide', async () => { + const prefix = `test:${crypto.randomUUID()}` + const client = mockClient() + const adapter = redis({ redis: client, prefix }) + + // Without escaping, { userId: 'a:b', sessionId: 'c' } and + // { userId: 'a', sessionId: 'b:c' } both serialize to key `a:b:c`. + await adapter.save({ userId: 'a:b', sessionId: 'c' }, { + user: 'confidential tenant one data', + assistant: 'ok', + }) + const other = await adapter.recall({ userId: 'a', sessionId: 'b:c' }, 'confidential') + expect(other.systemPrompt).toBe('') + expect(other.fragments ?? []).toHaveLength(0) + }) +}) + describe('nodeRedisAsRedisLike', () => { it('translates camelCase node-redis methods into lowercase RedisLike calls', async () => { const calls: Array<{ method: string; args: Array }> = [] @@ -90,46 +97,16 @@ describe('nodeRedisAsRedisLike', () => { calls.push({ method: 'mGet', args: [keys] }) return [] }, - scan: async ( - cursor: number | string, - opts?: { MATCH?: string; COUNT?: number }, - ) => { - calls.push({ method: 'scan', args: [cursor, opts] }) - return { cursor: 0, keys: [] as Array } - }, } const wrapped = nodeRedisAsRedisLike(fakeNodeRedis) - await wrapped.set('k', 'v') await wrapped.sadd('s', 'a', 'b') - await wrapped.sadd('s', 'c') await wrapped.mget('k1', 'k2') - const scanResult = await wrapped.scan( - '0', - 'MATCH', - 'pattern:*', - 'COUNT', - '50', - ) await wrapped.del('d1', 'd2') - // Cursor passthrough — node-redis v5 uses string cursors and v4 uses - // number cursors. The wrapper must thread either through unchanged so - // a string cursor past Number.MAX_SAFE_INTEGER round-trips losslessly. - await wrapped.scan('0', 'MATCH', 'p:*') - await wrapped.scan(0, 'MATCH', 'p:*') - const bigCursor = '90071992547409930' // > Number.MAX_SAFE_INTEGER - await wrapped.scan(bigCursor, 'MATCH', 'p:*') - // COUNT <= 0 must be silently dropped — Redis rejects COUNT 0. - await wrapped.scan(0, 'MATCH', 'p:*', 'COUNT', '0') - - expect(calls.find((c) => c.method === 'set')).toMatchObject({ - args: ['k', 'v'], - }) - // First sAdd was called with two members; assert it was forwarded as an - // array (not as variadic args) so node-redis' single-or-array overload - // resolves to the array branch. + expect(calls.find((c) => c.method === 'set')).toMatchObject({ args: ['k', 'v'] }) + // Variadic members forwarded as an array so node-redis' overload resolves right. expect( calls.find( (c) => @@ -138,29 +115,7 @@ describe('nodeRedisAsRedisLike', () => { (c.args[1] as Array).length === 2, ), ).toBeTruthy() - expect(calls.find((c) => c.method === 'mGet')).toMatchObject({ - args: [['k1', 'k2']], - }) - const scanCalls = calls.filter((c) => c.method === 'scan') - // First scan: numeric COUNT translated correctly; cursor '0' threaded as-is - // (no Number() coercion). - expect(scanCalls[0]).toMatchObject({ - args: ['0', { MATCH: 'pattern:*', COUNT: 50 }], - }) - // String cursor passed through as a string (v5 shape). - expect(scanCalls[1]?.args[0]).toBe('0') - // Number cursor passed through as a number (v4 shape). - expect(scanCalls[2]?.args[0]).toBe(0) - // Big string cursor past Number.MAX_SAFE_INTEGER round-trips losslessly. - expect(scanCalls[3]?.args[0]).toBe('90071992547409930') - // COUNT 0 is silently dropped (Redis rejects COUNT <= 0). - expect(scanCalls[4]?.args[1]).toEqual({ MATCH: 'p:*' }) - expect(calls.find((c) => c.method === 'del')).toMatchObject({ - args: [['d1', 'd2']], - }) - - // The scan reply is unwrapped from { cursor, keys } back into the - // ioredis-style [nextCursor, matchedKeys] tuple the adapter consumes. - expect(scanResult).toEqual(['0', []]) + expect(calls.find((c) => c.method === 'mGet')).toMatchObject({ args: [['k1', 'k2']] }) + expect(calls.find((c) => c.method === 'del')).toMatchObject({ args: [['d1', 'd2']] }) }) }) diff --git a/packages/ai-memory/vite.config.ts b/packages/ai-memory/vite.config.ts index 435aec10e..1e9778958 100644 --- a/packages/ai-memory/vite.config.ts +++ b/packages/ai-memory/vite.config.ts @@ -28,7 +28,14 @@ const config = defineConfig({ export default mergeConfig( config, tanstackViteConfig({ - entry: ['./src/index.ts'], + entry: [ + './src/index.ts', + './src/in-memory.ts', + './src/redis.ts', + './src/providers/hindsight/index.ts', + './src/providers/mem0/index.ts', + './src/providers/honcho/index.ts', + ], srcDir: './src', cjs: false, }), diff --git a/packages/ai/package.json b/packages/ai/package.json index 51f0e3264..36a7dd811 100644 --- a/packages/ai/package.json +++ b/packages/ai/package.json @@ -41,10 +41,6 @@ "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/ai/skills/tanstack-ai-memory/SKILL.md b/packages/ai/skills/tanstack-ai-memory/SKILL.md deleted file mode 100644 index df5c1ab90..000000000 --- a/packages/ai/skills/tanstack-ai-memory/SKILL.md +++ /dev/null @@ -1,115 +0,0 @@ ---- -name: tanstack-ai-memory -description: Use when wiring memoryMiddleware from @tanstack/ai/memory into a chat() call — covers scope shape, server-side scope security, retrieval/persistence semantics, and the extension hooks (shouldRetrieve, rerank, extractMemories, onToolResult, afterPersist). ---- - -# TanStack AI Memory Middleware - -Use this when adding **server-side memory** to a `chat()` call. Memory persists across user turns and is retrieved relevance-first into the system prompt. - -## When to reach for it - -- A user expects "remember what I told you last time." -- Multi-tenant chat where each tenant/user/thread has its own context. -- A bot that should learn preferences or extracted facts over time. - -Do NOT use this just to keep recent messages — that's the `messages` array on `chat()`. Memory is for cross-turn / cross-session recall, not within-turn history. - -## Wire it up - -```ts -import { chat } from '@tanstack/ai' -import { openaiText } from '@tanstack/ai-openai' -import { memoryMiddleware } from '@tanstack/ai/memory' -import { inMemoryMemoryAdapter } from '@tanstack/ai-memory' - -const memory = inMemoryMemoryAdapter() // dev/tests only — see in-memory skill - -// In a real handler you'd attach the server-validated session (and any -// other per-request values you trust) via `chat({ context })`. Inside the -// middleware, scope is then derived from `ctx.context` — never from a -// request body field the client controls. -type AppCtx = { - session: { - tenantId: string - userId: string - activeThreadId: string - } -} - -// Stand-in for whichever embedding client you use (OpenAI, Cohere, local -// model, etc.). The middleware only requires `embed(text): number[]`. -declare const myEmbeddings: { - embed(text: string): Promise> -} - -const stream = chat({ - adapter: openaiText('gpt-4o'), - messages, - context: { session }, // attached by your auth middleware - middleware: [ - memoryMiddleware({ - adapter: memory, - scope: (ctx) => { - const { session } = ctx.context as AppCtx - return { - tenantId: session.tenantId, - userId: session.userId, - threadId: session.activeThreadId, - } - }, - // Optional: provide an embedder for semantic search. - embedder: { - async embed(text) { - return myEmbeddings.embed(text) - }, - }, - }), - ], -}) -``` - -## Scope security - -Scope is the isolation boundary. **Never trust client-supplied tenantId/userId.** Resolve scope server-side from session/auth: - -```ts -scope: (ctx) => { - const { session } = ctx.context as AppCtx - return { - tenantId: session.tenantId, // from server-validated session - userId: session.userId, // from server-validated session - threadId: session.activeThreadId, // server-side resolved thread - } -} -``` - -Pass the validated session through `chat({ context: { session } })`. If you need to accept a `threadId` from the request body, validate server-side that it belongs to `session.userId` BEFORE attaching it to the chat context — never feed an unvalidated body field straight into scope. - -## Adapters - -- `inMemoryMemoryAdapter()` — dev, tests, single-process demos. See `tanstack-ai-memory-in-memory` skill. -- `redisMemoryAdapter({ redis })` — production. See `tanstack-ai-memory-redis` skill. -- Custom — implement `MemoryAdapter` from `@tanstack/ai/memory`. - -## Extension hooks - -| Hook | When | Use for | -| ---------------------------------------------------------------------- | --------------------------- | ---------------------------------------------------- | -| `shouldRetrieve({ userText, scope })` | before search | Skip retrieval (cost, content gating) | -| `rerank(hits, { scope, query, ctx })` | after search, before render | MMR / RRF / cross-encoder rerankers | -| `shouldRemember({ message, responseText })` | before persist | Drop short / sensitive messages | -| `extractMemories({ userText, responseText, scope, adapter })` | after model finishes | Add/update/delete records (Mem0-style consolidation) | -| `onToolResult({ toolName, toolCallId, args, result, scope, adapter })` | per completed tool call | Persist tool outputs as `kind: 'tool-result'` | -| `afterPersist({ newRecords, scope, adapter })` | after add | Background work: summarization, eviction | - -`extractMemories` and `onToolResult` may return `MemoryRecord[]` (treated as all-add) or `MemoryOp[]` for mixed ADD/UPDATE/DELETE. - -## Failure modes - -Default `strict: false` — retrieval/persist failures emit `memory:error` devtools events and a callback (`events.onError`), but the chat run continues. Set `strict: true` in tests or compliance-sensitive deploys to make failures throw. - -## Devtools - -Five events on `aiEventClient` (from `@tanstack/ai-event-client`): -`memory:retrieve:started`, `memory:retrieve:completed`, `memory:persist:started`, `memory:persist:completed`, `memory:error`. Hits and records carry a 200-char `preview` only — full text is never streamed by default. diff --git a/packages/ai/src/memory/helpers.ts b/packages/ai/src/memory/helpers.ts deleted file mode 100644 index f7ca849db..000000000 --- a/packages/ai/src/memory/helpers.ts +++ /dev/null @@ -1,147 +0,0 @@ -import type { MemoryHit, MemoryQuery, MemoryRecord, MemoryScope } from './types' - -const DEFAULT_HALF_LIFE_MS = 1000 * 60 * 60 * 24 * 30 // 30 days - -/** - * Decide whether a record's scope satisfies a query scope. - * - * **Strict-by-default empty-scope semantics.** When `queryScope` has no - * defined keys (every key is `undefined`/null, the empty string, or the - * object is `{}`), this returns `false` — i.e. an empty query scope matches - * NOTHING. This is a deliberate cross-tenant safety guard: callers like - * `clear({})` or `search({ scope: {}, ... })` would otherwise wipe / leak - * every tenant's records. Adapters that want to operate on a specific scope - * key (e.g. all records for a tenant regardless of user) must pass that key - * explicitly, e.g. `{ tenantId: 't1' }`. - * - * **Empty-string scope values are treated as undefined.** Scope values MUST - * be non-empty strings to be meaningful. A query of `{ tenantId: '' }` is - * equivalent to `{}` and matches nothing — this prevents callers from - * accidentally producing a degenerate "blank-tenant" bucket that would be - * unreachable from any normal query and indistinguishable from records whose - * scope key was simply unset. - */ -export function scopeMatches( - recordScope: MemoryScope, - queryScope: MemoryScope, -): boolean { - let definedKeys = 0 - for (const key of Object.keys(queryScope) as Array) { - const value = queryScope[key] - if (value == null) continue - // Empty strings are treated as undefined — they cannot be a defined - // scope value. Mirrored in adapters' `hasAnyScopeKey` guards so the same - // rule applies at every isolation boundary. - if (typeof value === 'string' && value.length === 0) continue - definedKeys++ - if (recordScope[key] !== value) return false - } - if (definedKeys === 0) return false - return true -} - -export function cosine(a?: Array, b?: Array): number { - if (!a || !b || a.length !== b.length || a.length === 0) return 0 - let dot = 0 - let aMag = 0 - let bMag = 0 - for (let i = 0; i < a.length; i++) { - const av = a[i] as number - const bv = b[i] as number - dot += av * bv - aMag += av ** 2 - bMag += bv ** 2 - } - if (aMag === 0 || bMag === 0) return 0 - return dot / (Math.sqrt(aMag) * Math.sqrt(bMag)) -} - -export function lexicalOverlap(query: string, text: string): number { - const queryTokens = new Set(query.toLowerCase().split(/\W+/).filter(Boolean)) - if (queryTokens.size === 0) return 0 - const textTokens = new Set(text.toLowerCase().split(/\W+/).filter(Boolean)) - let overlap = 0 - for (const token of queryTokens) { - if (textTokens.has(token)) overlap++ - } - return overlap / queryTokens.size -} - -/** - * Exponential decay score over record age. - * - * @param createdAt Record creation timestamp (epoch ms). - * @param halfLifeMs Time (ms) at which the score reaches 0.5. Defaults to 30 days. - * @param now Reference "current" time (epoch ms). Defaults to `Date.now()`. - * Callers MAY pass an explicit `now` to make scoring deterministic - * (e.g. in tests or batch re-scoring jobs). - */ -export function recencyScore( - createdAt: number, - halfLifeMs: number = DEFAULT_HALF_LIFE_MS, - now: number = Date.now(), -): number { - const age = Math.max(0, now - createdAt) - return Math.pow(0.5, age / halfLifeMs) -} - -export function isExpired( - record: MemoryRecord, - now: number = Date.now(), -): boolean { - return record.expiresAt !== undefined && record.expiresAt < now -} - -/** - * Reference ranking function used by adapters that want a sensible default. - * - * Weighted sum of four signals, each in `[0, 1]`: - * - semantic similarity (cosine) — 0.55 - * - lexical overlap — 0.20 - * - recency (exp decay) — 0.15 - * - importance — 0.10 - * - * Importance is read from `record.importance`. **If unset, importance - * contributes 0** — the function deliberately does NOT fall back to a - * mid-range default. With the `MemoryMiddlewareOptions.minScore` floor at - * `0.15`, a non-zero importance default would push every recent record over - * the floor regardless of relevance. Callers who want recent records to - * float MUST set `importance` on the record explicitly. - * - * @param args.now Optional reference "current" time (epoch ms) threaded - * through to `recencyScore` so callers can score - * deterministically. Defaults to `Date.now()`. - */ -export function defaultScoreHit(args: { - record: MemoryRecord - query: MemoryQuery - now?: number -}): number { - const { record, query, now } = args - const semantic = cosine(query.embedding, record.embedding) - const lexical = lexicalOverlap(query.text, record.text) - const recency = recencyScore(record.createdAt, undefined, now) - // No default fallback for importance — unset means "no importance signal", - // which contributes 0 to the score. See JSDoc above for rationale. - const importance = record.importance ?? 0 - return semantic * 0.55 + lexical * 0.2 + recency * 0.15 + importance * 0.1 -} - -export function defaultRenderMemory(hits: Array): string { - if (hits.length === 0) return '' - return [ - 'Relevant memory:', - 'Use this information only when it is relevant to the current user request.', - 'Do not mention memory directly unless the user asks about it.', - 'If current conversation context contradicts memory, prefer the current conversation.', - '', - // JSON.stringify the record text so persisted memory containing newlines - // or instruction-shaped content cannot break out of the list structure - // and steer subsequent turns at system priority. The double-quoted form - // also makes the content visibly data-shaped rather than instruction-shaped. - ...hits.map( - (hit, index) => - `${index + 1}. [${hit.record.kind}] ${JSON.stringify(hit.record.text)}`, - ), - ].join('\n') -} diff --git a/packages/ai/src/memory/index.ts b/packages/ai/src/memory/index.ts deleted file mode 100644 index aa76eaa8a..000000000 --- a/packages/ai/src/memory/index.ts +++ /dev/null @@ -1,28 +0,0 @@ -export type { - MemoryScope, - MemoryKind, - MemoryRole, - MemoryRecord, - MemoryRecordPatch, - MemoryHit, - MemoryQuery, - MemorySearchResult, - MemoryListOptions, - MemoryListResult, - MemoryAdapter, - MemoryEmbedder, - MemoryOp, - MemoryMiddlewareOptions, -} from './types' - -export { - scopeMatches, - cosine, - lexicalOverlap, - recencyScore, - isExpired, - defaultRenderMemory, - defaultScoreHit, -} from './helpers' - -export { memoryMiddleware } from './middleware' diff --git a/packages/ai/src/memory/middleware.ts b/packages/ai/src/memory/middleware.ts deleted file mode 100644 index e96abf43a..000000000 --- a/packages/ai/src/memory/middleware.ts +++ /dev/null @@ -1,802 +0,0 @@ -import { aiEventClient } from '@tanstack/ai-event-client' -import { defaultRenderMemory } from './helpers' -import type { - ChatMiddleware, - ChatMiddlewareConfig, - ChatMiddlewareContext, -} from '../activities/chat/middleware/types' -import type { ModelMessage } from '../types' -import type { - MemoryHit, - MemoryMiddlewareOptions, - MemoryOp, - MemoryRecord, - MemoryScope, -} from './types' - -/** - * Per-request scratch state. Keyed by `ChatMiddlewareContext` in a - * module-level `WeakMap` so the SAME `memoryMiddleware()` factory output can - * be safely shared across many concurrent `chat()` calls — each request gets - * its own `MemoryRequestState`. Mirrors the OTEL middleware's pattern. - */ -interface MemoryRequestState { - resolvedScope?: MemoryScope - lastUserText: string - lastUserEmbedding?: Array - retrievedHits: Array - /** - * Tool-result ops buffered from `onAfterToolCall` until `onFinish`. Flushed - * inside `persistTurn` AFTER the per-turn `shouldRemember` gate passes — - * returning `false` from `shouldRemember` short-circuits both base records, - * `extractMemories`, AND these tool-result ops, matching the documented - * "short-circuits the entire persist path for the current turn" contract. - */ - pendingToolOps: Array -} - -const stateByCtx = new WeakMap() - -/** - * Server-side memory middleware. See docs/middlewares/memory.md and the - * tanstack-ai-memory skill for usage. - */ -export function memoryMiddleware( - options: MemoryMiddlewareOptions, -): ChatMiddleware { - async function resolveScope( - ctx: ChatMiddlewareContext, - state: MemoryRequestState, - ): Promise { - if (state.resolvedScope) return state.resolvedScope - state.resolvedScope = - typeof options.scope === 'function' - ? await options.scope(ctx) - : options.scope - return state.resolvedScope - } - - return { - name: 'memory', - - async onConfig(ctx, config) { - if (ctx.phase !== 'init') return - - // Allocate per-request state once at the init phase. - const state: MemoryRequestState = { - lastUserText: '', - retrievedHits: [], - pendingToolOps: [], - } - stateByCtx.set(ctx, state) - - const lastUser = findLastUserMessage(config.messages) - state.lastUserText = getMessageText(lastUser) - if (!state.lastUserText) return - - // Scope resolution and the `shouldRetrieve` gate run user-supplied - // callbacks, so they live INSIDE the guarded region below. If either - // throws, the failure routes through `memory:error` + `events.onError` - // and honours `strict` — rather than escaping `onConfig` uncaught and - // breaking the chat request even in non-strict mode. - const startedAt = Date.now() - try { - const scope = await resolveScope(ctx, state) - - if (options.shouldRetrieve) { - const ok = await options.shouldRetrieve({ - userText: state.lastUserText, - scope, - }) - if (!ok) return - } - - safeEmit('memory:retrieve:started', { - scope, - query: preview(state.lastUserText), - topK: options.topK ?? 6, - minScore: options.minScore ?? 0.15, - embedderUsed: !!options.embedder, - timestamp: startedAt, - }) - await options.events?.onRetrieveStart?.({ - scope, - query: state.lastUserText, - }) - - if (options.embedder) { - state.lastUserEmbedding = await options.embedder.embed( - state.lastUserText, - ) - } - - state.retrievedHits = await searchAllPages( - options, - scope, - state.lastUserText, - state.lastUserEmbedding, - ) - - if (options.rerank && state.retrievedHits.length > 0) { - state.retrievedHits = await options.rerank(state.retrievedHits, { - scope, - query: state.lastUserText, - ctx, - }) - } - - safeEmit('memory:retrieve:completed', { - scope, - hits: state.retrievedHits.map((h) => ({ - id: h.record.id, - kind: h.record.kind, - score: h.score, - preview: preview(h.record.text), - })), - durationMs: Date.now() - startedAt, - timestamp: Date.now(), - }) - await options.events?.onRetrieveEnd?.({ - scope, - hits: state.retrievedHits, - }) - } catch (error) { - // `resolveScope` may have thrown before assigning, so fall back to the - // partially-resolved scope (or `{}`) for the error payload. - const errScope = state.resolvedScope ?? {} - safeEmit('memory:error', { - scope: errScope, - phase: 'retrieve', - error: errorInfo(error), - timestamp: Date.now(), - }) - await emitError(options, errScope, 'retrieve', error) - if (options.strict) throw error - return - } - - if (state.retrievedHits.length === 0) return - - const memoryPrompt = - options.render?.(state.retrievedHits) ?? - defaultRenderMemory(state.retrievedHits) - - return { - systemPrompts: [...config.systemPrompts, memoryPrompt], - } satisfies Partial - }, - - async onAfterToolCall(ctx, info) { - if (!options.onToolResult || !info.ok) return - const state = stateByCtx.get(ctx) - if (!state) return - // `scope` is resolved INSIDE the try so a throwing scope resolver routes - // through the same plumbing as an `onToolResult` failure instead of - // escaping the hook and breaking chat in non-strict mode. - let scope: MemoryScope - try { - scope = await resolveScope(ctx, state) - let parsedArgs: unknown = {} - try { - const raw = info.toolCall.function.arguments - if (typeof raw === 'string' && raw.length > 0) { - parsedArgs = JSON.parse(raw) - } - } catch (parseError) { - // Tool-args JSON parse failure: the engine yielded malformed - // tool-call arguments. We still want `onToolResult` to run with the - // result it has — but observers MUST see this as a real failure - // because callers receive `args: {}` regardless of what the model - // actually sent. Fire `memory:error` (phase: 'extract') and route - // through `events.onError` so the failure isn't silent. - // - // Intentionally NOT rethrowing on strict: the malformed payload is - // an engine/provider bug, not a memory failure, and rethrowing here - // would also cause the outer `onAfterToolCall` catch to emit a - // second `phase: 'extract'` event for the same root cause. Falling - // back to `parsedArgs = {}` lets `onToolResult` still derive a - // record from `result`, which is the more useful signal anyway. - parsedArgs = {} - safeEmit('memory:error', { - scope, - phase: 'extract', - error: errorInfo(parseError), - timestamp: Date.now(), - }) - await emitError(options, scope, 'extract', parseError) - } - const out = await options.onToolResult({ - toolName: info.toolName, - toolCallId: info.toolCallId, - args: parsedArgs, - result: info.result, - scope, - adapter: options.adapter, - }) - if (!out) return - // Buffer the tool-result ops for the per-turn `shouldRemember` gate - // inside `persistTurn`. Per the JSDoc contract on `shouldRemember`, - // returning `false` short-circuits the ENTIRE persist path for the - // current turn — including tool-result memories. Persist then flushes - // these buffered ops in a single observed round at finish-turn time - // alongside base records and `extractMemories` output. - state.pendingToolOps.push(...normalizeOps(out)) - } catch (error) { - // Errors from the scope resolver or `onToolResult` itself (synchronous - // extraction failure) — the persist phase is wrapped separately above. - // `scope` may be unassigned if `resolveScope` threw, so fall back. - const errScope = state.resolvedScope ?? {} - safeEmit('memory:error', { - scope: errScope, - phase: 'extract', - error: errorInfo(error), - timestamp: Date.now(), - }) - await emitError(options, errScope, 'extract', error) - if (options.strict) throw error - } - }, - - async onFinish(ctx, info) { - const state = stateByCtx.get(ctx) - if (!state) return - const responseText = info.content - if (!state.lastUserText && !responseText) { - stateByCtx.delete(ctx) - return - } - // Resolve scope defensively: a throwing scope resolver here would - // otherwise escape the terminal `onFinish` hook. Route it through the - // persist error plumbing and skip persistence for the turn instead. - let scope: MemoryScope - try { - scope = await resolveScope(ctx, state) - } catch (error) { - const errScope = state.resolvedScope ?? {} - safeEmit('memory:error', { - scope: errScope, - phase: 'persist', - error: errorInfo(error), - timestamp: Date.now(), - }) - await emitError(options, errScope, 'persist', error) - stateByCtx.delete(ctx) - // Mirror the deferred strict-mode semantics of `persistTurn`: reject a - // deferred promise (collected by the engine's `Promise.allSettled`). - if (options.strict) ctx.defer(Promise.reject(error)) - return - } - const userText = state.lastUserText - const userEmbedding = state.lastUserEmbedding - const retrievedMemoryIds = state.retrievedHits.map((h) => h.record.id) - // Snapshot tool-result ops buffered by `onAfterToolCall` so they can be - // gated by `shouldRemember` and flushed in the same observed persist - // round as base records + `extractMemories` output. - const pendingToolOps = state.pendingToolOps - // Done with state — drop the WeakMap entry now so the deferred work - // below cannot accidentally observe stale fields. (The WeakMap would - // GC the entry once `ctx` is dropped anyway; this is just defensive.) - stateByCtx.delete(ctx) - ctx.defer( - persistTurn({ - options, - scope, - userText, - userEmbedding, - responseText, - retrievedMemoryIds, - pendingToolOps, - }), - ) - }, - } -} - -// =========================== -// Internals -// =========================== - -async function searchAllPages( - options: MemoryMiddlewareOptions, - scope: MemoryScope, - text: string, - embedding: Array | undefined, -): Promise> { - const topK = options.topK ?? 6 - const minScore = options.minScore ?? 0.15 - const all: Array = [] - let cursor: string | undefined - do { - const page = await options.adapter.search({ - scope, - text, - embedding, - topK, - minScore, - kinds: options.kinds, - cursor, - }) - all.push(...page.hits) - cursor = page.nextCursor - if (all.length >= topK) break - } while (cursor) - return all.slice(0, topK) -} - -function normalizeOps( - input: Array | Array, -): Array { - if (input.length === 0) return [] - const first = input[0] - if (first && 'op' in first) return input as Array - return (input as Array).map((record) => ({ - op: 'add' as const, - record, - })) -} - -/** - * Apply ops in array order, dispatching each to the matching adapter method. - * - * **Order matters.** A previous implementation batched all `add` ops to the - * end so they could be flushed in one `adapter.add(records[])` call; that - * meant `[{add X}, {update X}]` silently no-op'd because the update fired - * against an empty store before the add committed. Strict in-order dispatch - * is correct at the cost of per-op round-trips. For high-throughput callers, - * `afterPersist` is the right place to do bulk fan-out. - * - * **Scope is enforced on add.** The resolved scope overrides whatever scope - * the user-supplied record carried. A buggy or hostile `extractMemories` / - * `onToolResult` callback cannot write into another tenant's bucket — the - * record's scope is silently corrected to the resolved scope before - * `adapter.add`. Update and delete already take `scope` as an explicit - * parameter, so they're isolated by the adapter's own `scopeMatches` check. - */ -async function applyOps( - options: MemoryMiddlewareOptions, - scope: MemoryScope, - ops: Array, -): Promise> { - const newRecords: Array = [] - for (const op of ops) { - if (op.op === 'add') { - // Force the resolved scope onto user-supplied records to prevent a - // buggy extractMemories / onToolResult callback from writing into - // another tenant. This is defence-in-depth: the contract docs already - // promise tenant isolation, but enforcing it here means a single - // mistaken `scope: { tenantId: 'wrong' }` in a callback cannot breach - // the boundary. - const record: MemoryRecord = { ...op.record, scope } - await options.adapter.add(record) - newRecords.push(record) - } else if (op.op === 'update') { - await options.adapter.update(op.id, scope, op.patch) - } else { - await options.adapter.delete([op.id], scope) - } - } - return newRecords -} - -/** - * Run a persist batch with the full observability pipeline: - * 1. Emit `memory:persist:started` (skipped when there are no `add` ops, to - * avoid noise on update-only / delete-only batches). - * 2. Fire `events.onPersistStart` with the to-be-added records. - * 3. Apply ops via `applyOps`. - * 4. Emit `memory:persist:completed`. - * 5. Fire `events.onPersistEnd` with the actually-added records. - * 6. Call `options.afterPersist` with the newly-added records. - * - * Used by BOTH finish-turn persistence (via `persistTurn`) and `onToolResult` - * deferred persistence so that observability is symmetric across the two - * paths — `afterPersist` and the persist devtools events fire for every - * `adapter.add` commit, not just the finish-turn one. - * - * Adapter failures always surface via `memory:error` + `events.onError`. - * In strict mode they additionally re-throw, which short-circuits the rest of - * this persist batch and rejects the enclosing deferred promise. - * - * NOTE: because persistence runs via `ctx.defer`, the chat engine awaits the - * deferred promise with `Promise.allSettled` and discards the settled results - * (see `activities/chat/index.ts`). A strict-mode rejection therefore does NOT - * abort the already-finished run or propagate to the `chat()` caller — the - * `memory:error` event / `events.onError` callback is the observable failure - * signal in BOTH modes. Strict mode only aborts the run on the synchronously- - * awaited paths (`onConfig` retrieval, `onAfterToolCall`). - */ -async function runObservedPersist( - options: MemoryMiddlewareOptions, - scope: MemoryScope, - ops: Array, -): Promise> { - if (ops.length === 0) return [] - const startedAt = Date.now() - const adds = ops.filter( - (o): o is Extract => o.op === 'add', - ) - // Only emit persist:started when there's at least one add. Update-only or - // delete-only batches don't represent a new write that observers care about. - if (adds.length > 0) { - safeEmit('memory:persist:started', { - scope, - records: adds.map((o) => { - const r = o.record - return { - id: r.id, - kind: r.kind, - role: r.role, - preview: preview(r.text), - } - }), - timestamp: startedAt, - }) - await options.events?.onPersistStart?.({ - scope, - records: adds.map((o) => o.record), - }) - } - let newRecords: Array = [] - try { - newRecords = await applyOps(options, scope, ops) - } catch (error) { - safeEmit('memory:error', { - scope, - phase: 'persist', - error: errorInfo(error), - timestamp: Date.now(), - }) - await emitError(options, scope, 'persist', error) - if (options.strict) throw error - return [] - } - if (adds.length > 0) { - safeEmit('memory:persist:completed', { - scope, - recordIds: newRecords.map((r) => r.id), - durationMs: Date.now() - startedAt, - timestamp: Date.now(), - }) - await options.events?.onPersistEnd?.({ scope, records: newRecords }) - } - if (options.afterPersist && newRecords.length > 0) { - try { - await options.afterPersist({ - newRecords, - scope, - adapter: options.adapter, - }) - } catch (error) { - // afterPersist is documented as background work — surface failures via - // the same plumbing as adapter failures so they aren't swallowed, but - // route through phase: 'persist' since it's part of the persist arc. - safeEmit('memory:error', { - scope, - phase: 'persist', - error: errorInfo(error), - timestamp: Date.now(), - }) - await emitError(options, scope, 'persist', error) - if (options.strict) throw error - } - } - return newRecords -} - -async function persistTurn(args: { - options: MemoryMiddlewareOptions - scope: MemoryScope - userText: string - userEmbedding?: Array - responseText: string - retrievedMemoryIds: Array - /** - * Tool-result ops buffered by `onAfterToolCall` during the turn. Flushed - * AFTER the `shouldRemember` gate passes so a `false` return short-circuits - * tool-result memories along with base records and `extractMemories`. - */ - pendingToolOps: Array -}): Promise { - const { options, scope } = args - // Hoisted out of the try block so the outer catch can read them when - // deciding whether the thrown value is the strict-mode extract re-throw - // (already-emitted, must not double-emit). - let extractError: unknown - let extractFailed = false - // OUTERMOST try/catch so any throw — extract, persist, afterPersist — - // routes through the same error plumbing. In strict mode it re-throws to - // reject the deferred promise; note that the engine collects deferred - // rejections via `Promise.allSettled` and discards them, so this rejection - // does not abort the run (see the `runObservedPersist` JSDoc). The - // observable failure signal is the `memory:error` event either way. - try { - const now = Date.now() - - // Per-turn `shouldRemember` gate. Per JSDoc: "Returning `false` - // short-circuits `extractMemories` and the persist path for the current - // turn." We evaluate ONCE here with the user message + responseText — - // returning `false` skips both the base records and `extractMemories`. - // The call is wrapped so a throwing `shouldRemember` emits `memory:error` - // at the source (the outer catch assumes the event already fired). - if (options.shouldRemember) { - let keep: boolean - try { - keep = await options.shouldRemember({ - message: { role: 'user', content: args.userText }, - responseText: args.responseText, - }) - } catch (error) { - // A throwing `shouldRemember` is a persist-arc failure. Emit here so - // the outer catch's "already emitted at the source" invariant holds; - // in non-strict mode skip persistence for the turn rather than - // breaking anything downstream. - safeEmit('memory:error', { - scope, - phase: 'persist', - error: errorInfo(error), - timestamp: Date.now(), - }) - await emitError(options, scope, 'persist', error) - if (options.strict) throw error - return - } - if (!keep) return - } - - const baseRecords: Array = [] - if (args.userText) { - baseRecords.push({ - id: newRecordId(), - scope, - text: args.userText, - kind: 'message', - role: 'user', - createdAt: now, - importance: 0.4, - embedding: args.userEmbedding, - }) - } - if (args.responseText) { - // The assistant-side embedder call lives OUTSIDE `runObservedPersist`, - // so a throw here would bypass the persist-phase observability if it - // escaped uncaught. Wrap it locally and route failures through the same - // `memory:error` + `events.onError` plumbing as every other site. - // Mirrors the user-text embedder catch in `onConfig`'s retrieval block. - // In strict mode we rethrow so the outer catch turns it into a deferred - // persist rejection. In non-strict mode we continue with - // `embedding: undefined` so the assistant record still lands. - let assistantEmbedding: Array | undefined - if (options.embedder) { - try { - assistantEmbedding = await options.embedder.embed(args.responseText) - } catch (error) { - safeEmit('memory:error', { - scope, - phase: 'persist', - error: errorInfo(error), - timestamp: Date.now(), - }) - await emitError(options, scope, 'persist', error) - if (options.strict) throw error - // Non-strict: leave `assistantEmbedding` undefined and continue. - } - } - baseRecords.push({ - id: newRecordId(), - scope, - text: args.responseText, - kind: 'message', - role: 'assistant', - createdAt: now, - importance: 0.4, - embedding: assistantEmbedding, - metadata: { retrievedMemoryIds: args.retrievedMemoryIds }, - }) - } - - // Op ordering is intentional and documented: - // 1. base records (user, assistant) — always first - // 2. extractMemories output — appended after base - // 3. pendingToolOps — appended last - // `applyOps` dispatches in array order (see its JSDoc for why ordering - // matters), so `[{add X}, {update X}]` from extractMemories will see the - // base records already committed, and tool-result ops referring to ids - // that extractMemories created will be applied last. - let ops: Array = baseRecords.map((record) => ({ - op: 'add' as const, - record, - })) - - // Strict-mode `extractMemories` failure semantics: - // 1. The error is emitted exactly ONCE via `memory:error`/`onError` - // with `phase: 'extract'` — the outer persist catch is suppressed - // below so it does not re-emit with `phase: 'persist'`. - // 2. Base user/assistant records still land. We commit `applyOps` for - // the records already accumulated before re-throwing so an extract - // failure does not silently lose the conversation turn. - // 3. In strict mode the original extract error is re-thrown AFTER - // `applyOps` commits, so the deferred persist promise rejects and - // the engine surfaces the failure through `Promise.allSettled`. - // 4. In non-strict mode the error is swallowed after the single emit - // and persistence continues with the base records. - if (options.extractMemories) { - try { - const extras = await options.extractMemories({ - userText: args.userText, - responseText: args.responseText, - scope, - adapter: options.adapter, - }) - if (extras) ops = ops.concat(normalizeOps(extras)) - } catch (error) { - extractFailed = true - extractError = error - safeEmit('memory:error', { - scope, - phase: 'extract', - error: errorInfo(error), - timestamp: Date.now(), - }) - await emitError(options, scope, 'extract', error) - // Intentionally NOT re-throwing here — see note (2)/(3) above. The - // re-throw happens after `applyOps` so base records still persist. - } - } - - // Append tool-result ops (buffered from `onAfterToolCall`) AFTER the - // shouldRemember gate has passed. This is what enforces the contract: - // returning `false` from `shouldRemember` discards tool-result memories - // along with base records and `extractMemories` output, since none of - // them ever reach `runObservedPersist`. - if (args.pendingToolOps.length > 0) { - ops = ops.concat(args.pendingToolOps) - } - - // `runObservedPersist` owns the persist:started/completed events, the - // onPersistStart/onPersistEnd callbacks, afterPersist, and the - // memory:error+strict rethrow on adapter failure. Letting it handle - // strict-mode rethrows itself means the catch below ONLY has to deal - // with the strict-mode extract rethrow (and a guard against double- - // emitting memory:error for that case). - await runObservedPersist(options, scope, ops) - - // Strict-mode extract failure: base records have now been committed via - // `runObservedPersist`. Re-throw the original extract error so the - // deferred persist promise rejects. The outer catch below recognises - // this case and does NOT re-emit `memory:error` (it would otherwise - // fire a second event with phase: 'persist' for the same failure). - if (extractFailed && options.strict) throw extractError - } catch (error) { - // By the time we reach this catch, `memory:error` has ALREADY been - // emitted at the source — either: - // (a) Strict-mode extract rethrow: the inner extract catch above - // emitted `phase: 'extract'`. The `extractFailed` / - // `extractError` hoisted state lets future maintainers verify - // at a glance that this branch is reachable. - // (b) Strict-mode adapter or afterPersist rethrow: emitted inside - // `runObservedPersist` with `phase: 'persist'` immediately - // before it threw. - // (c) Strict-mode assistant-side embedder rethrow: the local - // try/catch around the assistant embedder call above emitted - // `phase: 'persist'` before rethrowing. - // (d) Strict-mode `shouldRemember` rethrow: the gate's own try/catch - // above emitted `phase: 'persist'` before rethrowing. - // Either way the event already fired with the correct phase; re- - // emitting here would produce a duplicate event for the same failure. - // So this catch is intentionally a pass-through in non-strict mode - // and a rethrow-only path in strict mode. - if (options.strict) throw error - } -} - -async function emitError( - options: MemoryMiddlewareOptions, - scope: MemoryScope, - phase: 'retrieve' | 'persist' | 'extract', - error: unknown, -): Promise { - // Defensive like `safeEmit`: a throwing `onError` handler must never break - // chat (non-strict) or mask the original failure by replacing the in-flight - // error with its own. `onError` is telemetry — swallow anything it throws. - try { - await options.events?.onError?.({ scope, phase, error }) - } catch { - // ignored — an observability callback must not affect chat behaviour - } -} - -/** - * 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.name === 'string' - ) { - return { - name: error.name, - message: String((error as { message?: unknown }).message ?? error), - } - } - return { name: 'Error', message: String(error) } -} - -function findLastUserMessage( - messages: ReadonlyArray, -): ModelMessage | undefined { - for (let i = messages.length - 1; i >= 0; i--) { - const message = messages[i] - if (message && message.role === 'user') return message - } - return undefined -} - -function preview(text: string, max = 200): string { - return text.length > max ? text.slice(0, max) + '…' : text -} - -/** - * Portable memory-record id. `crypto.randomUUID()` is NOT a bare global on the - * package's declared Node 18 floor (Web Crypto became an unflagged global only - * in Node 19+), so calling it directly would throw `ReferenceError` there — - * and because id minting happens inside the persist path, that throw would - * silently drop the whole turn's memory in non-strict mode. Prefer the real - * UUID when the global exists (Node 19+, browsers, edge runtimes) and fall - * back to the same `Date.now()`+`Math.random()` pattern used by every other id - * generator in this package. - */ -function newRecordId(): string { - // `try`/`catch` rather than `globalThis.crypto?.randomUUID?.()`: the DOM/Node - // lib types `crypto` as always-present, so optional chaining reads as dead - // code to the linter — but the whole point is that the global genuinely can - // be absent at runtime on Node 18, where the bare access throws - // `ReferenceError`. Catch it and fall back to the package's portable pattern. - try { - return crypto.randomUUID() - } catch { - return `mem-${Date.now()}-${Math.random().toString(36).slice(2, 10)}` - } -} - -/** - * Defensive devtools emit. Devtools events should be fire-and-forget — if the - * event client throws synchronously (misconfigured global, broken transport), - * we swallow it so middleware behaviour never depends on devtools health. - */ -const safeEmit: typeof aiEventClient.emit = (...args) => { - try { - return aiEventClient.emit(...args) - } catch { - // ignored — telemetry failures must not affect chat behaviour - } -} - -function getMessageText(message?: ModelMessage): string { - if (!message) return '' - if (typeof message.content === 'string') return message.content - if (Array.isArray(message.content)) { - // Per `TextPart` in ../types.ts the text payload lives on `content`, not - // `text`. Bare strings are still tolerated because a handful of adapters - // pass them through in the content array. All other ContentPart kinds - // (tool-call, tool-result, image, audio, …) yield '' so they don't - // pollute the retrieval query or persisted record text. - return message.content - .map((part) => { - if (typeof part === 'string') return part - if (part.type === 'text' && typeof part.content === 'string') { - return part.content - } - return '' - }) - .filter(Boolean) - .join('\n') - } - return '' -} diff --git a/packages/ai/src/memory/types.ts b/packages/ai/src/memory/types.ts deleted file mode 100644 index 70b300664..000000000 --- a/packages/ai/src/memory/types.ts +++ /dev/null @@ -1,617 +0,0 @@ -/** - * Memory subsystem type definitions. - * - * This module defines the public contract for the memory adapter ecosystem: - * the storage-shaped {@link MemoryAdapter} interface, the record/query/op shapes - * adapters operate on, and the {@link MemoryMiddlewareOptions} surface used to - * wire memory into a chat run via the memory middleware. - * - * The architectural split is intentional: - * - **Adapters are thin storage.** They persist, fetch, search, and scope-filter - * records. They do not decide what to remember, when to retrieve, or how to - * render hits into a prompt. - * - **Policy lives in the middleware.** Decisions like "should we retrieve here?", - * "what facts should we extract from this turn?", or "how do we render hits - * into a system prompt?" are configured on the middleware, not the adapter. - * - * Third-party adapter implementers should treat this file as the source of truth - * for the contract. Method-level semantics (upsert behaviour, scope isolation, - * expiry filtering, error vs. no-op for unknown ids) are documented on each - * member of {@link MemoryAdapter} below. - */ - -import type { ChatMiddlewareContext } from '../activities/chat/middleware/types' - -// =========================== -// Scope & primitives -// =========================== - -/** - * Multi-dimensional scope used to isolate memory records across tenants, - * users, sessions, threads, and arbitrary namespaces. - * - * Each key is optional and orthogonal: - * - `tenantId` — top-level organisation / workspace boundary in multi-tenant apps. - * - `userId` — end-user identity within a tenant. - * - `sessionId` — short-lived session (e.g. browser session, anonymous visitor). - * - `threadId` — conversation / thread identifier within a session. - * - `namespace` — application-defined bucket (e.g. `'preferences'`, `'kb'`). - * - * Adapters MUST treat scope as a strict isolation boundary: a `get`/`search`/ - * `list`/`update`/`delete` call against scope `A` MUST NOT return, mutate, or - * remove records that belong to a different scope `B`. Cross-contamination - * between scopes is a correctness bug, especially for multi-tenant deployments. - */ -export type MemoryScope = { - tenantId?: string - userId?: string - sessionId?: string - threadId?: string - namespace?: string -} - -/** - * Classification of a stored memory record. - * - * - `'message'` — a raw conversation turn (user or assistant utterance) captured verbatim. - * - `'summary'` — a compressed summary of prior conversation history, used to keep - * long threads within context windows. - * - `'fact'` — an extracted statement of fact about the user or world - * (e.g. "user lives in Berlin"). - * - `'preference'` — an extracted user preference (e.g. "prefers concise answers"). - * - `'tool-result'` — a persisted tool execution result, kept for future recall - * (e.g. cached search results, expensive computations). - * - * Middleware can filter retrieval by `kinds` to scope what gets surfaced into - * a given prompt (for example, retrieve only `'fact'` and `'preference'` for - * persona injection, or only `'tool-result'` for cache-style recall). - */ -export type MemoryKind = - | 'message' - | 'summary' - | 'fact' - | 'preference' - | 'tool-result' - -/** - * Role attached to a memory record when it represents a conversation turn. - * Mirrors the standard chat role taxonomy. - */ -export type MemoryRole = 'user' | 'assistant' | 'system' | 'tool' - -// =========================== -// Records -// =========================== - -/** - * A single memory record persisted by an adapter. - */ -export type MemoryRecord = { - /** - * Globally unique identifier within the adapter. The adapter owns id-space - * uniqueness across all scopes — two records with the same `id` MUST NOT - * coexist in the adapter, regardless of scope. - */ - id: string - /** Scope this record belongs to. Used by adapters for isolation. */ - scope: MemoryScope - /** Human-readable text content of the memory. Indexed for search. */ - text: string - /** Classification — see {@link MemoryKind}. */ - kind: MemoryKind - /** Optional originating role when this record represents a chat turn. */ - role?: MemoryRole - /** Creation timestamp in epoch milliseconds. Set by the adapter on `add` if absent. */ - createdAt: number - /** - * Last update timestamp in epoch milliseconds. Bumped automatically by the - * adapter on `update`. Equal to `createdAt` for never-updated records. - */ - updatedAt?: number - /** - * Optional epoch-ms expiration. Adapters MUST filter expired records out of - * `search`/`list`/`get` and SHOULD opportunistically remove them on `add`. - */ - expiresAt?: number - /** - * Importance hint in the range `0..1` (higher = more important). This is a - * soft signal a re-ranker, eviction policy, or summariser may consult — it - * is not enforced by the adapter contract. - * - * The reference `defaultScoreHit` ranker treats unset importance as `0` - * (no contribution to the score) — it deliberately does NOT fall back to - * a mid-range default. Set this explicitly (e.g. `0.4` for raw turns, `1` - * for pinned facts) to bias retrieval; otherwise the record competes on - * semantic, lexical, and recency signals alone. - */ - importance?: number - /** - * Optional precomputed embedding vector. Length is consumer-defined (model- - * dependent) — the adapter does not validate dimensionality, but all records - * within a single adapter deployment SHOULD share a consistent dimension if - * vector search is used. - */ - embedding?: Array - /** Free-form metadata bag for adapter-specific or app-specific annotations. */ - metadata?: Record -} - -/** - * Patch shape for in-place updates. - * - * `id`, `scope`, and `createdAt` are immutable and cannot be patched. The - * adapter preserves `createdAt` and bumps `updatedAt` automatically on every - * successful `update` call — callers SHOULD NOT set `updatedAt` themselves. - */ -export type MemoryRecordPatch = Partial< - Omit -> - -/** - * A single search result: the matched record plus the relevance score the - * adapter assigned. Score semantics (cosine, BM25, hybrid, etc.) are - * adapter-defined; consumers should treat scores as relative within a single - * search result set, not as absolute values across adapters. - */ -export type MemoryHit = { record: MemoryRecord; score: number } - -// =========================== -// Queries -// =========================== - -/** - * Relevance-ranked search query passed to {@link MemoryAdapter.search}. - */ -export type MemoryQuery = { - /** Scope to search within. Records outside this scope MUST NOT be returned. */ - scope: MemoryScope - /** Query text used by the adapter for ranking (lexical, semantic, or hybrid). */ - text: string - /** Optional precomputed query embedding. If provided, the adapter MAY use it instead of embedding `text`. */ - embedding?: Array - /** Maximum number of hits to return. */ - topK?: number - /** Drop hits with `score < minScore`. */ - minScore?: number - /** Restrict matches to the given record kinds. */ - kinds?: Array - /** - * Opaque pagination cursor returned from a previous `search` call. The - * cursor format is adapter-defined and MUST NOT be parsed by callers. - */ - cursor?: string -} - -/** - * Result of a {@link MemoryAdapter.search} call. - */ -export type MemorySearchResult = { - /** Hits ordered by descending relevance. */ - hits: Array - /** Opaque cursor for fetching the next page, or `undefined` if no more results. */ - nextCursor?: string -} - -/** - * Options for non-relevance browsing via {@link MemoryAdapter.list}. - */ -export type MemoryListOptions = { - /** Restrict to the given record kinds. */ - kinds?: Array - /** Maximum number of records to return. */ - limit?: number - /** Opaque pagination cursor returned from a previous `list` call. */ - cursor?: string - /** Sort order. Defaults are adapter-defined when omitted. */ - order?: 'createdAt:desc' | 'createdAt:asc' | 'updatedAt:desc' -} - -/** - * Result of a {@link MemoryAdapter.list} call. - */ -export type MemoryListResult = { - /** Records ordered per `MemoryListOptions.order`. */ - items: Array - /** Opaque cursor for fetching the next page, or `undefined` if no more records. */ - nextCursor?: string -} - -// =========================== -// Adapter contract -// =========================== - -/** - * Storage-shaped contract every memory backend implements. - * - * **Design principle: thin storage; policy lives in the middleware.** Adapters - * are responsible for persistence, retrieval, scope isolation, and expiry - * filtering — nothing else. Decisions about what to remember, when to retrieve, - * how to rank, or how to render hits into a prompt belong on - * {@link MemoryMiddlewareOptions}, not on the adapter. - * - * Cross-cutting invariants every adapter MUST uphold: - * - **Scope isolation.** No method may return, mutate, or delete records that - * live outside the supplied scope. See {@link MemoryScope}. - * - **Expiry filtering.** Records whose `expiresAt` has passed MUST be filtered - * out of `search`, `list`, and `get`. Adapters SHOULD opportunistically remove - * them on `add`. - * - **Id uniqueness.** Ids are globally unique within the adapter, across all scopes. - */ -export interface MemoryAdapter { - /** Stable adapter name (used for logging, devtools, and diagnostics). */ - name: string - - /** - * Upsert one or more records by id. - * - * `add` is **upsert-by-id**, not insert-only: if a record with the same `id` - * already exists, it is replaced. The single-record form - * (`add(record)`) and the array form (`add([record, ...])`) behave - * identically — passing a single record is exactly equivalent to passing a - * one-element array. - * - * Adapters SHOULD opportunistically evict expired records on `add`. - */ - add: (records: MemoryRecord | Array) => Promise - - /** - * Fetch a record by id within a scope. - * - * Returns `undefined` when: - * - no record exists with the given id, OR - * - a record exists but its scope does not match the supplied `scope`, OR - * - the record has expired (`expiresAt` is in the past). - * - * In all three cases the adapter returns `undefined` — it does not throw and - * does not leak the existence of out-of-scope records. - */ - get: (id: string, scope: MemoryScope) => Promise - - /** - * Patch a record in place. - * - * On success, returns the updated record. The adapter: - * - preserves `id`, `scope`, and `createdAt` (these cannot be patched), - * - bumps `updatedAt` to the current epoch ms, - * - merges the supplied patch over the existing record. - * - * Returns `undefined` when the target record does not exist, lives in a - * different scope, or has expired — symmetric with {@link MemoryAdapter.get}. - */ - update: ( - id: string, - scope: MemoryScope, - patch: MemoryRecordPatch, - ) => Promise - - /** - * Run a relevance-ranked search within a scope. - * - * The ranking strategy (lexical, semantic, hybrid) is adapter-defined. - * Pagination is via the opaque `query.cursor` / `result.nextCursor` pair — - * the cursor format is adapter-internal and MUST NOT be parsed by callers. - * Expired records are filtered out. - * - * An empty `query.scope` (`{}`) matches NOTHING — adapters MUST return an - * empty hit set rather than treating it as a wildcard. This is the - * symmetric counterpart of the empty-scope safety guard on `clear` and - * the reference `scopeMatches` helper. - */ - search: (query: MemoryQuery) => Promise - - /** - * Browse records by scope without relevance ranking. - * - * This is the non-relevance counterpart to `search`, intended for inspector - * UIs, admin tooling, and bulk export. Ordering is controlled by - * `options.order`. Expired records are filtered out. - * - * An empty `scope` (`{}`) matches NOTHING — adapters MUST return an empty - * item set rather than treating it as a wildcard. Same cross-tenant - * safety rationale as `search` and `clear`. - */ - list: ( - scope: MemoryScope, - options?: MemoryListOptions, - ) => Promise - - /** - * Delete records by id within a scope. - * - * Ids that do not exist or whose record lives in a different scope are - * silently no-op'd — `delete` does not throw on missing ids, and it MUST NOT - * cross scope boundaries. - */ - delete: (ids: Array, scope: MemoryScope) => Promise - - /** - * Remove ALL records that match the supplied scope. - * - * Scope matching uses the same isolation semantics as every other method: - * only records whose scope matches the supplied scope are removed. An empty - * scope (`{}`) matches NOTHING — adapters MUST treat empty-scope - * `clear({})` as a no-op rather than a global wipe. The reference - * `scopeMatches` helper rejects empty query scopes precisely so this is - * the default for any adapter built on top of it. Implementations that - * bypass `scopeMatches` (e.g. index-driven optimisations like the Redis - * adapter) MUST add an equivalent empty-scope check before deleting. - * - * Callers who actually intend to wipe an entire scope dimension must pass - * the relevant scope key explicitly (e.g. `{ tenantId: 't1' }` to clear - * every record for tenant `t1`). - */ - clear: (scope: MemoryScope) => Promise -} - -/** - * Pluggable embedding provider. Used by the middleware to compute query and - * record embeddings when the adapter relies on vector search. - * - * `embed` may be invoked multiple times within a single chat run — once on the - * retrieval path (to embed the user query) and optionally again on the persist - * path (to embed assistant text or extracted facts). Implementations SHOULD be - * idempotent: embedding the same input twice should yield the same vector. - */ -export interface MemoryEmbedder { - embed: (text: string) => Promise> -} - -// =========================== -// Mutation ops -// =========================== - -/** - * A single memory mutation, used as the return type of `extractMemories` and - * `onToolResult` to express add/update/delete intent in one stream. - * - * As shorthand, those hooks may also return a plain `MemoryRecord[]`, which - * the middleware treats as `[{ op: 'add', record }, ...]` — one add per - * record. - */ -export type MemoryOp = - | { op: 'add'; record: MemoryRecord } - | { op: 'update'; id: string; patch: MemoryRecordPatch } - | { op: 'delete'; id: string } - -// =========================== -// Middleware options -// =========================== - -/** - * Configuration for the memory middleware. - * - * The middleware orchestrates two paths around a chat run: - * - **Retrieval (read-side)**: gated by `shouldRetrieve`, runs `adapter.search`, - * optionally pipes hits through `rerank`, then renders into the prompt via - * `render`. - * - **Persistence (write-side)**: gated by `shouldRemember`, calls - * `extractMemories` at finish (and `onToolResult` per completed tool call), - * commits ops to the adapter, then invokes `afterPersist` with the records - * that were newly added. - * - * `events.*` callbacks are app-level lifecycle hooks that fire alongside the - * devtools events — use them for application telemetry that should not depend - * on devtools being installed. - */ -export interface MemoryMiddlewareOptions { - /** The storage adapter to read from / write to. */ - adapter: MemoryAdapter - - /** - * Scope for every adapter call this middleware makes. - * - * The function form is the safer default for multi-tenant apps: it lets the - * middleware derive scope per request from the chat context (e.g. from - * authenticated session info attached by the host). Scope MUST be derived - * server-side from trusted state — never accept scope fields directly from - * client input, or one user's request can read or write another user's - * memory. - */ - scope: - | MemoryScope - | ((ctx: ChatMiddlewareContext) => MemoryScope | Promise) - - /** - * Optional embedding provider. Required when the configured adapter relies - * on vector search and records / queries do not arrive pre-embedded. - */ - embedder?: MemoryEmbedder - - /** Maximum number of hits to retrieve per turn. Defaults to `6`. */ - topK?: number - /** Drop hits with `score < minScore`. Defaults to `0.15`. */ - minScore?: number - /** Restrict retrieval to the given record kinds. Defaults to all kinds. */ - kinds?: Array - /** - * Render retrieved hits into a string injected into the prompt. Replaces - * the built-in `defaultRenderMemory` formatter when provided. - */ - render?: (hits: Array) => string - - /** - * Write-side gate: decide whether a given turn should produce memories at - * all. Evaluated **once per turn** (not per record) with the latest user - * message and the assistant `responseText`. Returning `false` short- - * circuits the entire persist path — base user/assistant records, - * `extractMemories`, and `afterPersist` are all skipped for the current - * turn. Use this when the application has a hard rule for the whole turn - * (e.g. PII guard, opt-out flag); use `extractMemories` itself for - * per-record decisions. - */ - shouldRemember?: (args: { - message: { role: MemoryRole; content: string } - responseText?: string - }) => boolean | Promise - - /** - * Read-side gate: decide whether to run retrieval for the current user - * message. Returning `false` skips the entire retrieval path (search, - * rerank, render) for this turn — symmetric with `shouldRemember` on the - * write side. - */ - shouldRetrieve?: (args: { - userText: string - scope: MemoryScope - }) => boolean | Promise - - /** - * Optional re-ranker. Runs after `adapter.search` returns hits and before - * `render` formats them into the prompt — use this to apply application- - * specific ranking signals (recency boosts, importance weighting, - * cross-encoder reranking, etc.). - */ - rerank?: ( - hits: Array, - args: { scope: MemoryScope; query: string; ctx: ChatMiddlewareContext }, - ) => Array | Promise> - - /** - * Extract memory mutations from a completed turn. Runs at finish, after the - * assistant response is fully accumulated. - * - * May return a mixed `MemoryOp[]` to express adds, updates, and deletes in a - * single batch, or — as shorthand — a plain `MemoryRecord[]`, which the - * middleware treats as all-add (`[{ op: 'add', record }, ...]`). Returning - * `undefined` is a no-op. - * - * **Failure semantics.** If this callback throws, the middleware emits a - * single `memory:error` event with `phase: 'extract'` and calls - * `events.onError({ phase: 'extract' })`. Base user/assistant records are - * still committed to the adapter regardless — an extract failure must not - * silently drop the raw turn. In non-strict mode (the default) the error - * is then swallowed and chat continues. In strict mode (`strict: true`) - * the original extract error is re-thrown AFTER the base records have - * committed, so the deferred persist promise rejects — but `memory:error` - * still fires exactly once with `phase: 'extract'` (NOT a second time - * with `phase: 'persist'`). - * - * **Scope is enforced.** Records returned by this callback have their - * `scope` field overridden with the resolved scope before being persisted, - * regardless of what scope the callback set. This is a defence-in-depth - * guarantee — callers cannot accidentally (or maliciously) write into - * another tenant's scope by returning a record with a different `scope`. - */ - extractMemories?: (args: { - userText: string - responseText: string - scope: MemoryScope - adapter: MemoryAdapter - }) => - | Promise | Array | undefined> - | Array - | Array - | undefined - - /** - * Per-tool-call persistence hook. Runs once for each completed tool call - * with its arguments and result, allowing the app to persist tool output as - * memory (typical `kind` is `'tool-result'`). - * - * The middleware buffers the returned ops and flushes them in the - * finish-turn persist round so the per-turn `shouldRemember` gate applies - * uniformly to base records, `extractMemories` output, AND tool-result - * memories. Same return-shape conventions as `extractMemories` — - * `MemoryOp[]`, `MemoryRecord[]` shorthand, or `undefined`. - * - * **Persist events fire once per turn.** A single `memory:persist:started` - * / `:completed` pair (and one `events.onPersistStart` / `onPersistEnd` / - * `afterPersist` invocation) covers base records, extracted ops, and - * tool-result ops together — they all commit in one observed round. - * - * **Scope is enforced.** Records returned by this callback have their - * `scope` field overridden with the resolved scope before being persisted, - * regardless of what scope the callback set. This is a defence-in-depth - * guarantee — callers cannot accidentally (or maliciously) write into - * another tenant's scope by returning a record with a different `scope`. - */ - onToolResult?: (args: { - toolName: string - toolCallId: string - args: unknown - result: unknown - scope: MemoryScope - adapter: MemoryAdapter - }) => - | Promise | Array | undefined> - | Array - | Array - | undefined - - /** - * Post-persist callback invoked after `adapter.add` commits successfully. - * - * `newRecords` contains only the records that were newly added on this - * turn — it does NOT include records that were updated or deleted. Use this - * for "memory was just written" side-effects (analytics, indexing, - * notifications). - */ - afterPersist?: (args: { - newRecords: Array - scope: MemoryScope - adapter: MemoryAdapter - }) => Promise | void - - /** - * Application-level lifecycle callbacks. - * - * These fire in addition to (not instead of) the devtools events emitted by - * the middleware — they are the appropriate place to wire app telemetry, - * logging, or custom progress UX that should not depend on devtools. - */ - events?: { - /** Fired before the retrieval path runs. */ - onRetrieveStart?: (args: { - scope: MemoryScope - query: string - }) => void | Promise - /** Fired after retrieval completes, with the final hit set (post-rerank). */ - onRetrieveEnd?: (args: { - scope: MemoryScope - hits: Array - }) => void | Promise - /** Fired before the persist path commits records to the adapter. */ - onPersistStart?: (args: { - scope: MemoryScope - records: Array - }) => void | Promise - /** Fired after the persist path commits records to the adapter. */ - onPersistEnd?: (args: { - scope: MemoryScope - records: Array - }) => void | Promise - /** - * Fired when retrieval, persistence, or extraction throws. Always paired - * with a `memory:error` devtools event for the same failure. - * - * Phase taxonomy: - * - `'retrieve'` — failures during the retrieval arc: the user-text - * `embedder.embed` call, `adapter.search` (including paginated - * continuations), and `rerank` failures. - * - `'persist'` — failures during the persist arc: `adapter.add`, - * `adapter.update`, `adapter.delete` against the configured adapter, - * the assistant-side `embedder.embed` call inside the finish-turn - * persist (NOT the user-side embed; that is `'retrieve'`), and any - * throw from `afterPersist`. - * - `'extract'` — failures from extraction-shaped callbacks: - * `extractMemories` throwing, `onToolResult` throwing, and the JSON - * parse of tool-call arguments inside `onAfterToolCall` (parse failure - * is non-fatal — `onToolResult` still runs with `args: {}` — but the - * event is emitted so observers can see the malformed payload). - */ - onError?: (args: { - scope: MemoryScope - phase: 'retrieve' | 'persist' | 'extract' - error: unknown - }) => void | Promise - } - - /** - * Strict mode. When `false` (the default) the middleware swallows retrieval - * and persistence failures so chat continues to function even if memory is - * degraded. When `true`, those failures throw and abort the run — choose - * this when memory correctness is critical (e.g. compliance contexts where - * a missed write is worse than a failed turn). - */ - strict?: boolean -} diff --git a/packages/ai/tests/memory/helpers.test.ts b/packages/ai/tests/memory/helpers.test.ts deleted file mode 100644 index 45b5b0dcb..000000000 --- a/packages/ai/tests/memory/helpers.test.ts +++ /dev/null @@ -1,224 +0,0 @@ -import { describe, expect, it } from 'vitest' -import { - cosine, - defaultRenderMemory, - defaultScoreHit, - isExpired, - lexicalOverlap, - recencyScore, - scopeMatches, -} from '../../src/memory/helpers' -import type { MemoryRecord } from '../../src/memory/types' - -describe('scopeMatches', () => { - it('rejects empty query scope (strict-by-default cross-tenant guard)', () => { - // An empty query scope ({}) intentionally matches NOTHING — see JSDoc on - // scopeMatches. This prevents `clear({})` / `search({ scope: {} })` from - // wiping or leaking every tenant's records. - expect(scopeMatches({ tenantId: 'a' }, {})).toBe(false) - }) - it('rejects query scope with only nullish values', () => { - expect( - scopeMatches( - { tenantId: 'a' }, - { tenantId: undefined, userId: undefined }, - ), - ).toBe(false) - }) - it('matches when all query keys are equal', () => { - expect( - scopeMatches({ tenantId: 'a', userId: 'u' }, { tenantId: 'a' }), - ).toBe(true) - }) - it('rejects when any provided key differs', () => { - expect(scopeMatches({ tenantId: 'a' }, { tenantId: 'b' })).toBe(false) - }) - - describe('empty-string scope values', () => { - // Empty-string values are treated as undefined per the JSDoc on - // `scopeMatches` — a degenerate "blank-tenant" bucket would otherwise be - // unreachable from any normal query and indistinguishable from records - // whose scope key was simply unset. Mirrored in adapters' `hasAnyScopeKey` - // so the same rule applies at every isolation boundary. - it('treats empty-string scope values as undefined in the query', () => { - // A query with all empty-string values is equivalent to {} — matches nothing. - expect(scopeMatches({ tenantId: 't1' }, { tenantId: '' })).toBe(false) - expect( - scopeMatches({ tenantId: 't1' }, { tenantId: '', userId: '' }), - ).toBe(false) - }) - - it('a record with an empty-string scope value is unreachable via that key', () => { - // Defensive check: callers should not write empty-string scopes, but if - // they slip through (e.g. via a buggy callback), an empty-string query - // STILL matches nothing rather than colliding with the record. - expect(scopeMatches({ tenantId: '' }, { tenantId: '' })).toBe(false) - }) - - it('skips empty-string keys but still honours other defined keys', () => { - // `{ tenantId: 't1', userId: '' }` is equivalent to `{ tenantId: 't1' }` - // — the empty userId is ignored and tenant matching proceeds normally. - expect( - scopeMatches( - { tenantId: 't1', userId: 'u1' }, - { tenantId: 't1', userId: '' }, - ), - ).toBe(true) - expect( - scopeMatches( - { tenantId: 't2', userId: 'u1' }, - { tenantId: 't1', userId: '' }, - ), - ).toBe(false) - }) - }) -}) - -describe('cosine', () => { - it('returns 0 for missing vectors or mismatched length', () => { - expect(cosine(undefined, [1])).toBe(0) - expect(cosine([1, 2], [1])).toBe(0) - }) - it('returns 1 for identical unit-length vectors', () => { - expect(cosine([1, 0], [1, 0])).toBeCloseTo(1, 5) - }) - it('returns 0 for orthogonal vectors', () => { - expect(cosine([1, 0], [0, 1])).toBeCloseTo(0, 5) - }) -}) - -describe('lexicalOverlap', () => { - it('returns 0 when query has no tokens', () => { - expect(lexicalOverlap('', 'anything')).toBe(0) - }) - it('returns fraction of query tokens present in text', () => { - expect(lexicalOverlap('foo bar baz', 'foo bar')).toBeCloseTo(2 / 3, 5) - }) -}) - -describe('recencyScore', () => { - it('returns ~1 for now', () => { - expect(recencyScore(Date.now())).toBeGreaterThan(0.99) - }) - it('halves at one half-life', () => { - const halfLife = 1000 - const now = Date.now() - const t = now - halfLife - expect(recencyScore(t, halfLife, now)).toBeCloseTo(0.5, 5) - }) -}) - -describe('isExpired', () => { - it('false when expiresAt is unset', () => { - expect(isExpired({ expiresAt: undefined } as MemoryRecord)).toBe(false) - }) - it('true when expiresAt < now', () => { - expect(isExpired({ expiresAt: Date.now() - 1 } as MemoryRecord)).toBe(true) - }) - it('false when expiresAt > now', () => { - expect(isExpired({ expiresAt: Date.now() + 10000 } as MemoryRecord)).toBe( - false, - ) - }) -}) - -describe('defaultRenderMemory', () => { - it('renders empty hits as empty string-ish', () => { - expect(defaultRenderMemory([])).toBe('') - }) - it('renders kinds and text in numbered list', () => { - const out = defaultRenderMemory([ - { - score: 1, - record: { - id: '1', - scope: {}, - kind: 'fact', - text: 'User is on Windows.', - createdAt: 0, - }, - }, - ]) - expect(out).toContain('Relevant memory:') - // Text is JSON.stringify'd so memory content cannot break out of the - // list structure (see defaultRenderMemory implementation). - expect(out).toContain('1. [fact] "User is on Windows."') - }) -}) - -describe('defaultScoreHit', () => { - it('weighted sum stays in [0,1] for in-range inputs', () => { - const score = defaultScoreHit({ - record: { - id: 'r', - scope: {}, - kind: 'fact', - text: 'foo bar', - createdAt: Date.now(), - embedding: [1, 0], - importance: 1, - }, - query: { scope: {}, text: 'foo bar', embedding: [1, 0] }, - }) - expect(score).toBeGreaterThan(0) - expect(score).toBeLessThanOrEqual(1) - }) - - it('threads `now` through to recencyScore for deterministic scoring', () => { - // Fixed-timestamps regression test: passing `now` MUST make the score - // independent of wall-clock time. Two calls with the same `now` must - // return exactly the same score even if `Date.now()` has advanced - // between them. - const record: MemoryRecord = { - id: 'r', - scope: {}, - kind: 'fact', - text: 'foo bar', - createdAt: 1000, - embedding: [1, 0], - importance: 1, - } - const query = { scope: {}, text: 'foo bar', embedding: [1, 0] } - const a = defaultScoreHit({ record, query, now: 2000 }) - const b = defaultScoreHit({ record, query, now: 2000 }) - expect(a).toBe(b) - // And a different `now` must produce a (lower) recency contribution — - // the older effective age means recencyScore drops, so the total drops. - const c = defaultScoreHit({ - record, - query, - now: 2000 + 1000 * 60 * 60 * 24 * 30, // +1 half-life - }) - expect(c).toBeLessThan(a) - }) - - it('unset importance contributes 0 (record with no relevance scores below default minScore)', () => { - // Default ranking floor regression test. With the previous default of - // `importance ?? 0.5`, a recent record with zero semantic + zero lexical - // match scored ~0.20 — over the default minScore floor of 0.15, so - // every recent irrelevant record leaked into retrieval. The new default - // (no fallback) keeps the score below the floor. - // - // We use `now` slightly ahead of `createdAt` so recency decays a hair - // below 1.0; the score is then strictly < 0.15 (the default minScore). - const createdAt = 1000 - const now = createdAt + 1000 * 60 * 60 * 24 // one day later - const score = defaultScoreHit({ - record: { - id: 'r', - scope: {}, - kind: 'fact', - text: 'completely unrelated content', // no overlap with query - createdAt, - // no embedding, no importance - }, - query: { scope: {}, text: 'foo bar' }, - now, - }) - expect(score).toBeLessThan(0.15) - - // Sanity-check the converse: the OLD default of importance=0.5 would - // have pushed the same record above the 0.15 floor. - expect(score + 0.5 * 0.1).toBeGreaterThan(0.15) - }) -}) diff --git a/packages/ai/tests/middlewares/memory.test.ts b/packages/ai/tests/middlewares/memory.test.ts deleted file mode 100644 index e1f67c6d9..000000000 --- a/packages/ai/tests/middlewares/memory.test.ts +++ /dev/null @@ -1,1175 +0,0 @@ -// packages/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 { collectChunks, createMockAdapter, ev } from '../test-utils' -import type { - MemoryAdapter, - MemoryHit, - MemoryListResult, - MemoryQuery, - MemoryRecord, - MemoryScope, - MemorySearchResult, -} from '../../src/memory' -import type { StreamChunk } from '../../src/types' - -// Local test double — keeps tests isolated from @tanstack/ai-memory. -function fakeAdapter(seed: Array = []): MemoryAdapter & { - store: Map - searchCalls: Array -} { - const store = new Map() - for (const r of seed) store.set(r.id, r) - const searchCalls: Array = [] - 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: Array = [] - 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: Array = [] - 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?: Array } - 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?: Array }).systemPrompts?.length ?? 0 - const iter2 = - (calls[1] as { systemPrompts?: Array }).systemPrompts?.length ?? 0 - // Guard against the degenerate case where injection is fully broken in - // BOTH iterations: `iter1 === iter2 === 0` would still satisfy the - // equality below but defeat the regression's intent (memory was actually - // injected on iteration 1 and not re-injected on iteration 2). - expect(iter1).toBeGreaterThan(0) - expect(iter1).toBe(iter2) - }) - - it('skips retrieval and injection when shouldRetrieve returns false', async () => { - const memory = fakeAdapter([rec({ text: 'X' })]) - const { adapter } = createMockAdapter({ - iterations: [ - [ev.runStarted(), ev.textContent('ok'), ev.runFinished('stop')], - ], - }) - const stream = chat({ - adapter, - messages: [{ role: 'user', content: 'hi' }], - middleware: [ - memoryMiddleware({ - adapter: memory, - scope: baseScope, - shouldRetrieve: () => false, - }), - ], - }) - await collectChunks(stream as AsyncIterable) - expect(memory.searchCalls).toHaveLength(0) - }) - - it('calls rerank between search and render', async () => { - const memory = fakeAdapter([ - rec({ id: 'a', text: 'A' }), - rec({ id: 'b', text: 'B' }), - ]) - const { adapter, calls } = createMockAdapter({ - iterations: [ - [ev.runStarted(), ev.textContent('ok'), ev.runFinished('stop')], - ], - }) - const rerank = vi.fn(async (hits: Array) => [...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: Array } - ).systemPrompts.join('\n') - expect(promptText.indexOf('B')).toBeLessThan(promptText.indexOf('A')) - }) - - it('handles structured content (ContentPart[]) on the user message', async () => { - // Regression: `getMessageText` previously read `part.text`, but the - // actual TextPart shape (see ../../src/types.ts) carries the string on - // `part.content`. With the bug, a structured user message yielded - // lastUserText === '', which silently disabled retrieval AND skipped - // the user-side persist record. Verify retrieval IS attempted with the - // structured text and the user record IS persisted with that text. - const memory = fakeAdapter([rec({ text: 'X' })]) - const { adapter } = createMockAdapter({ - iterations: [ - [ev.runStarted(), ev.textContent('ok'), ev.runFinished('stop')], - ], - }) - const stream = chat({ - adapter, - messages: [ - { - role: 'user', - content: [{ type: 'text', content: 'hello structured' }], - }, - ], - middleware: [memoryMiddleware({ adapter: memory, scope: baseScope })], - }) - await collectChunks(stream as AsyncIterable) - expect(memory.searchCalls.length).toBeGreaterThan(0) - expect(memory.searchCalls[0]?.text).toBe('hello structured') - const userRecord = [...memory.store.values()].find((r) => r.role === 'user') - expect(userRecord?.text).toBe('hello structured') - }) - - it('resolves function-form scope once and caches it', async () => { - const memory = fakeAdapter([rec({ text: 'X' })]) - const { adapter } = createMockAdapter({ - iterations: [ - [ev.runStarted(), ev.textContent('ok'), ev.runFinished('stop')], - ], - }) - const scopeFn = vi.fn(() => baseScope) - const stream = chat({ - adapter, - messages: [{ role: 'user', content: 'hi' }], - middleware: [memoryMiddleware({ adapter: memory, scope: scopeFn })], - }) - await collectChunks(stream as AsyncIterable) - expect(scopeFn).toHaveBeenCalledTimes(1) - }) -}) - -describe('memoryMiddleware — persistence', () => { - it('persists user and assistant messages on finish', async () => { - const memory = fakeAdapter() - const { adapter } = createMockAdapter({ - iterations: [ - [ev.runStarted(), ev.textContent('Pong.'), ev.runFinished('stop')], - ], - }) - const stream = chat({ - adapter, - messages: [{ role: 'user', content: 'Ping' }], - middleware: [memoryMiddleware({ adapter: memory, scope: baseScope })], - }) - await collectChunks(stream as AsyncIterable) - const texts = [...memory.store.values()].map((r) => r.text).sort() - expect(texts).toEqual(['Ping', 'Pong.']) - }) - - it('round trip: a turn persisted on one chat() surfaces in retrieval on the next', async () => { - // The headline behaviour of the whole feature — memory written in one - // turn is retrieved and injected in a LATER turn — exercised end to end - // through two sequential chat() calls sharing one adapter + scope. Unlike - // the retrieval tests (which seed the adapter directly), this drives the - // real persist path in turn 1 and the real retrieval path in turn 2, so a - // mismatch between the persisted record shape and the search contract - // (scope serialization, kind, embedding handling) would fail here. - const memory = fakeAdapter() - - // Turn 1 — persist a distinctive assistant answer. - const turn1 = createMockAdapter({ - iterations: [ - [ - ev.runStarted(), - ev.textContent('Paris is the capital of France.'), - ev.runFinished('stop'), - ], - ], - }) - await collectChunks( - chat({ - adapter: turn1.adapter, - messages: [{ role: 'user', content: 'What is the capital of France?' }], - middleware: [memoryMiddleware({ adapter: memory, scope: baseScope })], - }) as AsyncIterable, - ) - // Deferred persistence has completed by the time collectChunks returns. - expect(memory.store.size).toBeGreaterThan(0) - - // Turn 2 — brand-new chat(), same adapter + scope. Memory from turn 1 - // must be retrieved and injected as a system prompt. - const turn2 = createMockAdapter({ - iterations: [ - [ev.runStarted(), ev.textContent('Sure.'), ev.runFinished('stop')], - ], - }) - await collectChunks( - chat({ - adapter: turn2.adapter, - messages: [{ role: 'user', content: 'remind me what you said' }], - middleware: [memoryMiddleware({ adapter: memory, scope: baseScope })], - }) as AsyncIterable, - ) - const injected = ( - turn2.calls[0] as { systemPrompts?: Array } - ).systemPrompts?.join('\n') - expect(injected).toContain('Paris is the capital of France.') - }) - - it('shouldRemember=false skips the entire turn (base records and extractMemories)', async () => { - // Per-turn semantics: shouldRemember is evaluated ONCE per turn and - // gates the whole persist path. The user message is short ("hi", 2 - // chars) so the gate returns false and NOTHING is persisted — the - // assistant message is dropped too, and `extractMemories` is never - // called. - const memory = fakeAdapter() - const { adapter } = createMockAdapter({ - iterations: [ - [ - ev.runStarted(), - ev.textContent('long enough response text'), - ev.runFinished('stop'), - ], - ], - }) - const extractMemories = vi.fn(async () => [ - rec({ text: 'should not run', kind: 'fact' }), - ]) - const stream = chat({ - adapter, - messages: [{ role: 'user', content: 'hi' }], - middleware: [ - memoryMiddleware({ - adapter: memory, - scope: baseScope, - shouldRemember: ({ message }) => message.content.length > 10, - extractMemories, - }), - ], - }) - await collectChunks(stream as AsyncIterable) - expect([...memory.store.values()]).toEqual([]) - expect(extractMemories).not.toHaveBeenCalled() - }) - - it('shouldRemember=true persists user, assistant, and extracted records', async () => { - const memory = fakeAdapter() - const { adapter } = createMockAdapter({ - iterations: [ - [ - ev.runStarted(), - ev.textContent('long enough response text'), - ev.runFinished('stop'), - ], - ], - }) - const stream = chat({ - adapter, - messages: [{ role: 'user', content: 'a meaningful user message' }], - middleware: [ - memoryMiddleware({ - adapter: memory, - scope: baseScope, - // 25-char user message + non-empty response — gate keeps the turn. - shouldRemember: ({ message }) => message.content.length > 10, - }), - ], - }) - await collectChunks(stream as AsyncIterable) - const texts = [...memory.store.values()].map((r) => r.text).sort() - expect(texts).toEqual([ - 'a meaningful user message', - 'long enough response text', - ]) - }) - - it('extractMemories returning records adds them as kind: fact', async () => { - const memory = fakeAdapter() - const { adapter } = createMockAdapter({ - iterations: [ - [ev.runStarted(), ev.textContent('R'), ev.runFinished('stop')], - ], - }) - const extractMemories = vi.fn(async () => [ - rec({ text: 'extracted', kind: 'fact' }), - ]) - const stream = chat({ - adapter, - messages: [{ role: 'user', content: 'U' }], - middleware: [ - memoryMiddleware({ - adapter: memory, - scope: baseScope, - extractMemories, - }), - ], - }) - await collectChunks(stream as AsyncIterable) - expect(extractMemories).toHaveBeenCalledTimes(1) - const kinds = [...memory.store.values()].map((r) => r.kind).sort() - expect(kinds).toEqual(['fact', 'message', 'message']) - }) - - it('extractMemories MemoryOp[] dispatches to add/update/delete', async () => { - const existing = rec({ id: 'old', text: 'old text', kind: 'fact' }) - const memory = fakeAdapter([existing]) - const { adapter } = createMockAdapter({ - iterations: [ - [ev.runStarted(), ev.textContent('R'), ev.runFinished('stop')], - ], - }) - const stream = chat({ - adapter, - messages: [{ role: 'user', content: 'U' }], - middleware: [ - memoryMiddleware({ - adapter: memory, - scope: baseScope, - extractMemories: () => [ - { op: 'add', record: rec({ text: 'new fact', kind: 'fact' }) }, - { op: 'update', id: 'old', patch: { text: 'updated text' } }, - ], - }), - ], - }) - await collectChunks(stream as AsyncIterable) - expect(memory.store.get('old')?.text).toBe('updated text') - expect([...memory.store.values()].some((r) => r.text === 'new fact')).toBe( - true, - ) - }) - - it('applies ops in array order: update after add in same batch sees the add', async () => { - // Order-sensitivity regression test. Previously, all `add` ops were - // batched and flushed at the END after updates/deletes, meaning an - // `update` of an id added in the SAME batch silently no-op'd. With - // strict in-order dispatch the update now sees the just-added record. - const memory = fakeAdapter() - const { adapter } = createMockAdapter({ - iterations: [ - [ev.runStarted(), ev.textContent('R'), ev.runFinished('stop')], - ], - }) - const stream = chat({ - adapter, - messages: [{ role: 'user', content: 'U' }], - middleware: [ - memoryMiddleware({ - adapter: memory, - scope: baseScope, - extractMemories: () => [ - { - op: 'add', - record: rec({ id: 'X', text: 'initial', kind: 'fact' }), - }, - { op: 'update', id: 'X', patch: { text: 'patched' } }, - ], - }), - ], - }) - await collectChunks(stream as AsyncIterable) - expect(memory.store.get('X')?.text).toBe('patched') - }) - - it('forces the resolved scope onto records returned by extractMemories', async () => { - // Defence-in-depth: a buggy or hostile `extractMemories` callback that - // returns a record with a DIFFERENT scope than the resolved one must NOT - // be able to write into another tenant's bucket. The middleware silently - // overrides the record's scope with the resolved scope before persisting. - const memory = fakeAdapter() - const { adapter } = createMockAdapter({ - iterations: [ - [ev.runStarted(), ev.textContent('R'), ev.runFinished('stop')], - ], - }) - const stream = chat({ - adapter, - messages: [{ role: 'user', content: 'U' }], - middleware: [ - memoryMiddleware({ - adapter: memory, - scope: baseScope, - extractMemories: () => [ - // Buggy callback returning a record under a DIFFERENT scope — - // middleware must override to baseScope before persisting. - rec({ - scope: { tenantId: 'wrong-tenant' }, - 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' }, - text: 'tool-leaked', - kind: 'tool-result', - role: 'tool', - }), - ], - }), - ], - }) - await collectChunks(stream as AsyncIterable) - await new Promise((resolve) => setTimeout(resolve, 0)) - const leaked = [...memory.store.values()].find( - (r) => r.text === 'tool-leaked', - ) - expect(leaked).toBeDefined() - expect(leaked?.scope).toEqual(baseScope) - }) - - it('afterPersist receives newly-added records (not updates/deletes)', async () => { - const memory = fakeAdapter() - const { adapter } = createMockAdapter({ - iterations: [ - [ev.runStarted(), ev.textContent('R'), ev.runFinished('stop')], - ], - }) - const afterPersist = vi.fn() - const stream = chat({ - adapter, - messages: [{ role: 'user', content: 'U' }], - middleware: [ - memoryMiddleware({ adapter: memory, scope: baseScope, afterPersist }), - ], - }) - await collectChunks(stream as AsyncIterable) - expect(afterPersist).toHaveBeenCalledTimes(1) - const arg = afterPersist.mock.calls[0]?.[0] as - | { newRecords: Array } - | undefined - expect(arg?.newRecords.length).toBe(2) // user + assistant - }) - - it('onToolResult persists kind: tool-result records', async () => { - const memory = fakeAdapter() - const { adapter } = createMockAdapter({ - iterations: [ - [ - ev.runStarted(), - ev.toolStart('c1', 'echo'), - ev.toolArgs('c1', '{}'), - ev.toolEnd('c1', 'echo'), - ev.runFinished('tool_calls'), - ], - [ev.runStarted(), ev.textContent('done'), ev.runFinished('stop')], - ], - }) - const stream = chat({ - adapter, - messages: [{ role: 'user', content: 'U' }], - tools: [ - { name: 'echo', description: 'noop', execute: async () => ({ ok: 1 }) }, - ], - middleware: [ - memoryMiddleware({ - adapter: memory, - scope: baseScope, - onToolResult: ({ toolName, result }) => [ - rec({ - text: `${toolName}:${JSON.stringify(result)}`, - kind: 'tool-result', - role: 'tool', - }), - ], - }), - ], - }) - await collectChunks(stream as AsyncIterable) - const toolResults = [...memory.store.values()].filter( - (r) => r.kind === 'tool-result', - ) - expect(toolResults).toHaveLength(1) - expect(toolResults[0]?.text).toContain('echo') - }) - - it('shouldRemember=false skips tool-result memories from onToolResult', async () => { - // Regression: previously `onToolResult` deferred persists fired - // immediately and `shouldRemember` only gated the finish-turn path, - // so a `false` return left tool-result memories already committed. - // After buffering + flushing inside `persistTurn`, `shouldRemember` - // gates the entire turn — tool-result ops included. - const memory = fakeAdapter() - const { adapter } = createMockAdapter({ - iterations: [ - [ - ev.runStarted(), - ev.toolStart('c1', 'echo'), - ev.toolArgs('c1', '{}'), - ev.toolEnd('c1', 'echo'), - ev.runFinished('tool_calls'), - ], - [ev.runStarted(), ev.textContent('done'), ev.runFinished('stop')], - ], - }) - const stream = chat({ - adapter, - messages: [{ role: 'user', content: 'U' }], - tools: [ - { name: 'echo', description: 'noop', execute: async () => ({ ok: 1 }) }, - ], - middleware: [ - memoryMiddleware({ - adapter: memory, - scope: baseScope, - shouldRemember: () => false, - onToolResult: ({ toolName, result }) => [ - rec({ - text: `${toolName}:${JSON.stringify(result)}`, - kind: 'tool-result', - role: 'tool', - }), - ], - }), - ], - }) - await collectChunks(stream as AsyncIterable) - // Wait a tick for any deferred work — there should be none. - await new Promise((resolve) => setTimeout(resolve, 0)) - expect(memory.store.size).toBe(0) - }) - - it('onToolResult ops flow through finish-turn observability pipeline', async () => { - // Behaviour: `onToolResult` returned ops are buffered on per-request - // state and flushed inside the finish-turn persist round AFTER the - // per-turn `shouldRemember` gate passes. They share a single observed - // persist with base + extracted records, so persist:started/completed, - // events.onPersistStart/End, and afterPersist each fire ONCE per turn - // (not once per tool call + once for finish-turn). - const memory = fakeAdapter() - const { adapter } = createMockAdapter({ - iterations: [ - [ - ev.runStarted(), - ev.toolStart('c1', 'echo'), - ev.toolArgs('c1', '{"q":"x"}'), - ev.toolEnd('c1', 'echo'), - ev.runFinished('tool_calls'), - ], - [ev.runStarted(), ev.textContent('done'), ev.runFinished('stop')], - ], - }) - const startCount = { n: 0 } - const endCount = { n: 0 } - const onPersistStart = vi.fn() - const onPersistEnd = vi.fn() - const afterPersist = vi.fn() - const opts = { withEventTarget: true } as const - const off1 = aiEventClient.on( - 'memory:persist:started', - () => { - startCount.n++ - }, - opts, - ) - const off2 = aiEventClient.on( - 'memory:persist:completed', - () => { - endCount.n++ - }, - opts, - ) - try { - const stream = chat({ - adapter, - messages: [{ role: 'user', content: 'U' }], - tools: [ - { - name: 'echo', - description: 'noop', - execute: async () => ({ ok: 1 }), - }, - ], - middleware: [ - memoryMiddleware({ - adapter: memory, - scope: baseScope, - afterPersist, - events: { onPersistStart, onPersistEnd }, - onToolResult: ({ toolName, result }) => [ - rec({ - text: `${toolName}:${JSON.stringify(result)}`, - kind: 'tool-result', - role: 'tool', - }), - ], - }), - ], - }) - await collectChunks(stream as AsyncIterable) - // Wait for deferred work to settle. - await new Promise((resolve) => setTimeout(resolve, 0)) - } finally { - off1() - off2() - } - // Single unified finish-turn persist round covers base + extracted + - // tool-result records — exactly one start/end pair per turn. - expect(startCount.n).toBe(1) - expect(endCount.n).toBe(1) - expect(onPersistStart).toHaveBeenCalledTimes(1) - expect(onPersistEnd).toHaveBeenCalledTimes(1) - expect(afterPersist).toHaveBeenCalledTimes(1) - // Tool-result records still visible to afterPersist (folded into the - // single newRecords array passed to the callback). - const allNewRecords = afterPersist.mock.calls.flatMap( - (c) => (c[0] as { newRecords: Array<{ kind: string }> }).newRecords, - ) - expect(allNewRecords.some((r) => r.kind === 'tool-result')).toBe(true) - }) -}) - -describe('memoryMiddleware — failure handling', () => { - it('non-strict: retrieval failure does not abort chat', async () => { - const memory = fakeAdapter() - memory.search = async () => { - throw new Error('boom') - } - const { adapter } = createMockAdapter({ - iterations: [ - [ev.runStarted(), ev.textContent('ok'), ev.runFinished('stop')], - ], - }) - const stream = chat({ - adapter, - messages: [{ role: 'user', content: 'hi' }], - middleware: [memoryMiddleware({ adapter: memory, scope: baseScope })], - }) - const chunks = await collectChunks(stream as AsyncIterable) - expect(chunks.some((c) => c.type === 'TEXT_MESSAGE_CONTENT')).toBe(true) - }) - - it('strict: retrieval failure rejects the stream', async () => { - const memory = fakeAdapter() - memory.search = async () => { - throw new Error('boom') - } - const { adapter } = createMockAdapter({ - iterations: [ - [ev.runStarted(), ev.textContent('ok'), ev.runFinished('stop')], - ], - }) - const stream = chat({ - adapter, - messages: [{ role: 'user', content: 'hi' }], - middleware: [ - memoryMiddleware({ adapter: memory, scope: baseScope, strict: true }), - ], - }) - await expect( - collectChunks(stream as AsyncIterable), - ).rejects.toThrow('boom') - }) - - it('strict: extractMemories failure persists base records and emits exactly one memory:error (phase: extract)', async () => { - // Regression: previously the inner try/catch rethrew on strict, then - // the outer persist catch caught the rethrow and emitted a SECOND - // memory:error with phase: 'persist'. The double-emit also bypassed - // applyOps, so base user/assistant records never landed. New behaviour: - // - memory:error fires exactly ONCE with phase: 'extract' - // - base user + assistant records DO persist - const memory = fakeAdapter() - const { adapter } = createMockAdapter({ - iterations: [ - [ev.runStarted(), ev.textContent('Pong.'), ev.runFinished('stop')], - ], - }) - const errorEvents: Array<{ phase: string }> = [] - const opts = { withEventTarget: true } as const - const off = aiEventClient.on( - 'memory:error', - (e) => errorEvents.push({ phase: e.payload.phase }), - opts, - ) - try { - const stream = chat({ - adapter, - messages: [{ role: 'user', content: 'Ping' }], - middleware: [ - memoryMiddleware({ - adapter: memory, - scope: baseScope, - strict: true, - extractMemories: () => { - throw new Error('extract-boom') - }, - }), - ], - }) - // Stream itself succeeds — the deferred persist promise is the one - // that rejects in strict mode. Drain chunks normally. - await collectChunks(stream as AsyncIterable) - // Give the deferred persist promise a tick to settle before - // asserting on side-effects (event emissions, store state). - await new Promise((resolve) => setTimeout(resolve, 0)) - } finally { - off() - } - // Exactly one error event, with the correct phase. - expect(errorEvents).toEqual([{ phase: 'extract' }]) - // Base records still landed despite the strict extract failure. - const texts = [...memory.store.values()].map((r) => r.text).sort() - expect(texts).toEqual(['Ping', 'Pong.']) - }) -}) - -describe('memoryMiddleware — devtools events', () => { - it('emits retrieve and persist events in order', async () => { - const memory = fakeAdapter([rec({ text: 'X' })]) - const { adapter } = createMockAdapter({ - iterations: [ - [ev.runStarted(), ev.textContent('R'), ev.runFinished('stop')], - ], - }) - const seen: Array = [] - const opts = { withEventTarget: true } as const - const off1 = aiEventClient.on( - 'memory:retrieve:started', - () => seen.push('retrieve:started'), - opts, - ) - const off2 = aiEventClient.on( - 'memory:retrieve:completed', - () => seen.push('retrieve:completed'), - opts, - ) - const off3 = aiEventClient.on( - 'memory:persist:started', - () => seen.push('persist:started'), - opts, - ) - const off4 = aiEventClient.on( - 'memory:persist:completed', - () => seen.push('persist:completed'), - opts, - ) - try { - const stream = chat({ - adapter, - messages: [{ role: 'user', content: 'U' }], - middleware: [memoryMiddleware({ adapter: memory, scope: baseScope })], - }) - await collectChunks(stream as AsyncIterable) - expect(seen).toEqual([ - 'retrieve:started', - 'retrieve:completed', - 'persist:started', - 'persist:completed', - ]) - } finally { - off1() - off2() - off3() - off4() - } - }) -}) - -describe('memoryMiddleware — error-path observability', () => { - it('emits memory:error with phase: persist when assistant embedder fails (non-strict)', async () => { - // Round 3 finding: when `options.embedder.embed(args.responseText)` throws - // inside `persistTurn`, the assistant-side embed lives OUTSIDE - // `runObservedPersist` and therefore bypassed the persist-phase event - // pipeline. The fix wraps that call locally; this test pins the - // observable contract. - const memory = fakeAdapter() - const { adapter } = createMockAdapter({ - iterations: [ - [ev.runStarted(), ev.textContent('R'), ev.runFinished('stop')], - ], - }) - const flakyEmbedder = { - // Fail only on the assistant-side embed; succeed for the user-side - // query embed so the failure under test is unambiguously the - // assistant-side one. - async embed(text: string) { - if (text === 'R') throw new Error('embedder boom') - return [1, 0] - }, - } - const errorEvents: Array<{ phase: string; message: string }> = [] - const opts = { withEventTarget: true } as const - const off = aiEventClient.on( - 'memory:error', - (e) => - errorEvents.push({ - phase: e.payload.phase, - message: e.payload.error.message, - }), - opts, - ) - try { - const stream = chat({ - adapter, - messages: [{ role: 'user', content: 'U' }], - middleware: [ - memoryMiddleware({ - adapter: memory, - scope: baseScope, - embedder: flakyEmbedder, - }), - ], - }) - await collectChunks(stream as AsyncIterable) - // Allow deferred persist to settle. - await new Promise((resolve) => setTimeout(resolve, 0)) - // Both base records still land (user with embedding, assistant without). - expect(memory.store.size).toBeGreaterThanOrEqual(2) - const stored = [...memory.store.values()] - const assistantRecord = stored.find((r) => r.role === 'assistant') - expect(assistantRecord?.embedding).toBeUndefined() - // Exactly one persist-phase memory:error fired with the embedder cause. - const persistErrors = errorEvents.filter((e) => e.phase === 'persist') - expect(persistErrors.length).toBe(1) - expect(persistErrors[0]?.message).toContain('boom') - } finally { - off() - } - }) - - it('a throwing scope resolver does not break chat and emits memory:error (non-strict)', async () => { - // Scope resolution runs a user-supplied callback. If it throws it must - // route through memory:error/onError and be swallowed in non-strict mode - // rather than escaping onConfig/onFinish and breaking the chat request. - const memory = fakeAdapter() - const { adapter } = createMockAdapter({ - iterations: [ - [ev.runStarted(), ev.textContent('R'), ev.runFinished('stop')], - ], - }) - 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 chunks = await collectChunks( - chat({ - adapter, - messages: [{ role: 'user', content: 'U' }], - middleware: [ - memoryMiddleware({ - adapter: memory, - scope: () => { - throw new Error('scope boom') - }, - }), - ], - }) as AsyncIterable, - ) - await new Promise((resolve) => setTimeout(resolve, 0)) - // Chat still produced output — the memory failure did not break the run. - expect(chunks.length).toBeGreaterThan(0) - // The failure surfaced on the retrieve path (onConfig). - expect(errorEvents.some((e) => e.message.includes('scope boom'))).toBe( - true, - ) - // Nothing was persisted (scope never resolved). - expect(memory.store.size).toBe(0) - } finally { - off() - } - }) - - it('a throwing shouldRemember does not break chat and emits memory:error (non-strict)', async () => { - const memory = fakeAdapter() - const { adapter } = createMockAdapter({ - iterations: [ - [ev.runStarted(), ev.textContent('R'), ev.runFinished('stop')], - ], - }) - 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 chunks = await collectChunks( - chat({ - adapter, - messages: [{ role: 'user', content: 'U' }], - middleware: [ - memoryMiddleware({ - adapter: memory, - scope: baseScope, - shouldRemember: () => { - throw new Error('remember boom') - }, - }), - ], - }) as AsyncIterable, - ) - await new Promise((resolve) => setTimeout(resolve, 0)) - expect(chunks.length).toBeGreaterThan(0) - const persistErrors = errorEvents.filter((e) => e.phase === 'persist') - expect( - persistErrors.some((e) => e.message.includes('remember boom')), - ).toBe(true) - // The gate threw before any record was committed. - expect(memory.store.size).toBe(0) - } finally { - off() - } - }) - - it('emits memory:error with phase: extract when tool args fail to parse', async () => { - // Convergence-audit fix: the tool-args JSON parse fallback in - // `onAfterToolCall` used to silently coerce malformed payloads to `{}`. - // Observers now get a `memory:error` (phase: 'extract') for the same - // failure while the surrounding `onToolResult` path still runs. - // - // The chat engine itself fails fast on malformed tool arguments BEFORE - // `onAfterToolCall` fires, so the only way to exercise the defensive - // parse-catch in middleware.ts is to invoke the hook directly with a - // synthesized `info.toolCall.function.arguments` payload — this is the - // pure-unit test of that branch. - const memory = fakeAdapter() - const errorEvents: Array<{ phase: string }> = [] - const opts = { withEventTarget: true } as const - const off = aiEventClient.on( - 'memory:error', - (e) => errorEvents.push({ phase: e.payload.phase }), - opts, - ) - try { - const mw = memoryMiddleware({ - adapter: memory, - scope: baseScope, - onToolResult: ({ args }) => [ - rec({ - text: `args=${JSON.stringify(args)}`, - kind: 'tool-result', - role: 'tool', - }), - ], - }) - // Minimal `ChatMiddlewareContext` covering the fields the memory - // middleware actually reads (resolveScope needs none beyond its - // closure; onAfterToolCall calls `ctx.defer`). - const deferred: Array> = [] - const ctx = { - requestId: 'req-1', - streamId: 'stream-1', - phase: 'init' as const, - iteration: 0, - chunkIndex: 0, - abort: () => {}, - context: undefined, - defer: (p: Promise) => { - deferred.push(p) - }, - provider: 'mock', - model: 'm', - source: 'server' as const, - streaming: true, - systemPrompts: [], - messageCount: 1, - hasTools: true, - currentMessageId: null, - accumulatedContent: '', - messages: [{ role: 'user' as const, content: 'U' }], - createId: (p: string) => `${p}-id`, - } - // Prime per-request state via onConfig — `onAfterToolCall` short- - // circuits when state is missing. - await mw.onConfig?.(ctx as never, { - messages: [{ role: 'user', content: 'U' }], - systemPrompts: [], - tools: [], - }) - // Synthesize a tool call whose `arguments` is structurally a string - // but not valid JSON. The engine never produces this in practice (it - // throws first), so direct invocation is the only path that exercises - // the defensive parse-catch. - await mw.onAfterToolCall?.(ctx as never, { - toolCall: { - id: 'c1', - type: 'function', - function: { name: 'echo', arguments: 'NOT-VALID-JSON{' }, - }, - tool: undefined, - toolName: 'echo', - toolCallId: 'c1', - ok: true, - duration: 1, - result: { ok: 1 }, - }) - // Drain any deferred persists. - await Promise.all(deferred) - // The malformed args produced a memory:error with phase: 'extract'. - expect(errorEvents.some((e) => e.phase === 'extract')).toBe(true) - } finally { - off() - } - }) -}) diff --git a/packages/ai/vite.config.ts b/packages/ai/vite.config.ts index 685e82eca..5189bd6eb 100644 --- a/packages/ai/vite.config.ts +++ b/packages/ai/vite.config.ts @@ -35,7 +35,6 @@ export default mergeConfig( './src/activities/index.ts', './src/middlewares/index.ts', './src/middlewares/otel.ts', - './src/memory/index.ts', './src/adapter-internals.ts', ], srcDir: './src', diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index daf13daa7..897b27ded 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1944,13 +1944,22 @@ importers: packages/ai-memory: dependencies: + '@tanstack/ai-event-client': + specifier: workspace:* + version: link:../ai-event-client ioredis: specifier: '>=5.0.0' version: 5.9.2 devDependencies: + '@honcho-ai/sdk': + specifier: ^2.1.1 + version: 2.2.0 '@tanstack/ai': specifier: workspace:* version: link:../ai + '@vectorize-io/hindsight-client': + specifier: ^0.6.1 + version: 0.6.2 '@vitest/coverage-v8': specifier: 4.0.14 version: 4.0.14(supports-color@7.2.0)(vitest@4.1.10) @@ -2603,6 +2612,9 @@ importers: '@tanstack/ai-mcp': specifier: workspace:* version: link:../../packages/ai-mcp + '@tanstack/ai-memory': + specifier: workspace:* + version: link:../../packages/ai-memory '@tanstack/ai-mistral': specifier: workspace:* version: link:../../packages/ai-mistral @@ -4976,6 +4988,9 @@ packages: '@harperfast/extended-iterable@1.0.3': resolution: {integrity: sha512-sSAYhQca3rDWtQUHSAPeO7axFIUJOI6hn1gjRC5APVE1a90tuyT8f5WIgRsFhhWA7htNkju2veB9eWL6YHi/Lw==} + '@honcho-ai/sdk@2.2.0': + resolution: {integrity: sha512-SyygN+BrpUB2fRjhwcYmT+tcEhHrKmbj9nOZLVUFY7M5YBswJ+mZb/CeLpNbRh+QQTU8F8JWY3lQat95c6nwmA==} + '@hono/node-server@1.19.14': resolution: {integrity: sha512-GwtvgtXxnWsucXvbQXkRgqksiH2Qed37H9xHZocE5sA3N8O8O8/8FA3uclQXxXVzc9XBZuEOMK7+r02FmSpHtw==} engines: {node: '>=18.14.1'} @@ -9478,6 +9493,9 @@ packages: resolution: {integrity: sha512-WmoN8qaIAo7WTYWbAZuG8PYEhn5fkz7dZrqTBZ7dtt//lL2Gwms1IcnQ5yHqjDfX8Ft5j4YzDM23f87zBfDe9g==} deprecated: Potential CWE-502 - Update to 1.3.1 or higher + '@vectorize-io/hindsight-client@0.6.2': + resolution: {integrity: sha512-bymmlMWI1z0zOjgY+wRMLudNxzqcW20VHMtyV3QLhwJm63NeQN/nEZ4plWPR0p28DffaUM5nk2VSxzQljN+Mow==} + '@vercel/nft@1.3.0': resolution: {integrity: sha512-i4EYGkCsIjzu4vorDUbqglZc5eFtQI2syHb++9ZUDm6TU4edVywGpVnYDein35x9sevONOn9/UabfQXuNXtuzQ==} engines: {node: '>=20'} @@ -16200,6 +16218,9 @@ packages: zod@3.25.76: resolution: {integrity: sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==} + zod@4.0.0: + resolution: {integrity: sha512-9diLdTPc/L7w/5jI4C3gHYNiGHDV9IZYxo1e5LSD8cabi65WVTWWb+g2BGPEpUUCOxR4D+6O5B0AzyMdUAXwrw==} + zod@4.2.1: resolution: {integrity: sha512-0wZ1IRqGGhMP76gLqz8EyfBXKk0J2qo2+H3fi4mcUP/KtTocoX08nmIAHl1Z2kJIZbZee8KOpBCSNPRgauucjw==} @@ -19019,6 +19040,10 @@ snapshots: '@harperfast/extended-iterable@1.0.3': optional: true + '@honcho-ai/sdk@2.2.0': + dependencies: + zod: 4.0.0 + '@hono/node-server@1.19.14(hono@4.12.23)': dependencies: hono: 4.12.23 @@ -23854,6 +23879,8 @@ snapshots: '@ungap/structured-clone@1.3.0': {} + '@vectorize-io/hindsight-client@0.6.2': {} + '@vercel/nft@1.3.0(rollup@4.60.1)': dependencies: '@mapbox/node-pre-gyp': 2.0.3 @@ -32306,6 +32333,8 @@ snapshots: zod@3.25.76: {} + zod@4.0.0: {} + zod@4.2.1: {} zod@4.3.6: {} diff --git a/testing/e2e/package.json b/testing/e2e/package.json index 78ffbf406..b84b22a92 100644 --- a/testing/e2e/package.json +++ b/testing/e2e/package.json @@ -27,6 +27,7 @@ "@tanstack/ai-grok": "workspace:*", "@tanstack/ai-groq": "workspace:*", "@tanstack/ai-mcp": "workspace:*", + "@tanstack/ai-memory": "workspace:*", "@tanstack/ai-mistral": "workspace:*", "@tanstack/ai-ollama": "workspace:*", "@tanstack/ai-openai": "workspace:*", diff --git a/testing/e2e/src/lib/memory-capture.ts b/testing/e2e/src/lib/memory-capture.ts new file mode 100644 index 000000000..489593e53 --- /dev/null +++ b/testing/e2e/src/lib/memory-capture.ts @@ -0,0 +1,52 @@ +/** + * Per-testId capture for the `memory` mode of `/middleware-test`. A recorder + * middleware placed AFTER `memoryMiddleware` records the config the model + * actually sees (post-injection system prompts + tool names) plus a flag for + * each deferred `save`. The page fetches it via + * `GET /api/middleware-test?testId=...&kind=memory` and surfaces it in the DOM + * for the Playwright spec. Mirrors `phase-capture.ts`. + */ + +export interface MemoryConfigRecord { + /** System-prompt strings present in the config at `init` (post memory injection). */ + systemPrompts: Array + /** Tool names present in the config at `init` (post memory injection). */ + toolNames: Array +} + +export interface MemoryCapture { + /** One entry per `onConfig(init)` observed by the recorder. */ + configs: Array + /** Count of `save` calls the fake adapter observed. */ + saveCount: number +} + +const captures: Map = new Map() + +function bucketFor(captureId: string): MemoryCapture { + let bucket = captures.get(captureId) + if (!bucket) { + bucket = { configs: [], saveCount: 0 } + captures.set(captureId, bucket) + } + return bucket +} + +export function resetMemoryCapture(captureId: string): void { + captures.set(captureId, { configs: [], saveCount: 0 }) +} + +export function getMemoryCapture(captureId: string): MemoryCapture { + return bucketFor(captureId) +} + +export function recordMemoryConfig( + captureId: string, + record: MemoryConfigRecord, +): void { + bucketFor(captureId).configs.push(record) +} + +export function recordMemorySave(captureId: string): void { + bucketFor(captureId).saveCount += 1 +} diff --git a/testing/e2e/src/routes/api.middleware-test.ts b/testing/e2e/src/routes/api.middleware-test.ts index 9879df7dd..250e9fe64 100644 --- a/testing/e2e/src/routes/api.middleware-test.ts +++ b/testing/e2e/src/routes/api.middleware-test.ts @@ -7,8 +7,16 @@ import { toServerSentEventsResponse, toolDefinition, } from '@tanstack/ai' -import type { ChatMiddleware, StreamChunk } from '@tanstack/ai' +import type { ChatMiddleware, StreamChunk, Tool } from '@tanstack/ai' import { otelMiddleware } from '@tanstack/ai/middlewares/otel' +import { memoryMiddleware } from '@tanstack/ai-memory' +import type { MemoryAdapter } from '@tanstack/ai-memory' +import { + getMemoryCapture, + recordMemoryConfig, + recordMemorySave, + resetMemoryCapture, +} from '@/lib/memory-capture' import { guitarRecommendationSchema } from '@/lib/schemas' import { getPhaseCapture, @@ -165,6 +173,56 @@ async function* teeForPhaseCapture( } } +/** + * Fake memory adapter for `memory` mode. `recall` unconditionally returns a + * known system-prompt block plus a memory tool (so the spec can assert both the + * prompt injection AND tool injection reach the model config), and `save` + * records that the deferred write ran. Deterministic — no real vendor, no + * cross-request state. + */ +const RECALL_MORE_TOOL: Tool = { + name: 'recall_more', + description: 'Look up additional long-term memory by query.', + inputSchema: { type: 'object', properties: {}, additionalProperties: false }, +} + +function createFakeMemoryAdapter(captureId: string): MemoryAdapter { + return { + id: 'fake-memory', + recall: async () => ({ + systemPrompt: 'MEMORY: the user previously said they love TanStack.', + fragments: [{ text: 'the user loves TanStack', source: 'f1' }], + tools: [RECALL_MORE_TOOL], + toolGuidance: 'Use recall_more to look up additional memory when needed.', + }), + save: async () => { + recordMemorySave(captureId) + return [{ ok: true }] + }, + } +} + +/** + * Recorder placed AFTER `memoryMiddleware` so it observes the config the model + * actually sees — i.e. WITH the recalled system prompt + tools already merged + * in. Records that into the per-testId memory capture. + */ +function createMemoryConfigRecorder(captureId: string): ChatMiddleware { + return { + name: 'memory-config-recorder', + onConfig(ctx, config) { + if (ctx.phase !== 'init') return + recordMemoryConfig(captureId, { + systemPrompts: config.systemPrompts.map((p) => + typeof p === 'string' ? p : p.content, + ), + toolNames: config.tools.map((t) => t.name), + }) + return + }, + } +} + // Minimal in-memory tracer/meter. Captures into a per-testId bucket so that // the Playwright spec can fetch the recorded state via GET after the stream // finishes. Not exported — only used to build otelMiddleware for the test. @@ -353,6 +411,22 @@ export const Route = createFileRoute('/api/middleware-test')({ resetPhaseCapture(testId) middleware.push(createPhaseRecorderMiddleware(testId)) } + if (middlewareMode === 'memory') { + if (!testId) { + return new Response( + JSON.stringify({ error: 'memory mode requires testId' }), + { status: 400, headers: { 'Content-Type': 'application/json' } }, + ) + } + resetMemoryCapture(testId) + middleware.push( + memoryMiddleware({ + adapter: createFakeMemoryAdapter(testId), + scope: { sessionId: testId }, + }), + createMemoryConfigRecorder(testId), + ) + } if (middlewareMode === 'otel') { if (!OTEL_TEST_ENABLED) { return new Response(null, { status: 404 }) @@ -451,6 +525,19 @@ export const Route = createFileRoute('/api/middleware-test')({ }) } + // Memory capture — like phase capture, available without the OTEL gate. + if (kind === 'memory') { + if (!testId) { + return new Response( + JSON.stringify({ error: 'testId query param required' }), + { status: 400, headers: { 'Content-Type': 'application/json' } }, + ) + } + return new Response(JSON.stringify(getMemoryCapture(testId)), { + headers: { 'Content-Type': 'application/json' }, + }) + } + // OTEL capture remains gated — the GET cannot act as an oracle in a // production-like build. if (!OTEL_TEST_ENABLED) { diff --git a/testing/e2e/src/routes/middleware-test.tsx b/testing/e2e/src/routes/middleware-test.tsx index 784c4067a..28ccfa6ab 100644 --- a/testing/e2e/src/routes/middleware-test.tsx +++ b/testing/e2e/src/routes/middleware-test.tsx @@ -9,6 +9,7 @@ const MIDDLEWARE_MODES = [ { id: 'capability', label: 'Capability (provide/consume prefix)' }, { id: 'phase-recorder', label: 'Phase Recorder (capture phase + chunks)' }, { id: 'otel', label: 'OpenTelemetry (capture spans/metrics)' }, + { id: 'memory', label: 'Memory (recall/save)' }, ] as const interface PhaseCaptureSnapshot { @@ -84,6 +85,10 @@ function MiddlewareTestPage() { const [testComplete, setTestComplete] = useState(false) const [phaseCapture, setPhaseCapture] = useState(EMPTY_PHASE_CAPTURE) + const [memoryCapture, setMemoryCapture] = useState<{ + configs: Array<{ systemPrompts: Array; toolNames: Array }> + saveCount: number + }>({ configs: [], saveCount: 0 }) const { messages, sendMessage, isLoading } = useChat({ id: `mw-test-${scenario}-${middlewareMode}-${provider ?? 'openai'}-${model ?? 'default'}`, @@ -110,6 +115,23 @@ function MiddlewareTestPage() { }) return } + if (middlewareMode === 'memory' && testId) { + void fetch( + `/api/middleware-test?testId=${encodeURIComponent(testId)}&kind=memory`, + ) + .then((res) => + res.ok ? res.json() : { configs: [], saveCount: 0 }, + ) + .then((data) => { + setMemoryCapture(data) + setTestComplete(true) + }) + .catch(() => { + setMemoryCapture({ configs: [], saveCount: 0 }) + setTestComplete(true) + }) + return + } setTestComplete(true) }, }) @@ -224,6 +246,9 @@ function MiddlewareTestPage() {
         {JSON.stringify(phaseCapture.yieldedChunks)}
       
+
+        {JSON.stringify(memoryCapture)}
+      
{ expect(textPart?.content).not.toContain('[MW]') expect(textPart?.content).toContain('Hello') }) + + test('memory middleware injects recalled prompt + tools and defers save', async ({ + page, + testId, + aimockPort, + }) => { + const params = new URLSearchParams() + if (testId) params.set('testId', testId) + if (aimockPort) params.set('aimockPort', String(aimockPort)) + const qs = params.toString() + await page.goto(`/middleware-test${qs ? '?' + qs : ''}`) + await page.waitForTimeout(2000) // hydration + await page.locator('#mw-scenario-select').selectOption('basic-text') + await page.locator('#mw-mode-select').selectOption('memory') + await page.locator('#mw-run-button').click() + + await page.waitForFunction( + () => + document + .querySelector('#mw-metadata') + ?.getAttribute('data-test-complete') === 'true', + { timeout: 10000 }, + ) + + const memoryJson = await page.locator('#mw-memory-json').textContent() + const capture = JSON.parse(memoryJson || '{}') as { + configs: Array<{ systemPrompts: Array; toolNames: Array }> + saveCount: number + } + + // The recorder placed after memoryMiddleware saw the config the model + // receives: the recalled system prompt (+ tool guidance) and the injected + // tool must both be present. + expect(capture.configs.length).toBeGreaterThan(0) + const injected = capture.configs.find((c) => + c.systemPrompts.some((p) => p.includes('love TanStack')), + ) + expect(injected).toBeTruthy() + expect( + injected?.systemPrompts.some((p) => p.includes('recall_more')), + ).toBe(true) + expect(injected?.toolNames).toContain('recall_more') + + // The finished turn deferred a save through the adapter. + expect(capture.saveCount).toBeGreaterThanOrEqual(1) + }) }) From 3c1427c6de53ec1ee51d1dd6da8e20958017f7a4 Mon Sep 17 00:00:00 2001 From: "autofix-ci[bot]" <114827586+autofix-ci[bot]@users.noreply.github.com> Date: Wed, 22 Jul 2026 01:51:09 +0000 Subject: [PATCH 40/45] ci: apply automated fixes --- .../skills/tanstack-ai-memory-redis/SKILL.md | 5 ++- .../skills/tanstack-ai-memory/SKILL.md | 6 ++-- packages/ai-memory/src/in-memory.ts | 6 +++- packages/ai-memory/src/middleware.ts | 9 +++-- .../src/providers/hindsight/index.ts | 31 +++++++++++----- .../src/providers/hindsight/tools.ts | 22 ++++++++---- .../ai-memory/src/providers/honcho/index.ts | 35 ++++++++++++------- .../ai-memory/src/providers/mem0/index.ts | 20 +++++++---- packages/ai-memory/src/redis.ts | 11 ++++-- packages/ai-memory/tests/contract.ts | 4 ++- packages/ai-memory/tests/in-memory.test.ts | 15 +++++--- packages/ai-memory/tests/middleware.test.ts | 11 ++++-- packages/ai-memory/tests/redis.test.ts | 28 ++++++++++----- testing/e2e/src/routes/api.middleware-test.ts | 5 ++- testing/e2e/src/routes/middleware-test.tsx | 4 +-- testing/e2e/tests/middleware.spec.ts | 6 ++-- 16 files changed, 151 insertions(+), 67 deletions(-) diff --git a/packages/ai-memory/skills/tanstack-ai-memory-redis/SKILL.md b/packages/ai-memory/skills/tanstack-ai-memory-redis/SKILL.md index d410eb7f8..6cc1e6777 100644 --- a/packages/ai-memory/skills/tanstack-ai-memory-redis/SKILL.md +++ b/packages/ai-memory/skills/tanstack-ai-memory-redis/SKILL.md @@ -36,7 +36,10 @@ import { redis, nodeRedisAsRedisLike } from '@tanstack/ai-memory/redis' const client = createClient({ url: process.env.REDIS_URL }) await client.connect() -const memory = redis({ redis: nodeRedisAsRedisLike(client), prefix: 'myapp:memory' }) +const memory = redis({ + redis: nodeRedisAsRedisLike(client), + prefix: 'myapp:memory', +}) memoryMiddleware({ adapter: memory, scope }) ``` diff --git a/packages/ai-memory/skills/tanstack-ai-memory/SKILL.md b/packages/ai-memory/skills/tanstack-ai-memory/SKILL.md index 0fd56b904..9721e3e09 100644 --- a/packages/ai-memory/skills/tanstack-ai-memory/SKILL.md +++ b/packages/ai-memory/skills/tanstack-ai-memory/SKILL.md @@ -55,10 +55,10 @@ callbacks. ```ts interface MemoryAdapter { id: string - recall(scope, query): Promise // { systemPrompt, fragments?, tools?, toolGuidance? } + recall(scope, query): Promise // { systemPrompt, fragments?, tools?, toolGuidance? } save(scope, turn): Promise> // turn = { user, assistant }; extraction lives HERE - inspect?(scope): Promise // optional (devtools) - listFacts?(scope): Promise> // optional (devtools) + inspect?(scope): Promise // optional (devtools) + listFacts?(scope): Promise> // optional (devtools) } ``` diff --git a/packages/ai-memory/src/in-memory.ts b/packages/ai-memory/src/in-memory.ts index 9984f5f78..348dfdcfd 100644 --- a/packages/ai-memory/src/in-memory.ts +++ b/packages/ai-memory/src/in-memory.ts @@ -6,7 +6,11 @@ import { sameScope, saveTurn, } from './internal/store' -import type { BuiltinOptions, MemoryRecord, RecordStore } from './internal/store' +import type { + BuiltinOptions, + MemoryRecord, + RecordStore, +} from './internal/store' import type { MemoryAdapter, MemoryScope } from './types' /** diff --git a/packages/ai-memory/src/middleware.ts b/packages/ai-memory/src/middleware.ts index 7c902ea92..e9f1d874e 100644 --- a/packages/ai-memory/src/middleware.ts +++ b/packages/ai-memory/src/middleware.ts @@ -145,7 +145,8 @@ export function memoryMiddleware( onFinish(ctx, info) { const state = stateByCtx.get(ctx) stateByCtx.delete(ctx) - const userText = state?.lastUserText || getMessageText(findLastUserMessage(ctx.messages)) + const userText = + state?.lastUserText || getMessageText(findLastUserMessage(ctx.messages)) const assistant = info.content if (!userText || !assistant) return const scope = state?.resolvedScope @@ -156,7 +157,8 @@ export function memoryMiddleware( // terminal hook. Memory failures are always non-fatal + observable. let resolved: MemoryScope try { - resolved = scope ?? (await resolveScope(ctx, { lastUserText: userText })) + resolved = + scope ?? (await resolveScope(ctx, { lastUserText: userText })) } catch (error) { safeEmit('memory:error', { scope: emptyScope(), @@ -304,7 +306,8 @@ function getMessageText(message?: ModelMessage): string { } function errorInfo(error: unknown): { name: string; message: string } { - if (error instanceof Error) return { name: error.name, message: error.message } + if (error instanceof Error) + return { name: error.name, message: error.message } if ( error && typeof error === 'object' && diff --git a/packages/ai-memory/src/providers/hindsight/index.ts b/packages/ai-memory/src/providers/hindsight/index.ts index 60034b01b..91b9d466f 100644 --- a/packages/ai-memory/src/providers/hindsight/index.ts +++ b/packages/ai-memory/src/providers/hindsight/index.ts @@ -92,9 +92,13 @@ export function hindsight(options: HindsightOptions = {}): MemoryAdapter { runtimePromise = (async () => { const mod = await import('@vectorize-io/hindsight-client') const baseUrl = - options.baseUrl ?? process.env.HINDSIGHT_URL ?? 'http://localhost:8888' + options.baseUrl ?? + process.env.HINDSIGHT_URL ?? + 'http://localhost:8888' // oxlint-disable-next-line eslint-js/no-restricted-syntax -- intentionally decoupled from the SDK's exact client type; the adapter only uses the HindsightClientLike subset - const client = new mod.HindsightClient({ baseUrl }) as unknown as HindsightClientLike + const client = new mod.HindsightClient({ + baseUrl, + }) as unknown as HindsightClientLike const recallToPrompt = mod.recallResponseToPromptString as ( data: unknown, ) => string @@ -118,7 +122,10 @@ export function hindsight(options: HindsightOptions = {}): MemoryAdapter { async save(scope, turn: MemoryTurn): Promise> { const bank = bankId(scope) const timestamp = new Date() - async function retain(text: string, context: string): Promise { + async function retain( + text: string, + context: string, + ): Promise { const start = Date.now() try { const { client } = await getRuntime() @@ -150,10 +157,12 @@ export function hindsight(options: HindsightOptions = {}): MemoryAdapter { try { const { client, recallToPrompt } = await getRuntime() const data = await client.recall(bank, query, { budget }) - const fragments: Array = (data.results ?? []).map((r) => ({ - text: r.text, - source: r.type ?? r.id, - })) + const fragments: Array = (data.results ?? []).map( + (r) => ({ + text: r.text, + source: r.type ?? r.id, + }), + ) return { systemPrompt: recallToPrompt(data), fragments, @@ -180,7 +189,10 @@ export function hindsight(options: HindsightOptions = {}): MemoryAdapter { client.listMemories(bank, { limit: 200 }), client.getBankProfile(bank), ]) - return { takenAt: new Date().toISOString(), data: { memories, profile } } + return { + takenAt: new Date().toISOString(), + data: { memories, profile }, + } } catch (err) { return { takenAt: new Date().toISOString(), @@ -205,7 +217,8 @@ export function hindsight(options: HindsightOptions = {}): MemoryAdapter { id: typeof m.id === 'string' ? m.id : `hindsight-${i}`, text, source: typeof m.context === 'string' ? m.context : 'memory', - createdAt: typeof m.created_at === 'string' ? m.created_at : undefined, + createdAt: + typeof m.created_at === 'string' ? m.created_at : undefined, } }) .filter((f): f is MemoryFact => f !== null) diff --git a/packages/ai-memory/src/providers/hindsight/tools.ts b/packages/ai-memory/src/providers/hindsight/tools.ts index ac2b75d95..bac1179e4 100644 --- a/packages/ai-memory/src/providers/hindsight/tools.ts +++ b/packages/ai-memory/src/providers/hindsight/tools.ts @@ -49,7 +49,11 @@ export function makeHindsightTools(deps: HindsightToolDeps): Array { context: 'chat:tool', timestamp: new Date(), }) - deps.onToolRetain?.({ ok: true, latencyMs: Date.now() - start, raw: data }) + deps.onToolRetain?.({ + ok: true, + latencyMs: Date.now() - start, + raw: data, + }) return { ok: true } } catch (err) { const error = err instanceof Error ? err.message : String(err) @@ -79,12 +83,16 @@ export function makeHindsightTools(deps: HindsightToolDeps): Array { const start = Date.now() try { const { client, recallToPrompt } = await deps.getRuntime() - const data = await client.recall(deps.bankId, query, { budget: deps.budget }) + const data = await client.recall(deps.bankId, query, { + budget: deps.budget, + }) const systemPrompt = recallToPrompt(data) - const fragments: Array = (data.results ?? []).map((r) => ({ - text: r.text, - source: r.type ?? r.id, - })) + const fragments: Array = (data.results ?? []).map( + (r) => ({ + text: r.text, + source: r.type ?? r.id, + }), + ) deps.onToolRecall?.(query, { systemPrompt, fragments, @@ -109,7 +117,7 @@ export function makeHindsightTools(deps: HindsightToolDeps): Array { query: { type: 'string', description: - "The synthesis question to reflect on, e.g. \"what do I know about the user's preferences?\"", + 'The synthesis question to reflect on, e.g. "what do I know about the user\'s preferences?"', }, }, required: ['query'], diff --git a/packages/ai-memory/src/providers/honcho/index.ts b/packages/ai-memory/src/providers/honcho/index.ts index 72ebe34d6..88cb1fd0e 100644 --- a/packages/ai-memory/src/providers/honcho/index.ts +++ b/packages/ai-memory/src/providers/honcho/index.ts @@ -90,8 +90,10 @@ export function honcho(options: HonchoOptions = {}): MemoryAdapter { async function loadClient() { const mod = await import('@honcho-ai/sdk') return new mod.Honcho({ - baseURL: options.baseURL ?? process.env.HONCHO_URL ?? 'http://localhost:8001', - workspaceId: options.workspaceId ?? process.env.HONCHO_APP_NAME ?? 'ai-memory', + baseURL: + options.baseURL ?? process.env.HONCHO_URL ?? 'http://localhost:8001', + workspaceId: + options.workspaceId ?? process.env.HONCHO_APP_NAME ?? 'ai-memory', apiKey: options.apiKey ?? process.env.HONCHO_API_KEY ?? 'dev-no-auth', }) } @@ -117,21 +119,24 @@ export function honcho(options: HonchoOptions = {}): MemoryAdapter { } function getUserPeer(userId: string): Promise { - return cached(userPeerCache, userId, async () => (await getClient()).peer(userId)) + return cached(userPeerCache, userId, async () => + (await getClient()).peer(userId), + ) } function getAssistantPeer(): Promise { if (!assistantPeerPromise) { - assistantPeerPromise = (async () => (await getClient()).peer(assistantId))().catch( - (err) => { - assistantPeerPromise = null - throw err - }, - ) + assistantPeerPromise = (async () => + (await getClient()).peer(assistantId))().catch((err) => { + assistantPeerPromise = null + throw err + }) } return assistantPeerPromise } function getSession(sessionId: string): Promise { - return cached(sessionCache, sessionId, async () => (await getClient()).session(sessionId)) + return cached(sessionCache, sessionId, async () => + (await getClient()).session(sessionId), + ) } function userIdFor(scope: MemoryScope): string { @@ -181,7 +186,10 @@ export function honcho(options: HonchoOptions = {}): MemoryAdapter { async inspect(scope): Promise { const session = await getSession(scope.sessionId).catch(() => null) if (!session) { - return { takenAt: new Date().toISOString(), data: { error: 'failed to get session' } } + return { + takenAt: new Date().toISOString(), + data: { error: 'failed to get session' }, + } } const [messages, summaries] = await Promise.all([ timed(() => session.messages({ size: 50 })), @@ -205,7 +213,10 @@ export function honcho(options: HonchoOptions = {}): MemoryAdapter { const raw = typeof result.data === 'string' ? result.data - : String((result.data as { representation?: unknown }).representation ?? '') + : String( + (result.data as { representation?: unknown }).representation ?? + '', + ) return parseHonchoRepresentation(raw) }, } diff --git a/packages/ai-memory/src/providers/mem0/index.ts b/packages/ai-memory/src/providers/mem0/index.ts index 92b82fc52..f6aa3f1f2 100644 --- a/packages/ai-memory/src/providers/mem0/index.ts +++ b/packages/ai-memory/src/providers/mem0/index.ts @@ -36,7 +36,9 @@ type JsonResult = | { ok: false; latencyMs: number; error: string } function asRecord(value: unknown): Record | undefined { - return value && typeof value === 'object' ? (value as Record) : undefined + return value && typeof value === 'object' + ? (value as Record) + : undefined } function asString(value: unknown): string | undefined { @@ -77,7 +79,11 @@ export function mem0(options: Mem0Options = {}): MemoryAdapter { const latencyMs = Date.now() - start if (!res.ok) { const text = await res.text().catch(() => '') - return { ok: false, latencyMs, error: `HTTP ${res.status}: ${text.slice(0, 300)}` } + return { + ok: false, + latencyMs, + error: `HTTP ${res.status}: ${text.slice(0, 300)}`, + } } const data = await res.json().catch(() => null) return { ok: true, latencyMs, data } @@ -138,10 +144,12 @@ export function mem0(options: Mem0Options = {}): MemoryAdapter { if (!result.ok) { return { systemPrompt: '', fragments: [], raw: { error: result.error } } } - const fragments: Array = itemsOf(result.data).map((m) => ({ - text: asString(m.memory) ?? asString(m.text) ?? JSON.stringify(m), - source: asString(m.id) ?? 'mem0', - })) + const fragments: Array = itemsOf(result.data).map( + (m) => ({ + text: asString(m.memory) ?? asString(m.text) ?? JSON.stringify(m), + source: asString(m.id) ?? 'mem0', + }), + ) const systemPrompt = fragments.length === 0 ? '' diff --git a/packages/ai-memory/src/redis.ts b/packages/ai-memory/src/redis.ts index d9580afea..1e8f98374 100644 --- a/packages/ai-memory/src/redis.ts +++ b/packages/ai-memory/src/redis.ts @@ -5,7 +5,11 @@ import { recallRecords, saveTurn, } from './internal/store' -import type { BuiltinOptions, MemoryRecord, RecordStore } from './internal/store' +import type { + BuiltinOptions, + MemoryRecord, + RecordStore, +} from './internal/store' import type { MemoryAdapter, MemoryScope } from './types' /** @@ -74,7 +78,10 @@ function escapeScopeValue(value: string): string { const warnedMalformedIds = new Set() const MALFORMED_WARN_CAP = 100 function warnMalformedRow(id: string, err: unknown): void { - if (warnedMalformedIds.has(id) || warnedMalformedIds.size >= MALFORMED_WARN_CAP) { + if ( + warnedMalformedIds.has(id) || + warnedMalformedIds.size >= MALFORMED_WARN_CAP + ) { return } warnedMalformedIds.add(id) diff --git a/packages/ai-memory/tests/contract.ts b/packages/ai-memory/tests/contract.ts index 232582d1c..7b409e633 100644 --- a/packages/ai-memory/tests/contract.ts +++ b/packages/ai-memory/tests/contract.ts @@ -75,7 +75,9 @@ export function runMemoryAdapterContract( const facts = await adapter.listFacts(scopeA) expect(Array.isArray(facts)).toBe(true) expect( - facts.every((f) => typeof f.id === 'string' && typeof f.text === 'string'), + facts.every( + (f) => typeof f.id === 'string' && typeof f.text === 'string', + ), ).toBe(true) }) }) diff --git a/packages/ai-memory/tests/in-memory.test.ts b/packages/ai-memory/tests/in-memory.test.ts index e37fc1241..05680833e 100644 --- a/packages/ai-memory/tests/in-memory.test.ts +++ b/packages/ai-memory/tests/in-memory.test.ts @@ -7,7 +7,9 @@ runMemoryAdapterContract('inMemory', () => inMemory()) describe('inMemory options', () => { it('runs an extractor on save and surfaces extracted facts on recall', async () => { const adapter = inMemory({ - extract: (turn) => [{ text: `fact: ${turn.user}`, kind: 'fact', importance: 1 }], + extract: (turn) => [ + { text: `fact: ${turn.user}`, kind: 'fact', importance: 1 }, + ], }) const scope = { sessionId: 's1', userId: 'u1' } await adapter.save(scope, { user: 'I live in Berlin', assistant: 'ok' }) @@ -18,10 +20,13 @@ describe('inMemory options', () => { it('respects the userId dimension of scope', async () => { const adapter = inMemory() - await adapter.save({ sessionId: 's', userId: 'a' }, { - user: 'apples are red', - assistant: 'ok', - }) + await adapter.save( + { sessionId: 's', userId: 'a' }, + { + user: 'apples are red', + assistant: 'ok', + }, + ) const sameSessionOtherUser = await adapter.recall( { sessionId: 's', userId: 'b' }, 'apples', diff --git a/packages/ai-memory/tests/middleware.test.ts b/packages/ai-memory/tests/middleware.test.ts index 4e65c4d05..2f56c8288 100644 --- a/packages/ai-memory/tests/middleware.test.ts +++ b/packages/ai-memory/tests/middleware.test.ts @@ -70,7 +70,11 @@ describe('memoryMiddleware', () => { }) it('save-only role skips recall entirely', async () => { - const mw = memoryMiddleware({ adapter: fakeAdapter([]), scope, role: 'save-only' }) + const mw = memoryMiddleware({ + adapter: fakeAdapter([]), + scope, + role: 'save-only', + }) const config = makeConfig('hello') const result = await mw.onConfig?.(makeCtx(config, []), config) expect(result).toBeUndefined() @@ -96,7 +100,10 @@ describe('memoryMiddleware', () => { await Promise.all(deferred) expect(saved).toHaveLength(1) - expect(saved[0]?.turn).toEqual({ user: 'remember I like cats', assistant: 'You like cats!' }) + expect(saved[0]?.turn).toEqual({ + user: 'remember I like cats', + assistant: 'You like cats!', + }) expect(onSave).toHaveBeenCalledOnce() }) diff --git a/packages/ai-memory/tests/redis.test.ts b/packages/ai-memory/tests/redis.test.ts index 7be5a7885..8d7db191c 100644 --- a/packages/ai-memory/tests/redis.test.ts +++ b/packages/ai-memory/tests/redis.test.ts @@ -55,11 +55,17 @@ describe('redis scope-key hardening', () => { // Without escaping, { userId: 'a:b', sessionId: 'c' } and // { userId: 'a', sessionId: 'b:c' } both serialize to key `a:b:c`. - await adapter.save({ userId: 'a:b', sessionId: 'c' }, { - user: 'confidential tenant one data', - assistant: 'ok', - }) - const other = await adapter.recall({ userId: 'a', sessionId: 'b:c' }, 'confidential') + await adapter.save( + { userId: 'a:b', sessionId: 'c' }, + { + user: 'confidential tenant one data', + assistant: 'ok', + }, + ) + const other = await adapter.recall( + { userId: 'a', sessionId: 'b:c' }, + 'confidential', + ) expect(other.systemPrompt).toBe('') expect(other.fragments ?? []).toHaveLength(0) }) @@ -105,7 +111,9 @@ describe('nodeRedisAsRedisLike', () => { await wrapped.mget('k1', 'k2') await wrapped.del('d1', 'd2') - expect(calls.find((c) => c.method === 'set')).toMatchObject({ args: ['k', 'v'] }) + expect(calls.find((c) => c.method === 'set')).toMatchObject({ + args: ['k', 'v'], + }) // Variadic members forwarded as an array so node-redis' overload resolves right. expect( calls.find( @@ -115,7 +123,11 @@ describe('nodeRedisAsRedisLike', () => { (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 === 'del')).toMatchObject({ args: [['d1', 'd2']] }) + expect(calls.find((c) => c.method === 'mGet')).toMatchObject({ + args: [['k1', 'k2']], + }) + expect(calls.find((c) => c.method === 'del')).toMatchObject({ + args: [['d1', 'd2']], + }) }) }) diff --git a/testing/e2e/src/routes/api.middleware-test.ts b/testing/e2e/src/routes/api.middleware-test.ts index 250e9fe64..09ed416d7 100644 --- a/testing/e2e/src/routes/api.middleware-test.ts +++ b/testing/e2e/src/routes/api.middleware-test.ts @@ -415,7 +415,10 @@ export const Route = createFileRoute('/api/middleware-test')({ if (!testId) { return new Response( JSON.stringify({ error: 'memory mode requires testId' }), - { status: 400, headers: { 'Content-Type': 'application/json' } }, + { + status: 400, + headers: { 'Content-Type': 'application/json' }, + }, ) } resetMemoryCapture(testId) diff --git a/testing/e2e/src/routes/middleware-test.tsx b/testing/e2e/src/routes/middleware-test.tsx index 28ccfa6ab..ce36ed656 100644 --- a/testing/e2e/src/routes/middleware-test.tsx +++ b/testing/e2e/src/routes/middleware-test.tsx @@ -119,9 +119,7 @@ function MiddlewareTestPage() { void fetch( `/api/middleware-test?testId=${encodeURIComponent(testId)}&kind=memory`, ) - .then((res) => - res.ok ? res.json() : { configs: [], saveCount: 0 }, - ) + .then((res) => (res.ok ? res.json() : { configs: [], saveCount: 0 })) .then((data) => { setMemoryCapture(data) setTestComplete(true) diff --git a/testing/e2e/tests/middleware.spec.ts b/testing/e2e/tests/middleware.spec.ts index a7083fbf7..d1f146a7f 100644 --- a/testing/e2e/tests/middleware.spec.ts +++ b/testing/e2e/tests/middleware.spec.ts @@ -403,9 +403,9 @@ test.describe('Middleware Lifecycle', () => { c.systemPrompts.some((p) => p.includes('love TanStack')), ) expect(injected).toBeTruthy() - expect( - injected?.systemPrompts.some((p) => p.includes('recall_more')), - ).toBe(true) + expect(injected?.systemPrompts.some((p) => p.includes('recall_more'))).toBe( + true, + ) expect(injected?.toolNames).toContain('recall_more') // The finished turn deferred a save through the adapter. From 848c47a537a2f4cae2fc9eb3f1442db72fa91e38 Mon Sep 17 00:00:00 2001 From: Jack Herrington Date: Wed, 22 Jul 2026 10:34:18 -0700 Subject: [PATCH 41/45] refactor(memory): nest in-memory/redis under providers, add provider tests MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Move the built-in inMemory()/redis() adapters into src/providers/ so all adapters share one layout, and mirror that structure under tests/providers/. Public subpaths (@tanstack/ai-memory/in-memory, /redis) are unchanged — only internal file locations moved (package.json exports + vite entries updated). Add non-networked unit tests for the vendor providers: hindsight (fake runtime driving makeHindsightTools), mem0 (stubbed fetch), and honcho (mocked SDK + pure representation parser). Drop composeMemoryMiddleware — the recall/save contract + save-only role cover multi-backend use without a dedicated combinator. Co-Authored-By: Claude Opus 4.8 --- .changeset/memory-middleware.md | 2 +- docs/config.json | 3 +- docs/memory/overview.md | 21 --- packages/ai-memory/package.json | 8 +- packages/ai-memory/src/index.ts | 1 - packages/ai-memory/src/middleware.ts | 59 -------- .../in-memory/index.ts} | 6 +- .../{redis.ts => providers/redis/index.ts} | 6 +- .../tests/providers/hindsight.test.ts | 127 ++++++++++++++++++ .../ai-memory/tests/providers/honcho.test.ts | 99 ++++++++++++++ .../tests/{ => providers}/in-memory.test.ts | 4 +- .../ai-memory/tests/providers/mem0.test.ts | 126 +++++++++++++++++ .../tests/{ => providers}/redis.test.ts | 6 +- packages/ai-memory/vite.config.ts | 4 +- 14 files changed, 372 insertions(+), 100 deletions(-) rename packages/ai-memory/src/{in-memory.ts => providers/in-memory/index.ts} (93%) rename packages/ai-memory/src/{redis.ts => providers/redis/index.ts} (98%) create mode 100644 packages/ai-memory/tests/providers/hindsight.test.ts create mode 100644 packages/ai-memory/tests/providers/honcho.test.ts rename packages/ai-memory/tests/{ => providers}/in-memory.test.ts (90%) create mode 100644 packages/ai-memory/tests/providers/mem0.test.ts rename packages/ai-memory/tests/{ => providers}/redis.test.ts (96%) diff --git a/.changeset/memory-middleware.md b/.changeset/memory-middleware.md index 0a993124d..76a59b6b4 100644 --- a/.changeset/memory-middleware.md +++ b/.changeset/memory-middleware.md @@ -15,7 +15,7 @@ Extraction, ranking, and rendering live inside each adapter — the middleware i `@tanstack/ai-memory` (new package) — everything ships here: -- Root: `memoryMiddleware` + `composeMemoryMiddleware`, the `MemoryAdapter` contract +- Root: `memoryMiddleware`, the `MemoryAdapter` contract (`recall` / `save` / optional `inspect` / `listFacts`), and the `MemoryScope` / `MemoryTurn` / `RecallResult` / `SaveReceipt` types. - `@tanstack/ai-memory/in-memory` → `inMemory()` — zero-dependency adapter for dev, diff --git a/docs/config.json b/docs/config.json index 2400e284a..cfe555e7a 100644 --- a/docs/config.json +++ b/docs/config.json @@ -428,7 +428,8 @@ { "label": "Overview", "to": "memory/overview", - "addedAt": "2026-07-21" + "addedAt": "2026-07-21", + "updatedAt": "2026-07-22" }, { "label": "Quickstart", diff --git a/docs/memory/overview.md b/docs/memory/overview.md index fe7adac07..081ef13b3 100644 --- a/docs/memory/overview.md +++ b/docs/memory/overview.md @@ -152,27 +152,6 @@ const mw = memoryMiddleware({ See the [Adapters](./adapters) page for every adapter's own options. -## Stacking adapters - -`composeMemoryMiddleware` runs several memory middlewares as one — e.g. save to two -backends, or recall from one while saving to another: - -```ts ignore -// ignore: `scope` and `client` come from your app. -import { - memoryMiddleware, - composeMemoryMiddleware, -} from '@tanstack/ai-memory' -import { inMemory } from '@tanstack/ai-memory/in-memory' -import { redis } from '@tanstack/ai-memory/redis' - -const memory = composeMemoryMiddleware([ - memoryMiddleware({ adapter: redis({ redis: client }), scope }), - // second adapter only writes (no recall injection) - memoryMiddleware({ adapter: inMemory(), scope, role: 'save-only' }), -]) -``` - ## Devtools events The middleware emits five events on `aiEventClient` (from `@tanstack/ai-event-client`): diff --git a/packages/ai-memory/package.json b/packages/ai-memory/package.json index 9dcb8fb2a..c96b8256b 100644 --- a/packages/ai-memory/package.json +++ b/packages/ai-memory/package.json @@ -18,12 +18,12 @@ "import": "./dist/esm/index.js" }, "./in-memory": { - "types": "./dist/esm/in-memory.d.ts", - "import": "./dist/esm/in-memory.js" + "types": "./dist/esm/providers/in-memory/index.d.ts", + "import": "./dist/esm/providers/in-memory/index.js" }, "./redis": { - "types": "./dist/esm/redis.d.ts", - "import": "./dist/esm/redis.js" + "types": "./dist/esm/providers/redis/index.d.ts", + "import": "./dist/esm/providers/redis/index.js" }, "./hindsight": { "types": "./dist/esm/providers/hindsight/index.d.ts", diff --git a/packages/ai-memory/src/index.ts b/packages/ai-memory/src/index.ts index 4a724fd7d..001a90b9d 100644 --- a/packages/ai-memory/src/index.ts +++ b/packages/ai-memory/src/index.ts @@ -1,6 +1,5 @@ export { memoryMiddleware, - composeMemoryMiddleware, type MemoryMiddlewareOptions, type MemoryMiddlewareRole, type MemoryRecallInfo, diff --git a/packages/ai-memory/src/middleware.ts b/packages/ai-memory/src/middleware.ts index e9f1d874e..64946f677 100644 --- a/packages/ai-memory/src/middleware.ts +++ b/packages/ai-memory/src/middleware.ts @@ -4,7 +4,6 @@ import type { ChatMiddlewareConfig, ChatMiddlewareContext, ModelMessage, - StreamChunk, } from '@tanstack/ai' import type { MemoryAdapter, @@ -205,64 +204,6 @@ export function memoryMiddleware( } } -/** - * Compose multiple memory middlewares into one — useful for saving to (or - * recalling from) more than one backend in a single run. `onConfig` results are - * merged in order; every other hook fans out to each middleware. - */ -export function composeMemoryMiddleware( - middlewares: Array, -): ChatMiddleware { - return { - name: 'memory:compose', - - async onConfig(ctx, config) { - let current: ChatMiddlewareConfig = config - let changed = false - for (const middleware of middlewares) { - const result = await middleware.onConfig?.(ctx, current) - if (result != null) { - current = { ...current, ...result } - changed = true - } - } - return changed ? current : undefined - }, - - async onChunk(ctx, chunk) { - let chunks: Array = [chunk] - for (const middleware of middlewares) { - if (!middleware.onChunk) continue - const next: Array = [] - for (const item of chunks) { - const result = await middleware.onChunk(ctx, item) - if (result === null) continue - if (result === undefined) next.push(item) - else if (Array.isArray(result)) next.push(...result) - else next.push(result) - } - chunks = next - } - if (chunks.length === 0) return null - if (chunks.length === 1) return chunks[0] - return chunks - }, - - async onStart(ctx) { - for (const m of middlewares) await m.onStart?.(ctx) - }, - async onFinish(ctx, info) { - for (const m of middlewares) await m.onFinish?.(ctx, info) - }, - async onAbort(ctx, info) { - for (const m of middlewares) await m.onAbort?.(ctx, info) - }, - async onError(ctx, info) { - for (const m of middlewares) await m.onError?.(ctx, info) - }, - } -} - // =========================== // Internals // =========================== diff --git a/packages/ai-memory/src/in-memory.ts b/packages/ai-memory/src/providers/in-memory/index.ts similarity index 93% rename from packages/ai-memory/src/in-memory.ts rename to packages/ai-memory/src/providers/in-memory/index.ts index 348dfdcfd..e9bb743a5 100644 --- a/packages/ai-memory/src/in-memory.ts +++ b/packages/ai-memory/src/providers/in-memory/index.ts @@ -5,13 +5,13 @@ import { recallRecords, sameScope, saveTurn, -} from './internal/store' +} from '../../internal/store' import type { BuiltinOptions, MemoryRecord, RecordStore, -} from './internal/store' -import type { MemoryAdapter, MemoryScope } from './types' +} from '../../internal/store' +import type { MemoryAdapter, MemoryScope } from '../../types' /** * Options for {@link inMemory}. Retrieval/extraction knobs that used to live on diff --git a/packages/ai-memory/src/redis.ts b/packages/ai-memory/src/providers/redis/index.ts similarity index 98% rename from packages/ai-memory/src/redis.ts rename to packages/ai-memory/src/providers/redis/index.ts index 1e8f98374..4cba9b80c 100644 --- a/packages/ai-memory/src/redis.ts +++ b/packages/ai-memory/src/providers/redis/index.ts @@ -4,13 +4,13 @@ import { listRecordFacts, recallRecords, saveTurn, -} from './internal/store' +} from '../../internal/store' import type { BuiltinOptions, MemoryRecord, RecordStore, -} from './internal/store' -import type { MemoryAdapter, MemoryScope } from './types' +} from '../../internal/store' +import type { MemoryAdapter, MemoryScope } from '../../types' /** * Minimal subset of the Redis client API the adapter uses. Shaped to match diff --git a/packages/ai-memory/tests/providers/hindsight.test.ts b/packages/ai-memory/tests/providers/hindsight.test.ts new file mode 100644 index 000000000..77a9d2735 --- /dev/null +++ b/packages/ai-memory/tests/providers/hindsight.test.ts @@ -0,0 +1,127 @@ +import { describe, expect, it, vi } from 'vitest' +import { + hindsight, + makeHindsightTools, +} from '../../src/providers/hindsight/index' +import type { + HindsightClientLike, + HindsightRuntime, +} from '../../src/providers/hindsight/index' +import type { SaveReceipt } from '../../src/types' + +/** + * These tests never touch a real Hindsight server. The factory-level assertions + * check the adapter shape; the tool-level assertions drive `makeHindsightTools` + * with a fake {@link HindsightRuntime} so we can verify the retain/recall/reflect + * wiring (and the `onToolRetain`/`onToolRecall` callbacks) in isolation. + */ + +function fakeRuntime( + overrides: Partial = {}, +): HindsightRuntime { + const client: HindsightClientLike = { + retain: async () => ({ stored: true }), + recall: async () => ({ + results: [{ text: 'the user likes penguins', type: 'fact', id: 'm1' }], + }), + reflect: async () => ({ text: 'synthesized reflection' }), + listMemories: async () => ({ items: [] }), + getBankProfile: async () => ({}), + deleteBank: async () => ({}), + ...overrides, + } + return { client, recallToPrompt: (data) => JSON.stringify(data) } +} + +describe('hindsight factory', () => { + it('exposes the recall/save contract with a stable id', () => { + const adapter = hindsight({ user: 'u1' }) + expect(adapter.id).toBe('hindsight') + expect(typeof adapter.recall).toBe('function') + expect(typeof adapter.save).toBe('function') + expect(typeof adapter.inspect).toBe('function') + expect(typeof adapter.listFacts).toBe('function') + }) +}) + +describe('makeHindsightTools', () => { + const deps = () => ({ + getRuntime: async () => fakeRuntime(), + bankId: 'u1__s1', + budget: 'mid', + }) + + it('returns the three memory tools with valid input schemas', () => { + const tools = makeHindsightTools(deps()) + expect(tools.map((t) => t.name)).toEqual([ + 'hindsight_retain', + 'hindsight_recall', + 'hindsight_reflect', + ]) + for (const tool of tools) { + expect(tool.inputSchema).toMatchObject({ + type: 'object', + additionalProperties: false, + }) + } + expect(tools[0]?.inputSchema).toMatchObject({ required: ['content'] }) + expect(tools[1]?.inputSchema).toMatchObject({ required: ['query'] }) + }) + + it('retain tool stores content and fires onToolRetain', async () => { + const retain = vi.fn(async () => ({ id: 'stored-1' })) + const receipts: Array = [] + const tools = makeHindsightTools({ + getRuntime: async () => fakeRuntime({ retain }), + bankId: 'u1__s1', + budget: 'mid', + onToolRetain: (r) => receipts.push(r), + }) + const result = await tools[0]?.execute?.({ content: 'remember this' }) + expect(result).toMatchObject({ ok: true }) + expect(retain).toHaveBeenCalledWith('u1__s1', 'remember this', { + context: 'chat:tool', + timestamp: expect.any(Date), + }) + expect(receipts).toHaveLength(1) + expect(receipts[0]?.ok).toBe(true) + }) + + it('recall tool renders memories and fires onToolRecall', async () => { + const seen: Array<{ query: string }> = [] + const tools = makeHindsightTools({ + getRuntime: async () => fakeRuntime(), + bankId: 'u1__s1', + budget: 'mid', + onToolRecall: (query) => seen.push({ query }), + }) + const result = await tools[1]?.execute?.({ query: 'penguins' }) + expect(String(result)).toContain('penguins') + expect(seen).toEqual([{ query: 'penguins' }]) + }) + + it('reflect tool returns the synthesized text', async () => { + const tools = makeHindsightTools(deps()) + const result = await tools[2]?.execute?.({ query: 'what do I know?' }) + expect(result).toBe('synthesized reflection') + }) + + it('retain tool degrades to an error receipt when the client throws', async () => { + const receipts: Array = [] + const tools = makeHindsightTools({ + getRuntime: async () => + fakeRuntime({ + retain: async () => { + throw new Error('server down') + }, + }), + bankId: 'u1__s1', + budget: 'mid', + onToolRetain: (r) => receipts.push(r), + }) + const result = await tools[0]?.execute?.({ content: 'x' }) + expect(result).toMatchObject({ ok: false }) + expect(receipts[0]?.ok).toBe(false) + expect(receipts[0]?.error).toContain('server down') + }) +}) diff --git a/packages/ai-memory/tests/providers/honcho.test.ts b/packages/ai-memory/tests/providers/honcho.test.ts new file mode 100644 index 000000000..1b638b7d5 --- /dev/null +++ b/packages/ai-memory/tests/providers/honcho.test.ts @@ -0,0 +1,99 @@ +import { describe, expect, it, vi } from 'vitest' +import { + honcho, + parseHonchoRepresentation, +} from '../../src/providers/honcho/index' + +/** + * `@honcho-ai/sdk` is mocked here — no server, no socket. The mock returns + * canned peers/sessions so we can assert the adapter maps `recall`/`save` onto + * the SDK correctly. `parseHonchoRepresentation` is a pure function and needs + * no mocking at all. + */ +vi.mock('@honcho-ai/sdk', () => { + class Honcho { + constructor(_opts: unknown) {} + peer(id: string) { + return { + id, + message: (content: string) => ({ peer: id, content }), + chat: async (query: string) => `dialectic answer about: ${query}`, + representation: async () => + '[2024-05-01T10:00:00Z] user lives in Berlin\n[2024-05-02T11:00:00Z] user likes hiking', + } + } + session(id: string) { + return { + id, + addMessages: async (messages: Array) => ({ + added: messages.length, + }), + messages: async () => [], + summaries: async () => [], + } + } + } + return { Honcho } +}) + +describe('parseHonchoRepresentation', () => { + it('parses timestamped observation lines into fact rows', () => { + const facts = parseHonchoRepresentation( + '[2024-05-01T10:00:00Z] user lives in Berlin', + ) + expect(facts).toHaveLength(1) + expect(facts[0]).toMatchObject({ + text: 'user lives in Berlin', + source: 'observation', + createdAt: '2024-05-01T10:00:00Z', + }) + }) + + it('keeps plain lines and skips headers/blank lines', () => { + const facts = parseHonchoRepresentation( + ['## Explicit Observations', '', 'user prefers dark mode'].join('\n'), + ) + expect(facts.map((f) => f.text)).toEqual(['user prefers dark mode']) + expect(facts[0]?.source).toBe('representation') + }) +}) + +describe('honcho factory', () => { + it('exposes the recall/save contract with a stable id', () => { + const adapter = honcho({ user: 'u1' }) + expect(adapter.id).toBe('honcho') + expect(typeof adapter.recall).toBe('function') + expect(typeof adapter.save).toBe('function') + expect(typeof adapter.inspect).toBe('function') + expect(typeof adapter.listFacts).toBe('function') + }) + + it('save appends the turn and returns an ok receipt', async () => { + const adapter = honcho({ user: 'u1' }) + const receipts = await adapter.save( + { sessionId: 's1', userId: 'u1' }, + { user: 'I live in Berlin', assistant: 'noted' }, + ) + expect(receipts).toHaveLength(1) + expect(receipts[0]?.ok).toBe(true) + expect(receipts[0]?.raw).toMatchObject({ added: 2 }) + }) + + it('recall returns the dialectic answer as the systemPrompt', async () => { + const adapter = honcho({ user: 'u1' }) + const result = await adapter.recall( + { sessionId: 's1', userId: 'u1' }, + 'where do I live', + ) + expect(result.systemPrompt).toBe('dialectic answer about: where do I live') + }) + + it('listFacts parses the peer representation into rows', async () => { + const adapter = honcho({ user: 'u1' }) + const facts = await adapter.listFacts?.({ sessionId: 's1', userId: 'u1' }) + expect(facts?.map((f) => f.text)).toEqual([ + 'user lives in Berlin', + 'user likes hiking', + ]) + }) +}) diff --git a/packages/ai-memory/tests/in-memory.test.ts b/packages/ai-memory/tests/providers/in-memory.test.ts similarity index 90% rename from packages/ai-memory/tests/in-memory.test.ts rename to packages/ai-memory/tests/providers/in-memory.test.ts index 05680833e..d4c0a0c1e 100644 --- a/packages/ai-memory/tests/in-memory.test.ts +++ b/packages/ai-memory/tests/providers/in-memory.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from 'vitest' -import { inMemory } from '../src/in-memory' -import { runMemoryAdapterContract } from './contract' +import { inMemory } from '../../src/providers/in-memory' +import { runMemoryAdapterContract } from '../contract' runMemoryAdapterContract('inMemory', () => inMemory()) diff --git a/packages/ai-memory/tests/providers/mem0.test.ts b/packages/ai-memory/tests/providers/mem0.test.ts new file mode 100644 index 000000000..4bf938ea3 --- /dev/null +++ b/packages/ai-memory/tests/providers/mem0.test.ts @@ -0,0 +1,126 @@ +import { afterEach, describe, expect, it, vi } from 'vitest' +import { mem0 } from '../../src/providers/mem0/index' + +/** + * mem0 talks to its server over plain `fetch`, so these tests stub `fetch` and + * never open a socket. They assert the `recall`/`save` mapping onto mem0's + * `/memories` and `/search` endpoints — request shape out, contract shape back. + */ + +interface FetchCall { + url: string + method: string + body: unknown +} + +function stubFetch( + handler: (call: FetchCall) => { + ok?: boolean + status?: number + data: unknown + }, +): Array { + const calls: Array = [] + vi.stubGlobal( + 'fetch', + vi.fn(async (url: string, init?: RequestInit) => { + const body = + typeof init?.body === 'string' ? JSON.parse(init.body) : undefined + const call: FetchCall = { url, method: init?.method ?? 'GET', body } + calls.push(call) + const res = handler(call) + const ok = res.ok ?? true + return { + ok, + status: res.status ?? (ok ? 200 : 500), + json: async () => res.data, + text: async () => JSON.stringify(res.data), + } as Response + }), + ) + return calls +} + +afterEach(() => { + vi.unstubAllGlobals() +}) + +describe('mem0 factory', () => { + it('exposes the recall/save contract with a stable id', () => { + const adapter = mem0() + expect(adapter.id).toBe('mem0') + expect(typeof adapter.recall).toBe('function') + expect(typeof adapter.save).toBe('function') + expect(typeof adapter.inspect).toBe('function') + expect(typeof adapter.listFacts).toBe('function') + }) +}) + +describe('mem0 save', () => { + it('POSTs the turn to /memories and returns an ok receipt', async () => { + const calls = stubFetch(() => ({ data: { id: 'mem-1' } })) + const adapter = mem0({ baseUrl: 'http://mem0.test', user: 'u1' }) + const receipts = await adapter.save( + { sessionId: 's1', userId: 'u1' }, + { user: 'I live in Berlin', assistant: 'noted' }, + ) + expect(receipts).toHaveLength(1) + expect(receipts[0]?.ok).toBe(true) + expect(calls[0]?.url).toBe('http://mem0.test/memories') + expect(calls[0]?.method).toBe('POST') + expect(calls[0]?.body).toMatchObject({ + user_id: 'u1', + messages: [ + { role: 'user', content: 'I live in Berlin' }, + { role: 'assistant', content: 'noted' }, + ], + }) + }) +}) + +describe('mem0 recall', () => { + it('maps /search results into fragments and a rendered systemPrompt', async () => { + const calls = stubFetch(() => ({ + data: { + results: [ + { memory: 'lives in Berlin', id: 'm1' }, + { memory: 'likes hiking', id: 'm2' }, + ], + }, + })) + const adapter = mem0({ baseUrl: 'http://mem0.test', user: 'u1' }) + const result = await adapter.recall( + { sessionId: 's1', userId: 'u1' }, + 'where do I live', + ) + expect(calls[0]?.url).toBe('http://mem0.test/search') + expect(calls[0]?.body).toMatchObject({ + query: 'where do I live', + user_id: 'u1', + }) + expect(result.fragments).toHaveLength(2) + expect(result.fragments?.[0]).toMatchObject({ + text: 'lives in Berlin', + source: 'm1', + }) + expect(result.systemPrompt).toContain('lives in Berlin') + expect(result.systemPrompt).toContain('likes hiking') + }) + + it('returns an empty result when the server finds nothing', async () => { + stubFetch(() => ({ data: { results: [] } })) + const adapter = mem0({ baseUrl: 'http://mem0.test' }) + const result = await adapter.recall({ sessionId: 's1' }, 'anything') + expect(result.systemPrompt).toBe('') + expect(result.fragments).toHaveLength(0) + }) + + it('degrades to an empty result on an HTTP error', async () => { + stubFetch(() => ({ ok: false, status: 500, data: 'boom' })) + const adapter = mem0({ baseUrl: 'http://mem0.test' }) + const result = await adapter.recall({ sessionId: 's1' }, 'anything') + expect(result.systemPrompt).toBe('') + expect(result.fragments).toHaveLength(0) + expect(result.raw).toBeDefined() + }) +}) diff --git a/packages/ai-memory/tests/redis.test.ts b/packages/ai-memory/tests/providers/redis.test.ts similarity index 96% rename from packages/ai-memory/tests/redis.test.ts rename to packages/ai-memory/tests/providers/redis.test.ts index 8d7db191c..3ec52b8c0 100644 --- a/packages/ai-memory/tests/redis.test.ts +++ b/packages/ai-memory/tests/providers/redis.test.ts @@ -2,9 +2,9 @@ // the lowercase RedisLike subset ioredis-mock implements (cast below). import RedisMock from 'ioredis-mock' import { describe, expect, it, vi } from 'vitest' -import { nodeRedisAsRedisLike, redis } from '../src/redis' -import type { RedisLike } from '../src/redis' -import { runMemoryAdapterContract } from './contract' +import { nodeRedisAsRedisLike, redis } from '../../src/providers/redis' +import type { RedisLike } from '../../src/providers/redis' +import { runMemoryAdapterContract } from '../contract' function mockClient(): RedisLike { return new RedisMock() as unknown as RedisLike diff --git a/packages/ai-memory/vite.config.ts b/packages/ai-memory/vite.config.ts index 1e9778958..2ecd6238e 100644 --- a/packages/ai-memory/vite.config.ts +++ b/packages/ai-memory/vite.config.ts @@ -30,8 +30,8 @@ export default mergeConfig( tanstackViteConfig({ entry: [ './src/index.ts', - './src/in-memory.ts', - './src/redis.ts', + './src/providers/in-memory/index.ts', + './src/providers/redis/index.ts', './src/providers/hindsight/index.ts', './src/providers/mem0/index.ts', './src/providers/honcho/index.ts', From 61748438b30162abf64faf0fb9b1dc63bedec132 Mon Sep 17 00:00:00 2001 From: Jack Herrington Date: Wed, 22 Jul 2026 11:20:38 -0700 Subject: [PATCH 42/45] feat(panel): add in-memory Memory demo page MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds a `/memory` route to testing/panel that wires memoryMiddleware with the in-memory adapter into a chat and shows what's stored, letting you watch recall/save work end-to-end. - Shared inMemory() singleton (src/lib/memory-store.ts) so the chat route (writes via middleware) and the inspect route (reads via inspect/listFacts) hit the same process-local store. - api.memory-chat.ts: chat with memoryMiddleware, scope keyed on a client sessionId; onRecall records the injected recall for display. - api.memory-inspect.ts: GET returning { snapshot, facts, lastRecall }. - memory.tsx: two-pane UI — chat on the left, live memory inspector on the right (last recalled prompt, stored records, listFacts), sessionId persisted in localStorage with New session / Refresh controls. - Header nav link. Co-Authored-By: Claude Opus 4.8 --- pnpm-lock.yaml | 3 + testing/panel/package.json | 1 + testing/panel/src/components/Header.tsx | 19 + testing/panel/src/lib/memory-store.ts | 24 ++ testing/panel/src/routeTree.gen.ts | 63 ++++ testing/panel/src/routes/api.memory-chat.ts | 121 +++++++ .../panel/src/routes/api.memory-inspect.ts | 36 ++ testing/panel/src/routes/memory.tsx | 337 ++++++++++++++++++ 8 files changed, 604 insertions(+) create mode 100644 testing/panel/src/lib/memory-store.ts create mode 100644 testing/panel/src/routes/api.memory-chat.ts create mode 100644 testing/panel/src/routes/api.memory-inspect.ts create mode 100644 testing/panel/src/routes/memory.tsx diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 897b27ded..38ae6d59c 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -2730,6 +2730,9 @@ importers: '@tanstack/ai-grok': specifier: workspace:* version: link:../../packages/ai-grok + '@tanstack/ai-memory': + specifier: workspace:* + version: link:../../packages/ai-memory '@tanstack/ai-ollama': specifier: workspace:* version: link:../../packages/ai-ollama diff --git a/testing/panel/package.json b/testing/panel/package.json index d33648586..2be191842 100644 --- a/testing/panel/package.json +++ b/testing/panel/package.json @@ -18,6 +18,7 @@ "@tanstack/ai-event-client": "workspace:*", "@tanstack/ai-gemini": "workspace:*", "@tanstack/ai-grok": "workspace:*", + "@tanstack/ai-memory": "workspace:*", "@tanstack/ai-ollama": "workspace:*", "@tanstack/ai-openai": "workspace:*", "@tanstack/ai-openrouter": "workspace:*", diff --git a/testing/panel/src/components/Header.tsx b/testing/panel/src/components/Header.tsx index 21e94f96f..b7711d91a 100644 --- a/testing/panel/src/components/Header.tsx +++ b/testing/panel/src/components/Header.tsx @@ -3,6 +3,7 @@ import { Link } from '@tanstack/react-router' import { useState } from 'react' import { Beaker, + BrainCircuit, ChefHat, FileText, FlaskConical, @@ -120,6 +121,24 @@ export default function Header() {
+ setIsOpen(false)} + className="flex items-center gap-3 p-3 rounded-lg hover:bg-gray-800 transition-colors mb-2" + activeProps={{ + className: + 'flex items-center gap-3 p-3 rounded-lg bg-cyan-600 hover:bg-cyan-700 transition-colors mb-2', + }} + > + +
+ Memory + + recall/save + +
+ +

Activities diff --git a/testing/panel/src/lib/memory-store.ts b/testing/panel/src/lib/memory-store.ts new file mode 100644 index 000000000..337294765 --- /dev/null +++ b/testing/panel/src/lib/memory-store.ts @@ -0,0 +1,24 @@ +import { inMemory } from '@tanstack/ai-memory/in-memory' +import type { RecallResult } from '@tanstack/ai-memory' + +/** + * Process-local memory backing the `/memory` demo page. + * + * `inMemory()` stores everything in an in-process `Map`, so the chat route + * (which writes via `memoryMiddleware`) and the inspect route (which reads via + * `inspect`/`listFacts`) MUST share this exact singleton — a second + * `inMemory()` call would have its own, empty store. + * + * Defaults are deliberate: no `embedder`/`extract`, so `save` just stores the + * raw user/assistant turn (kind `message`). That keeps the demo zero-dep and + * makes the stored content legible in the panel. To demo derived facts or + * semantic recall, pass `{ extract, embedder }` to `inMemory()` here. + */ +export const memoryAdapter = inMemory() + +/** + * Records what the last `recall` injected for each session, so the page can + * show "what memory fed into this turn". Populated from the middleware's + * `onRecall` callback in the chat route; read by the inspect route. + */ +export const lastRecallBySession = new Map() diff --git a/testing/panel/src/routeTree.gen.ts b/testing/panel/src/routeTree.gen.ts index 837280778..c9ce6fdaf 100644 --- a/testing/panel/src/routeTree.gen.ts +++ b/testing/panel/src/routeTree.gen.ts @@ -16,6 +16,7 @@ import { Route as SummarizeRouteImport } from './routes/summarize' import { Route as StructuredRouteImport } from './routes/structured' import { Route as StreamDebuggerRouteImport } from './routes/stream-debugger' import { Route as SimulatorRouteImport } from './routes/simulator' +import { Route as MemoryRouteImport } from './routes/memory' import { Route as ImageRouteImport } from './routes/image' import { Route as AddonManagerRouteImport } from './routes/addon-manager' import { Route as IndexRouteImport } from './routes/index' @@ -25,6 +26,8 @@ import { Route as ApiTranscriptionRouteImport } from './routes/api.transcription import { Route as ApiSummarizeRouteImport } from './routes/api.summarize' import { Route as ApiStructuredRouteImport } from './routes/api.structured' import { Route as ApiSimulatorChatRouteImport } from './routes/api.simulator-chat' +import { Route as ApiMemoryInspectRouteImport } from './routes/api.memory-inspect' +import { Route as ApiMemoryChatRouteImport } from './routes/api.memory-chat' import { Route as ApiLoadTraceRouteImport } from './routes/api.load-trace' import { Route as ApiListTracesRouteImport } from './routes/api.list-traces' import { Route as ApiImageRouteImport } from './routes/api.image' @@ -66,6 +69,11 @@ const SimulatorRoute = SimulatorRouteImport.update({ path: '/simulator', getParentRoute: () => rootRouteImport, } as any) +const MemoryRoute = MemoryRouteImport.update({ + id: '/memory', + path: '/memory', + getParentRoute: () => rootRouteImport, +} as any) const ImageRoute = ImageRouteImport.update({ id: '/image', path: '/image', @@ -111,6 +119,16 @@ const ApiSimulatorChatRoute = ApiSimulatorChatRouteImport.update({ path: '/api/simulator-chat', getParentRoute: () => rootRouteImport, } as any) +const ApiMemoryInspectRoute = ApiMemoryInspectRouteImport.update({ + id: '/api/memory-inspect', + path: '/api/memory-inspect', + getParentRoute: () => rootRouteImport, +} as any) +const ApiMemoryChatRoute = ApiMemoryChatRouteImport.update({ + id: '/api/memory-chat', + path: '/api/memory-chat', + getParentRoute: () => rootRouteImport, +} as any) const ApiLoadTraceRoute = ApiLoadTraceRouteImport.update({ id: '/api/load-trace', path: '/api/load-trace', @@ -141,6 +159,7 @@ export interface FileRoutesByFullPath { '/': typeof IndexRoute '/addon-manager': typeof AddonManagerRoute '/image': typeof ImageRoute + '/memory': typeof MemoryRoute '/simulator': typeof SimulatorRoute '/stream-debugger': typeof StreamDebuggerRoute '/structured': typeof StructuredRoute @@ -153,6 +172,8 @@ export interface FileRoutesByFullPath { '/api/image': typeof ApiImageRoute '/api/list-traces': typeof ApiListTracesRoute '/api/load-trace': typeof ApiLoadTraceRoute + '/api/memory-chat': typeof ApiMemoryChatRoute + '/api/memory-inspect': typeof ApiMemoryInspectRoute '/api/simulator-chat': typeof ApiSimulatorChatRoute '/api/structured': typeof ApiStructuredRoute '/api/summarize': typeof ApiSummarizeRoute @@ -164,6 +185,7 @@ export interface FileRoutesByTo { '/': typeof IndexRoute '/addon-manager': typeof AddonManagerRoute '/image': typeof ImageRoute + '/memory': typeof MemoryRoute '/simulator': typeof SimulatorRoute '/stream-debugger': typeof StreamDebuggerRoute '/structured': typeof StructuredRoute @@ -176,6 +198,8 @@ export interface FileRoutesByTo { '/api/image': typeof ApiImageRoute '/api/list-traces': typeof ApiListTracesRoute '/api/load-trace': typeof ApiLoadTraceRoute + '/api/memory-chat': typeof ApiMemoryChatRoute + '/api/memory-inspect': typeof ApiMemoryInspectRoute '/api/simulator-chat': typeof ApiSimulatorChatRoute '/api/structured': typeof ApiStructuredRoute '/api/summarize': typeof ApiSummarizeRoute @@ -188,6 +212,7 @@ export interface FileRoutesById { '/': typeof IndexRoute '/addon-manager': typeof AddonManagerRoute '/image': typeof ImageRoute + '/memory': typeof MemoryRoute '/simulator': typeof SimulatorRoute '/stream-debugger': typeof StreamDebuggerRoute '/structured': typeof StructuredRoute @@ -200,6 +225,8 @@ export interface FileRoutesById { '/api/image': typeof ApiImageRoute '/api/list-traces': typeof ApiListTracesRoute '/api/load-trace': typeof ApiLoadTraceRoute + '/api/memory-chat': typeof ApiMemoryChatRoute + '/api/memory-inspect': typeof ApiMemoryInspectRoute '/api/simulator-chat': typeof ApiSimulatorChatRoute '/api/structured': typeof ApiStructuredRoute '/api/summarize': typeof ApiSummarizeRoute @@ -213,6 +240,7 @@ export interface FileRouteTypes { | '/' | '/addon-manager' | '/image' + | '/memory' | '/simulator' | '/stream-debugger' | '/structured' @@ -225,6 +253,8 @@ export interface FileRouteTypes { | '/api/image' | '/api/list-traces' | '/api/load-trace' + | '/api/memory-chat' + | '/api/memory-inspect' | '/api/simulator-chat' | '/api/structured' | '/api/summarize' @@ -236,6 +266,7 @@ export interface FileRouteTypes { | '/' | '/addon-manager' | '/image' + | '/memory' | '/simulator' | '/stream-debugger' | '/structured' @@ -248,6 +279,8 @@ export interface FileRouteTypes { | '/api/image' | '/api/list-traces' | '/api/load-trace' + | '/api/memory-chat' + | '/api/memory-inspect' | '/api/simulator-chat' | '/api/structured' | '/api/summarize' @@ -259,6 +292,7 @@ export interface FileRouteTypes { | '/' | '/addon-manager' | '/image' + | '/memory' | '/simulator' | '/stream-debugger' | '/structured' @@ -271,6 +305,8 @@ export interface FileRouteTypes { | '/api/image' | '/api/list-traces' | '/api/load-trace' + | '/api/memory-chat' + | '/api/memory-inspect' | '/api/simulator-chat' | '/api/structured' | '/api/summarize' @@ -283,6 +319,7 @@ export interface RootRouteChildren { IndexRoute: typeof IndexRoute AddonManagerRoute: typeof AddonManagerRoute ImageRoute: typeof ImageRoute + MemoryRoute: typeof MemoryRoute SimulatorRoute: typeof SimulatorRoute StreamDebuggerRoute: typeof StreamDebuggerRoute StructuredRoute: typeof StructuredRoute @@ -295,6 +332,8 @@ export interface RootRouteChildren { ApiImageRoute: typeof ApiImageRoute ApiListTracesRoute: typeof ApiListTracesRoute ApiLoadTraceRoute: typeof ApiLoadTraceRoute + ApiMemoryChatRoute: typeof ApiMemoryChatRoute + ApiMemoryInspectRoute: typeof ApiMemoryInspectRoute ApiSimulatorChatRoute: typeof ApiSimulatorChatRoute ApiStructuredRoute: typeof ApiStructuredRoute ApiSummarizeRoute: typeof ApiSummarizeRoute @@ -354,6 +393,13 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof SimulatorRouteImport parentRoute: typeof rootRouteImport } + '/memory': { + id: '/memory' + path: '/memory' + fullPath: '/memory' + preLoaderRoute: typeof MemoryRouteImport + parentRoute: typeof rootRouteImport + } '/image': { id: '/image' path: '/image' @@ -417,6 +463,20 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof ApiSimulatorChatRouteImport parentRoute: typeof rootRouteImport } + '/api/memory-inspect': { + id: '/api/memory-inspect' + path: '/api/memory-inspect' + fullPath: '/api/memory-inspect' + preLoaderRoute: typeof ApiMemoryInspectRouteImport + parentRoute: typeof rootRouteImport + } + '/api/memory-chat': { + id: '/api/memory-chat' + path: '/api/memory-chat' + fullPath: '/api/memory-chat' + preLoaderRoute: typeof ApiMemoryChatRouteImport + parentRoute: typeof rootRouteImport + } '/api/load-trace': { id: '/api/load-trace' path: '/api/load-trace' @@ -459,6 +519,7 @@ const rootRouteChildren: RootRouteChildren = { IndexRoute: IndexRoute, AddonManagerRoute: AddonManagerRoute, ImageRoute: ImageRoute, + MemoryRoute: MemoryRoute, SimulatorRoute: SimulatorRoute, StreamDebuggerRoute: StreamDebuggerRoute, StructuredRoute: StructuredRoute, @@ -471,6 +532,8 @@ const rootRouteChildren: RootRouteChildren = { ApiImageRoute: ApiImageRoute, ApiListTracesRoute: ApiListTracesRoute, ApiLoadTraceRoute: ApiLoadTraceRoute, + ApiMemoryChatRoute: ApiMemoryChatRoute, + ApiMemoryInspectRoute: ApiMemoryInspectRoute, ApiSimulatorChatRoute: ApiSimulatorChatRoute, ApiStructuredRoute: ApiStructuredRoute, ApiSummarizeRoute: ApiSummarizeRoute, diff --git a/testing/panel/src/routes/api.memory-chat.ts b/testing/panel/src/routes/api.memory-chat.ts new file mode 100644 index 000000000..be1146576 --- /dev/null +++ b/testing/panel/src/routes/api.memory-chat.ts @@ -0,0 +1,121 @@ +import { createFileRoute } from '@tanstack/react-router' +import { + chat, + createChatOptions, + maxIterations, + toServerSentEventsResponse, +} from '@tanstack/ai' +import { memoryMiddleware } from '@tanstack/ai-memory' +import { anthropicText } from '@tanstack/ai-anthropic' +import { geminiText } from '@tanstack/ai-gemini' +import { grokText } from '@tanstack/ai-grok' +import { openaiText } from '@tanstack/ai-openai' +import { ollamaText } from '@tanstack/ai-ollama' +import { openRouterText } from '@tanstack/ai-openrouter' +import { lastRecallBySession, memoryAdapter } from '@/lib/memory-store' +import type { Provider } from '@/lib/model-selection' + +const SYSTEM_PROMPT = `You are a helpful, friendly assistant with long-term memory. + +Earlier facts the user shared may be injected into your system prompt under a +"memory" heading. When they are, use them to answer — for example, if the user +tells you their name in one turn and asks for it in a later turn, recall it from +memory rather than saying you don't know.` + +/** + * Chat endpoint for the `/memory` demo. Identical in spirit to `/api/chat`, + * minus the guitar tools and trace recording, plus a `memoryMiddleware` wired + * to the shared {@link memoryAdapter} singleton so recall/save persist across + * requests. The middleware is built per request with a static scope derived + * from the client-supplied `sessionId`. + */ +export const Route = createFileRoute('/api/memory-chat')({ + server: { + handlers: { + POST: async ({ request }) => { + const requestSignal = request.signal + if (requestSignal.aborted) { + return new Response(null, { status: 499 }) + } + + const abortController = new AbortController() + const body = await request.json() + const messages = body.messages + const data = body.data || {} + + const provider: Provider = data.provider || 'openai' + const model: string | undefined = data.model + const sessionId: string = data.sessionId || 'panel-default-session' + + try { + const adapterConfig = { + anthropic: () => + createChatOptions({ + adapter: anthropicText((model || 'claude-sonnet-4-5') as any), + }), + gemini: () => + createChatOptions({ + adapter: geminiText((model || 'gemini-2.5-flash') as any), + }), + grok: () => + createChatOptions({ + adapter: grokText((model || 'grok-build-0.1') as any), + }), + ollama: () => + createChatOptions({ + adapter: ollamaText((model || 'mistral:7b') as any), + }), + openai: () => + createChatOptions({ + adapter: openaiText((model || 'gpt-4o') as any), + }), + openrouter: () => + createChatOptions({ + adapter: openRouterText((model || 'openai/gpt-4o') as any), + }), + } + + const options = adapterConfig[provider]() + const { adapter } = options + + console.log( + `>> memory chat: model ${model} on ${provider} (session ${sessionId})`, + ) + + const memory = memoryMiddleware({ + adapter: memoryAdapter, + scope: { sessionId }, + onRecall: (info) => { + lastRecallBySession.set(sessionId, info.result) + }, + }) + + const stream = chat({ + ...options, + adapter, + tools: [], + systemPrompts: [SYSTEM_PROMPT], + middleware: [memory], + agentLoopStrategy: maxIterations(5), + messages, + abortController, + }) + + return toServerSentEventsResponse(stream, { abortController }) + } catch (error: any) { + console.error('[api.memory-chat] Error:', error?.message) + if (error.name === 'AbortError' || abortController.signal.aborted) { + return new Response(null, { status: 499 }) + } + return new Response( + JSON.stringify({ error: error.message || 'An error occurred' }), + { + status: 500, + headers: { 'Content-Type': 'application/json' }, + }, + ) + } + }, + }, + }, +}) diff --git a/testing/panel/src/routes/api.memory-inspect.ts b/testing/panel/src/routes/api.memory-inspect.ts new file mode 100644 index 000000000..9a859487b --- /dev/null +++ b/testing/panel/src/routes/api.memory-inspect.ts @@ -0,0 +1,36 @@ +import { createFileRoute } from '@tanstack/react-router' +import { lastRecallBySession, memoryAdapter } from '@/lib/memory-store' + +/** + * Read side of the `/memory` demo. Returns everything the panel needs to show + * "what's in memory" for a session, straight off the shared singleton adapter: + * the full record snapshot, the flat fact list, and what the most recent + * `recall` injected into the prompt. + */ +export const Route = createFileRoute('/api/memory-inspect')({ + server: { + handlers: { + GET: async ({ request }) => { + const sessionId = + new URL(request.url).searchParams.get('sessionId') ?? '' + const scope = { sessionId } + + const snapshot = await memoryAdapter.inspect?.(scope) + const facts = await memoryAdapter.listFacts?.(scope) + const lastRecall = lastRecallBySession.get(sessionId) ?? null + + return new Response( + JSON.stringify({ + snapshot: snapshot ?? null, + facts: facts ?? [], + lastRecall, + }), + { + status: 200, + headers: { 'Content-Type': 'application/json' }, + }, + ) + }, + }, + }, +}) diff --git a/testing/panel/src/routes/memory.tsx b/testing/panel/src/routes/memory.tsx new file mode 100644 index 000000000..f7e3be7f0 --- /dev/null +++ b/testing/panel/src/routes/memory.tsx @@ -0,0 +1,337 @@ +import { useCallback, useEffect, useMemo, useRef, useState } from 'react' +import { createFileRoute } from '@tanstack/react-router' +import { RefreshCw, RotateCcw, Send } from 'lucide-react' +import { fetchServerSentEvents, useChat } from '@tanstack/ai-react' +import type { UIMessage } from '@tanstack/ai-react' +import { MODEL_OPTIONS, getDefaultModelOption } from '@/lib/model-selection' +import type { ModelOption } from '@/lib/model-selection' + +const SESSION_STORAGE_KEY = 'panel-memory-session' + +// Shapes returned by /api/memory-inspect. These mirror the `inMemory()` +// snapshot payload + the RecallResult contract; kept local so the page has no +// build-time dependency on server internals. +interface RecordRow { + id: string + text: string + kind: string + role?: 'user' | 'assistant' + createdAt: number + importance?: number +} +interface FactRow { + id: string + text: string + source?: string + createdAt?: string +} +interface Fragment { + text: string + source: string +} +interface LastRecall { + systemPrompt: string + fragments?: Array + toolGuidance?: string +} +interface InspectResponse { + snapshot: { takenAt: string; data: { records?: Array } } | null + facts: Array + lastRecall: LastRecall | null +} + +function getMessageText(parts: UIMessage['parts']): string { + return parts + .filter((part) => part.type === 'text' && 'content' in part && part.content) + .map((part) => (part as { type: 'text'; content: string }).content) + .join('') +} + +function formatTime(value: number | string | undefined): string { + if (value === undefined) return '' + const date = new Date(value) + if (Number.isNaN(date.getTime())) return '' + return date.toLocaleTimeString() +} + +function MemoryPage() { + const [selectedModel, setSelectedModel] = useState( + getDefaultModelOption(), + ) + const [sessionId, setSessionId] = useState('') + const [inspect, setInspect] = useState(null) + const [input, setInput] = useState('') + + // Resolve (or create) a stable session id, persisted so memory survives reloads. + useEffect(() => { + let existing = localStorage.getItem(SESSION_STORAGE_KEY) + if (!existing) { + existing = crypto.randomUUID() + localStorage.setItem(SESSION_STORAGE_KEY, existing) + } + setSessionId(existing) + }, []) + + const body = useMemo( + () => ({ + provider: selectedModel.provider, + model: selectedModel.model, + sessionId, + }), + [selectedModel.provider, selectedModel.model, sessionId], + ) + + const { messages, sendMessage, isLoading } = useChat({ + connection: fetchServerSentEvents('/api/memory-chat'), + body, + }) + + const refreshInspect = useCallback(async () => { + if (!sessionId) return + try { + const res = await fetch( + `/api/memory-inspect?sessionId=${encodeURIComponent(sessionId)}`, + ) + if (res.ok) setInspect(await res.json()) + } catch { + // Non-fatal: the inspector is a read-only view; leave the last snapshot. + } + }, [sessionId]) + + // Refresh the inspector whenever the session changes and each time a turn + // finishes (isLoading falls back to false). + const wasLoading = useRef(false) + useEffect(() => { + if (wasLoading.current && !isLoading) refreshInspect() + wasLoading.current = isLoading + }, [isLoading, refreshInspect]) + useEffect(() => { + refreshInspect() + }, [refreshInspect]) + + const startNewSession = () => { + const next = crypto.randomUUID() + localStorage.setItem(SESSION_STORAGE_KEY, next) + setSessionId(next) + setInspect(null) + } + + const submit = () => { + const text = input.trim() + if (!text || isLoading) return + sendMessage(text) + setInput('') + } + + const records = inspect?.snapshot?.data.records ?? [] + const facts = inspect?.facts ?? [] + const lastRecall = inspect?.lastRecall ?? null + + return ( +

+ {/* Left: chat */} +
+
+ + +
+ +
+ {messages.length === 0 ? ( +

+ Say something like "My name is Jack and I love guitars", then in a + later turn ask "What's my name?" — the answer comes from memory. +

+ ) : ( + messages.map(({ id, role, parts }) => ( +
+
+ {getMessageText(parts)} +
+
+ )) + )} +
+ +
+
+ setInput(e.target.value)} + onKeyDown={(e) => { + if (e.key === 'Enter' && !e.shiftKey) { + e.preventDefault() + submit() + } + }} + placeholder="Type a message…" + disabled={isLoading} + className="flex-1 rounded-lg border border-cyan-500/20 bg-gray-800 px-3 py-2 text-sm text-white focus:outline-none focus:ring-2 focus:ring-cyan-500/50 disabled:opacity-50" + /> + +
+
+
+ + {/* Right: memory inspector */} +
+
+
+

What's in memory

+

+ session: {sessionId ? sessionId.slice(0, 8) : '…'} +

+
+
+ + +
+
+ +
+ {/* Last recalled */} +
+

+ Last recalled (injected into the prompt) +

+ {lastRecall && lastRecall.systemPrompt ? ( +
+
+                  {lastRecall.systemPrompt}
+                
+ {lastRecall.fragments && lastRecall.fragments.length > 0 && ( +
    + {lastRecall.fragments.map((frag, i) => ( +
  • + {frag.source}:{' '} + {frag.text} +
  • + ))} +
+ )} +
+ ) : ( +

+ Nothing recalled yet — send a follow-up question that relates to + an earlier message. +

+ )} +
+ + {/* Records */} +
+

+ Stored records ({records.length}) +

+ {records.length === 0 ? ( +

+ No memories yet — send a message to store the first turn. +

+ ) : ( +
    + {records.map((rec) => ( +
  • +
    + + {rec.kind} + + {rec.role && {rec.role}} + {rec.importance !== undefined && ( + importance {rec.importance.toFixed(2)} + )} + + {formatTime(rec.createdAt)} + +
    +

    {rec.text}

    +
  • + ))} +
+ )} +
+ + {/* Facts */} +
+

+ listFacts() ({facts.length}) +

+ {facts.length === 0 ? ( +

No facts.

+ ) : ( +
    + {facts.map((fact) => ( +
  • + {fact.source && ( + {fact.source}: + )} + {fact.text} +
  • + ))} +
+ )} +
+
+
+
+ ) +} + +export const Route = createFileRoute('/memory')({ + component: MemoryPage, +}) From 2fbd8137bf480bfa929d917f3ca39670084942e0 Mon Sep 17 00:00:00 2001 From: Jack Herrington Date: Wed, 22 Jul 2026 16:34:19 -0700 Subject: [PATCH 43/45] feat(devtools): surface memory state in the AI DevTools MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add a Memory tab to the TanStack AI DevTools for chat hooks wired with `memoryMiddleware`. Per session scope it shows an operations timeline (each turn's recall — query, fragment count, injected prompt size, tools, duration) and the current stored records/facts when the adapter implements `inspect`/`listFacts`. The tab is chat-only (hidden for generation hooks). Server-side memory never reaches the browser event bus, so the middleware transports its state over the chat stream as a `memory:state` CUSTOM event (recall metrics + a start-of-turn snapshot). The chat client routes that event through its `onCustomEvent` handler — the designated path for CUSTOM stream events — to a first-class `ClientDevtoolsBridge.recordMemoryState`, which re-emits the browser `memory:*` events (mirroring how generation results reach the panel). The bridge caches the last state and replays it on `devtools:request-state`, so opening the panel mid-conversation isn't empty. - ai-event-client: add the `memory:snapshot` event. - ai-memory: inject the `memory:state` CUSTOM chunk from `onChunk`; export `MEMORY_STATE_EVENT` / `MemoryStateEventValue`. - ai-client: `recordMemoryState` bridge method (+ no-op parity), wired from `onCustomEvent`; replay on request-state. - ai-devtools-core: Memory tab + per-scope memory store slice. - testing/panel: mount `` so the panel exposes the DevTools (and the /memory demo hook is named). - e2e: devtools-memory route + spec covering the full browser flow. Co-Authored-By: Claude Opus 4.8 --- .changeset/devtools-memory-inspector.md | 29 ++ docs/config.json | 17 +- docs/getting-started/devtools.md | 10 + packages/ai-client/src/chat-client.ts | 7 + packages/ai-client/src/devtools-noop.ts | 1 + packages/ai-client/src/devtools.ts | 114 ++++++- packages/ai-client/tests/devtools.test.ts | 93 ++++++ .../src/components/hooks/HookDetails.tsx | 21 +- .../src/components/hooks/MemoryPanel.tsx | 290 ++++++++++++++++++ .../ai-devtools/src/components/hooks/index.ts | 1 + packages/ai-devtools/src/store/ai-context.tsx | 61 ++++ .../ai-devtools/src/store/memory-registry.ts | 186 +++++++++++ packages/ai-devtools/src/styles/use-styles.ts | 112 +++++++ .../ai-devtools/tests/memory-registry.test.ts | 139 +++++++++ packages/ai-event-client/src/index.ts | 27 ++ packages/ai-memory/src/index.ts | 2 + packages/ai-memory/src/middleware.ts | 121 +++++++- packages/ai-memory/tests/middleware.test.ts | 113 ++++++- pnpm-lock.yaml | 6 + testing/e2e/src/lib/devtools-memory-store.ts | 9 + testing/e2e/src/routeTree.gen.ts | 42 +++ testing/e2e/src/routes/api.devtools-memory.ts | 94 ++++++ testing/e2e/src/routes/devtools-memory.tsx | 43 +++ testing/e2e/tests/devtools-memory.spec.ts | 49 +++ testing/panel/package.json | 2 + testing/panel/src/routes/__root.tsx | 7 + testing/panel/src/routes/memory.tsx | 1 + 27 files changed, 1586 insertions(+), 11 deletions(-) create mode 100644 .changeset/devtools-memory-inspector.md create mode 100644 packages/ai-devtools/src/components/hooks/MemoryPanel.tsx create mode 100644 packages/ai-devtools/src/store/memory-registry.ts create mode 100644 packages/ai-devtools/tests/memory-registry.test.ts create mode 100644 testing/e2e/src/lib/devtools-memory-store.ts create mode 100644 testing/e2e/src/routes/api.devtools-memory.ts create mode 100644 testing/e2e/src/routes/devtools-memory.tsx create mode 100644 testing/e2e/tests/devtools-memory.spec.ts diff --git a/.changeset/devtools-memory-inspector.md b/.changeset/devtools-memory-inspector.md new file mode 100644 index 000000000..5fd1e33aa --- /dev/null +++ b/.changeset/devtools-memory-inspector.md @@ -0,0 +1,29 @@ +--- +'@tanstack/ai-memory': minor +'@tanstack/ai-event-client': minor +'@tanstack/ai-client': minor +'@tanstack/ai-devtools-core': minor +--- + +**Surface server-side memory state in the TanStack AI DevTools.** + +The DevTools panel now has a **Memory** tab for any chat wired with +`memoryMiddleware`. It shows, per scope (session), an operations timeline (each +turn's recall — query, fragment count, injected system-prompt size, whether +memory tools were exposed, duration) and the current stored records/facts when +the adapter implements the optional `inspect`/`listFacts` methods. + +Because memory runs on the server (whose event bus never reaches the browser), +the middleware transports its state to the panel over the chat stream as a +`memory:state` `CUSTOM` event, which `@tanstack/ai-client`'s devtools bridge +re-emits as browser `memory:*` events — the same pattern generation results use. +The snapshot reflects memory as of the start of each turn; opening the panel +mid-conversation replays the latest state so the tab isn't empty. + +- `@tanstack/ai-memory` — `memoryMiddleware` injects a `memory:state` `CUSTOM` + chunk carrying recall metrics + an `inspect`/`listFacts` snapshot; exports + `MEMORY_STATE_EVENT` and `MemoryStateEventValue`. +- `@tanstack/ai-event-client` — adds the `memory:snapshot` devtools event. +- `@tanstack/ai-client` — the chat devtools bridge re-emits `memory:*` from the + transported chunk and replays the last snapshot on `devtools:request-state`. +- `@tanstack/ai-devtools-core` — new Memory tab + per-scope memory store slice. diff --git a/docs/config.json b/docs/config.json index cfe555e7a..b28a4d2f9 100644 --- a/docs/config.json +++ b/docs/config.json @@ -27,7 +27,8 @@ { "label": "Devtools", "to": "getting-started/devtools", - "addedAt": "2026-04-15" + "addedAt": "2026-04-15", + "updatedAt": "2026-07-22" }, { "label": "Quick Start: Vue", @@ -434,17 +435,25 @@ { "label": "Quickstart", "to": "memory/quickstart", - "addedAt": "2026-07-21" + "addedAt": "2026-07-21", + "updatedAt": "2026-07-22" }, { "label": "Adapters", "to": "memory/adapters", - "addedAt": "2026-07-21" + "addedAt": "2026-07-21", + "updatedAt": "2026-07-22" }, { "label": "Custom Adapter", "to": "memory/custom-adapter", - "addedAt": "2026-07-21" + "addedAt": "2026-07-21", + "updatedAt": "2026-07-22" + }, + { + "label": "Operating", + "to": "memory/operating", + "addedAt": "2026-07-22" } ] }, diff --git a/docs/getting-started/devtools.md b/docs/getting-started/devtools.md index 259363da6..6569c087b 100644 --- a/docs/getting-started/devtools.md +++ b/docs/getting-started/devtools.md @@ -22,6 +22,7 @@ TanStack Devtools is a unified devtools panel for inspecting and debugging TanSt - **Tool Call Inspection** - Inspect input and output of tool calls. - **Tool Fixture Replay** - Build tool payloads from a tool's standard-schema input, append the result into chat messages, and save fixtures in localStorage for repeated UI iteration. - **State Visualization** - Visualize chat state and message history. +- **Memory Inspector** - For chats wired with `memoryMiddleware`, see what memory recalled and injected each turn plus the current stored records and facts. - **Error Tracking** - Monitor errors and exceptions in AI interactions. ## Hook Dashboard @@ -74,6 +75,15 @@ When a `useChat` hook receives tools, the devtools panel lists those tools and t Applying a tool fixture appends the tool call and result into the real chat messages for that hook. Saved fixtures are stored in browser localStorage under the AI devtools namespace so they are available the next time you open the panel. +## Memory Inspector + +When a chat is wired with [`memoryMiddleware`](../memory/overview.md), the hook's **Memory** tab shows what the server-side memory backend did for that conversation, grouped by scope (session): + +- **Operations timeline** - Each turn's recall: the query, how many fragments came back, how many characters were injected into the system prompt, whether memory-provided tools were exposed, and the recall duration. +- **Stored records & facts** - The current contents of the memory store for the scope, when the adapter implements the optional `inspect`/`listFacts` methods (the built-in `inMemory()` and `redis()` adapters do). Adapters without introspection still show the operations timeline. + +Because memory runs on the server, its state is transported to the panel over the chat stream (a `CUSTOM` event the client re-emits) rather than a separate channel — the same way generation results reach the panel. The snapshot reflects memory as of the start of each turn, so a turn's own writes appear in the next turn's snapshot. Opening the panel after a turn replays the latest memory state, so the tab is populated even when you open devtools mid-conversation. + ## Event Sources Client-visible state is emitted by the headless client. Server-only details, such as middleware and provider stream events that never exist on the client, are emitted from the server counterpart. Events include a source descriptor and stable envelope id so the panel can link related events and avoid displaying duplicates. diff --git a/packages/ai-client/src/chat-client.ts b/packages/ai-client/src/chat-client.ts index 5c6d45ed2..ab5252abb 100644 --- a/packages/ai-client/src/chat-client.ts +++ b/packages/ai-client/src/chat-client.ts @@ -544,6 +544,13 @@ export class ChatClient< data: unknown, context: { toolCallId?: string }, ) => { + // Server-side memory middleware transports its state as a `memory:state` + // CUSTOM event (its own event bus never reaches this browser runtime). + // Route it to the devtools bridge here — the designated custom-event + // path — then still forward to the app's callback. + if (eventType === 'memory:state') { + this.devtoolsBridge.recordMemoryState(data) + } this.callbacksRef.current.onCustomEvent(eventType, data, context) }, }, diff --git a/packages/ai-client/src/devtools-noop.ts b/packages/ai-client/src/devtools-noop.ts index 4f1848eec..6a1f1ce00 100644 --- a/packages/ai-client/src/devtools-noop.ts +++ b/packages/ai-client/src/devtools-noop.ts @@ -86,6 +86,7 @@ export class NoOpChatDevtoolsBridge { return '' } observeChunk(_chunk: StreamChunk): void {} + recordMemoryState(_value: unknown): void {} beginRun(_runId: string, _threadId: string): void {} getCurrentRunEventContext(): ChatClientRunEventContext | undefined { return undefined diff --git a/packages/ai-client/src/devtools.ts b/packages/ai-client/src/devtools.ts index aba6ee5d3..eea282067 100644 --- a/packages/ai-client/src/devtools.ts +++ b/packages/ai-client/src/devtools.ts @@ -25,6 +25,33 @@ export interface AIDevtoolsDisplayOptions { name?: string } +/** + * Structural mirror of `MemoryStateEventValue` from `@tanstack/ai-memory` — the + * payload of the `memory:state` CUSTOM chunk. Kept local so `ai-client` doesn't + * depend on `ai-memory`; the memory middleware is the producer. + */ +interface MemoryStateEventValue { + scope: { sessionId?: string; userId?: string } + adapter: string + query?: string + recall?: { + fragmentCount?: number + hasTools?: boolean + systemPromptChars?: number + durationMs?: number + } + snapshot?: { + takenAt: string + data: unknown + facts?: Array<{ + id: string + text: string + source?: string + createdAt?: string + }> + } +} + export interface AIDevtoolsClientMetadata extends AIDevtoolsDisplayOptions { framework?: string hookName: string @@ -626,8 +653,16 @@ export class ClientDevtoolsBridge { this.emitRegistered() this.emitToolsRegistered() this.emitSnapshot() + this.onReplayState() } + /** + * Extension hook for subclasses to replay any additional cached state on a + * `devtools:request-state` (i.e. when a panel opens). Called only after the + * base guards (disposed/superseded/targetHookId) pass. No-op by default. + */ + protected onReplayState(): void {} + private async handleToolFixtureApply( event: AIDevtoolsEvent, ): Promise { @@ -657,13 +692,16 @@ export class ClientDevtoolsBridge { return true } - private createEnvelope( + protected createEnvelope( eventType: | 'hook:registered' | 'hook:updated' | 'hook:unregistered' | 'hook:state-snapshot' | 'tools:registered' + | 'memory:retrieve:started' + | 'memory:retrieve:completed' + | 'memory:snapshot' | AIDevtoolsRunEventType, visibility: AIDevtoolsEventVisibility = 'client-state', context: { runId?: string } = {}, @@ -740,6 +778,8 @@ export class ChatDevtoolsBridge extends ClientDevtoolsBridge { client.dispose() }) + it('re-emits memory:* devtools events from a transported memory:state CUSTOM chunk', async () => { + const runContexts: Array = [] + const chunks: Array = [ + runStartedChunk({ threadId: 'thread-1', runId: 'run-mem' }), + { + type: EventType.CUSTOM, + model: 'test', + timestamp: Date.now(), + name: 'memory:state', + value: { + scope: { sessionId: 'sess-1' }, + adapter: 'in-memory', + query: 'what is my name?', + recall: { + fragmentCount: 2, + hasTools: false, + systemPromptChars: 96, + durationMs: 4, + }, + snapshot: { + takenAt: '2026-07-22T00:00:00.000Z', + data: { records: [{ id: 'r1', text: 'name is Jack', kind: 'message' }] }, + facts: [{ id: 'r1', text: 'name is Jack', source: 'user' }], + }, + }, + }, + textContentChunk({ + messageId: 'msg-mem', + delta: 'Your name is Jack', + content: 'Your name is Jack', + }), + runFinishedChunk({ threadId: 'thread-1', runId: 'run-mem' }), + ] + const client = createClient({ + connection: createRunTrackingAdapter([chunks], runContexts), + }) + vi.clearAllMocks() + + await client.sendMessage('what is my name?') + await waitForCondition( + () => eventClientMock.emitted('memory:snapshot').length > 0, + ) + + expect(eventClientMock.emitted('memory:retrieve:started')).toEqual([ + [ + 'memory:retrieve:started', + expect.objectContaining({ + scope: { sessionId: 'sess-1' }, + adapter: 'in-memory', + query: 'what is my name?', + }), + ], + ]) + expect(eventClientMock.emitted('memory:retrieve:completed')).toEqual([ + [ + 'memory:retrieve:completed', + expect.objectContaining({ + adapter: 'in-memory', + fragmentCount: 2, + hasTools: false, + systemPromptChars: 96, + durationMs: 4, + }), + ], + ]) + expect(eventClientMock.emitted('memory:snapshot')).toEqual([ + [ + 'memory:snapshot', + expect.objectContaining({ + adapter: 'in-memory', + takenAt: '2026-07-22T00:00:00.000Z', + facts: [{ id: 'r1', text: 'name is Jack', source: 'user' }], + }), + ], + ]) + + // Replay: a devtools panel opening AFTER the turn requests state; the bridge + // re-emits the last memory:state so a late-opened panel isn't empty. + vi.clearAllMocks() + eventClientMock.dispatch('devtools:request-state', {}) + await waitForCondition( + () => eventClientMock.emitted('memory:snapshot').length > 0, + ) + expect(eventClientMock.emitted('memory:retrieve:completed')).toEqual([ + [ + 'memory:retrieve:completed', + expect.objectContaining({ adapter: 'in-memory', fragmentCount: 2 }), + ], + ]) + + client.dispose() + }) + it('batches structured output update events while preserving final state', async () => { const runContexts: Array = [] const finalObject = { title: 'Pasta', servings: 2 } diff --git a/packages/ai-devtools/src/components/hooks/HookDetails.tsx b/packages/ai-devtools/src/components/hooks/HookDetails.tsx index 2176711fb..cebade060 100644 --- a/packages/ai-devtools/src/components/hooks/HookDetails.tsx +++ b/packages/ai-devtools/src/components/hooks/HookDetails.tsx @@ -36,6 +36,7 @@ import { visiblePreviewPartsForMessage, } from './preview-messages' import { GenerationPanel, GenerationPreview } from './GenerationPanel' +import { MemoryPanel } from './MemoryPanel' import type { HoverOrigin, HoverTarget, PreviewJsonItem } from './preview-model' import type { HookRecord, @@ -46,7 +47,7 @@ import type { import type { Conversation, Message, ToolCall } from '../../store/ai-store' import type { Component, Setter } from 'solid-js' -type DetailTab = 'conversation' | 'tools' | 'state' +type DetailTab = 'conversation' | 'tools' | 'state' | 'memory' type MessagePart = NonNullable[number] const scrollAnimations = new WeakMap() @@ -144,7 +145,12 @@ export const HookDetails: Component = () => { }) createEffect(() => { - if (isGenerationHook() && activeTab() === 'tools') { + // Tools and Memory are chat-only tabs; if a generation hook becomes active + // while one of them is selected, fall back to the conversation view. + if ( + isGenerationHook() && + (activeTab() === 'tools' || activeTab() === 'memory') + ) { setActiveTab('conversation') } }) @@ -240,6 +246,14 @@ export const HookDetails: Component = () => { activeTab={activeTab()} onSelect={setActiveTab} /> + + +
{ + + + diff --git a/packages/ai-devtools/src/components/hooks/MemoryPanel.tsx b/packages/ai-devtools/src/components/hooks/MemoryPanel.tsx new file mode 100644 index 000000000..86a857078 --- /dev/null +++ b/packages/ai-devtools/src/components/hooks/MemoryPanel.tsx @@ -0,0 +1,290 @@ +import { For, Show, createMemo, createSignal } from 'solid-js' +import { useAIStore } from '../../store/ai-context' +import { useStyles } from '../../styles/use-styles' +import type { Component } from 'solid-js' +import type { + MemoryEventRecord, + MemoryScopeState, +} from '../../store/memory-registry' + +/** + * DevTools "Memory" tab. Memory is per-scope (sessionId), not per-hook, so this + * panel reads the whole `state.memory` registry and lets the user pick a scope + * (defaulting to the most recently active). It renders two things: + * 1. Live contents — the latest `inspect()` records + `listFacts()` facts, + * pushed via `memory:snapshot` (only for adapters that support inspection). + * 2. Operations timeline — the `memory:*` recall/save/error events (always + * available, even when the adapter has no introspection). + */ + +/** Shape of a record inside the built-in adapters' `inspect()` payload. */ +interface MemoryRecordRow { + id: string + text: string + kind: string + role?: string + createdAt?: number + importance?: number +} + +/** Best-effort extraction of `{ records: [...] }` from the opaque snapshot data. */ +function extractRecords(data: unknown): Array { + if (!data || typeof data !== 'object') return [] + const records = (data as { records?: unknown }).records + if (!Array.isArray(records)) return [] + return records.filter( + (r): r is MemoryRecordRow => + Boolean(r) && + typeof r === 'object' && + typeof (r as MemoryRecordRow).id === 'string' && + typeof (r as MemoryRecordRow).text === 'string', + ) +} + +function formatTime(value: number | string | undefined): string { + if (value === undefined) return '' + const date = new Date(value) + if (Number.isNaN(date.getTime())) return '' + return date.toLocaleTimeString() +} + +function eventSummary(event: MemoryEventRecord): string { + switch (event.type) { + case 'retrieve:started': + return `recall "${event.query ?? ''}"` + case 'retrieve:completed': + return `recalled ${event.fragmentCount ?? 0} fragment(s), ${ + event.systemPromptChars ?? 0 + } prompt chars${event.hasTools ? ', +tools' : ''} (${event.durationMs ?? 0}ms)` + case 'persist:started': + return 'save started' + case 'persist:completed': + return `saved ${event.okCount ?? 0}/${event.receiptCount ?? 0} receipt(s) (${ + event.durationMs ?? 0 + }ms)` + case 'error': + return `${event.phase ?? ''} error: ${event.error?.message ?? 'unknown'}` + default: + return event.type + } +} + +export const MemoryPanel: Component = () => { + const { state, clearMemory } = useAIStore() + const styles = useStyles() + const [override, setOverride] = createSignal(null) + + // Scope keys sorted most-recently-active first. + const scopeKeys = createMemo(() => + Object.values(state.memory.scopes) + .slice() + .sort((a, b) => b.lastActivity - a.lastActivity) + .map((s) => s.key), + ) + + const selectedKey = createMemo(() => { + const chosen = override() + if (chosen && state.memory.scopes[chosen]) return chosen + return scopeKeys()[0] ?? null + }) + + const scope = createMemo((): MemoryScopeState | undefined => { + const key = selectedKey() + return key ? state.memory.scopes[key] : undefined + }) + + const records = createMemo(() => extractRecords(scope()?.snapshot?.data)) + const facts = createMemo(() => scope()?.snapshot?.facts ?? []) + // Timeline newest-first. + const events = createMemo(() => + (scope()?.events ?? []).slice().sort((a, b) => b.timestamp - a.timestamp), + ) + + return ( +
+ + No memory activity yet. Send a message through a chat wired with + memoryMiddleware and recall/save events will appear + here. +
+ } + > + {(activeScope) => ( + <> +
+
+ 1} + fallback={ + + {activeScope().adapter ?? 'memory'} + + } + > + + +
+ +
+ + {/* Live contents (inspect + listFacts) */} +
+
+ Stored records ({records().length}) + + {(snap) => ( + + snapshot {formatTime(snap().takenAt)} + + )} + +
+ 0} + fallback={ +
+ {activeScope().snapshot + ? 'Snapshot is empty.' + : 'This adapter does not expose inspect() — see the timeline below for activity.'} +
+ } + > +
+ + {(rec) => ( +
+
+ + {rec.kind} + + + {rec.role} + + + importance {rec.importance?.toFixed(2)} + + + {formatTime(rec.createdAt)} + +
+
+ {rec.text} +
+
+ )} +
+
+
+
+ + {/* Facts */} +
+
+ listFacts() ({facts().length}) +
+ 0} + fallback={ +
No facts.
+ } + > +
+ + {(fact) => ( +
+
+ + + {fact.source}:{' '} + + + {fact.text} +
+
+ )} +
+
+
+
+ + {/* Operations timeline */} +
+
+ Operations ({events().length}) +
+ 0} + fallback={ +
+ No operations recorded. +
+ } + > +
+ + {(event) => ( +
+
+ + {event.type} + + + {formatTime(event.timestamp)} + +
+
+ {eventSummary(event)} +
+
+ )} +
+
+
+
+ + )} +
+
+ ) +} diff --git a/packages/ai-devtools/src/components/hooks/index.ts b/packages/ai-devtools/src/components/hooks/index.ts index 2cdc8a2f0..73a7fafb7 100644 --- a/packages/ai-devtools/src/components/hooks/index.ts +++ b/packages/ai-devtools/src/components/hooks/index.ts @@ -1,4 +1,5 @@ export { HookDashboard } from './HookDashboard' export { HookDetails } from './HookDetails' export { GenerationPanel, GenerationPreview } from './GenerationPanel' +export { MemoryPanel } from './MemoryPanel' export { ToolFixtureForm } from './ToolFixtureForm' diff --git a/packages/ai-devtools/src/store/ai-context.tsx b/packages/ai-devtools/src/store/ai-context.tsx index 65397303a..24c72290a 100644 --- a/packages/ai-devtools/src/store/ai-context.tsx +++ b/packages/ai-devtools/src/store/ai-context.tsx @@ -14,12 +14,19 @@ import { createClientToolCallMessage, shouldSkipClientAssistantPlaceholder, } from './message-event-utils' +import { + applyMemoryEvent, + applyMemorySnapshot, + clearMemoryRegistry, + createMemoryRegistryState, +} from './memory-registry' import type { ContentPartSource, TokenUsage } from '@tanstack/ai' import type { DevtoolsToolFixtureApplyEvent, RunLifecycleEvent, } from '@tanstack/ai-event-client' import type { HookRegistryState, ToolFixtureRecord } from './hook-registry' +import type { MemoryRegistryState } from './memory-registry' import type { ParentComponent } from 'solid-js' interface MessagePart { @@ -227,6 +234,7 @@ interface AIStoreState { conversations: Record activeConversationId: string | null hooks: HookRegistryState + memory: MemoryRegistryState } interface AIContextValue { @@ -234,6 +242,7 @@ interface AIContextValue { clearAllConversations: () => void selectConversation: (id: string) => void clearHooks: () => void + clearMemory: () => void selectHook: (id: string | null) => void saveToolFixture: (fixture: ToolFixtureRecord) => void deleteToolFixture: (fixtureId: string) => void @@ -255,6 +264,7 @@ export const AIProvider: ParentComponent = (props) => { conversations: {}, activeConversationId: null, hooks: createHookRegistryState(), + memory: createMemoryRegistryState(), }) const streamToConversation = new Map() @@ -641,6 +651,15 @@ export const AIProvider: ParentComponent = (props) => { ) } + function clearMemory() { + setState( + 'memory', + produce((memory: MemoryRegistryState) => { + clearMemoryRegistry(memory) + }), + ) + } + function selectHook(id: string | null) { setState( 'hooks', @@ -1188,6 +1207,47 @@ export const AIProvider: ParentComponent = (props) => { }), ) + // Memory: the 5 `memory:*` operation events feed the timeline; `memory:snapshot` + // replaces the per-scope stored-state view. Keyed by scope (sessionId). + type MemoryEventInput = Parameters[1] + const recordMemoryEvent = ( + type: MemoryEventInput['type'], + payload: object, + ) => { + setState( + 'memory', + produce((memory: MemoryRegistryState) => { + applyMemoryEvent(memory, { ...payload, type } as MemoryEventInput) + }), + ) + } + + cleanupFns.push( + aiEventClient.on('memory:retrieve:started', (e) => { + recordMemoryEvent('retrieve:started', e.payload) + }), + aiEventClient.on('memory:retrieve:completed', (e) => { + recordMemoryEvent('retrieve:completed', e.payload) + }), + aiEventClient.on('memory:persist:started', (e) => { + recordMemoryEvent('persist:started', e.payload) + }), + aiEventClient.on('memory:persist:completed', (e) => { + recordMemoryEvent('persist:completed', e.payload) + }), + aiEventClient.on('memory:error', (e) => { + recordMemoryEvent('error', e.payload) + }), + aiEventClient.on('memory:snapshot', (e) => { + setState( + 'memory', + produce((memory: MemoryRegistryState) => { + applyMemorySnapshot(memory, e.payload) + }), + ) + }), + ) + const recordRunEvent = ( eventName: | 'run:created' @@ -3402,6 +3462,7 @@ export const AIProvider: ParentComponent = (props) => { clearAllConversations, selectConversation, clearHooks, + clearMemory, selectHook, saveToolFixture, deleteToolFixture, diff --git a/packages/ai-devtools/src/store/memory-registry.ts b/packages/ai-devtools/src/store/memory-registry.ts new file mode 100644 index 000000000..9f2d0b270 --- /dev/null +++ b/packages/ai-devtools/src/store/memory-registry.ts @@ -0,0 +1,186 @@ +import type { + MemoryErrorEvent, + MemoryPersistCompletedEvent, + MemoryPersistStartedEvent, + MemoryRetrieveCompletedEvent, + MemoryRetrieveStartedEvent, + MemoryScopeLite, + MemorySnapshotEvent, +} from '@tanstack/ai-event-client' + +/** + * DevTools-side accumulator for the `memory:*` event stream. Kept as a set of + * pure reducers (mirroring `hook-registry.ts`) so the mapping from events → + * view state is unit-testable in isolation, without a Solid store. + * + * Memory is keyed by scope (sessionId), NOT by hook — several hooks can share + * one session. The `MemoryPanel` reads a single `MemoryScopeState` by key; the + * per-hook tab just resolves which key to show. + */ + +/** One row in a scope's operations timeline. */ +export interface MemoryEventRecord { + id: string + type: + | 'retrieve:started' + | 'retrieve:completed' + | 'persist:started' + | 'persist:completed' + | 'error' + timestamp: number + adapter: string + /** recall:started — the recall query (last user text). */ + query?: string + /** recall:completed. */ + fragmentCount?: number + hasTools?: boolean + systemPromptChars?: number + /** persist:completed. */ + receiptCount?: number + okCount?: number + /** recall:completed / persist:completed. */ + durationMs?: number + /** error. */ + phase?: 'recall' | 'save' + error?: { name: string; message: string } +} + +/** A flat fact row, mirroring `MemoryFact` from `@tanstack/ai-memory`. */ +export interface MemoryFactRecord { + id: string + text: string + source?: string + createdAt?: string +} + +/** Latest `inspect()` + `listFacts()` snapshot pushed via `memory:snapshot`. */ +export interface MemorySnapshotRecord { + takenAt: string + data: unknown + facts: Array +} + +/** Everything known about memory for a single scope (sessionId). */ +export interface MemoryScopeState { + key: string + sessionId: string + userId?: string + /** Most recent adapter id seen for this scope. */ + adapter?: string + events: Array + snapshot?: MemorySnapshotRecord + lastActivity: number +} + +export interface MemoryRegistryState { + scopes: Record +} + +export function createMemoryRegistryState(): MemoryRegistryState { + return { scopes: {} } +} + +/** Stable scope key. Empty/absent sessionId (e.g. error scope) buckets to `(unknown)`. */ +export function memoryScopeKey(scope: MemoryScopeLite | undefined): string { + const sessionId = scope?.sessionId + return sessionId && sessionId.length > 0 ? sessionId : '(unknown)' +} + +const MAX_EVENTS_PER_SCOPE = 200 + +function ensureScope( + state: MemoryRegistryState, + scope: MemoryScopeLite | undefined, +): MemoryScopeState { + const key = memoryScopeKey(scope) + let entry = state.scopes[key] + if (!entry) { + entry = { + key, + sessionId: scope?.sessionId ?? '', + userId: scope?.userId, + events: [], + lastActivity: 0, + } + state.scopes[key] = entry + } + if (scope?.userId) entry.userId = scope.userId + return entry +} + +let fallbackCounter = 0 + +function eventId(payload: { eventId?: string }, type: string, ts: number): string { + if (payload.eventId && payload.eventId.length > 0) return payload.eventId + return `${type}:${ts}:${fallbackCounter++}` +} + +type MemoryEventPayload = + | ({ type: 'retrieve:started' } & MemoryRetrieveStartedEvent) + | ({ type: 'retrieve:completed' } & MemoryRetrieveCompletedEvent) + | ({ type: 'persist:started' } & MemoryPersistStartedEvent) + | ({ type: 'persist:completed' } & MemoryPersistCompletedEvent) + | ({ type: 'error' } & MemoryErrorEvent) + +/** Append one `memory:*` operation event to its scope's timeline. */ +export function applyMemoryEvent( + state: MemoryRegistryState, + event: MemoryEventPayload, +): void { + const entry = ensureScope(state, event.scope) + entry.adapter = event.adapter + entry.lastActivity = Math.max(entry.lastActivity, event.timestamp) + + const record: MemoryEventRecord = { + id: eventId(event, event.type, event.timestamp), + type: event.type, + timestamp: event.timestamp, + adapter: event.adapter, + } + switch (event.type) { + case 'retrieve:started': + record.query = event.query + break + case 'retrieve:completed': + record.fragmentCount = event.fragmentCount + record.hasTools = event.hasTools + record.systemPromptChars = event.systemPromptChars + record.durationMs = event.durationMs + break + case 'persist:completed': + record.receiptCount = event.receiptCount + record.okCount = event.okCount + record.durationMs = event.durationMs + break + case 'error': + record.phase = event.phase + record.error = event.error + break + case 'persist:started': + break + } + + entry.events.push(record) + if (entry.events.length > MAX_EVENTS_PER_SCOPE) { + entry.events.splice(0, entry.events.length - MAX_EVENTS_PER_SCOPE) + } +} + +/** Replace a scope's stored-state snapshot from a `memory:snapshot` event. */ +export function applyMemorySnapshot( + state: MemoryRegistryState, + event: MemorySnapshotEvent, +): void { + const entry = ensureScope(state, event.scope) + entry.adapter = event.adapter + entry.lastActivity = Math.max(entry.lastActivity, event.timestamp) + entry.snapshot = { + takenAt: event.takenAt, + data: event.data, + facts: event.facts, + } +} + +export function clearMemoryRegistry(state: MemoryRegistryState): void { + state.scopes = {} +} diff --git a/packages/ai-devtools/src/styles/use-styles.ts b/packages/ai-devtools/src/styles/use-styles.ts index 62d9b6c57..43e003d9a 100644 --- a/packages/ai-devtools/src/styles/use-styles.ts +++ b/packages/ai-devtools/src/styles/use-styles.ts @@ -1898,6 +1898,118 @@ const stylesFactory = (theme: 'light' | 'dark') => { padding: 0 ${size[3]}; `, }, + // MemoryPanel component styles + memoryPanel: { + container: css` + display: flex; + flex: 1; + min-height: 0; + flex-direction: column; + gap: ${size[4]}; + overflow-y: auto; + padding: ${size[4]}; + `, + toolbar: css` + display: flex; + align-items: center; + justify-content: space-between; + gap: ${size[3]}; + `, + scopeControls: css` + display: flex; + align-items: center; + gap: ${size[2]}; + min-width: 0; + `, + scopeSelect: css` + max-width: 220px; + border: 1px solid ${t(colors.gray[200], colors.darkGray[700])}; + border-radius: ${border.radius.sm}; + background: ${t(colors.white, colors.darkGray[800])}; + color: ${t(colors.gray[900], colors.gray[100])}; + font-family: ${fontFamily.mono}; + font-size: ${fontSize.xs}; + padding: ${size[1]} ${size[2]}; + `, + clearButton: css` + border: 1px solid ${t(colors.gray[200], colors.darkGray[700])}; + border-radius: ${border.radius.sm}; + background: ${t(colors.gray[50], colors.darkGray[700])}; + color: ${t(colors.gray[700], colors.gray[200])}; + cursor: pointer; + font-size: ${fontSize.xs}; + padding: ${size[1]} ${size[2]}; + &:hover { + border-color: ${t(colors.blue[300], colors.blue[700])}; + } + `, + empty: css` + color: ${t(colors.gray[500], colors.gray[500])}; + font-size: ${fontSize.sm}; + padding: ${size[4]}; + text-align: center; + `, + section: css` + display: flex; + flex-direction: column; + gap: ${size[2]}; + `, + sectionTitle: css` + color: ${t(colors.gray[600], colors.gray[300])}; + font-size: ${fontSize.xs}; + font-weight: ${font.weight.semibold}; + text-transform: uppercase; + letter-spacing: 0.04em; + `, + sectionEmpty: css` + color: ${t(colors.gray[500], colors.gray[500])}; + font-size: ${fontSize.xs}; + `, + list: css` + display: flex; + flex-direction: column; + gap: ${size[1]}; + `, + row: css` + display: flex; + flex-direction: column; + gap: 2px; + border: 1px solid ${t(colors.gray[200], colors.darkGray[700])}; + border-radius: ${border.radius.sm}; + background: ${t(colors.white, colors.darkGray[800])}; + padding: ${size[2]}; + `, + rowError: css` + border-color: ${t(colors.red[300], colors.red[700])}; + background: ${t(colors.red[50], colors.red[900] + '20')}; + `, + rowHeader: css` + display: flex; + align-items: center; + gap: ${size[2]}; + font-size: ${fontSize.xs}; + color: ${t(colors.gray[500], colors.gray[400])}; + `, + badge: css` + border-radius: ${border.radius.xs}; + background: ${t(colors.blue[50], colors.blue[900] + '30')}; + color: ${t(colors.blue[700], colors.blue[300])}; + font-family: ${fontFamily.mono}; + font-size: 10px; + padding: 1px 6px; + `, + time: css` + margin-left: auto; + font-family: ${fontFamily.mono}; + font-size: 10px; + `, + rowText: css` + color: ${t(colors.gray[900], colors.gray[100])}; + font-size: ${fontSize.sm}; + white-space: pre-wrap; + word-break: break-word; + `, + }, // ConversationDetails component styles conversationDetails: { emptyState: css` diff --git a/packages/ai-devtools/tests/memory-registry.test.ts b/packages/ai-devtools/tests/memory-registry.test.ts new file mode 100644 index 000000000..d9d41c3ac --- /dev/null +++ b/packages/ai-devtools/tests/memory-registry.test.ts @@ -0,0 +1,139 @@ +import { describe, expect, it } from 'vitest' +import { + applyMemoryEvent, + applyMemorySnapshot, + clearMemoryRegistry, + createMemoryRegistryState, + memoryScopeKey, +} from '../src/store/memory-registry' +import type { MemorySnapshotEvent } from '@tanstack/ai-event-client' + +const SCOPE = { sessionId: 'session-1' } + +describe('memory registry', () => { + it('accumulates the operation timeline per scope', () => { + const state = createMemoryRegistryState() + + applyMemoryEvent(state, { + type: 'retrieve:started', + scope: SCOPE, + adapter: 'in-memory', + query: 'What is my name?', + timestamp: 10, + }) + applyMemoryEvent(state, { + type: 'retrieve:completed', + scope: SCOPE, + adapter: 'in-memory', + fragmentCount: 2, + hasTools: false, + systemPromptChars: 128, + durationMs: 5, + timestamp: 12, + }) + applyMemoryEvent(state, { + type: 'persist:completed', + scope: SCOPE, + adapter: 'in-memory', + receiptCount: 2, + okCount: 2, + durationMs: 3, + timestamp: 20, + }) + + const entry = state.scopes[memoryScopeKey(SCOPE)] + expect(entry).toBeDefined() + expect(entry!.adapter).toBe('in-memory') + expect(entry!.lastActivity).toBe(20) + expect(entry!.events).toHaveLength(3) + expect(entry!.events[0]).toMatchObject({ + type: 'retrieve:started', + query: 'What is my name?', + }) + expect(entry!.events[1]).toMatchObject({ + type: 'retrieve:completed', + fragmentCount: 2, + systemPromptChars: 128, + }) + expect(entry!.events[2]).toMatchObject({ + type: 'persist:completed', + okCount: 2, + receiptCount: 2, + }) + }) + + it('records error events with phase and message', () => { + const state = createMemoryRegistryState() + applyMemoryEvent(state, { + type: 'error', + scope: SCOPE, + adapter: 'in-memory', + phase: 'recall', + error: { name: 'Error', message: 'boom' }, + timestamp: 1, + }) + const entry = state.scopes[memoryScopeKey(SCOPE)] + expect(entry!.events[0]).toMatchObject({ + type: 'error', + phase: 'recall', + error: { message: 'boom' }, + }) + }) + + it('replaces the snapshot on memory:snapshot', () => { + const state = createMemoryRegistryState() + const snapshot: MemorySnapshotEvent = { + scope: SCOPE, + adapter: 'in-memory', + takenAt: '2026-07-22T00:00:00.000Z', + data: { + records: [ + { id: 'r1', text: 'My name is Jack', kind: 'message', role: 'user' }, + ], + }, + facts: [{ id: 'r1', text: 'My name is Jack', source: 'user' }], + timestamp: 30, + } + applyMemorySnapshot(state, snapshot) + + const entry = state.scopes[memoryScopeKey(SCOPE)] + expect(entry!.snapshot?.takenAt).toBe('2026-07-22T00:00:00.000Z') + expect(entry!.snapshot?.facts).toHaveLength(1) + + // A newer snapshot fully replaces the prior one. + applyMemorySnapshot(state, { ...snapshot, facts: [], timestamp: 40 }) + expect(state.scopes[memoryScopeKey(SCOPE)]!.snapshot?.facts).toEqual([]) + expect(state.scopes[memoryScopeKey(SCOPE)]!.lastActivity).toBe(40) + }) + + it('isolates scopes and buckets missing sessionId to (unknown)', () => { + const state = createMemoryRegistryState() + applyMemoryEvent(state, { + type: 'persist:started', + scope: { sessionId: 'a' }, + adapter: 'in-memory', + timestamp: 1, + }) + applyMemoryEvent(state, { + type: 'error', + scope: { sessionId: '' }, + adapter: 'in-memory', + phase: 'save', + error: { name: 'Error', message: 'x' }, + timestamp: 2, + }) + expect(Object.keys(state.scopes).sort()).toEqual(['(unknown)', 'a']) + }) + + it('clears the registry', () => { + const state = createMemoryRegistryState() + applyMemoryEvent(state, { + type: 'persist:started', + scope: SCOPE, + adapter: 'in-memory', + timestamp: 1, + }) + clearMemoryRegistry(state) + expect(state.scopes).toEqual({}) + }) +}) diff --git a/packages/ai-event-client/src/index.ts b/packages/ai-event-client/src/index.ts index 7f60e2bbe..fa36b3d24 100644 --- a/packages/ai-event-client/src/index.ts +++ b/packages/ai-event-client/src/index.ts @@ -881,6 +881,32 @@ export interface MemoryErrorEvent extends BaseEventContext { error: { name: string; message: string } } +/** A flat fact row, mirroring `MemoryFact` from `@tanstack/ai-memory`. */ +export interface MemoryFactLite { + id: string + text: string + source?: string + createdAt?: string +} + +/** + * Emitted after a successful `save` when the adapter supports introspection + * (`inspect`/`listFacts`), carrying the current stored state for the scope so + * DevTools can render "what's in memory". Adapters without introspection never + * emit this — DevTools then falls back to the metrics-only timeline. Structurally + * decoupled from `@tanstack/ai-memory` (mirrors `MemorySnapshot` + `MemoryFact`). + */ +export interface MemorySnapshotEvent extends BaseEventContext { + scope: MemoryScopeLite + adapter: string + /** ISO timestamp the snapshot was taken (from `MemorySnapshot.takenAt`). */ + takenAt: string + /** Adapter-defined `inspect()` payload (e.g. `{ records: [...] }`). */ + data: unknown + /** Flat fact list from `listFacts()`; `[]` when the adapter lacks it. */ + facts: Array +} + // =========================== // Client Events // =========================== @@ -1118,6 +1144,7 @@ export interface AIDevtoolsEventMap { 'memory:persist:started': MemoryPersistStartedEvent 'memory:persist:completed': MemoryPersistCompletedEvent 'memory:error': MemoryErrorEvent + 'memory:snapshot': MemorySnapshotEvent } class AiEventClient extends EventClient { diff --git a/packages/ai-memory/src/index.ts b/packages/ai-memory/src/index.ts index 001a90b9d..992b73f17 100644 --- a/packages/ai-memory/src/index.ts +++ b/packages/ai-memory/src/index.ts @@ -1,9 +1,11 @@ export { memoryMiddleware, + MEMORY_STATE_EVENT, type MemoryMiddlewareOptions, type MemoryMiddlewareRole, type MemoryRecallInfo, type MemorySaveInfo, + type MemoryStateEventValue, } from './middleware' export type { diff --git a/packages/ai-memory/src/middleware.ts b/packages/ai-memory/src/middleware.ts index 64946f677..caa4bf4ee 100644 --- a/packages/ai-memory/src/middleware.ts +++ b/packages/ai-memory/src/middleware.ts @@ -4,15 +4,51 @@ import type { ChatMiddlewareConfig, ChatMiddlewareContext, ModelMessage, + StreamChunk, } from '@tanstack/ai' import type { MemoryAdapter, + MemoryFact, MemoryScope, MemoryTurn, RecallResult, SaveReceipt, } from './types' +/** + * CUSTOM stream-event name carrying server-side memory state to the browser. + * The middleware injects one of these per turn (via `onChunk`); the client + * devtools bridge (`@tanstack/ai-client`) recognizes it and re-emits `memory:*` + * on the browser event bus. This is how server-side memory reaches the browser + * DevTools panel — server-emitted `aiEventClient` events never cross runtimes; + * everything the panel shows is re-derived client-side from the chat stream + * (mirrors how generation results ride `CUSTOM` events — see `GENERATION_EVENTS`). + */ +export const MEMORY_STATE_EVENT = 'memory:state' + +/** Payload of the {@link MEMORY_STATE_EVENT} CUSTOM chunk. Captures memory state + * as of the turn's START — the snapshot reflects every prior turn's save; this + * turn's own save (deferred) surfaces in the next turn's snapshot. */ +export interface MemoryStateEventValue { + scope: MemoryScope + adapter: string + /** The recall query (last user text). */ + query: string + /** Recall metrics for the operations timeline. */ + recall: { + fragmentCount: number + hasTools: boolean + systemPromptChars: number + durationMs: number + } + /** Live store snapshot, when the adapter supports `inspect`/`listFacts`. */ + snapshot?: { + takenAt: string + data: unknown + facts: Array + } +} + /** * How the middleware participates in the run: * - `'recall+save'` (default): recall on init (inject prompt + tools), save on finish. @@ -56,6 +92,8 @@ export interface MemoryMiddlewareOptions { interface MemoryRequestState { resolvedScope?: MemoryScope lastUserText: string + /** Pending devtools transport chunk, injected once by the first `onChunk`. */ + stateChunk?: { emitted: boolean; value: MemoryStateEventValue } } const stateByCtx = new WeakMap() @@ -120,17 +158,35 @@ export function memoryMiddleware( } const tools = result.tools ?? [] - safeEmit('memory:retrieve:completed', { - scope, - adapter: options.adapter.id, + const recallMetrics = { fragmentCount: result.fragments?.length ?? 0, hasTools: tools.length > 0, systemPromptChars: result.systemPrompt.length, durationMs: Date.now() - startedAt, + } + safeEmit('memory:retrieve:completed', { + scope, + adapter: options.adapter.id, + ...recallMetrics, timestamp: Date.now(), }) await options.onRecall?.({ scope, query: state.lastUserText, result }) + // Stage the devtools transport chunk (recall metrics + current store + // snapshot). Injected into the stream by `onChunk` so it reaches the + // browser panel; see MEMORY_STATE_EVENT. + const snapshot = await gatherSnapshot(options.adapter, scope) + state.stateChunk = { + emitted: false, + value: { + scope, + adapter: options.adapter.id, + query: state.lastUserText, + recall: recallMetrics, + ...(snapshot ? { snapshot } : {}), + }, + } + const memoryPrompts = [result.toolGuidance ?? '', result.systemPrompt] const additions = memoryPrompts.filter((p) => p.length > 0) if (additions.length === 0 && tools.length === 0) return @@ -141,6 +197,22 @@ export function memoryMiddleware( } satisfies Partial }, + onChunk(ctx, chunk) { + // Inject the staged memory-state chunk exactly once, riding alongside the + // first stream chunk (typically RUN_STARTED) so the browser devtools sees + // it. Returning an array expands the stream; see ChatMiddleware.onChunk. + const state = stateByCtx.get(ctx) + if (!state?.stateChunk || state.stateChunk.emitted) return + state.stateChunk.emitted = true + const custom: StreamChunk = { + type: 'CUSTOM', + name: MEMORY_STATE_EVENT, + value: state.stateChunk.value, + timestamp: Date.now(), + } + return [chunk, custom] + }, + onFinish(ctx, info) { const state = stateByCtx.get(ctx) stateByCtx.delete(ctx) @@ -197,6 +269,7 @@ export function memoryMiddleware( durationMs: Date.now() - startedAt, timestamp: Date.now(), }) + await emitSnapshot(options.adapter, resolved) await options.onSave?.({ scope: resolved, turn, receipts }) })(), ) @@ -212,6 +285,48 @@ function emptyScope(): MemoryScope { return { sessionId: '' } } +/** + * Read the adapter's current stored state via the optional `inspect`/`listFacts` + * introspection methods. Returns `undefined` for adapters that don't implement + * `inspect` (they degrade to the metrics-only timeline). Fully guarded: + * introspection must never affect chat. + */ +async function gatherSnapshot( + adapter: MemoryAdapter, + scope: MemoryScope, +): Promise<{ takenAt: string; data: unknown; facts: Array } | undefined> { + if (!adapter.inspect) return undefined + try { + const snapshot = await adapter.inspect(scope) + const facts = (await adapter.listFacts?.(scope)) ?? [] + return { takenAt: snapshot.takenAt, data: snapshot.data, facts } + } catch { + // ignored — introspection is best-effort telemetry. + return undefined + } +} + +/** + * DevTools-only: after a save, emit the adapter's current stored state on the + * (in-process) event bus, so a devtools consumer running in the SAME runtime as + * the chat (client-side execution / server-side listener) sees "what's in + * memory". For the standard server-side topology, the browser panel instead + * gets state via the {@link MEMORY_STATE_EVENT} stream chunk (see `onChunk`). + */ +async function emitSnapshot( + adapter: MemoryAdapter, + scope: MemoryScope, +): Promise { + const snapshot = await gatherSnapshot(adapter, scope) + if (!snapshot) return + safeEmit('memory:snapshot', { + scope, + adapter: adapter.id, + ...snapshot, + timestamp: Date.now(), + }) +} + function findLastUserMessage( messages: ReadonlyArray, ): ModelMessage | undefined { diff --git a/packages/ai-memory/tests/middleware.test.ts b/packages/ai-memory/tests/middleware.test.ts index 2f56c8288..afbea526c 100644 --- a/packages/ai-memory/tests/middleware.test.ts +++ b/packages/ai-memory/tests/middleware.test.ts @@ -1,5 +1,7 @@ import { describe, expect, it, vi } from 'vitest' -import { memoryMiddleware } from '../src' +import { aiEventClient } from '@tanstack/ai-event-client' +import { MEMORY_STATE_EVENT, memoryMiddleware } from '../src' +import type { StreamChunk } from '@tanstack/ai' import type { ChatMiddlewareConfig, ChatMiddlewareContext, @@ -107,6 +109,115 @@ describe('memoryMiddleware', () => { expect(onSave).toHaveBeenCalledOnce() }) + it('emits memory:snapshot after save when the adapter supports inspection', async () => { + const base = fakeAdapter([]) + const inspectable: MemoryAdapter = { + ...base, + inspect: async () => ({ + takenAt: '2026-07-22T00:00:00.000Z', + data: { records: [{ id: 'r1', text: 'You like cats!', kind: 'message' }] }, + }), + listFacts: async () => [{ id: 'r1', text: 'You like cats!', source: 'assistant' }], + } + const emit = vi.spyOn(aiEventClient, 'emit').mockImplementation(() => {}) + try { + const mw = memoryMiddleware({ adapter: inspectable, scope }) + const deferred: Array> = [] + const config = makeConfig('remember I like cats') + const ctx = makeCtx(config, deferred) + await mw.onConfig?.(ctx, config) + mw.onFinish?.(ctx, { + finishReason: 'stop', + duration: 1, + content: 'You like cats!', + }) + await Promise.all(deferred) + + const snapshotCall = emit.mock.calls.find((c) => c[0] === 'memory:snapshot') + expect(snapshotCall).toBeTruthy() + expect(snapshotCall?.[1]).toMatchObject({ + adapter: 'fake', + takenAt: '2026-07-22T00:00:00.000Z', + facts: [{ id: 'r1', text: 'You like cats!' }], + }) + } finally { + emit.mockRestore() + } + }) + + it('does not emit memory:snapshot for adapters without inspect', async () => { + const emit = vi.spyOn(aiEventClient, 'emit').mockImplementation(() => {}) + try { + const mw = memoryMiddleware({ adapter: fakeAdapter([]), scope }) + const deferred: Array> = [] + const config = makeConfig('hi there') + const ctx = makeCtx(config, deferred) + await mw.onConfig?.(ctx, config) + mw.onFinish?.(ctx, { finishReason: 'stop', duration: 1, content: 'hello' }) + await Promise.all(deferred) + + expect(emit.mock.calls.some((c) => c[0] === 'memory:snapshot')).toBe(false) + } finally { + emit.mockRestore() + } + }) + + it('injects one memory:state CUSTOM chunk carrying recall metrics + snapshot', async () => { + const inspectable = { + ...fakeAdapter([]), + inspect: async () => ({ + takenAt: '2026-07-22T00:00:00.000Z', + data: { records: [{ id: 'r1', text: 'likes cats', kind: 'message' }] }, + }), + listFacts: async () => [{ id: 'r1', text: 'likes cats', source: 'user' }], + } + const mw = memoryMiddleware({ adapter: inspectable, scope }) + const config = makeConfig('what do I like?') + const ctx = makeCtx(config, []) + await mw.onConfig?.(ctx, config) + + const runStarted = { + type: 'RUN_STARTED', + threadId: 't1', + runId: 'run1', + } as unknown as StreamChunk + const out = await mw.onChunk?.(ctx, runStarted) + + expect(Array.isArray(out)).toBe(true) + const chunks = out as Array + expect(chunks[0]).toBe(runStarted) + const custom = chunks[1] as Extract + expect(custom.type).toBe('CUSTOM') + expect(custom.name).toBe(MEMORY_STATE_EVENT) + expect(custom.value).toMatchObject({ + adapter: 'fake', + query: 'what do I like?', + recall: { fragmentCount: 1, hasTools: true }, + snapshot: { + takenAt: '2026-07-22T00:00:00.000Z', + facts: [{ id: 'r1', text: 'likes cats' }], + }, + }) + + // Injected exactly once per turn — a second chunk passes through untouched. + const again = await mw.onChunk?.(ctx, runStarted) + expect(again).toBeUndefined() + }) + + it('omits the snapshot in memory:state for adapters without inspect', async () => { + const mw = memoryMiddleware({ adapter: fakeAdapter([]), scope }) + const config = makeConfig('hi') + const ctx = makeCtx(config, []) + await mw.onConfig?.(ctx, config) + const out = (await mw.onChunk?.( + ctx, + { type: 'RUN_STARTED' } as unknown as StreamChunk, + )) as Array + const custom = out[1] as Extract + expect(custom.name).toBe(MEMORY_STATE_EVENT) + expect((custom.value as { snapshot?: unknown }).snapshot).toBeUndefined() + }) + it('recall failures are non-fatal (no throw, no injection)', async () => { const failing: MemoryAdapter = { id: 'boom', diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 38ae6d59c..ad5c72283 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -2751,6 +2751,12 @@ importers: '@tanstack/nitro-v2-vite-plugin': specifier: ^1.155.0 version: 1.155.0(aws4fetch@1.0.20)(rolldown@1.1.5)(vite@8.1.4(@types/node@24.10.3)(esbuild@0.28.1)(jiti@2.7.0)(less@4.6.6)(sass@1.101.0)(terser@5.44.1)(tsx@4.21.0)(yaml@2.9.0)) + '@tanstack/react-ai-devtools': + specifier: workspace:* + version: link:../../packages/react-ai-devtools + '@tanstack/react-devtools': + specifier: ^0.9.10 + version: 0.9.10(@types/react-dom@19.2.3(@types/react@19.2.7))(@types/react@19.2.7)(csstype@3.2.3)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(solid-js@1.9.10) '@tanstack/react-router': specifier: ^1.158.4 version: 1.159.5(react-dom@19.2.3(react@19.2.3))(react@19.2.3) diff --git a/testing/e2e/src/lib/devtools-memory-store.ts b/testing/e2e/src/lib/devtools-memory-store.ts new file mode 100644 index 000000000..1869eea95 --- /dev/null +++ b/testing/e2e/src/lib/devtools-memory-store.ts @@ -0,0 +1,9 @@ +import { inMemory } from '@tanstack/ai-memory/in-memory' + +/** + * Shared process-local memory adapter for the `/devtools-memory` E2E route. + * The default `inMemory()` stores raw user/assistant turns (kind `message`) + * with zero deps — legible for asserting "what's in memory" in the devtools + * panel. Scope is keyed per-test by `sessionId` (the Playwright `testId`). + */ +export const devtoolsMemoryAdapter = inMemory() diff --git a/testing/e2e/src/routeTree.gen.ts b/testing/e2e/src/routeTree.gen.ts index 13ad39d5f..7e34f8c89 100644 --- a/testing/e2e/src/routeTree.gen.ts +++ b/testing/e2e/src/routeTree.gen.ts @@ -16,6 +16,7 @@ import { Route as DevtoolsToolsRouteImport } from './routes/devtools-tools' import { Route as DevtoolsStructuredRouteImport } from './routes/devtools-structured' import { Route as DevtoolsRouteBRouteImport } from './routes/devtools-route-b' import { Route as DevtoolsRouteARouteImport } from './routes/devtools-route-a' +import { Route as DevtoolsMemoryRouteImport } from './routes/devtools-memory' import { Route as DevtoolsGenerationHooksRouteImport } from './routes/devtools-generation-hooks' import { Route as DevtoolsChatRouteImport } from './routes/devtools-chat' import { Route as ChatClientDefaultBridgeRouteImport } from './routes/chat-client-default-bridge' @@ -47,6 +48,7 @@ import { Route as ApiMaxToolCallsWireRouteImport } from './routes/api.max-tool-c import { Route as ApiLazyToolsWireRouteImport } from './routes/api.lazy-tools-wire' import { Route as ApiImageRouteImport } from './routes/api.image' import { Route as ApiDurableDeliveryRouteImport } from './routes/api.durable-delivery' +import { Route as ApiDevtoolsMemoryRouteImport } from './routes/api.devtools-memory' import { Route as ApiChatRouteImport } from './routes/api.chat' import { Route as ApiAudioRouteImport } from './routes/api.audio' import { Route as ApiArktypeToolWireRouteImport } from './routes/api.arktype-tool-wire' @@ -95,6 +97,11 @@ const DevtoolsRouteARoute = DevtoolsRouteARouteImport.update({ path: '/devtools-route-a', getParentRoute: () => rootRouteImport, } as any) +const DevtoolsMemoryRoute = DevtoolsMemoryRouteImport.update({ + id: '/devtools-memory', + path: '/devtools-memory', + getParentRoute: () => rootRouteImport, +} as any) const DevtoolsGenerationHooksRoute = DevtoolsGenerationHooksRouteImport.update({ id: '/devtools-generation-hooks', path: '/devtools-generation-hooks', @@ -254,6 +261,11 @@ const ApiDurableDeliveryRoute = ApiDurableDeliveryRouteImport.update({ path: '/api/durable-delivery', getParentRoute: () => rootRouteImport, } as any) +const ApiDevtoolsMemoryRoute = ApiDevtoolsMemoryRouteImport.update({ + id: '/api/devtools-memory', + path: '/api/devtools-memory', + getParentRoute: () => rootRouteImport, +} as any) const ApiChatRoute = ApiChatRouteImport.update({ id: '/api/chat', path: '/api/chat', @@ -321,6 +333,7 @@ export interface FileRoutesByFullPath { '/chat-client-default-bridge': typeof ChatClientDefaultBridgeRoute '/devtools-chat': typeof DevtoolsChatRoute '/devtools-generation-hooks': typeof DevtoolsGenerationHooksRoute + '/devtools-memory': typeof DevtoolsMemoryRoute '/devtools-route-a': typeof DevtoolsRouteARoute '/devtools-route-b': typeof DevtoolsRouteBRoute '/devtools-structured': typeof DevtoolsStructuredRoute @@ -335,6 +348,7 @@ export interface FileRoutesByFullPath { '/api/arktype-tool-wire': typeof ApiArktypeToolWireRoute '/api/audio': typeof ApiAudioRouteWithChildren '/api/chat': typeof ApiChatRoute + '/api/devtools-memory': typeof ApiDevtoolsMemoryRoute '/api/durable-delivery': typeof ApiDurableDeliveryRoute '/api/image': typeof ApiImageRouteWithChildren '/api/lazy-tools-wire': typeof ApiLazyToolsWireRoute @@ -373,6 +387,7 @@ export interface FileRoutesByTo { '/chat-client-default-bridge': typeof ChatClientDefaultBridgeRoute '/devtools-chat': typeof DevtoolsChatRoute '/devtools-generation-hooks': typeof DevtoolsGenerationHooksRoute + '/devtools-memory': typeof DevtoolsMemoryRoute '/devtools-route-a': typeof DevtoolsRouteARoute '/devtools-route-b': typeof DevtoolsRouteBRoute '/devtools-structured': typeof DevtoolsStructuredRoute @@ -387,6 +402,7 @@ export interface FileRoutesByTo { '/api/arktype-tool-wire': typeof ApiArktypeToolWireRoute '/api/audio': typeof ApiAudioRouteWithChildren '/api/chat': typeof ApiChatRoute + '/api/devtools-memory': typeof ApiDevtoolsMemoryRoute '/api/durable-delivery': typeof ApiDurableDeliveryRoute '/api/image': typeof ApiImageRouteWithChildren '/api/lazy-tools-wire': typeof ApiLazyToolsWireRoute @@ -426,6 +442,7 @@ export interface FileRoutesById { '/chat-client-default-bridge': typeof ChatClientDefaultBridgeRoute '/devtools-chat': typeof DevtoolsChatRoute '/devtools-generation-hooks': typeof DevtoolsGenerationHooksRoute + '/devtools-memory': typeof DevtoolsMemoryRoute '/devtools-route-a': typeof DevtoolsRouteARoute '/devtools-route-b': typeof DevtoolsRouteBRoute '/devtools-structured': typeof DevtoolsStructuredRoute @@ -440,6 +457,7 @@ export interface FileRoutesById { '/api/arktype-tool-wire': typeof ApiArktypeToolWireRoute '/api/audio': typeof ApiAudioRouteWithChildren '/api/chat': typeof ApiChatRoute + '/api/devtools-memory': typeof ApiDevtoolsMemoryRoute '/api/durable-delivery': typeof ApiDurableDeliveryRoute '/api/image': typeof ApiImageRouteWithChildren '/api/lazy-tools-wire': typeof ApiLazyToolsWireRoute @@ -480,6 +498,7 @@ export interface FileRouteTypes { | '/chat-client-default-bridge' | '/devtools-chat' | '/devtools-generation-hooks' + | '/devtools-memory' | '/devtools-route-a' | '/devtools-route-b' | '/devtools-structured' @@ -494,6 +513,7 @@ export interface FileRouteTypes { | '/api/arktype-tool-wire' | '/api/audio' | '/api/chat' + | '/api/devtools-memory' | '/api/durable-delivery' | '/api/image' | '/api/lazy-tools-wire' @@ -532,6 +552,7 @@ export interface FileRouteTypes { | '/chat-client-default-bridge' | '/devtools-chat' | '/devtools-generation-hooks' + | '/devtools-memory' | '/devtools-route-a' | '/devtools-route-b' | '/devtools-structured' @@ -546,6 +567,7 @@ export interface FileRouteTypes { | '/api/arktype-tool-wire' | '/api/audio' | '/api/chat' + | '/api/devtools-memory' | '/api/durable-delivery' | '/api/image' | '/api/lazy-tools-wire' @@ -584,6 +606,7 @@ export interface FileRouteTypes { | '/chat-client-default-bridge' | '/devtools-chat' | '/devtools-generation-hooks' + | '/devtools-memory' | '/devtools-route-a' | '/devtools-route-b' | '/devtools-structured' @@ -598,6 +621,7 @@ export interface FileRouteTypes { | '/api/arktype-tool-wire' | '/api/audio' | '/api/chat' + | '/api/devtools-memory' | '/api/durable-delivery' | '/api/image' | '/api/lazy-tools-wire' @@ -637,6 +661,7 @@ export interface RootRouteChildren { ChatClientDefaultBridgeRoute: typeof ChatClientDefaultBridgeRoute DevtoolsChatRoute: typeof DevtoolsChatRoute DevtoolsGenerationHooksRoute: typeof DevtoolsGenerationHooksRoute + DevtoolsMemoryRoute: typeof DevtoolsMemoryRoute DevtoolsRouteARoute: typeof DevtoolsRouteARoute DevtoolsRouteBRoute: typeof DevtoolsRouteBRoute DevtoolsStructuredRoute: typeof DevtoolsStructuredRoute @@ -651,6 +676,7 @@ export interface RootRouteChildren { ApiArktypeToolWireRoute: typeof ApiArktypeToolWireRoute ApiAudioRoute: typeof ApiAudioRouteWithChildren ApiChatRoute: typeof ApiChatRoute + ApiDevtoolsMemoryRoute: typeof ApiDevtoolsMemoryRoute ApiDurableDeliveryRoute: typeof ApiDurableDeliveryRoute ApiImageRoute: typeof ApiImageRouteWithChildren ApiLazyToolsWireRoute: typeof ApiLazyToolsWireRoute @@ -731,6 +757,13 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof DevtoolsRouteARouteImport parentRoute: typeof rootRouteImport } + '/devtools-memory': { + id: '/devtools-memory' + path: '/devtools-memory' + fullPath: '/devtools-memory' + preLoaderRoute: typeof DevtoolsMemoryRouteImport + parentRoute: typeof rootRouteImport + } '/devtools-generation-hooks': { id: '/devtools-generation-hooks' path: '/devtools-generation-hooks' @@ -948,6 +981,13 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof ApiDurableDeliveryRouteImport parentRoute: typeof rootRouteImport } + '/api/devtools-memory': { + id: '/api/devtools-memory' + path: '/api/devtools-memory' + fullPath: '/api/devtools-memory' + preLoaderRoute: typeof ApiDevtoolsMemoryRouteImport + parentRoute: typeof rootRouteImport + } '/api/chat': { id: '/api/chat' path: '/api/chat' @@ -1098,6 +1138,7 @@ const rootRouteChildren: RootRouteChildren = { ChatClientDefaultBridgeRoute: ChatClientDefaultBridgeRoute, DevtoolsChatRoute: DevtoolsChatRoute, DevtoolsGenerationHooksRoute: DevtoolsGenerationHooksRoute, + DevtoolsMemoryRoute: DevtoolsMemoryRoute, DevtoolsRouteARoute: DevtoolsRouteARoute, DevtoolsRouteBRoute: DevtoolsRouteBRoute, DevtoolsStructuredRoute: DevtoolsStructuredRoute, @@ -1112,6 +1153,7 @@ const rootRouteChildren: RootRouteChildren = { ApiArktypeToolWireRoute: ApiArktypeToolWireRoute, ApiAudioRoute: ApiAudioRouteWithChildren, ApiChatRoute: ApiChatRoute, + ApiDevtoolsMemoryRoute: ApiDevtoolsMemoryRoute, ApiDurableDeliveryRoute: ApiDurableDeliveryRoute, ApiImageRoute: ApiImageRouteWithChildren, ApiLazyToolsWireRoute: ApiLazyToolsWireRoute, diff --git a/testing/e2e/src/routes/api.devtools-memory.ts b/testing/e2e/src/routes/api.devtools-memory.ts new file mode 100644 index 000000000..2b73c92b7 --- /dev/null +++ b/testing/e2e/src/routes/api.devtools-memory.ts @@ -0,0 +1,94 @@ +import { createFileRoute } from '@tanstack/react-router' +import { + chat, + chatParamsFromRequestBody, + maxIterations, + toServerSentEventsResponse, +} from '@tanstack/ai' +import { memoryMiddleware } from '@tanstack/ai-memory' +import { createTextAdapter } from '@/lib/providers' +import { devtoolsMemoryAdapter } from '@/lib/devtools-memory-store' +import type { StreamChunk } from '@tanstack/ai' + +/** + * Chat endpoint for the `/devtools-memory` E2E route. Mirrors `/api/chat` but + * wires `memoryMiddleware({ adapter: inMemory() })` so recall/save run and the + * middleware injects the `memory:state` CUSTOM chunk the client devtools bridge + * re-emits as `memory:*`. Scope is the per-test `testId`. + */ +export const Route = createFileRoute('/api/devtools-memory')({ + server: { + handlers: { + POST: async ({ request }) => { + await import('@/lib/llmock-server').then((m) => m.ensureLLMock()) + if (request.signal.aborted) { + return new Response(null, { status: 499 }) + } + + const abortController = new AbortController() + + let params + try { + params = await chatParamsFromRequestBody(await request.json()) + } catch (error) { + return new Response( + error instanceof Error ? error.message : 'Bad request', + { status: 400 }, + ) + } + + const fp = params.forwardedProps as Record + const testId = typeof fp.testId === 'string' ? fp.testId : undefined + const aimockPort = + fp.aimockPort != null ? Number(fp.aimockPort) : undefined + const sessionId = testId ?? 'devtools-memory' + + const adapterOptions = createTextAdapter( + 'openai', + undefined, + aimockPort, + testId, + 'chat', + ) + + try { + const memory = memoryMiddleware({ + adapter: devtoolsMemoryAdapter, + scope: { sessionId }, + }) + + const stream = chat({ + ...adapterOptions, + tools: [], + systemPrompts: ['You are a helpful assistant with long-term memory.'], + middleware: [memory], + agentLoopStrategy: maxIterations(5), + messages: params.messages, + threadId: params.threadId, + runId: params.runId, + abortController, + }) + + return toServerSentEventsResponse( + stream as AsyncIterable, + { abortController }, + ) + } catch (error) { + console.error('[api.devtools-memory] Error:', error) + if ( + (error instanceof Error && error.name === 'AbortError') || + abortController.signal.aborted + ) { + return new Response(null, { status: 499 }) + } + return new Response( + JSON.stringify({ + error: error instanceof Error ? error.message : 'error', + }), + { status: 500, headers: { 'Content-Type': 'application/json' } }, + ) + } + }, + }, + }, +}) diff --git a/testing/e2e/src/routes/devtools-memory.tsx b/testing/e2e/src/routes/devtools-memory.tsx new file mode 100644 index 000000000..159af9da2 --- /dev/null +++ b/testing/e2e/src/routes/devtools-memory.tsx @@ -0,0 +1,43 @@ +import { createFileRoute } from '@tanstack/react-router' +import { fetchServerSentEvents, useChat } from '@tanstack/ai-react' +import { ChatUI } from '@/components/ChatUI' +import { DevtoolsHarness } from '@/components/DevtoolsHarness' +import { parseDevtoolsRouteSearch } from '@/lib/devtools-test' + +export const Route = createFileRoute('/devtools-memory')({ + component: DevtoolsMemoryRoute, + validateSearch: parseDevtoolsRouteSearch, +}) + +function DevtoolsMemoryRoute() { + const { testId, aimockPort } = Route.useSearch() + const chat = useChat({ + id: 'devtools-memory:primary', + connection: fetchServerSentEvents('/api/devtools-memory'), + body: { feature: 'chat', testId, aimockPort }, + devtools: { name: 'Memory Chat' }, + }) + + return ( + +
+
+
+ Memory Chat +
+
+ {chat.status} +
+
+ { + void chat.sendMessage(text) + }} + onStop={chat.stop} + /> +
+
+ ) +} diff --git a/testing/e2e/tests/devtools-memory.spec.ts b/testing/e2e/tests/devtools-memory.spec.ts new file mode 100644 index 000000000..e4fea019c --- /dev/null +++ b/testing/e2e/tests/devtools-memory.spec.ts @@ -0,0 +1,49 @@ +import { test, expect } from './fixtures' +import { sendMessage, waitForResponse } from './helpers' +import { + devtoolsUrl, + openDevtools, + selectDevtoolsTab, + selectHook, +} from './devtools-helpers' + +test.beforeEach(async ({ page }) => { + await page.addInitScript(() => localStorage.clear()) +}) + +test('memory middleware surfaces recall + stored records in the devtools Memory tab', async ({ + page, + testId, + aimockPort, +}) => { + await page.goto(devtoolsUrl('/devtools-memory', testId, aimockPort)) + + // Turn 1: the save is deferred until after the turn, so this turn's + // start-of-turn snapshot is empty — it seeds memory for turn 2. + await sendMessage(page, '[chat] recommend a guitar') + await waitForResponse(page) + await expect(page.getByTestId('assistant-message').first()).toBeVisible() + + // Turn 2: recall runs and the transported snapshot now reflects turn 1's + // saved user/assistant turn. + await sendMessage(page, '[chat] recommend a guitar') + await waitForResponse(page) + + await openDevtools(page) + await selectHook(page, 'Memory Chat') + await selectDevtoolsTab(page, 'Memory') + + await expect(page.getByTestId('ai-devtools-memory-panel')).toBeVisible() + + // Operations timeline: at least the recall for turn 2 was re-emitted. + await expect( + page.getByTestId('ai-devtools-memory-event').first(), + ).toBeVisible() + + // Live contents: turn 1's user + assistant messages are stored and shown. + await expect + .poll(async () => + page.getByTestId('ai-devtools-memory-record').count(), + ) + .toBeGreaterThanOrEqual(2) +}) diff --git a/testing/panel/package.json b/testing/panel/package.json index 2be191842..c0da6d9a1 100644 --- a/testing/panel/package.json +++ b/testing/panel/package.json @@ -25,6 +25,8 @@ "@tanstack/ai-react": "workspace:*", "@tanstack/ai-react-ui": "workspace:*", "@tanstack/nitro-v2-vite-plugin": "^1.155.0", + "@tanstack/react-ai-devtools": "workspace:*", + "@tanstack/react-devtools": "^0.9.10", "@tanstack/react-router": "^1.158.4", "@tanstack/react-start": "^1.159.0", "@tanstack/start": "^1.120.20", diff --git a/testing/panel/src/routes/__root.tsx b/testing/panel/src/routes/__root.tsx index 3801738e2..d4e517b83 100644 --- a/testing/panel/src/routes/__root.tsx +++ b/testing/panel/src/routes/__root.tsx @@ -1,4 +1,6 @@ import { createRootRoute, HeadContent, Scripts } from '@tanstack/react-router' +import { TanStackDevtools } from '@tanstack/react-devtools' +import { aiDevtoolsPlugin } from '@tanstack/react-ai-devtools' import Header from '@/components/Header' import appCss from '../styles.css?url' @@ -36,6 +38,11 @@ function RootDocument({ children }: { children: React.ReactNode }) {
{children}
+ diff --git a/testing/panel/src/routes/memory.tsx b/testing/panel/src/routes/memory.tsx index f7e3be7f0..34fec675f 100644 --- a/testing/panel/src/routes/memory.tsx +++ b/testing/panel/src/routes/memory.tsx @@ -84,6 +84,7 @@ function MemoryPage() { const { messages, sendMessage, isLoading } = useChat({ connection: fetchServerSentEvents('/api/memory-chat'), body, + devtools: { name: 'Memory' }, }) const refreshInspect = useCallback(async () => { From 3268a7ab86bc316e1118a83e53bf28a1ab64207a Mon Sep 17 00:00:00 2001 From: Jack Herrington Date: Wed, 22 Jul 2026 17:12:47 -0700 Subject: [PATCH 44/45] docs(memory): polish memory guides and rename node-redis wrapper Docs pass over docs/memory: lead each page with the reader's problem, drop em dashes, and un-ignore the Redis code samples so they typecheck. Add an Operating page and point its devtools section at the Memory Inspector. Rename the node-redis wrapper nodeRedisAsRedisLike -> fromNodeRedis across source, tests, skill, changeset, and docs, and add ioredis to the kiira dependency resolver so the un-ignored samples check. Co-Authored-By: Claude Opus 4.8 --- .changeset/memory-middleware.md | 2 +- docs/memory/adapters.md | 58 +++---- docs/memory/custom-adapter.md | 61 ++++--- docs/memory/operating.md | 100 +++++++++++ docs/memory/overview.md | 161 ++++++------------ docs/memory/quickstart.md | 83 ++++----- kiira.config.ts | 1 + .../skills/tanstack-ai-memory-redis/SKILL.md | 8 +- .../ai-memory/src/providers/redis/index.ts | 8 +- .../ai-memory/tests/providers/redis.test.ts | 6 +- 10 files changed, 278 insertions(+), 210 deletions(-) create mode 100644 docs/memory/operating.md diff --git a/.changeset/memory-middleware.md b/.changeset/memory-middleware.md index 76a59b6b4..6e9cd9f31 100644 --- a/.changeset/memory-middleware.md +++ b/.changeset/memory-middleware.md @@ -23,7 +23,7 @@ Extraction, ranking, and rendering live inside each adapter — the middleware i `extract` function to persist derived facts. - `@tanstack/ai-memory/redis` → `redis({ redis, prefix? })` — production adapter for plain Redis. `ioredis` wires in directly; `redis` (node-redis v4+) via the - `nodeRedisAsRedisLike(client)` wrapper. Both are optional peer dependencies. + `fromNodeRedis(client)` wrapper. Both are optional peer dependencies. - `@tanstack/ai-memory/hindsight` → `hindsight()`, `@tanstack/ai-memory/mem0` → `mem0()`, `@tanstack/ai-memory/honcho` → `honcho()` — hosted-vendor adapters. Their SDKs (`@vectorize-io/hindsight-client`, `@honcho-ai/sdk`) are optional peers loaded diff --git a/docs/memory/adapters.md b/docs/memory/adapters.md index e5263d853..97f6695aa 100644 --- a/docs/memory/adapters.md +++ b/docs/memory/adapters.md @@ -2,7 +2,7 @@ title: Adapters id: memory-adapters order: 3 -description: "Every built-in and vendor memory adapter in @tanstack/ai-memory, with all of their options and an example of each — inMemory, redis, hindsight, mem0, honcho." +description: "Every built-in and vendor memory adapter in @tanstack/ai-memory, with all of their options and an example of each: inMemory, redis, hindsight, mem0, honcho." keywords: - tanstack ai - memory @@ -16,11 +16,11 @@ keywords: --- Every adapter implements the same `recall`/`save` contract, so they're interchangeable -in `memoryMiddleware`. This page is the exhaustive option reference — each adapter's -options with an example of each. +in `memoryMiddleware`. This page is the full option reference: each adapter's options with +an example of each. -- [Common options](#common-options) — shared by `inMemory()` and `redis()` -- [`inMemory()`](#inmemory) · [`redis()`](#redis) · [`hindsight()`](#hindsight) · [`mem0()`](#mem0) · [`honcho()`](#honcho) +- [Common options](#common-options), shared by `inMemory()` and `redis()` +- Adapters: [`inMemory()`](#inmemory), [`redis()`](#redis), [`hindsight()`](#hindsight), [`mem0()`](#mem0), [`honcho()`](#honcho) ## Common options @@ -32,8 +32,8 @@ they share these options. | `topK` | `number` | `6` | Max hits returned by `recall`. | | `minScore` | `number` | `0.15` | Drop hits scoring below this. | | `kinds` | `Array` | all | Restrict recall to these record kinds (`'message'`, `'summary'`, `'fact'`, `'preference'`). | -| `embedder` | `{ embed(text): Promise }` | — | Enable semantic scoring (embeds on both `recall` and `save`). | -| `extract` | `(turn, scope) => ExtractedFact[]` | — | Persist derived facts on `save`, alongside the raw turn. | +| `embedder` | `{ embed(text): Promise }` | none | Enable semantic scoring (embeds on both `recall` and `save`). | +| `extract` | `(turn, scope) => ExtractedFact[]` | none | Persist derived facts on `save`, alongside the raw turn. | | `render` | `(hits) => string` | built-in | Replace the prompt renderer. | Every option, in one adapter: @@ -59,7 +59,7 @@ const memory = inMemory({ }) ``` -**`extract`** returns `ExtractedFact[]` — `{ text, kind?, importance?, metadata? }`. Return +**`extract`** returns `ExtractedFact[]` (`{ text, kind?, importance?, metadata? }`). Return `undefined` for a no-op. It's where an LLM-based fact extractor plugs in without the adapter taking a hard dependency on any model. @@ -69,7 +69,7 @@ embed stored text). Without it, scoring is lexical + recency only. ## `inMemory()` Zero-dependency, `Map`-backed. Takes only the [common options](#common-options) above. -Records vanish on restart — use it for dev, tests, and single-process demos. +Records vanish on restart, so use it for dev, tests, and single-process demos. ```ts import { inMemory } from '@tanstack/ai-memory/in-memory' @@ -84,18 +84,15 @@ requires a client. | Option | Type | Default | Purpose | |--------|------|---------|---------| -| `redis` | `RedisLike` | — (required) | Your Redis client (`ioredis`, or node-redis via `nodeRedisAsRedisLike`). | +| `redis` | `RedisLike` | (required) | Your Redis client (`ioredis`, or node-redis via `fromNodeRedis`). | | `prefix` | `string` | `'tanstack-ai:memory'` | Key namespace. | -```ts ignore -// ignore: needs a live ioredis client. ioredis's `Redis` type is structurally broader -// than the minimal `RedisLike` the adapter needs, so it doesn't nominally match — but it -// works at runtime, which is why the adapter accepts a BYO ioredis client directly. +```ts import Redis from 'ioredis' import { redis } from '@tanstack/ai-memory/redis' const memory = redis({ - redis: new Redis(process.env.REDIS_URL), // required + redis: new Redis(process.env.REDIS_URL ?? 'redis://localhost:6379'), // required prefix: 'myapp:memory', // key namespace topK: 8, // common options apply here too minScore: 0.2, @@ -103,20 +100,19 @@ const memory = redis({ ``` Using **node-redis** (`redis` package) instead of `ioredis`? Its camelCase API doesn't -match `RedisLike` — wrap it with `nodeRedisAsRedisLike`: +match `RedisLike`, so wrap it with `fromNodeRedis`: -```ts ignore -// ignore: needs a live node-redis client. +```ts import { createClient } from 'redis' -import { redis, nodeRedisAsRedisLike } from '@tanstack/ai-memory/redis' +import { redis, fromNodeRedis } from '@tanstack/ai-memory/redis' const client = createClient({ url: process.env.REDIS_URL }) await client.connect() -const memory = redis({ redis: nodeRedisAsRedisLike(client) }) +const memory = redis({ redis: fromNodeRedis(client) }) ``` -`ioredis` and `redis` are both optional peer dependencies — install whichever you use. +`ioredis` and `redis` are both optional peer dependencies. Install whichever you use. ## `hindsight()` @@ -129,11 +125,10 @@ is an optional peer, loaded lazily. | `user` | `string` | `scope.userId` | Durable user id used in the bank key (`{user}__{sessionId}`). | | `baseUrl` | `string` | `HINDSIGHT_URL` / `http://localhost:8888` | Server URL. | | `budget` | `'low' \| 'mid' \| 'high'` | `'mid'` | Recall budget. | -| `onToolRetain` | `(receipt) => void` | — | Fired when the model calls `hindsight_retain`. | -| `onToolRecall` | `(query, result) => void` | — | Fired when the model calls `hindsight_recall`. | +| `onToolRetain` | `(receipt) => void` | none | Fired when the model calls `hindsight_retain`. | +| `onToolRecall` | `(query, result) => void` | none | Fired when the model calls `hindsight_recall`. | -```ts ignore -// ignore: requires a running Hindsight server + the @vectorize-io/hindsight-client peer. +```ts import { hindsight } from '@tanstack/ai-memory/hindsight' const memory = hindsight({ @@ -159,8 +154,7 @@ mem0 server. | `rerank` | `boolean` | `true` | Ask mem0 to rerank search results. | | `threshold` | `number` | `0.1` | Minimum search score. | -```ts ignore -// ignore: requires a running mem0 server. +```ts import { mem0 } from '@tanstack/ai-memory/mem0' const memory = mem0({ @@ -186,8 +180,7 @@ loaded lazily. | `apiKey` | `string` | `HONCHO_API_KEY` / `'dev-no-auth'` | API key. | | `assistantId` | `string` | `'assistant'` | Assistant peer id. | -```ts ignore -// ignore: requires a running Honcho server + the @honcho-ai/sdk peer. +```ts import { honcho } from '@tanstack/ai-memory/honcho' const memory = honcho({ @@ -201,6 +194,7 @@ const memory = honcho({ ## Where to go next -- [Overview](./overview) — the contract, `memoryMiddleware` options, devtools events -- [Quickstart](./quickstart) — wire an adapter into a real `chat()` call -- [Custom Adapter](./custom-adapter) — implement `recall`/`save` for a backend not shipped +- [Overview](./overview): the `recall`/`save` contract and how a turn flows +- [Quickstart](./quickstart): wire an adapter into a real `chat()` call +- [Operating memory](./operating): options, telemetry, devtools events, and failures +- [Custom Adapter](./custom-adapter): implement `recall`/`save` for a backend that isn't shipped diff --git a/docs/memory/custom-adapter.md b/docs/memory/custom-adapter.md index 83ac2c8cc..d11bc142f 100644 --- a/docs/memory/custom-adapter.md +++ b/docs/memory/custom-adapter.md @@ -2,7 +2,7 @@ title: Custom Adapter id: memory-custom-adapter order: 4 -description: "Write a recall/save MemoryAdapter for a backend that isn't shipped — pgvector, MongoDB, DynamoDB, a hosted memory service. Two methods, one shared contract test." +description: "Write a recall/save MemoryAdapter for a backend that isn't shipped, such as pgvector, MongoDB, DynamoDB, or a hosted memory service. Two methods, one shared contract test." keywords: - tanstack ai - memory @@ -14,7 +14,7 @@ keywords: - contract suite --- -You have a backend in mind — pgvector, MongoDB, DynamoDB, a hosted memory API — and the +You have a backend in mind (pgvector, MongoDB, DynamoDB, a hosted memory API) and the built-in `inMemory()` / `redis()` adapters don't fit. A memory adapter is just an object with two methods, `recall` and `save`, so this is a short guide. @@ -28,13 +28,22 @@ with two methods, `recall` and `save`, so this is a short guide. import type { MemoryAdapter } from '@tanstack/ai-memory' ``` -```ts ignore -// ignore: the shape of the contract, shown for reference. +```ts +// The shape of the contract, shown for reference. +import type { + MemoryFact, + MemoryScope, + MemorySnapshot, + MemoryTurn, + RecallResult, + SaveReceipt, +} from '@tanstack/ai-memory' + interface MemoryAdapter { id: string recall(scope: MemoryScope, query: string): Promise save(scope: MemoryScope, turn: MemoryTurn): Promise> - inspect?(scope: MemoryScope): Promise // optional (devtools) + inspect?(scope: MemoryScope): Promise // optional (devtools) listFacts?(scope: MemoryScope): Promise> // optional (devtools) } ``` @@ -43,18 +52,16 @@ Two rules the middleware relies on: 1. **`recall` decides relevance.** Return a rendered `systemPrompt` (empty string when there's nothing), plus optional `fragments`, `tools`, and `toolGuidance`. Ranking - strategy is entirely yours — lexical, vector, hybrid, or vendor-native. + strategy is entirely yours: lexical, vector, hybrid, or vendor-native. 2. **`save` owns extraction.** Turn the `{ user, assistant }` turn into whatever you persist. Return one `SaveReceipt` per underlying write. Scope isolation is your responsibility: a `recall` for one `scope` must never surface another scope's data. -## Step 1 — Scaffold +## Step 1: Scaffold -```ts ignore -// ignore: `pg` is a peer dependency of a real pgvector adapter (not of these docs), -// and the method bodies are elided — this is a scaffold to copy. +```ts import type { MemoryAdapter, MemoryScope, @@ -62,7 +69,18 @@ import type { RecallResult, SaveReceipt, } from '@tanstack/ai-memory' -import type { Pool } from 'pg' + +// node-postgres' Pool, minimally. In your project, use the real type instead: +// import type { Pool } from 'pg' +type Pool = { + query: ( + text: string, + values: Array, + ) => Promise<{ rows: Array<{ text: string }> }> +} + +// Your embedding client. Swap in OpenAI, Cohere, a local model, etc. +type Embed = (text: string) => Promise> export function pgvectorMemory(options: { pool: Pool; embed: Embed }): MemoryAdapter { const { pool, embed } = options @@ -109,14 +127,14 @@ The shape generalizes: every method takes a `scope`, does its backend-specific w and keeps scopes isolated. For a backend without native search, load the scope's records and rank them yourself. -## Step 2 — Run the contract suite +## Step 2: Run the contract suite `@tanstack/ai-memory/tests/contract` exports `runMemoryAdapterContract`. Point it at a -factory that returns a fresh adapter — it verifies the save→recall round-trip, scope +factory that returns a fresh adapter. It verifies the save then recall round-trip, scope isolation, empty recall, receipt shape, and the optional introspection methods. ```ts ignore -// ignore: depends on `pg` (a peer dep) and a local `../src/pgvector` module. +// ignore: imports the `../src/pgvector` module you wrote in Step 1. // tests/pgvector.test.ts import { runMemoryAdapterContract } from '@tanstack/ai-memory/tests/contract' import { pgvectorMemory } from '../src/pgvector' @@ -127,12 +145,12 @@ runMemoryAdapterContract('pgvectorMemory', async () => { }) ``` -## Step 3 — Wire it into `memoryMiddleware` +## Step 3: Wire it into `memoryMiddleware` Once the suite is green, the adapter is interchangeable with the built-ins: ```ts ignore -// ignore: imports `pg` (a peer dep) and a local `./pgvector` module, and assumes +// ignore: imports the `./pgvector` module you wrote in Step 1, and assumes // `messages` / `scope` from your app. import { chat } from '@tanstack/ai' import { openaiText } from '@tanstack/ai-openai' @@ -148,7 +166,7 @@ const stream = chat({ }) ``` -The middleware never inspects the adapter's internals — `recall`/`save` is the entire +The middleware never inspects the adapter's internals. `recall`/`save` is the entire interface. ## Exposing tools (optional) @@ -163,11 +181,12 @@ recalled prompt. Return `tools: []` (or omit it) when your adapter exposes none. - **Keep scopes isolated.** If you serialize scope into a composite key, escape your delimiter so a `sessionId`/`userId` containing it can't collide with another scope. - **`recall` must not throw for an empty scope.** Return `{ systemPrompt: '' }`. -- **Extraction lives in `save`.** Don't expect the middleware to derive facts — the raw +- **Extraction lives in `save`.** Don't expect the middleware to derive facts. The raw turn is handed to you; store or summarize it however you like. ## Where to go next -- [Overview](./overview) — contract, scope, `memoryMiddleware` options, devtools events -- [Adapters](./adapters) — the built-in and vendor adapters, with every option -- [Quickstart](./quickstart) — wire `memoryMiddleware` into a real `chat()` call +- [Overview](./overview): the `recall`/`save` contract, scope, and how a turn flows +- [Adapters](./adapters): the built-in and vendor adapters, with every option +- [Quickstart](./quickstart): wire `memoryMiddleware` into a real `chat()` call +- [Operating memory](./operating): options, telemetry, devtools events, and failures diff --git a/docs/memory/operating.md b/docs/memory/operating.md new file mode 100644 index 000000000..718a4301b --- /dev/null +++ b/docs/memory/operating.md @@ -0,0 +1,100 @@ +--- +title: Operating +id: memory-operating +order: 5 +description: "Run memoryMiddleware in production: configure its options, add onRecall/onSave telemetry, watch recall and save in devtools, and rely on non-fatal failure handling that never breaks a chat run." +keywords: + - tanstack ai + - memory + - middleware options + - telemetry + - devtools + - observability + - save-only +--- + +Memory is wired into your `chat()` call. Now you want to see whether it actually recalls +anything, get that activity into your own logs, and know that a slow or broken store +won't take down the chat. This page covers the middleware's options and how to observe +and operate it. + +New to memory? Start with the [Overview](./overview) and [Quickstart](./quickstart) first. + +## `memoryMiddleware` options + +| Option | Type | Default | Purpose | +|--------|------|---------|---------| +| `adapter` | `MemoryAdapter` | (required) | The backend to `recall` from and `save` to. | +| `scope` | `MemoryScope \| (ctx) => MemoryScope` | (required) | Isolation scope, static or derived per request. | +| `role` | `'recall+save' \| 'save-only'` | `'recall+save'` | `'save-only'` persists turns without recalling or injecting. | +| `onRecall` | `({ scope, query, result }) => void` | none | App telemetry after each `recall`. | +| `onSave` | `({ scope, turn, receipts }) => void` | none | App telemetry after each deferred `save`. | + +Every option in one place: + +```ts +import { memoryMiddleware } from '@tanstack/ai-memory' +import { inMemory } from '@tanstack/ai-memory/in-memory' + +const mw = memoryMiddleware({ + adapter: inMemory(), + // Function form derives scope per request. `ctx.threadId` is the stable + // per-conversation id; add `userId` from your server-validated session. + scope: (ctx) => ({ sessionId: ctx.threadId }), + role: 'recall+save', // or 'save-only' to persist without injecting + onRecall: ({ query, result }) => { + console.log('recalled', result.fragments?.length ?? 0, 'hits for', query) + }, + onSave: ({ receipts }) => { + console.log('saved', receipts.filter((r) => r.ok).length, 'records') + }, +}) +``` + +## Persist without recalling + +By default the middleware both recalls and saves (`role: 'recall+save'`). Set +`role: 'save-only'` to persist each turn without reading memory back or injecting anything +into the prompt. Use it to build up a user's history before you turn recall on, or on +routes where you want to record turns but not shape the current answer. + +## Telemetry with onRecall and onSave + +The devtools events below are for watching memory during development. For telemetry that +ships with your app, use the `onRecall` and `onSave` callbacks shown above. `onRecall` +fires after each recall with the query and result, so you can count hits. `onSave` fires +after each deferred save with the write receipts, so you can count writes. Send those to +your metrics client instead of `console.log`. + +## Watch it in devtools + +The AI DevTools has a **Memory** tab for any chat wired with `memoryMiddleware`. It +shows each turn's recall (query, fragment count, characters injected, recall duration) +and, for adapters that implement `inspect`/`listFacts` (the built-in `inMemory()` and +`redis()` do), the current stored records and facts. See the +[Memory Inspector](../getting-started/devtools#memory-inspector) for what it renders and +how server-side memory reaches the panel. + +Under the hood the middleware emits these events on `aiEventClient` (from +`@tanstack/ai-event-client`). The panel reads them, and you can subscribe directly: + +| Event | When | +|-------|------| +| `memory:retrieve:started` | Recall begins | +| `memory:retrieve:completed` | Recall returns (fragment count, whether tools were injected) | +| `memory:persist:started` | A deferred save begins | +| `memory:persist:completed` | A save completes (receipt count) | +| `memory:error` | A `recall` or `save` threw (`phase: 'recall'` or `'save'`) | + +## Failures are non-fatal + +A memory failure never breaks a chat run. A throwing `recall` or `save` emits +`memory:error` and the run continues with degraded memory: recall returns nothing, and a +failed save is dropped. Streaming is never blocked, and a failed save never fails the +turn. This means a flaky store degrades the experience instead of taking down the chat. + +## Where to go next + +- [Overview](./overview): the `recall`/`save` contract and how a turn flows +- [Adapters](./adapters): every adapter's options, with an example of each +- [Custom Adapter](./custom-adapter): implement `recall`/`save` for a backend that isn't shipped diff --git a/docs/memory/overview.md b/docs/memory/overview.md index 081ef13b3..5f254a22f 100644 --- a/docs/memory/overview.md +++ b/docs/memory/overview.md @@ -2,7 +2,7 @@ title: Overview id: memory-overview order: 1 -description: "Persist and recall context across turns and sessions in TanStack AI — memoryMiddleware recalls relevant memory into the prompt through a pluggable recall/save adapter, then deferred-saves each finished turn." +description: "Give a TanStack AI chat() call memory across turns and sessions. memoryMiddleware recalls relevant memory into the prompt before the model runs, then saves each finished turn through a pluggable adapter." keywords: - tanstack ai - memory @@ -14,47 +14,51 @@ keywords: - personalization --- -`memoryMiddleware` plugs server-side memory into a `chat()` run. Before the model -runs it **recalls** relevant memory from a pluggable adapter into the system prompt; -after the run finishes it **saves** the turn — asynchronously, so streaming is never -blocked. It's the right tool when you need recall **across turns or across sessions**, -not for keeping recent messages in the same request. +Your assistant forgets everything the moment a session ends. A user tells it their name +this week; next week it asks again. `memoryMiddleware` fixes that. It gives a `chat()` +run memory that survives across turns and across sessions. -Everything lives in `@tanstack/ai-memory`: the middleware, the adapter contract, and -the built-in and vendor adapters. +It works in two moves. Before the model runs, it **recalls** relevant memory from a +pluggable adapter and adds it to the system prompt. After the run finishes, it **saves** +the turn. The save is deferred, so it never blocks streaming. -> **Want a copy-paste setup?** See the [Quickstart](./quickstart). **Building an -> adapter for a backend that isn't shipped?** See the [Custom Adapter](./custom-adapter) guide. +Reach for it when you need recall across turns or sessions. To keep the last few messages +of the same request, just pass them in `messages`. Memory is overkill for that. + +Everything lives in `@tanstack/ai-memory`: the middleware, the adapter contract, and the +built-in and vendor adapters. + +> Want a copy-paste setup? See the [Quickstart](./quickstart). Building an adapter for a +> backend that isn't shipped? See the [Custom Adapter](./custom-adapter) guide. ## When to reach for it | Need | Use this | |------|----------| -| "Remember what the user told me last week" | Memory middleware + a persistent adapter | +| "Remember what the user told me last week" | Memory middleware with a persistent adapter | | "Each user has their own context" | Memory middleware with a scoped adapter | | "Use a hosted memory service (mem0, Honcho, Hindsight)" | The matching vendor adapter | -| Keep the last N turns in the same request | Just pass them in `messages` — memory is overkill | +| Keep the last few turns in the same request | Pass them in `messages`, skip memory | -## The contract: `recall` + `save` +## The contract: recall and save -A memory adapter has one identifier and two verbs. Everything else — extraction, -ranking, rendering, storage — is the adapter's job. The middleware never inspects -records. +A memory adapter has one identifier and two verbs. Extraction, ranking, rendering, and +storage are all the adapter's job. The middleware never looks inside a record. | Member | Purpose | |--------|---------| | `id` | Stable identifier used in logs and devtools. | -| `recall(scope, query)` | Return what's relevant to `query` within `scope`: a rendered `systemPrompt`, optional `fragments`, and optional LLM `tools` + `toolGuidance`. | -| `save(scope, turn)` | Persist a completed `{ user, assistant }` turn. Extraction happens here. Returns one `SaveReceipt` per underlying write. | -| `inspect(scope)?` | Optional — a full snapshot for a devtools panel. | -| `listFacts(scope)?` | Optional — a flat fact list for a devtools panel. | +| `recall(scope, query)` | Return what's relevant to `query` within `scope`: a rendered `systemPrompt`, optional `fragments`, and optional LLM `tools` plus `toolGuidance`. | +| `save(scope, turn)` | Persist a finished `{ user, assistant }` turn. Extraction happens here. Returns one `SaveReceipt` per write. | +| `inspect(scope)?` | Optional. A full snapshot for a devtools panel. | +| `listFacts(scope)?` | Optional. A flat fact list for a devtools panel. | ```ts // The MemoryAdapter contract, from `@tanstack/ai-memory`: import type { MemoryAdapter } from '@tanstack/ai-memory' ``` -Built-in adapters (each a tree-shakeable subpath): +Built-in adapters, each a tree-shakeable subpath: ```ts import { inMemory } from '@tanstack/ai-memory/in-memory' @@ -69,9 +73,23 @@ import { mem0 } from '@tanstack/ai-memory/mem0' import { honcho } from '@tanstack/ai-memory/honcho' ``` +See [Adapters](./adapters) for every adapter and its options. + +## How a turn flows + +1. **Recall** runs before the model, during the run's `init` phase. + `adapter.recall(scope, userText)` returns memory, and the middleware adds the + `systemPrompt`, `toolGuidance`, and any `tools` to the run. +2. **Save** runs after the stream finishes, deferred through `ctx.defer` so it never + blocks the response. The middleware hands the `{ user, assistant }` turn to + `adapter.save(scope, turn)`. + +To add telemetry, watch memory in devtools, persist without recalling, or handle +failures, see [Operating memory](./operating). + ## Scope and security -`MemoryScope` is the isolation boundary — session-centric, with an optional durable +`MemoryScope` is the isolation boundary. It is session-centric, with an optional durable user id: ```ts @@ -82,13 +100,17 @@ type MemoryScope = { } ``` -**Always derive scope server-side from trusted state.** Accepting `userId` from the -request body is how one user reads another user's memory. The function form on `scope` -runs per request and only sees what your server attached to the chat context: +Always derive scope on the server from trusted state. Accepting `userId` from the request +body is how one user reads another user's memory. The function form of `scope` runs per +request and only sees what your server attached to the chat context: + +```ts +import { memoryMiddleware } from '@tanstack/ai-memory' +import type { MemoryAdapter } from '@tanstack/ai-memory' + +declare const adapter: MemoryAdapter +declare function getSession(ctx: unknown): { threadId: string; userId: string } -```ts ignore -// ignore: `adapter` and `getSession` are application-defined — this shows the -// server-side scope-derivation pattern. memoryMiddleware({ adapter, scope: (ctx) => { @@ -98,84 +120,9 @@ memoryMiddleware({ }) ``` -## Recall flow (read side) - -Runs once per `chat()` invocation, during the `init` phase: - -1. `adapter.recall({ sessionId, userId }, userText)` — the adapter decides how to - rank (lexical, semantic, hybrid, or vendor-native). -2. The middleware injects `result.toolGuidance` and `result.systemPrompt` into the - system prompts, and merges `result.tools` into the run's tools. - -Set `role: 'save-only'` to skip recall entirely (persist without reading). - -## Save flow (write side) - -Deferred via `ctx.defer` — runs after the stream finishes and never blocks the response: - -1. The middleware captures the `{ user, assistant }` turn. -2. `adapter.save(scope, turn)` persists it. Extraction (turn → stored facts) is the - adapter's responsibility — the built-in adapters store the raw turn by default and - accept an `extract` option; vendors extract server-side. - -## `memoryMiddleware` options - -| Option | Type | Default | Purpose | -|--------|------|---------|---------| -| `adapter` | `MemoryAdapter` | — (required) | The backend to `recall` from / `save` to. | -| `scope` | `MemoryScope \| (ctx) => MemoryScope` | — (required) | Isolation scope, static or derived per request. | -| `role` | `'recall+save' \| 'save-only'` | `'recall+save'` | `'save-only'` persists turns without recalling/injecting. | -| `onRecall` | `({ scope, query, result }) => void` | — | App telemetry after each `recall`. | -| `onSave` | `({ scope, turn, receipts }) => void` | — | App telemetry after each deferred `save`. | - -Every option in one place: - -```ts -import { memoryMiddleware } from '@tanstack/ai-memory' -import { inMemory } from '@tanstack/ai-memory/in-memory' - -const mw = memoryMiddleware({ - adapter: inMemory(), - // Function form derives scope per request. `ctx.threadId` is the stable - // per-conversation id; add `userId` from your server-validated session. - scope: (ctx) => ({ sessionId: ctx.threadId }), - // Static form is fine for fixtures: scope: { sessionId: 'demo', userId: 'alice' } - role: 'recall+save', // or 'save-only' to persist without injecting - onRecall: ({ query, result }) => { - console.log('recalled', result.fragments?.length ?? 0, 'hits for', query) - }, - onSave: ({ receipts }) => { - console.log('saved', receipts.filter((r) => r.ok).length, 'records') - }, -}) -``` - -See the [Adapters](./adapters) page for every adapter's own options. - -## Devtools events - -The middleware emits five events on `aiEventClient` (from `@tanstack/ai-event-client`): - -| Event | When | -|-------|------| -| `memory:retrieve:started` | Recall begins | -| `memory:retrieve:completed` | Recall returns (fragment count, whether tools were injected) | -| `memory:persist:started` | A deferred save begins | -| `memory:persist:completed` | A save completes (receipt count) | -| `memory:error` | A `recall` or `save` threw (`phase: 'recall' \| 'save'`) | - -For app telemetry that shouldn't depend on devtools, use the `onRecall` / `onSave` -callbacks on `memoryMiddleware`. - -## Failure modes - -Memory failures are **non-fatal**: a throwing `recall` or `save` emits `memory:error` -and the chat run continues with degraded memory. Streaming is never blocked, and a -failed save never fails the turn. - ## Next steps -- [Quickstart](./quickstart) — wire `memoryMiddleware` into a real `chat()` call -- [Adapters](./adapters) — every adapter's options, with an example of each -- [Custom Adapter](./custom-adapter) — implement `recall`/`save` for an unsupported backend -- [Middleware](../advanced/middleware) — the underlying `chat()` middleware lifecycle +- [Quickstart](./quickstart): wire `memoryMiddleware` into a real `chat()` call +- [Adapters](./adapters): every adapter's options, with an example of each +- [Custom Adapter](./custom-adapter): implement `recall`/`save` for a backend that isn't shipped +- [Operating memory](./operating): options, telemetry, devtools events, and failure behavior diff --git a/docs/memory/quickstart.md b/docs/memory/quickstart.md index 26c7b7883..20e4c52e8 100644 --- a/docs/memory/quickstart.md +++ b/docs/memory/quickstart.md @@ -2,7 +2,7 @@ title: Quickstart id: memory-quickstart order: 2 -description: "Add cross-session memory to a TanStack AI chat() call — install the package, pick a recall/save adapter, wire memoryMiddleware, and derive scope server-side." +description: "Add cross-session memory to a TanStack AI chat() call: install the package, pick a recall/save adapter, wire memoryMiddleware, and derive scope server-side." keywords: - tanstack ai - memory @@ -13,13 +13,13 @@ keywords: --- You have a working `chat()` call and you want it to remember context across turns or -sessions. By the end of this guide, `memoryMiddleware` will recall relevant memory into -the prompt and save each finished turn through a real adapter, scoped safely from your +sessions. By the end of this guide, `memoryMiddleware` recalls relevant memory into the +prompt and saves each finished turn through a real adapter, scoped safely from your server-validated session. -> **Want the full contract first?** See the [Overview](./overview). +> Want the full contract first? See the [Overview](./overview). -## Step 1 — Install the package +## Step 1: Install the package ```bash pnpm add @tanstack/ai-memory @@ -28,21 +28,21 @@ pnpm add @tanstack/ai-memory `@tanstack/ai-memory` ships `memoryMiddleware`, the `MemoryAdapter` contract, and the built-in and vendor adapters (each on its own subpath). -## Step 2 — Pick an adapter +## Step 2: Pick an adapter -> **In-memory** — `inMemory()` is zero-dependency and stores records in a `Map`. Use -> it for local development, tests, and single-process demos. Records vanish on restart. +> **In-memory:** `inMemory()` is zero-dependency and stores records in a `Map`. Use it +> for local development, tests, and single-process demos. Records vanish on restart. > -> **Redis** — `redis({ redis })` persists across restarts and shares state across -> processes. Bring your own client (`ioredis`, or `redis` via `nodeRedisAsRedisLike`). +> **Redis:** `redis({ redis })` persists across restarts and shares state across +> processes. Bring your own client (`ioredis`, or `redis` via `fromNodeRedis`). > -> **Vendors** — `hindsight()`, `mem0()`, `honcho()` delegate to a hosted memory service. +> **Vendors:** `hindsight()`, `mem0()`, and `honcho()` delegate to a hosted memory service. -Custom adapters implement the `recall`/`save` contract — see [Custom Adapter](./custom-adapter). +Custom adapters implement the `recall`/`save` contract. See [Custom Adapter](./custom-adapter). -## Step 3 — Wire `memoryMiddleware` into `chat()` +## Step 3: Wire `memoryMiddleware` into `chat()` -Start with the in-memory adapter — the fastest path to a working setup: +Start with the in-memory adapter, the fastest path to a working setup: ```ts import { chat } from '@tanstack/ai' @@ -64,33 +64,33 @@ const stream = chat({ }) ``` -Each turn, the middleware recalls relevant memory into the system prompt (lexical -scoring by default), then deferred-saves the user + assistant turn after the stream -finishes. +Each turn, the middleware recalls relevant memory into the system prompt (lexical scoring +by default), then deferred-saves the user and assistant turn after the stream finishes. When you're ready to ship, swap the adapter and keep everything else the same: -```ts ignore -// ignore: ioredis's `Redis` type is structurally broader than the adapter's minimal -// `RedisLike` contract, so it does not nominally match here — but it works at runtime, -// which is why the adapter accepts a BYO ioredis client directly. `scope` is from Step 5. +```ts import Redis from 'ioredis' +import { memoryMiddleware } from '@tanstack/ai-memory' import { redis } from '@tanstack/ai-memory/redis' +import type { MemoryScope } from '@tanstack/ai-memory' + +declare const scope: MemoryScope // from Step 5 -const client = new Redis(process.env.REDIS_URL) +const client = new Redis(process.env.REDIS_URL ?? 'redis://localhost:6379') const memory = redis({ redis: client }) memoryMiddleware({ adapter: memory, scope }) ``` -> **Using a hosted service?** Swap `inMemory()` for `hindsight({ user })`, -> `mem0({ user })`, or `honcho({ user })`. The middleware wiring is identical — the -> adapter maps `recall`/`save` onto the vendor API. +> Using a hosted service? Swap `inMemory()` for `hindsight({ user })`, `mem0({ user })`, +> or `honcho({ user })`. The middleware wiring is identical. The adapter maps +> `recall`/`save` onto the vendor API. -## Step 4 — Semantic scoring (optional) +## Step 4: Semantic scoring (optional) -The built-in adapters score lexically by default. Pass an `embedder` for semantic -recall when scopes grow large or queries don't share keywords with stored text: +The built-in adapters score lexically by default. Pass an `embedder` for semantic recall +when scopes grow large or queries don't share keywords with stored text: ```ts import OpenAI from 'openai' @@ -113,18 +113,24 @@ const memory = inMemory({ }) ``` -## Step 5 — Derive scope server-side +## Step 5: Derive scope server-side `scope` is the isolation boundary. Static scopes are fine for fixtures, but in any real -app derive scope per request from server-validated session data — never from the -request body. +app derive scope per request from server-validated session data, never from the request +body. -```ts ignore -// ignore: `getSession` and `memory` come from earlier steps / your auth layer — this -// shows the pattern rather than type-checking against a concrete context type. +```ts import { chat } from '@tanstack/ai' import { openaiText } from '@tanstack/ai-openai' import { memoryMiddleware } from '@tanstack/ai-memory' +import type { ModelMessage } from '@tanstack/ai' +import type { MemoryAdapter } from '@tanstack/ai-memory' + +// From earlier steps / your auth layer. +declare const messages: Array +declare const memory: MemoryAdapter +declare const session: { userId: string; threadId: string } +declare function getSession(ctx: unknown): { threadId: string; userId: string } const stream = chat({ adapter: openaiText('gpt-5.5'), @@ -142,11 +148,12 @@ const stream = chat({ }) ``` -On the client, nothing changes — `useChat` (or your connection adapter) consumes the +On the client, nothing changes. `useChat` (or your connection adapter) consumes the stream exactly as before. Memory is entirely server-side. ## Where to go next -- [Overview](./overview) — the `recall`/`save` contract, scope, `memoryMiddleware` options -- [Adapters](./adapters) — every adapter's options, with an example of each -- [Custom Adapter](./custom-adapter) — implement `recall`/`save` for a backend not shipped +- [Overview](./overview): the `recall`/`save` contract, scope, and how a turn flows +- [Adapters](./adapters): every adapter's options, with an example of each +- [Operating memory](./operating): options, telemetry, devtools events, and failures +- [Custom Adapter](./custom-adapter): implement `recall`/`save` for a backend that isn't shipped diff --git a/kiira.config.ts b/kiira.config.ts index 07fb98dce..159fba800 100644 --- a/kiira.config.ts +++ b/kiira.config.ts @@ -46,6 +46,7 @@ export default defineConfig({ hono: '^4.0.0', '@hono/node-server': '^2.0.0', redis: '^6.0.0', + ioredis: '^5.0.0', pino: '^10.0.0', '@opentelemetry/api': '^1.9.0', // Community adapters (each documented page imports its published package) diff --git a/packages/ai-memory/skills/tanstack-ai-memory-redis/SKILL.md b/packages/ai-memory/skills/tanstack-ai-memory-redis/SKILL.md index 6cc1e6777..09c20eb0e 100644 --- a/packages/ai-memory/skills/tanstack-ai-memory-redis/SKILL.md +++ b/packages/ai-memory/skills/tanstack-ai-memory-redis/SKILL.md @@ -1,6 +1,6 @@ --- name: tanstack-ai-memory-redis -description: Use when wiring redis() from @tanstack/ai-memory/redis in production — covers client setup (ioredis or node-redis via nodeRedisAsRedisLike), the storage model, client-side ranking limits, and troubleshooting. +description: Use when wiring redis() from @tanstack/ai-memory/redis in production — covers client setup (ioredis or node-redis via fromNodeRedis), the storage model, client-side ranking limits, and troubleshooting. --- # Redis Memory Adapter @@ -31,20 +31,20 @@ memoryMiddleware({ adapter: memory, scope }) ```ts import { createClient } from 'redis' import { memoryMiddleware } from '@tanstack/ai-memory' -import { redis, nodeRedisAsRedisLike } from '@tanstack/ai-memory/redis' +import { redis, fromNodeRedis } from '@tanstack/ai-memory/redis' const client = createClient({ url: process.env.REDIS_URL }) await client.connect() const memory = redis({ - redis: nodeRedisAsRedisLike(client), + redis: fromNodeRedis(client), prefix: 'myapp:memory', }) memoryMiddleware({ adapter: memory, scope }) ``` -node-redis exposes a camelCase API (`sAdd`, `mGet`); `nodeRedisAsRedisLike` translates it +node-redis exposes a camelCase API (`sAdd`, `mGet`); `fromNodeRedis` translates it to the lowercase `RedisLike` shape. Passing a raw node-redis client without the wrapper throws `client.sadd is not a function`. diff --git a/packages/ai-memory/src/providers/redis/index.ts b/packages/ai-memory/src/providers/redis/index.ts index 4cba9b80c..a3b0961dd 100644 --- a/packages/ai-memory/src/providers/redis/index.ts +++ b/packages/ai-memory/src/providers/redis/index.ts @@ -15,7 +15,7 @@ import type { MemoryAdapter, MemoryScope } from '../../types' /** * Minimal subset of the Redis client API the adapter uses. Shaped to match * `ioredis` directly (lowercase method names). For node-redis v4+'s camelCase - * API, wrap the client with {@link nodeRedisAsRedisLike}. + * API, wrap the client with {@link fromNodeRedis}. */ export interface RedisLike { set: (key: string, value: string) => Promise @@ -27,7 +27,7 @@ export interface RedisLike { mget: (...keys: Array) => Promise> } -/** node-redis v4+ default-mode (camelCase) surface used by {@link nodeRedisAsRedisLike}. */ +/** node-redis v4+ default-mode (camelCase) surface used by {@link fromNodeRedis}. */ export interface NodeRedisLike { get: (key: string) => Promise set: (key: string, value: string) => Promise @@ -43,7 +43,7 @@ export interface NodeRedisLike { * {@link RedisLike} shape this adapter expects. For `ioredis`, no wrapper is * needed — pass the client directly. */ -export function nodeRedisAsRedisLike(client: NodeRedisLike): RedisLike { +export function fromNodeRedis(client: NodeRedisLike): RedisLike { return { get: (key) => client.get(key), set: (key, value) => client.set(key, value), @@ -96,7 +96,7 @@ function warnMalformedRow(id: string, err: unknown): void { * Production memory adapter backed by plain Redis (no vector index required). * Ranks client-side (lexical + optional cosine + recency + importance), so it's * suited to up to ~10k records per scope. Bring your own client (`ioredis`, or - * node-redis wrapped with {@link nodeRedisAsRedisLike}). + * node-redis wrapped with {@link fromNodeRedis}). * * Storage model: * ```text diff --git a/packages/ai-memory/tests/providers/redis.test.ts b/packages/ai-memory/tests/providers/redis.test.ts index 3ec52b8c0..643565916 100644 --- a/packages/ai-memory/tests/providers/redis.test.ts +++ b/packages/ai-memory/tests/providers/redis.test.ts @@ -2,7 +2,7 @@ // the lowercase RedisLike subset ioredis-mock implements (cast below). import RedisMock from 'ioredis-mock' import { describe, expect, it, vi } from 'vitest' -import { nodeRedisAsRedisLike, redis } from '../../src/providers/redis' +import { fromNodeRedis, redis } from '../../src/providers/redis' import type { RedisLike } from '../../src/providers/redis' import { runMemoryAdapterContract } from '../contract' @@ -71,7 +71,7 @@ describe('redis scope-key hardening', () => { }) }) -describe('nodeRedisAsRedisLike', () => { +describe('fromNodeRedis', () => { it('translates camelCase node-redis methods into lowercase RedisLike calls', async () => { const calls: Array<{ method: string; args: Array }> = [] const fakeNodeRedis = { @@ -105,7 +105,7 @@ describe('nodeRedisAsRedisLike', () => { }, } - const wrapped = nodeRedisAsRedisLike(fakeNodeRedis) + const wrapped = fromNodeRedis(fakeNodeRedis) await wrapped.set('k', 'v') await wrapped.sadd('s', 'a', 'b') await wrapped.mget('k1', 'k2') From 12946cc32efc76248b28622ffd5685f46f23d60d Mon Sep 17 00:00:00 2001 From: "autofix-ci[bot]" <114827586+autofix-ci[bot]@users.noreply.github.com> Date: Thu, 23 Jul 2026 00:27:34 +0000 Subject: [PATCH 45/45] ci: apply automated fixes --- packages/ai-client/tests/devtools.test.ts | 4 ++- .../ai-devtools/src/store/memory-registry.ts | 6 +++- packages/ai-memory/src/middleware.ts | 4 ++- packages/ai-memory/tests/middleware.test.ts | 29 +++++++++++++------ testing/e2e/src/routes/api.devtools-memory.ts | 4 ++- testing/e2e/src/routes/devtools-memory.tsx | 5 +++- testing/e2e/tests/devtools-memory.spec.ts | 4 +-- 7 files changed, 39 insertions(+), 17 deletions(-) diff --git a/packages/ai-client/tests/devtools.test.ts b/packages/ai-client/tests/devtools.test.ts index 48ccbed02..ddd33b065 100644 --- a/packages/ai-client/tests/devtools.test.ts +++ b/packages/ai-client/tests/devtools.test.ts @@ -1296,7 +1296,9 @@ describe('ChatClient devtools bridge', () => { }, snapshot: { takenAt: '2026-07-22T00:00:00.000Z', - data: { records: [{ id: 'r1', text: 'name is Jack', kind: 'message' }] }, + data: { + records: [{ id: 'r1', text: 'name is Jack', kind: 'message' }], + }, facts: [{ id: 'r1', text: 'name is Jack', source: 'user' }], }, }, diff --git a/packages/ai-devtools/src/store/memory-registry.ts b/packages/ai-devtools/src/store/memory-registry.ts index 9f2d0b270..4fd1c9138 100644 --- a/packages/ai-devtools/src/store/memory-registry.ts +++ b/packages/ai-devtools/src/store/memory-registry.ts @@ -110,7 +110,11 @@ function ensureScope( let fallbackCounter = 0 -function eventId(payload: { eventId?: string }, type: string, ts: number): string { +function eventId( + payload: { eventId?: string }, + type: string, + ts: number, +): string { if (payload.eventId && payload.eventId.length > 0) return payload.eventId return `${type}:${ts}:${fallbackCounter++}` } diff --git a/packages/ai-memory/src/middleware.ts b/packages/ai-memory/src/middleware.ts index caa4bf4ee..ea6cb12eb 100644 --- a/packages/ai-memory/src/middleware.ts +++ b/packages/ai-memory/src/middleware.ts @@ -294,7 +294,9 @@ function emptyScope(): MemoryScope { async function gatherSnapshot( adapter: MemoryAdapter, scope: MemoryScope, -): Promise<{ takenAt: string; data: unknown; facts: Array } | undefined> { +): Promise< + { takenAt: string; data: unknown; facts: Array } | undefined +> { if (!adapter.inspect) return undefined try { const snapshot = await adapter.inspect(scope) diff --git a/packages/ai-memory/tests/middleware.test.ts b/packages/ai-memory/tests/middleware.test.ts index afbea526c..095e9339d 100644 --- a/packages/ai-memory/tests/middleware.test.ts +++ b/packages/ai-memory/tests/middleware.test.ts @@ -115,9 +115,13 @@ describe('memoryMiddleware', () => { ...base, inspect: async () => ({ takenAt: '2026-07-22T00:00:00.000Z', - data: { records: [{ id: 'r1', text: 'You like cats!', kind: 'message' }] }, + data: { + records: [{ id: 'r1', text: 'You like cats!', kind: 'message' }], + }, }), - listFacts: async () => [{ id: 'r1', text: 'You like cats!', source: 'assistant' }], + listFacts: async () => [ + { id: 'r1', text: 'You like cats!', source: 'assistant' }, + ], } const emit = vi.spyOn(aiEventClient, 'emit').mockImplementation(() => {}) try { @@ -133,7 +137,9 @@ describe('memoryMiddleware', () => { }) await Promise.all(deferred) - const snapshotCall = emit.mock.calls.find((c) => c[0] === 'memory:snapshot') + const snapshotCall = emit.mock.calls.find( + (c) => c[0] === 'memory:snapshot', + ) expect(snapshotCall).toBeTruthy() expect(snapshotCall?.[1]).toMatchObject({ adapter: 'fake', @@ -153,10 +159,16 @@ describe('memoryMiddleware', () => { const config = makeConfig('hi there') const ctx = makeCtx(config, deferred) await mw.onConfig?.(ctx, config) - mw.onFinish?.(ctx, { finishReason: 'stop', duration: 1, content: 'hello' }) + mw.onFinish?.(ctx, { + finishReason: 'stop', + duration: 1, + content: 'hello', + }) await Promise.all(deferred) - expect(emit.mock.calls.some((c) => c[0] === 'memory:snapshot')).toBe(false) + expect(emit.mock.calls.some((c) => c[0] === 'memory:snapshot')).toBe( + false, + ) } finally { emit.mockRestore() } @@ -209,10 +221,9 @@ describe('memoryMiddleware', () => { const config = makeConfig('hi') const ctx = makeCtx(config, []) await mw.onConfig?.(ctx, config) - const out = (await mw.onChunk?.( - ctx, - { type: 'RUN_STARTED' } as unknown as StreamChunk, - )) as Array + const out = (await mw.onChunk?.(ctx, { + type: 'RUN_STARTED', + } as unknown as StreamChunk)) as Array const custom = out[1] as Extract expect(custom.name).toBe(MEMORY_STATE_EVENT) expect((custom.value as { snapshot?: unknown }).snapshot).toBeUndefined() diff --git a/testing/e2e/src/routes/api.devtools-memory.ts b/testing/e2e/src/routes/api.devtools-memory.ts index 2b73c92b7..7a4d5bbd7 100644 --- a/testing/e2e/src/routes/api.devtools-memory.ts +++ b/testing/e2e/src/routes/api.devtools-memory.ts @@ -60,7 +60,9 @@ export const Route = createFileRoute('/api/devtools-memory')({ const stream = chat({ ...adapterOptions, tools: [], - systemPrompts: ['You are a helpful assistant with long-term memory.'], + systemPrompts: [ + 'You are a helpful assistant with long-term memory.', + ], middleware: [memory], agentLoopStrategy: maxIterations(5), messages: params.messages, diff --git a/testing/e2e/src/routes/devtools-memory.tsx b/testing/e2e/src/routes/devtools-memory.tsx index 159af9da2..2f6c45dd7 100644 --- a/testing/e2e/src/routes/devtools-memory.tsx +++ b/testing/e2e/src/routes/devtools-memory.tsx @@ -25,7 +25,10 @@ function DevtoolsMemoryRoute() {
Memory Chat
-
+
{chat.status}
diff --git a/testing/e2e/tests/devtools-memory.spec.ts b/testing/e2e/tests/devtools-memory.spec.ts index e4fea019c..7740adb80 100644 --- a/testing/e2e/tests/devtools-memory.spec.ts +++ b/testing/e2e/tests/devtools-memory.spec.ts @@ -42,8 +42,6 @@ test('memory middleware surfaces recall + stored records in the devtools Memory // Live contents: turn 1's user + assistant messages are stored and shown. await expect - .poll(async () => - page.getByTestId('ai-devtools-memory-record').count(), - ) + .poll(async () => page.getByTestId('ai-devtools-memory-record').count()) .toBeGreaterThanOrEqual(2) })