From b6a899fd0140ff0f804dbc73c6b494660187e8b9 Mon Sep 17 00:00:00 2001 From: Tom Beckenham <34339192+tombeckenham@users.noreply.github.com> Date: Fri, 24 Jul 2026 12:14:57 +1000 Subject: [PATCH 1/9] refactor(ai-memory): align MemoryScope to shared Scope (threadId) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace MemoryScope.sessionId with the shared Scope type from @tanstack/ai so memory and persistence use one conversation key (threadId) plus optional userId/tenantId. Update adapters, middleware, devtools, docs, and panel/e2e call sites. Hard cut — package is still 0.x. Closes #990 --- docs/config.json | 11 +++---- docs/memory/adapters.md | 4 +-- docs/memory/custom-adapter.md | 11 +++---- docs/memory/operating.md | 2 +- docs/memory/overview.md | 13 ++++---- docs/memory/quickstart.md | 4 +-- packages/ai-client/src/devtools.ts | 2 +- packages/ai-client/tests/devtools.test.ts | 4 +-- .../src/components/hooks/MemoryPanel.tsx | 2 +- packages/ai-devtools/src/store/ai-context.tsx | 2 +- .../ai-devtools/src/store/memory-registry.ts | 19 +++++++----- .../ai-devtools/tests/memory-registry.test.ts | 8 ++--- packages/ai-event-client/src/index.ts | 7 +++-- .../tanstack-ai-memory-hindsight/SKILL.md | 2 +- .../skills/tanstack-ai-memory-redis/SKILL.md | 8 ++--- .../skills/tanstack-ai-memory/SKILL.md | 12 ++++---- packages/ai-memory/src/internal/store.ts | 12 +++++--- packages/ai-memory/src/middleware.ts | 2 +- .../src/providers/hindsight/index.ts | 4 +-- .../ai-memory/src/providers/honcho/index.ts | 12 ++++---- .../ai-memory/src/providers/redis/index.ts | 12 +++++--- packages/ai-memory/src/types.ts | 18 +++++------ packages/ai-memory/tests/contract.ts | 4 +-- packages/ai-memory/tests/middleware.test.ts | 2 +- .../ai-memory/tests/providers/honcho.test.ts | 6 ++-- .../tests/providers/in-memory.test.ts | 26 ++++++++++++---- .../ai-memory/tests/providers/mem0.test.ts | 8 ++--- .../ai-memory/tests/providers/redis.test.ts | 13 ++++---- testing/e2e/src/lib/devtools-memory-store.ts | 2 +- testing/e2e/src/routes/api.devtools-memory.ts | 4 +-- testing/e2e/src/routes/api.middleware-test.ts | 2 +- testing/panel/src/lib/memory-store.ts | 2 +- testing/panel/src/routes/api.memory-chat.ts | 10 +++---- .../panel/src/routes/api.memory-inspect.ts | 8 ++--- testing/panel/src/routes/memory.tsx | 30 +++++++++---------- 35 files changed, 161 insertions(+), 127 deletions(-) diff --git a/docs/config.json b/docs/config.json index de57b5fb6..50795fb3d 100644 --- a/docs/config.json +++ b/docs/config.json @@ -468,30 +468,31 @@ "label": "Overview", "to": "memory/overview", "addedAt": "2026-07-21", - "updatedAt": "2026-07-22" + "updatedAt": "2026-07-24" }, { "label": "Quickstart", "to": "memory/quickstart", "addedAt": "2026-07-21", - "updatedAt": "2026-07-22" + "updatedAt": "2026-07-24" }, { "label": "Adapters", "to": "memory/adapters", "addedAt": "2026-07-21", - "updatedAt": "2026-07-22" + "updatedAt": "2026-07-24" }, { "label": "Custom Adapter", "to": "memory/custom-adapter", "addedAt": "2026-07-21", - "updatedAt": "2026-07-22" + "updatedAt": "2026-07-24" }, { "label": "Operating", "to": "memory/operating", - "addedAt": "2026-07-22" + "addedAt": "2026-07-22", + "updatedAt": "2026-07-24" } ] }, diff --git a/docs/memory/adapters.md b/docs/memory/adapters.md index 97f6695aa..c48bf94b2 100644 --- a/docs/memory/adapters.md +++ b/docs/memory/adapters.md @@ -122,7 +122,7 @@ is an optional peer, loaded lazily. | Option | Type | Default | Purpose | |--------|------|---------|---------| -| `user` | `string` | `scope.userId` | Durable user id used in the bank key (`{user}__{sessionId}`). | +| `user` | `string` | `scope.userId` | Durable user id used in the bank key (`{user}__{threadId}`). | | `baseUrl` | `string` | `HINDSIGHT_URL` / `http://localhost:8888` | Server URL. | | `budget` | `'low' \| 'mid' \| 'high'` | `'mid'` | Recall budget. | | `onToolRetain` | `(receipt) => void` | none | Fired when the model calls `hindsight_retain`. | @@ -132,7 +132,7 @@ is an optional peer, loaded lazily. import { hindsight } from '@tanstack/ai-memory/hindsight' const memory = hindsight({ - user: 'alice', // bank = alice__{sessionId} + user: 'alice', // bank = alice__{threadId} baseUrl: 'https://hindsight.internal', // default: HINDSIGHT_URL budget: 'high', // deeper recall onToolRetain: (receipt) => console.log('model retained', receipt.ok), diff --git a/docs/memory/custom-adapter.md b/docs/memory/custom-adapter.md index d11bc142f..4ce8cfe96 100644 --- a/docs/memory/custom-adapter.md +++ b/docs/memory/custom-adapter.md @@ -95,9 +95,9 @@ export function pgvectorMemory(options: { pool: Pool; embed: Embed }): MemoryAda for (const row of rows) { const vector = await embed(row.text) await pool.query( - `INSERT INTO memory (session_id, user_id, role, text, embedding) + `INSERT INTO memory (thread_id, user_id, role, text, embedding) VALUES ($1, $2, $3, $4, $5)`, - [scope.sessionId, scope.userId ?? null, row.role, row.text, JSON.stringify(vector)], + [scope.threadId, scope.userId ?? null, row.role, row.text, JSON.stringify(vector)], ) } return [{ ok: true }] @@ -108,10 +108,10 @@ export function pgvectorMemory(options: { pool: Pool; embed: Embed }): MemoryAda 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) + WHERE thread_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], + [JSON.stringify(q), scope.threadId, scope.userId ?? null], ) const fragments = rows.map((r) => ({ text: r.text, source: 'pgvector' })) const systemPrompt = fragments.length @@ -179,7 +179,8 @@ recalled prompt. Return `tools: []` (or omit it) when your adapter exposes none. ## Pitfalls - **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. + delimiter so a `threadId`/`userId`/`tenantId` 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. diff --git a/docs/memory/operating.md b/docs/memory/operating.md index 718a4301b..de17056bd 100644 --- a/docs/memory/operating.md +++ b/docs/memory/operating.md @@ -40,7 +40,7 @@ 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 }), + scope: (ctx) => ({ threadId: 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) diff --git a/docs/memory/overview.md b/docs/memory/overview.md index 5f254a22f..ef6bbad66 100644 --- a/docs/memory/overview.md +++ b/docs/memory/overview.md @@ -89,14 +89,17 @@ failures, see [Operating memory](./operating). ## Scope and security -`MemoryScope` is the isolation boundary. It is session-centric, with an optional durable -user id: +`MemoryScope` is the isolation boundary. It is an alias of the shared `Scope` identity +type from `@tanstack/ai` — the same vocabulary used by persistence — so memory and chat +center on one conversation key: ```ts -// The MemoryScope type, from `@tanstack/ai-memory`: +// MemoryScope = Scope, from `@tanstack/ai` (re-exported by `@tanstack/ai-memory`): type MemoryScope = { - sessionId: string + threadId: string // required — same as ChatMiddlewareContext.threadId userId?: string + tenantId?: string + namespace?: string // reserved } ``` @@ -115,7 +118,7 @@ memoryMiddleware({ adapter, scope: (ctx) => { const session = getSession(ctx) // your server-validated session - return { sessionId: session.threadId, userId: session.userId } + return { threadId: session.threadId, userId: session.userId } }, }) ``` diff --git a/docs/memory/quickstart.md b/docs/memory/quickstart.md index 20e4c52e8..cd6e38b44 100644 --- a/docs/memory/quickstart.md +++ b/docs/memory/quickstart.md @@ -58,7 +58,7 @@ const stream = chat({ middleware: [ memoryMiddleware({ adapter: memory, - scope: { sessionId: 'demo-thread', userId: 'alice' }, + scope: { threadId: 'demo-thread', userId: 'alice' }, }), ], }) @@ -141,7 +141,7 @@ const stream = chat({ adapter: memory, scope: (ctx) => { const session = getSession(ctx) - return { sessionId: session.threadId, userId: session.userId } + return { threadId: session.threadId, userId: session.userId } }, }), ], diff --git a/packages/ai-client/src/devtools.ts b/packages/ai-client/src/devtools.ts index eea282067..79acfada3 100644 --- a/packages/ai-client/src/devtools.ts +++ b/packages/ai-client/src/devtools.ts @@ -31,7 +31,7 @@ export interface AIDevtoolsDisplayOptions { * depend on `ai-memory`; the memory middleware is the producer. */ interface MemoryStateEventValue { - scope: { sessionId?: string; userId?: string } + scope: { threadId?: string; userId?: string; tenantId?: string } adapter: string query?: string recall?: { diff --git a/packages/ai-client/tests/devtools.test.ts b/packages/ai-client/tests/devtools.test.ts index ddd33b065..267b21c15 100644 --- a/packages/ai-client/tests/devtools.test.ts +++ b/packages/ai-client/tests/devtools.test.ts @@ -1285,7 +1285,7 @@ describe('ChatClient devtools bridge', () => { timestamp: Date.now(), name: 'memory:state', value: { - scope: { sessionId: 'sess-1' }, + scope: { threadId: 'sess-1' }, adapter: 'in-memory', query: 'what is my name?', recall: { @@ -1324,7 +1324,7 @@ describe('ChatClient devtools bridge', () => { [ 'memory:retrieve:started', expect.objectContaining({ - scope: { sessionId: 'sess-1' }, + scope: { threadId: 'sess-1' }, adapter: 'in-memory', query: 'what is my name?', }), diff --git a/packages/ai-devtools/src/components/hooks/MemoryPanel.tsx b/packages/ai-devtools/src/components/hooks/MemoryPanel.tsx index 86a857078..761903cb5 100644 --- a/packages/ai-devtools/src/components/hooks/MemoryPanel.tsx +++ b/packages/ai-devtools/src/components/hooks/MemoryPanel.tsx @@ -8,7 +8,7 @@ import type { } from '../../store/memory-registry' /** - * DevTools "Memory" tab. Memory is per-scope (sessionId), not per-hook, so this + * DevTools "Memory" tab. Memory is per-scope (threadId), 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, diff --git a/packages/ai-devtools/src/store/ai-context.tsx b/packages/ai-devtools/src/store/ai-context.tsx index 24c72290a..01898274d 100644 --- a/packages/ai-devtools/src/store/ai-context.tsx +++ b/packages/ai-devtools/src/store/ai-context.tsx @@ -1208,7 +1208,7 @@ 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). + // replaces the per-scope stored-state view. Keyed by scope (threadId). type MemoryEventInput = Parameters[1] const recordMemoryEvent = ( type: MemoryEventInput['type'], diff --git a/packages/ai-devtools/src/store/memory-registry.ts b/packages/ai-devtools/src/store/memory-registry.ts index 4fd1c9138..5bdd626ce 100644 --- a/packages/ai-devtools/src/store/memory-registry.ts +++ b/packages/ai-devtools/src/store/memory-registry.ts @@ -13,8 +13,8 @@ import type { * 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 + * Memory is keyed by scope (threadId), NOT by hook — several hooks can share + * one thread. The `MemoryPanel` reads a single `MemoryScopeState` by key; the * per-hook tab just resolves which key to show. */ @@ -60,11 +60,12 @@ export interface MemorySnapshotRecord { facts: Array } -/** Everything known about memory for a single scope (sessionId). */ +/** Everything known about memory for a single scope (threadId). */ export interface MemoryScopeState { key: string - sessionId: string + threadId: string userId?: string + tenantId?: string /** Most recent adapter id seen for this scope. */ adapter?: string events: Array @@ -80,10 +81,10 @@ export function createMemoryRegistryState(): MemoryRegistryState { return { scopes: {} } } -/** Stable scope key. Empty/absent sessionId (e.g. error scope) buckets to `(unknown)`. */ +/** Stable scope key. Empty/absent threadId (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 threadId = scope?.threadId + return threadId && threadId.length > 0 ? threadId : '(unknown)' } const MAX_EVENTS_PER_SCOPE = 200 @@ -97,14 +98,16 @@ function ensureScope( if (!entry) { entry = { key, - sessionId: scope?.sessionId ?? '', + threadId: scope?.threadId ?? '', userId: scope?.userId, + tenantId: scope?.tenantId, events: [], lastActivity: 0, } state.scopes[key] = entry } if (scope?.userId) entry.userId = scope.userId + if (scope?.tenantId) entry.tenantId = scope.tenantId return entry } diff --git a/packages/ai-devtools/tests/memory-registry.test.ts b/packages/ai-devtools/tests/memory-registry.test.ts index d9d41c3ac..70af4d187 100644 --- a/packages/ai-devtools/tests/memory-registry.test.ts +++ b/packages/ai-devtools/tests/memory-registry.test.ts @@ -8,7 +8,7 @@ import { } from '../src/store/memory-registry' import type { MemorySnapshotEvent } from '@tanstack/ai-event-client' -const SCOPE = { sessionId: 'session-1' } +const SCOPE = { threadId: 'session-1' } describe('memory registry', () => { it('accumulates the operation timeline per scope', () => { @@ -106,17 +106,17 @@ describe('memory registry', () => { expect(state.scopes[memoryScopeKey(SCOPE)]!.lastActivity).toBe(40) }) - it('isolates scopes and buckets missing sessionId to (unknown)', () => { + it('isolates scopes and buckets missing threadId to (unknown)', () => { const state = createMemoryRegistryState() applyMemoryEvent(state, { type: 'persist:started', - scope: { sessionId: 'a' }, + scope: { threadId: 'a' }, adapter: 'in-memory', timestamp: 1, }) applyMemoryEvent(state, { type: 'error', - scope: { sessionId: '' }, + scope: { threadId: '' }, adapter: 'in-memory', phase: 'save', error: { name: 'Error', message: 'x' }, diff --git a/packages/ai-event-client/src/index.ts b/packages/ai-event-client/src/index.ts index fa36b3d24..00fa6cfdd 100644 --- a/packages/ai-event-client/src/index.ts +++ b/packages/ai-event-client/src/index.ts @@ -825,13 +825,14 @@ export interface VideoUsageEvent extends BaseEventContext { // --------------------------------------------------------------------------- /** - * Lite scope for devtools payloads. Mirrors the `MemoryScope` contract in - * `@tanstack/ai-memory` (session-centric); kept structurally minimal so the + * Lite scope for devtools payloads. Mirrors the shared `Scope` / + * `MemoryScope` contract (`threadId`-centric); kept structurally minimal so the * event client stays decoupled from the memory package. */ export type MemoryScopeLite = { - sessionId?: string + threadId?: string userId?: string + tenantId?: string } /** Emitted when the middleware begins a `recall` for the current turn. */ diff --git a/packages/ai-memory/skills/tanstack-ai-memory-hindsight/SKILL.md b/packages/ai-memory/skills/tanstack-ai-memory-hindsight/SKILL.md index fa12efaa9..ab766f6a4 100644 --- a/packages/ai-memory/skills/tanstack-ai-memory-hindsight/SKILL.md +++ b/packages/ai-memory/skills/tanstack-ai-memory-hindsight/SKILL.md @@ -7,7 +7,7 @@ description: Use when wiring hindsight() from @tanstack/ai-memory/hindsight — 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 +(`{userId}__{threadId}`), and — uniquely — exposes LLM **tools** through `recall` so the model can retain/recall/reflect directly. ## Setup 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 09c20eb0e..97e0d7a21 100644 --- a/packages/ai-memory/skills/tanstack-ai-memory-redis/SKILL.md +++ b/packages/ai-memory/skills/tanstack-ai-memory-redis/SKILL.md @@ -54,13 +54,13 @@ as `inMemory()`. ## Storage model ```text -{prefix}:record:{id} -> JSON record -{prefix}:index:{userId or _}:{sessionId} -> Set +{prefix}:record:{id} -> JSON record +{prefix}:index:{tenantId or _}:{userId or _}:{threadId} -> Set ``` `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. +scores, and renders. Scope values are escaped so a `:` in a `tenantId`/`userId`/`threadId` +can't collide two scopes. ## Ranking limits diff --git a/packages/ai-memory/skills/tanstack-ai-memory/SKILL.md b/packages/ai-memory/skills/tanstack-ai-memory/SKILL.md index 9721e3e09..ebc87e96d 100644 --- a/packages/ai-memory/skills/tanstack-ai-memory/SKILL.md +++ b/packages/ai-memory/skills/tanstack-ai-memory/SKILL.md @@ -39,7 +39,7 @@ const stream = chat({ // Derive scope server-side from trusted session state. scope: (ctx) => { const session = getSession(ctx) - return { sessionId: session.threadId, userId: session.userId } + return { threadId: session.threadId, userId: session.userId } }, }), ], @@ -68,10 +68,12 @@ interface MemoryAdapter { ## 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. +`MemoryScope` is an alias of the shared `Scope` type from `@tanstack/ai`: +`{ threadId, userId?, tenantId?, namespace? }`. It is the isolation boundary. **Never +trust a client-supplied `userId`/`threadId`.** 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 diff --git a/packages/ai-memory/src/internal/store.ts b/packages/ai-memory/src/internal/store.ts index 013e4cb06..79aba3dc3 100644 --- a/packages/ai-memory/src/internal/store.ts +++ b/packages/ai-memory/src/internal/store.ts @@ -94,13 +94,17 @@ export interface RecordStore { /** * 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. + * record is in-scope iff its `threadId` matches and — when the query carries a + * `userId` / `tenantId` — those dimensions match too. `namespace` is reserved + * and ignored until a subsystem keys on it. */ export function sameScope(record: MemoryScope, query: MemoryScope): boolean { - if (record.sessionId !== query.sessionId) return false + if (record.threadId !== query.threadId) return false if (query.userId != null && query.userId !== '') { - return record.userId === query.userId + if (record.userId !== query.userId) return false + } + if (query.tenantId != null && query.tenantId !== '') { + if (record.tenantId !== query.tenantId) return false } return true } diff --git a/packages/ai-memory/src/middleware.ts b/packages/ai-memory/src/middleware.ts index ea6cb12eb..30af0867f 100644 --- a/packages/ai-memory/src/middleware.ts +++ b/packages/ai-memory/src/middleware.ts @@ -282,7 +282,7 @@ export function memoryMiddleware( // =========================== function emptyScope(): MemoryScope { - return { sessionId: '' } + return { threadId: '' } } /** diff --git a/packages/ai-memory/src/providers/hindsight/index.ts b/packages/ai-memory/src/providers/hindsight/index.ts index 91b9d466f..584460e80 100644 --- a/packages/ai-memory/src/providers/hindsight/index.ts +++ b/packages/ai-memory/src/providers/hindsight/index.ts @@ -1,6 +1,6 @@ /** * Hindsight memory adapter. Hindsight owns extraction/ranking server-side and - * buckets memory into per-conversation "banks" (`{userId}__{sessionId}`). Recall + * buckets memory into per-conversation "banks" (`{userId}__{threadId}`). Recall * returns a rendered prompt block AND a set of LLM tools (retain/recall/reflect) * that let the model take direct control of memory. * @@ -113,7 +113,7 @@ export function hindsight(options: HindsightOptions = {}): MemoryAdapter { function bankId(scope: MemoryScope): string { const user = options.user ?? scope.userId ?? 'demo-user' - return `${user}__${scope.sessionId}` + return `${user}__${scope.threadId}` } return { diff --git a/packages/ai-memory/src/providers/honcho/index.ts b/packages/ai-memory/src/providers/honcho/index.ts index 88cb1fd0e..a622a3c31 100644 --- a/packages/ai-memory/src/providers/honcho/index.ts +++ b/packages/ai-memory/src/providers/honcho/index.ts @@ -133,9 +133,9 @@ export function honcho(options: HonchoOptions = {}): MemoryAdapter { } return assistantPeerPromise } - function getSession(sessionId: string): Promise { - return cached(sessionCache, sessionId, async () => - (await getClient()).session(sessionId), + function getSession(threadId: string): Promise { + return cached(sessionCache, threadId, async () => + (await getClient()).session(threadId), ) } @@ -151,7 +151,7 @@ export function honcho(options: HonchoOptions = {}): MemoryAdapter { const [userPeer, assistantPeer, session] = await Promise.all([ getUserPeer(userIdFor(scope)), getAssistantPeer(), - getSession(scope.sessionId), + getSession(scope.threadId), ]) return session.addMessages([ userPeer.message(turn.user), @@ -172,7 +172,7 @@ export function honcho(options: HonchoOptions = {}): MemoryAdapter { const result = await timed(async () => { const [userPeer, session] = await Promise.all([ getUserPeer(userIdFor(scope)), - getSession(scope.sessionId), + getSession(scope.threadId), ]) return userPeer.chat(query, { session }) }) @@ -184,7 +184,7 @@ export function honcho(options: HonchoOptions = {}): MemoryAdapter { }, async inspect(scope): Promise { - const session = await getSession(scope.sessionId).catch(() => null) + const session = await getSession(scope.threadId).catch(() => null) if (!session) { return { takenAt: new Date().toISOString(), diff --git a/packages/ai-memory/src/providers/redis/index.ts b/packages/ai-memory/src/providers/redis/index.ts index a3b0961dd..ab05c4e80 100644 --- a/packages/ai-memory/src/providers/redis/index.ts +++ b/packages/ai-memory/src/providers/redis/index.ts @@ -100,16 +100,20 @@ function warnMalformedRow(id: string, err: unknown): void { * * Storage model: * ```text - * {prefix}:record:{id} -> JSON MemoryRecord - * {prefix}:index:{userId or _}:{sessionId} -> Set + * {prefix}:record:{id} -> JSON MemoryRecord + * {prefix}:index:{tenantId or _}:{userId or _}:{threadId} -> 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 scopeKey = (scope: MemoryScope): string => { + const tenant = + scope.tenantId != null && scope.tenantId !== '' ? scope.tenantId : '_' + const user = scope.userId != null && scope.userId !== '' ? scope.userId : '_' + return `${escapeScopeValue(tenant)}:${escapeScopeValue(user)}:${escapeScopeValue(scope.threadId)}` + } const indexKey = (scope: MemoryScope): string => `${prefix}:index:${scopeKey(scope)}` const recordKey = (id: string): string => `${prefix}:record:${id}` diff --git a/packages/ai-memory/src/types.ts b/packages/ai-memory/src/types.ts index 39afd4002..cabd26d33 100644 --- a/packages/ai-memory/src/types.ts +++ b/packages/ai-memory/src/types.ts @@ -14,26 +14,24 @@ * behind `recall`/`save`; vendor adapters map these two verbs onto their APIs. */ -import type { Tool } from '@tanstack/ai' +import type { Scope, 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). + * Isolation scope for memory reads and writes. Alias of the shared {@link Scope} + * identity type from `@tanstack/ai` so memory and persistence share one + * vocabulary (`threadId`, optional `userId` / `tenantId` / `namespace`). + * + * 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 -} +export type MemoryScope = Scope /** A completed conversation turn handed to {@link MemoryAdapter.save}. */ export interface MemoryTurn { diff --git a/packages/ai-memory/tests/contract.ts b/packages/ai-memory/tests/contract.ts index 7b409e633..192c6f06f 100644 --- a/packages/ai-memory/tests/contract.ts +++ b/packages/ai-memory/tests/contract.ts @@ -13,8 +13,8 @@ export function runMemoryAdapterContract( ) { describe(label, () => { let adapter: MemoryAdapter - const scopeA: MemoryScope = { sessionId: 's1', userId: 'u1' } - const scopeB: MemoryScope = { sessionId: 's2', userId: 'u2' } + const scopeA: MemoryScope = { threadId: 's1', userId: 'u1' } + const scopeB: MemoryScope = { threadId: 's2', userId: 'u2' } beforeEach(async () => { adapter = await factory() diff --git a/packages/ai-memory/tests/middleware.test.ts b/packages/ai-memory/tests/middleware.test.ts index 095e9339d..c9e3a1ef0 100644 --- a/packages/ai-memory/tests/middleware.test.ts +++ b/packages/ai-memory/tests/middleware.test.ts @@ -53,7 +53,7 @@ function fakeAdapter( } } -const scope: MemoryScope = { sessionId: 's1', userId: 'u1' } +const scope: MemoryScope = { threadId: 's1', userId: 'u1' } describe('memoryMiddleware', () => { it('injects recalled systemPrompt + toolGuidance + tools at init', async () => { diff --git a/packages/ai-memory/tests/providers/honcho.test.ts b/packages/ai-memory/tests/providers/honcho.test.ts index 1b638b7d5..867d3245b 100644 --- a/packages/ai-memory/tests/providers/honcho.test.ts +++ b/packages/ai-memory/tests/providers/honcho.test.ts @@ -71,7 +71,7 @@ describe('honcho factory', () => { 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' }, + { threadId: 's1', userId: 'u1' }, { user: 'I live in Berlin', assistant: 'noted' }, ) expect(receipts).toHaveLength(1) @@ -82,7 +82,7 @@ describe('honcho factory', () => { it('recall returns the dialectic answer as the systemPrompt', async () => { const adapter = honcho({ user: 'u1' }) const result = await adapter.recall( - { sessionId: 's1', userId: 'u1' }, + { threadId: 's1', userId: 'u1' }, 'where do I live', ) expect(result.systemPrompt).toBe('dialectic answer about: where do I live') @@ -90,7 +90,7 @@ describe('honcho factory', () => { it('listFacts parses the peer representation into rows', async () => { const adapter = honcho({ user: 'u1' }) - const facts = await adapter.listFacts?.({ sessionId: 's1', userId: 'u1' }) + const facts = await adapter.listFacts?.({ threadId: 's1', userId: 'u1' }) expect(facts?.map((f) => f.text)).toEqual([ 'user lives in Berlin', 'user likes hiking', diff --git a/packages/ai-memory/tests/providers/in-memory.test.ts b/packages/ai-memory/tests/providers/in-memory.test.ts index d4c0a0c1e..7ad625b7d 100644 --- a/packages/ai-memory/tests/providers/in-memory.test.ts +++ b/packages/ai-memory/tests/providers/in-memory.test.ts @@ -11,7 +11,7 @@ describe('inMemory options', () => { { text: `fact: ${turn.user}`, kind: 'fact', importance: 1 }, ], }) - const scope = { sessionId: 's1', userId: 'u1' } + const scope = { threadId: '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:') @@ -21,16 +21,32 @@ describe('inMemory options', () => { it('respects the userId dimension of scope', async () => { const adapter = inMemory() await adapter.save( - { sessionId: 's', userId: 'a' }, + { threadId: 's', userId: 'a' }, { user: 'apples are red', assistant: 'ok', }, ) - const sameSessionOtherUser = await adapter.recall( - { sessionId: 's', userId: 'b' }, + const sameThreadOtherUser = await adapter.recall( + { threadId: 's', userId: 'b' }, 'apples', ) - expect(sameSessionOtherUser.systemPrompt).toBe('') + expect(sameThreadOtherUser.systemPrompt).toBe('') + }) + + it('respects the tenantId dimension of scope', async () => { + const adapter = inMemory() + await adapter.save( + { threadId: 's', userId: 'u', tenantId: 'tenant-a' }, + { + user: 'apples are red', + assistant: 'ok', + }, + ) + const otherTenant = await adapter.recall( + { threadId: 's', userId: 'u', tenantId: 'tenant-b' }, + 'apples', + ) + expect(otherTenant.systemPrompt).toBe('') }) }) diff --git a/packages/ai-memory/tests/providers/mem0.test.ts b/packages/ai-memory/tests/providers/mem0.test.ts index 4bf938ea3..06569c293 100644 --- a/packages/ai-memory/tests/providers/mem0.test.ts +++ b/packages/ai-memory/tests/providers/mem0.test.ts @@ -61,7 +61,7 @@ describe('mem0 save', () => { 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' }, + { threadId: 's1', userId: 'u1' }, { user: 'I live in Berlin', assistant: 'noted' }, ) expect(receipts).toHaveLength(1) @@ -90,7 +90,7 @@ describe('mem0 recall', () => { })) const adapter = mem0({ baseUrl: 'http://mem0.test', user: 'u1' }) const result = await adapter.recall( - { sessionId: 's1', userId: 'u1' }, + { threadId: 's1', userId: 'u1' }, 'where do I live', ) expect(calls[0]?.url).toBe('http://mem0.test/search') @@ -110,7 +110,7 @@ describe('mem0 recall', () => { 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') + const result = await adapter.recall({ threadId: 's1' }, 'anything') expect(result.systemPrompt).toBe('') expect(result.fragments).toHaveLength(0) }) @@ -118,7 +118,7 @@ describe('mem0 recall', () => { 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') + const result = await adapter.recall({ threadId: 's1' }, 'anything') expect(result.systemPrompt).toBe('') expect(result.fragments).toHaveLength(0) expect(result.raw).toBeDefined() diff --git a/packages/ai-memory/tests/providers/redis.test.ts b/packages/ai-memory/tests/providers/redis.test.ts index 643565916..16390a517 100644 --- a/packages/ai-memory/tests/providers/redis.test.ts +++ b/packages/ai-memory/tests/providers/redis.test.ts @@ -19,7 +19,7 @@ describe('redis malformed rows', () => { const prefix = `test:${crypto.randomUUID()}` const client = mockClient() const adapter = redis({ redis: client, prefix }) - const scope = { sessionId: 's', userId: 'u' } + const scope = { threadId: 's', userId: 'u', tenantId: 't' } const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}) try { await adapter.save(scope, { @@ -27,7 +27,8 @@ describe('redis malformed rows', () => { assistant: 'noted', }) // Find the stored record ids from the scope index and corrupt one. - const indexKey = `${prefix}:index:u:s` + // Index key is {tenantId}:{userId}:{threadId} (unset dims use escaped `_`). + const indexKey = `${prefix}:index:t:u:s` const ids = await client.smembers(indexKey) expect(ids.length).toBeGreaterThan(0) const badId = ids[0] as string @@ -53,17 +54,17 @@ describe('redis scope-key hardening', () => { 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`. + // Without escaping, { userId: 'a:b', threadId: 'c' } and + // { userId: 'a', threadId: 'b:c' } both serialize to key `_:a:b:c`. await adapter.save( - { userId: 'a:b', sessionId: 'c' }, + { userId: 'a:b', threadId: 'c' }, { user: 'confidential tenant one data', assistant: 'ok', }, ) const other = await adapter.recall( - { userId: 'a', sessionId: 'b:c' }, + { userId: 'a', threadId: 'b:c' }, 'confidential', ) expect(other.systemPrompt).toBe('') diff --git a/testing/e2e/src/lib/devtools-memory-store.ts b/testing/e2e/src/lib/devtools-memory-store.ts index 1869eea95..d13aa6e93 100644 --- a/testing/e2e/src/lib/devtools-memory-store.ts +++ b/testing/e2e/src/lib/devtools-memory-store.ts @@ -4,6 +4,6 @@ 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`). + * panel. Scope is keyed per-test by `threadId` (the Playwright `testId`). */ export const devtoolsMemoryAdapter = inMemory() diff --git a/testing/e2e/src/routes/api.devtools-memory.ts b/testing/e2e/src/routes/api.devtools-memory.ts index 7a4d5bbd7..411adc388 100644 --- a/testing/e2e/src/routes/api.devtools-memory.ts +++ b/testing/e2e/src/routes/api.devtools-memory.ts @@ -41,7 +41,7 @@ export const Route = createFileRoute('/api/devtools-memory')({ const testId = typeof fp.testId === 'string' ? fp.testId : undefined const aimockPort = fp.aimockPort != null ? Number(fp.aimockPort) : undefined - const sessionId = testId ?? 'devtools-memory' + const threadId = testId ?? 'devtools-memory' const adapterOptions = createTextAdapter( 'openai', @@ -54,7 +54,7 @@ export const Route = createFileRoute('/api/devtools-memory')({ try { const memory = memoryMiddleware({ adapter: devtoolsMemoryAdapter, - scope: { sessionId }, + scope: { threadId }, }) const stream = chat({ diff --git a/testing/e2e/src/routes/api.middleware-test.ts b/testing/e2e/src/routes/api.middleware-test.ts index 8e5bead4d..33d588783 100644 --- a/testing/e2e/src/routes/api.middleware-test.ts +++ b/testing/e2e/src/routes/api.middleware-test.ts @@ -424,7 +424,7 @@ export const Route = createFileRoute('/api/middleware-test')({ middleware.push( memoryMiddleware({ adapter: createFakeMemoryAdapter(testId), - scope: { sessionId: testId }, + scope: { threadId: testId }, }), createMemoryConfigRecorder(testId), ) diff --git a/testing/panel/src/lib/memory-store.ts b/testing/panel/src/lib/memory-store.ts index 337294765..947db5fff 100644 --- a/testing/panel/src/lib/memory-store.ts +++ b/testing/panel/src/lib/memory-store.ts @@ -17,7 +17,7 @@ import type { RecallResult } from '@tanstack/ai-memory' export const memoryAdapter = inMemory() /** - * Records what the last `recall` injected for each session, so the page can + * Records what the last `recall` injected for each thread, 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. */ diff --git a/testing/panel/src/routes/api.memory-chat.ts b/testing/panel/src/routes/api.memory-chat.ts index be1146576..f91a05b15 100644 --- a/testing/panel/src/routes/api.memory-chat.ts +++ b/testing/panel/src/routes/api.memory-chat.ts @@ -27,7 +27,7 @@ memory rather than saying you don't know.` * 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`. + * from the client-supplied `threadId`. */ export const Route = createFileRoute('/api/memory-chat')({ server: { @@ -45,7 +45,7 @@ export const Route = createFileRoute('/api/memory-chat')({ const provider: Provider = data.provider || 'openai' const model: string | undefined = data.model - const sessionId: string = data.sessionId || 'panel-default-session' + const threadId: string = data.threadId || 'panel-default-thread' try { const adapterConfig = { @@ -79,14 +79,14 @@ export const Route = createFileRoute('/api/memory-chat')({ const { adapter } = options console.log( - `>> memory chat: model ${model} on ${provider} (session ${sessionId})`, + `>> memory chat: model ${model} on ${provider} (thread ${threadId})`, ) const memory = memoryMiddleware({ adapter: memoryAdapter, - scope: { sessionId }, + scope: { threadId }, onRecall: (info) => { - lastRecallBySession.set(sessionId, info.result) + lastRecallBySession.set(threadId, info.result) }, }) diff --git a/testing/panel/src/routes/api.memory-inspect.ts b/testing/panel/src/routes/api.memory-inspect.ts index 9a859487b..40a67e89c 100644 --- a/testing/panel/src/routes/api.memory-inspect.ts +++ b/testing/panel/src/routes/api.memory-inspect.ts @@ -11,13 +11,13 @@ 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 threadId = + new URL(request.url).searchParams.get('threadId') ?? '' + const scope = { threadId } const snapshot = await memoryAdapter.inspect?.(scope) const facts = await memoryAdapter.listFacts?.(scope) - const lastRecall = lastRecallBySession.get(sessionId) ?? null + const lastRecall = lastRecallBySession.get(threadId) ?? null return new Response( JSON.stringify({ diff --git a/testing/panel/src/routes/memory.tsx b/testing/panel/src/routes/memory.tsx index 34fec675f..1052a000a 100644 --- a/testing/panel/src/routes/memory.tsx +++ b/testing/panel/src/routes/memory.tsx @@ -6,7 +6,7 @@ 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' +const THREAD_STORAGE_KEY = 'panel-memory-thread' // Shapes returned by /api/memory-inspect. These mirror the `inMemory()` // snapshot payload + the RecallResult contract; kept local so the page has no @@ -58,27 +58,27 @@ function MemoryPage() { const [selectedModel, setSelectedModel] = useState( getDefaultModelOption(), ) - const [sessionId, setSessionId] = useState('') + const [threadId, setThreadId] = useState('') const [inspect, setInspect] = useState(null) const [input, setInput] = useState('') - // Resolve (or create) a stable session id, persisted so memory survives reloads. + // Resolve (or create) a stable thread id, persisted so memory survives reloads. useEffect(() => { - let existing = localStorage.getItem(SESSION_STORAGE_KEY) + let existing = localStorage.getItem(THREAD_STORAGE_KEY) if (!existing) { existing = crypto.randomUUID() - localStorage.setItem(SESSION_STORAGE_KEY, existing) + localStorage.setItem(THREAD_STORAGE_KEY, existing) } - setSessionId(existing) + setThreadId(existing) }, []) const body = useMemo( () => ({ provider: selectedModel.provider, model: selectedModel.model, - sessionId, + threadId, }), - [selectedModel.provider, selectedModel.model, sessionId], + [selectedModel.provider, selectedModel.model, threadId], ) const { messages, sendMessage, isLoading } = useChat({ @@ -88,18 +88,18 @@ function MemoryPage() { }) const refreshInspect = useCallback(async () => { - if (!sessionId) return + if (!threadId) return try { const res = await fetch( - `/api/memory-inspect?sessionId=${encodeURIComponent(sessionId)}`, + `/api/memory-inspect?threadId=${encodeURIComponent(threadId)}`, ) if (res.ok) setInspect(await res.json()) } catch { // Non-fatal: the inspector is a read-only view; leave the last snapshot. } - }, [sessionId]) + }, [threadId]) - // Refresh the inspector whenever the session changes and each time a turn + // Refresh the inspector whenever the thread changes and each time a turn // finishes (isLoading falls back to false). const wasLoading = useRef(false) useEffect(() => { @@ -112,8 +112,8 @@ function MemoryPage() { const startNewSession = () => { const next = crypto.randomUUID() - localStorage.setItem(SESSION_STORAGE_KEY, next) - setSessionId(next) + localStorage.setItem(THREAD_STORAGE_KEY, next) + setThreadId(next) setInspect(null) } @@ -215,7 +215,7 @@ function MemoryPage() {

What's in memory

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

From 1d6efac8f39151ced67d61603b6035ddee61ee2c Mon Sep 17 00:00:00 2001 From: "autofix-ci[bot]" <114827586+autofix-ci[bot]@users.noreply.github.com> Date: Fri, 24 Jul 2026 02:16:24 +0000 Subject: [PATCH 2/9] ci: apply automated fixes --- packages/ai-memory/src/providers/redis/index.ts | 3 ++- testing/panel/src/routes/api.memory-inspect.ts | 3 +-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/packages/ai-memory/src/providers/redis/index.ts b/packages/ai-memory/src/providers/redis/index.ts index ab05c4e80..045aa5ca3 100644 --- a/packages/ai-memory/src/providers/redis/index.ts +++ b/packages/ai-memory/src/providers/redis/index.ts @@ -111,7 +111,8 @@ export function redis(options: RedisOptions): MemoryAdapter { const scopeKey = (scope: MemoryScope): string => { const tenant = scope.tenantId != null && scope.tenantId !== '' ? scope.tenantId : '_' - const user = scope.userId != null && scope.userId !== '' ? scope.userId : '_' + const user = + scope.userId != null && scope.userId !== '' ? scope.userId : '_' return `${escapeScopeValue(tenant)}:${escapeScopeValue(user)}:${escapeScopeValue(scope.threadId)}` } const indexKey = (scope: MemoryScope): string => diff --git a/testing/panel/src/routes/api.memory-inspect.ts b/testing/panel/src/routes/api.memory-inspect.ts index 40a67e89c..921c7effc 100644 --- a/testing/panel/src/routes/api.memory-inspect.ts +++ b/testing/panel/src/routes/api.memory-inspect.ts @@ -11,8 +11,7 @@ export const Route = createFileRoute('/api/memory-inspect')({ server: { handlers: { GET: async ({ request }) => { - const threadId = - new URL(request.url).searchParams.get('threadId') ?? '' + const threadId = new URL(request.url).searchParams.get('threadId') ?? '' const scope = { threadId } const snapshot = await memoryAdapter.inspect?.(scope) From 45178c4246d9135b879df187720aebde5746f23f Mon Sep 17 00:00:00 2001 From: Tom Beckenham <34339192+tombeckenham@users.noreply.github.com> Date: Fri, 24 Jul 2026 12:38:12 +1000 Subject: [PATCH 3/9] test(ai-memory): harden scope isolation after threadId alignment Add sameScope/redis/contract isolation coverage, composite DevTools scope keys, vendor Scope field docs, and the memory-scope-threadid changeset. --- .changeset/memory-scope-threadid.md | 26 ++++++++ .changeset/shared-scope.md | 6 +- docs/memory/adapters.md | 27 ++++++++ docs/memory/overview.md | 9 +-- .../src/components/hooks/MemoryPanel.tsx | 28 ++++---- packages/ai-devtools/src/store/ai-context.tsx | 3 +- .../ai-devtools/src/store/memory-registry.ts | 40 ++++++++++-- .../ai-devtools/tests/memory-registry.test.ts | 53 ++++++++++++++- .../tanstack-ai-memory-hindsight/SKILL.md | 3 + .../skills/tanstack-ai-memory-honcho/SKILL.md | 4 ++ .../skills/tanstack-ai-memory-mem0/SKILL.md | 4 ++ .../skills/tanstack-ai-memory-redis/SKILL.md | 10 ++- .../skills/tanstack-ai-memory/SKILL.md | 8 ++- packages/ai-memory/src/internal/store.ts | 11 ++-- .../ai-memory/src/providers/redis/index.ts | 3 + packages/ai-memory/src/types.ts | 5 +- packages/ai-memory/tests/contract.ts | 32 +++++++++ .../tests/providers/in-memory.test.ts | 6 ++ .../ai-memory/tests/providers/redis.test.ts | 65 +++++++++++++++++++ packages/ai-memory/tests/same-scope.test.ts | 55 ++++++++++++++++ testing/panel/src/lib/memory-store.ts | 2 +- testing/panel/src/routes/api.memory-chat.ts | 7 +- .../panel/src/routes/api.memory-inspect.ts | 6 +- testing/panel/src/routes/memory.tsx | 6 +- 24 files changed, 371 insertions(+), 48 deletions(-) create mode 100644 .changeset/memory-scope-threadid.md create mode 100644 packages/ai-memory/tests/same-scope.test.ts diff --git a/.changeset/memory-scope-threadid.md b/.changeset/memory-scope-threadid.md new file mode 100644 index 000000000..ba5c66bfe --- /dev/null +++ b/.changeset/memory-scope-threadid.md @@ -0,0 +1,26 @@ +--- +'@tanstack/ai-memory': minor +'@tanstack/ai-event-client': minor +'@tanstack/ai-client': minor +'@tanstack/ai-devtools-core': minor +--- + +**Align `MemoryScope` to the shared `Scope` type (`threadId`).** + +`MemoryScope` is now an alias of `Scope` from `@tanstack/ai` so memory and +persistence share one isolation vocabulary. The conversation key is +`threadId` (required); optional dims are `userId`, `tenantId`, and reserved +`namespace`. There is no public `sessionId` on memory scope — hard cut while +`@tanstack/ai-memory` is still `0.x` / unreleased. + +- `@tanstack/ai-memory` — `export type MemoryScope = Scope`. Built-in adapters + (`inMemory`, `redis`) and middleware use `threadId`; `sameScope` also matches + `tenantId` when present on the query. Redis index keys are now + `{prefix}:index:{tenantId|_}:{userId|_}:{threadId}` (escaped). Hindsight banks + use `{user}__{threadId}`. Anyone who wrote Redis rows under the pre-rename + layout needs to reindex or wipe — keys are not dual-read. +- `@tanstack/ai-event-client` — `MemoryScopeLite` is + `{ threadId?, userId?, tenantId? }` (devtools telemetry; not an isolation + authority). +- `@tanstack/ai-client` / `@tanstack/ai-devtools-core` — memory event payloads + and the Memory panel registry follow the same `threadId` field names. diff --git a/.changeset/shared-scope.md b/.changeset/shared-scope.md index fc71894c4..7dee65362 100644 --- a/.changeset/shared-scope.md +++ b/.changeset/shared-scope.md @@ -24,6 +24,6 @@ favor of it) — subsystems must not introduce a second name (`sessionId`, …) the same concept. Every field is an isolation boundary and must be derived server-side from trusted session state, never from client input. -This is additive: nothing consumes `Scope` yet. It lands ahead of the -persistence and memory PRs so both build on one settled, unambiguous identity -contract instead of diverging. +Introduced ahead of the persistence and memory packages so both share one settled +identity contract. `@tanstack/ai-memory` now aliases `MemoryScope` to `Scope` +(see the memory-scope-threadid changeset). diff --git a/docs/memory/adapters.md b/docs/memory/adapters.md index c48bf94b2..41a701bb6 100644 --- a/docs/memory/adapters.md +++ b/docs/memory/adapters.md @@ -114,6 +114,26 @@ const memory = redis({ redis: fromNodeRedis(client) }) `ioredis` and `redis` are both optional peer dependencies. Install whichever you use. +**Scope fields:** index keys are `{prefix}:index:{tenantId|_}:{userId|_}:{threadId}` +(each segment escaped so `:`, `\`, and `_` in values cannot collide). Missing optional +dims become `_`, so a write with `tenantId` and a read without it hit **different** keys +— always pass the same dims you wrote with. There is no dual-read of older layouts; if +you previously wrote under a different index shape, reindex or wipe. + +## Which `Scope` fields each adapter honors + +| Adapter | `threadId` | `userId` | `tenantId` | `namespace` | +|---------|------------|----------|------------|-------------| +| `inMemory()` | yes | yes (filter when present) | yes (filter when present) | ignored | +| `redis()` | yes (key segment) | yes (key segment) | yes (key segment) | ignored | +| `hindsight()` | yes (bank id) | yes (bank id / `user` option) | **no** | ignored | +| `mem0()` | **no** (user-scoped only) | yes (`user_id` / `user` option) | **no** | ignored | +| `honcho()` | yes (Honcho session id) | yes (peer id / `user` option) | **no** | ignored | + +Vendor backends that do not model tenants will share memory across tenants when +`userId`+`threadId` (or just `userId` for mem0) collide — use a tenant-aware adapter +or encode the tenant into `user`/`threadId` yourself. + ## `hindsight()` Hosted adapter backed by Hindsight. Owns extraction/ranking server-side and exposes @@ -141,6 +161,8 @@ const memory = hindsight({ }) ``` +Bank id is `{user}__{threadId}`. `tenantId` is not part of the bank key. + ## `mem0()` Hosted adapter backed by a mem0 server, over plain HTTP (no SDK peer). Requires a running @@ -166,6 +188,8 @@ const memory = mem0({ }) ``` +mem0 is **user-scoped only** — `threadId` and `tenantId` are not sent to the server. + ## `honcho()` Hosted adapter backed by Honcho. `recall` returns a synthesized dialectic answer over the @@ -192,6 +216,9 @@ const memory = honcho({ }) ``` +Honcho sessions are keyed by `threadId`; peers by `user` / `scope.userId`. `tenantId` is +not sent. + ## Where to go next - [Overview](./overview): the `recall`/`save` contract and how a turn flows diff --git a/docs/memory/overview.md b/docs/memory/overview.md index ef6bbad66..6f6c53142 100644 --- a/docs/memory/overview.md +++ b/docs/memory/overview.md @@ -94,17 +94,18 @@ type from `@tanstack/ai` — the same vocabulary used by persistence — so memo center on one conversation key: ```ts -// MemoryScope = Scope, from `@tanstack/ai` (re-exported by `@tanstack/ai-memory`): +// MemoryScope is an alias of Scope from `@tanstack/ai`, exported by `@tanstack/ai-memory`: type MemoryScope = { threadId: string // required — same as ChatMiddlewareContext.threadId userId?: string tenantId?: string - namespace?: string // reserved + namespace?: string // reserved — no adapter keys on it yet } ``` -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 +Always resolve scope on the server from trusted session/auth state. A client-originated +`threadId` is fine only after you validate it belongs to the session user; never accept +`userId`/`tenantId` from the request body alone. The function form of `scope` runs per request and only sees what your server attached to the chat context: ```ts diff --git a/packages/ai-devtools/src/components/hooks/MemoryPanel.tsx b/packages/ai-devtools/src/components/hooks/MemoryPanel.tsx index 761903cb5..b3e65c754 100644 --- a/packages/ai-devtools/src/components/hooks/MemoryPanel.tsx +++ b/packages/ai-devtools/src/components/hooks/MemoryPanel.tsx @@ -2,15 +2,17 @@ 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 { memoryScopeLabel } from '../../store/memory-registry' import type { MemoryEventRecord, MemoryScopeState, } from '../../store/memory-registry' /** - * DevTools "Memory" tab. Memory is per-scope (threadId), 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: + * DevTools "Memory" tab. Memory is per composite scope (tenant/user/thread), + * 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 @@ -134,14 +136,18 @@ export const MemoryPanel: Component = () => { data-testid="ai-devtools-memory-scope-select" > - {(key) => ( - - )} + {(key) => { + const entry = state.memory.scopes[key] + const label = entry + ? memoryScopeLabel(entry) + : key + return ( + + ) + }} diff --git a/packages/ai-devtools/src/store/ai-context.tsx b/packages/ai-devtools/src/store/ai-context.tsx index 01898274d..3a9546358 100644 --- a/packages/ai-devtools/src/store/ai-context.tsx +++ b/packages/ai-devtools/src/store/ai-context.tsx @@ -1208,7 +1208,8 @@ 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 (threadId). + // replaces the per-scope stored-state view. Keyed by composite scope + // (tenantId/userId/threadId). type MemoryEventInput = Parameters[1] const recordMemoryEvent = ( type: MemoryEventInput['type'], diff --git a/packages/ai-devtools/src/store/memory-registry.ts b/packages/ai-devtools/src/store/memory-registry.ts index 5bdd626ce..fb7ad0daa 100644 --- a/packages/ai-devtools/src/store/memory-registry.ts +++ b/packages/ai-devtools/src/store/memory-registry.ts @@ -13,9 +13,9 @@ import type { * 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 (threadId), NOT by hook — several hooks can share - * one thread. The `MemoryPanel` reads a single `MemoryScopeState` by key; the - * per-hook tab just resolves which key to show. + * Memory is keyed by composite scope (`tenantId`/`userId`/`threadId`), NOT by + * hook — several hooks can share one scope. 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. */ @@ -60,7 +60,7 @@ export interface MemorySnapshotRecord { facts: Array } -/** Everything known about memory for a single scope (threadId). */ +/** Everything known about memory for a single composite scope. */ export interface MemoryScopeState { key: string threadId: string @@ -81,10 +81,38 @@ export function createMemoryRegistryState(): MemoryRegistryState { return { scopes: {} } } -/** Stable scope key. Empty/absent threadId (e.g. error scope) buckets to `(unknown)`. */ +/** + * Escape `:` / `\` so composite keys cannot collide when a dim contains the + * separator (mirrors redis scope-key hardening). + */ +function escapeScopeDim(value: string): string { + return value.replace(/[\\:]/g, '\\$&') +} + +/** Unset optional dims serialize as `_` (same convention as the redis adapter). */ +function scopeDim(value: string | undefined): string { + return value != null && value.length > 0 ? escapeScopeDim(value) : '_' +} + +/** + * Stable scope key: `{tenantId|_}:{userId|_}:{threadId}`. Empty/absent + * `threadId` (e.g. error scope) buckets to `(unknown)` so broken events do not + * invent a fake conversation. + */ export function memoryScopeKey(scope: MemoryScopeLite | undefined): string { const threadId = scope?.threadId - return threadId && threadId.length > 0 ? threadId : '(unknown)' + if (!threadId || threadId.length === 0) return '(unknown)' + return `${scopeDim(scope?.tenantId)}:${scopeDim(scope?.userId)}:${escapeScopeDim(threadId)}` +} + +/** Human-readable label for the Memory panel scope picker. */ +export function memoryScopeLabel(entry: MemoryScopeState): string { + const thread = + entry.threadId && entry.threadId.length > 0 ? entry.threadId : '(unknown)' + const parts = [entry.tenantId, entry.userId, thread].filter( + (p): p is string => p != null && p.length > 0, + ) + return parts.join(' · ') } const MAX_EVENTS_PER_SCOPE = 200 diff --git a/packages/ai-devtools/tests/memory-registry.test.ts b/packages/ai-devtools/tests/memory-registry.test.ts index 70af4d187..d1116e7fd 100644 --- a/packages/ai-devtools/tests/memory-registry.test.ts +++ b/packages/ai-devtools/tests/memory-registry.test.ts @@ -5,10 +5,11 @@ import { clearMemoryRegistry, createMemoryRegistryState, memoryScopeKey, + memoryScopeLabel, } from '../src/store/memory-registry' import type { MemorySnapshotEvent } from '@tanstack/ai-event-client' -const SCOPE = { threadId: 'session-1' } +const SCOPE = { threadId: 'thread-1' } describe('memory registry', () => { it('accumulates the operation timeline per scope', () => { @@ -122,7 +123,55 @@ describe('memory registry', () => { error: { name: 'Error', message: 'x' }, timestamp: 2, }) - expect(Object.keys(state.scopes).sort()).toEqual(['(unknown)', 'a']) + expect(Object.keys(state.scopes).sort()).toEqual([ + '(unknown)', + memoryScopeKey({ threadId: 'a' }), + ]) + }) + + it('stores userId/tenantId and isolates composite scopes', () => { + const state = createMemoryRegistryState() + const tenantA = { + threadId: 'shared', + userId: 'u', + tenantId: 'tenant-a', + } + const tenantB = { + threadId: 'shared', + userId: 'u', + tenantId: 'tenant-b', + } + applyMemoryEvent(state, { + type: 'persist:started', + scope: tenantA, + adapter: 'in-memory', + timestamp: 1, + }) + applyMemoryEvent(state, { + type: 'persist:started', + scope: tenantB, + adapter: 'redis', + timestamp: 2, + }) + + const keyA = memoryScopeKey(tenantA) + const keyB = memoryScopeKey(tenantB) + expect(keyA).not.toBe(keyB) + expect(state.scopes[keyA]).toMatchObject({ + threadId: 'shared', + userId: 'u', + tenantId: 'tenant-a', + adapter: 'in-memory', + }) + expect(state.scopes[keyB]).toMatchObject({ + threadId: 'shared', + userId: 'u', + tenantId: 'tenant-b', + adapter: 'redis', + }) + expect(memoryScopeLabel(state.scopes[keyA]!)).toBe( + 'tenant-a · u · shared', + ) }) it('clears the registry', () => { diff --git a/packages/ai-memory/skills/tanstack-ai-memory-hindsight/SKILL.md b/packages/ai-memory/skills/tanstack-ai-memory-hindsight/SKILL.md index ab766f6a4..d7fdc6a20 100644 --- a/packages/ai-memory/skills/tanstack-ai-memory-hindsight/SKILL.md +++ b/packages/ai-memory/skills/tanstack-ai-memory-hindsight/SKILL.md @@ -31,6 +31,9 @@ first use — install it where you use `hindsight()`. - `budget` — recall budget: `'low' | 'mid' | 'high'` (default `'mid'`). - `onToolRetain` / `onToolRecall` — callbacks fired when the model uses the memory tools. +**Scope fields:** bank id is `{user}__{threadId}`. `tenantId` and `namespace` are not +part of the bank key — multi-tenant isolation must be encoded into `user` if needed. + ## Tools `recall` returns `hindsight_retain`, `hindsight_recall`, and `hindsight_reflect` in its diff --git a/packages/ai-memory/skills/tanstack-ai-memory-honcho/SKILL.md b/packages/ai-memory/skills/tanstack-ai-memory-honcho/SKILL.md index a41b59790..250cba7dd 100644 --- a/packages/ai-memory/skills/tanstack-ai-memory-honcho/SKILL.md +++ b/packages/ai-memory/skills/tanstack-ai-memory-honcho/SKILL.md @@ -32,5 +32,9 @@ it where you use `honcho()`. - `apiKey` — default `HONCHO_API_KEY`. - `assistantId` — assistant peer id (default `'assistant'`). +**Scope fields:** Honcho session id = `scope.threadId`; peer id = `user` / +`scope.userId`. `tenantId` and `namespace` are not sent — encode multi-tenant +isolation into `user` or `workspaceId` if needed. + `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-mem0/SKILL.md b/packages/ai-memory/skills/tanstack-ai-memory-mem0/SKILL.md index 7f442f340..05af4d8de 100644 --- a/packages/ai-memory/skills/tanstack-ai-memory-mem0/SKILL.md +++ b/packages/ai-memory/skills/tanstack-ai-memory-mem0/SKILL.md @@ -29,5 +29,9 @@ Requires a running mem0 server (self-hosted or hosted). Point it via `baseUrl` ( - `apiKey` — bearer token (default `MEM0_ADMIN_API_KEY`). - `rerank` (default `true`), `threshold` (default `0.1`) — search tuning. +**Scope fields:** mem0 is **user-scoped only** — only `user_id` is sent. `threadId`, +`tenantId`, and `namespace` are ignored. Encode tenant/thread isolation into `user` +if the backend must not share memory across those dims. + `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 97e0d7a21..cb5ed2fbb 100644 --- a/packages/ai-memory/skills/tanstack-ai-memory-redis/SKILL.md +++ b/packages/ai-memory/skills/tanstack-ai-memory-redis/SKILL.md @@ -59,8 +59,14 @@ as `inMemory()`. ``` `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 `tenantId`/`userId`/`threadId` -can't collide two scopes. +scores, and renders. Scope values are escaped (`:`, `\`, and `_`) so a delimiter or the +unset placeholder inside a dim can't collide two scopes. + +**Hard cut:** there is no dual-read of older index layouts. If you previously wrote under +a different shape (e.g. without `tenantId`), reindex or wipe — old keys are orphaned. + +Always pass the same `tenantId`/`userId`/`threadId` on write and read: missing optional +dims become `_`, so omit ≠ "match any". ## Ranking limits diff --git a/packages/ai-memory/skills/tanstack-ai-memory/SKILL.md b/packages/ai-memory/skills/tanstack-ai-memory/SKILL.md index ebc87e96d..ce0750467 100644 --- a/packages/ai-memory/skills/tanstack-ai-memory/SKILL.md +++ b/packages/ai-memory/skills/tanstack-ai-memory/SKILL.md @@ -78,8 +78,12 @@ 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). + Honors `threadId` + optional `userId`/`tenantId` (`namespace` ignored). +- `redis({ redis })` from `@tanstack/ai-memory/redis` — production, plain Redis. Index + keys include `tenantId`/`userId`/`threadId`; no dual-read of older layouts. +- `hindsight()` — bank `{user}__{threadId}`; does **not** use `tenantId`. +- `mem0()` — **user-scoped only** (`user_id`); `threadId`/`tenantId` are not sent. +- `honcho()` — session = `threadId`, peer = user; does **not** use `tenantId`. - Custom — implement `recall`/`save` and run `@tanstack/ai-memory/tests/contract`. ## Failure modes diff --git a/packages/ai-memory/src/internal/store.ts b/packages/ai-memory/src/internal/store.ts index 79aba3dc3..39e53f3c4 100644 --- a/packages/ai-memory/src/internal/store.ts +++ b/packages/ai-memory/src/internal/store.ts @@ -92,11 +92,12 @@ export interface RecordStore { // =========================== /** - * 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 `threadId` matches and — when the query carries a - * `userId` / `tenantId` — those dimensions match too. `namespace` is reserved - * and ignored until a subsystem keys on it. + * Scope match for built-in stores. `threadId` must always match. Optional + * `userId` / `tenantId` are filter-when-present: when the query supplies a + * non-empty value, the record must match it; when the query omits them, any + * record value is accepted. Callers must pass the same dims they wrote with + * (the middleware does). Redis keys unset dims as `_` instead — omit ≠ match + * any there. `namespace` is reserved and ignored until a subsystem keys on it. */ export function sameScope(record: MemoryScope, query: MemoryScope): boolean { if (record.threadId !== query.threadId) return false diff --git a/packages/ai-memory/src/providers/redis/index.ts b/packages/ai-memory/src/providers/redis/index.ts index 045aa5ca3..ddeeecfb6 100644 --- a/packages/ai-memory/src/providers/redis/index.ts +++ b/packages/ai-memory/src/providers/redis/index.ts @@ -103,6 +103,9 @@ function warnMalformedRow(id: string, err: unknown): void { * {prefix}:record:{id} -> JSON MemoryRecord * {prefix}:index:{tenantId or _}:{userId or _}:{threadId} -> Set * ``` + * Segments are escaped (so `:`, `\\`, `_` in values cannot collide). Missing + * optional dims become `_` (omit ≠ match any). No dual-read of older index + * layouts. */ export function redis(options: RedisOptions): MemoryAdapter { const client = options.redis diff --git a/packages/ai-memory/src/types.ts b/packages/ai-memory/src/types.ts index cabd26d33..5d2028d32 100644 --- a/packages/ai-memory/src/types.ts +++ b/packages/ai-memory/src/types.ts @@ -28,8 +28,9 @@ import type { Scope, Tool } from '@tanstack/ai' * 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. + * Resolve every field server-side from trusted session/auth state. A client- + * originated `threadId` is only safe after you validate it belongs to the + * session user; never accept bare `userId`/`tenantId` from the request body. */ export type MemoryScope = Scope diff --git a/packages/ai-memory/tests/contract.ts b/packages/ai-memory/tests/contract.ts index 192c6f06f..1046b9922 100644 --- a/packages/ai-memory/tests/contract.ts +++ b/packages/ai-memory/tests/contract.ts @@ -58,6 +58,38 @@ export function runMemoryAdapterContract( expect(other.systemPrompt).toBe('') expect(other.fragments ?? []).toHaveLength(0) }) + + it('isolates same threadId across different userId', async () => { + await adapter.save( + { threadId: 'shared-thread', userId: 'alice' }, + { + user: 'Alice secret token is red-fox', + assistant: 'Understood.', + }, + ) + const otherUser = await adapter.recall( + { threadId: 'shared-thread', userId: 'bob' }, + 'secret token', + ) + expect(otherUser.systemPrompt).toBe('') + expect(otherUser.fragments ?? []).toHaveLength(0) + }) + + it('isolates same threadId+userId across different tenantId', async () => { + await adapter.save( + { threadId: 'shared-thread', userId: 'u', tenantId: 'tenant-a' }, + { + user: 'Tenant A vault code is blue-jay', + assistant: 'Understood.', + }, + ) + const otherTenant = await adapter.recall( + { threadId: 'shared-thread', userId: 'u', tenantId: 'tenant-b' }, + 'vault code', + ) + expect(otherTenant.systemPrompt).toBe('') + expect(otherTenant.fragments ?? []).toHaveLength(0) + }) }) describe('optional introspection', () => { diff --git a/packages/ai-memory/tests/providers/in-memory.test.ts b/packages/ai-memory/tests/providers/in-memory.test.ts index 7ad625b7d..a7b2fc7b3 100644 --- a/packages/ai-memory/tests/providers/in-memory.test.ts +++ b/packages/ai-memory/tests/providers/in-memory.test.ts @@ -48,5 +48,11 @@ describe('inMemory options', () => { 'apples', ) expect(otherTenant.systemPrompt).toBe('') + + const sameTenant = await adapter.recall( + { threadId: 's', userId: 'u', tenantId: 'tenant-a' }, + 'apples', + ) + expect(sameTenant.systemPrompt.toLowerCase()).toContain('apples') }) }) diff --git a/packages/ai-memory/tests/providers/redis.test.ts b/packages/ai-memory/tests/providers/redis.test.ts index 16390a517..775c698b3 100644 --- a/packages/ai-memory/tests/providers/redis.test.ts +++ b/packages/ai-memory/tests/providers/redis.test.ts @@ -48,6 +48,50 @@ describe('redis malformed rows', () => { }) }) +describe('redis scope isolation', () => { + it('respects the tenantId dimension of scope', async () => { + const prefix = `test:${crypto.randomUUID()}` + const adapter = redis({ redis: mockClient(), prefix }) + await adapter.save( + { threadId: 's', userId: 'u', tenantId: 'tenant-a' }, + { + user: 'tenant A confidential penguins', + assistant: 'ok', + }, + ) + const otherTenant = await adapter.recall( + { threadId: 's', userId: 'u', tenantId: 'tenant-b' }, + 'penguins', + ) + expect(otherTenant.systemPrompt).toBe('') + expect(otherTenant.fragments ?? []).toHaveLength(0) + + const sameTenant = await adapter.recall( + { threadId: 's', userId: 'u', tenantId: 'tenant-a' }, + 'penguins', + ) + expect(sameTenant.systemPrompt.toLowerCase()).toContain('penguins') + }) + + it('does not hit a tenant-scoped index when tenantId is omitted on recall', async () => { + // Redis keys missing dims as `_`, so omit ≠ "match any". + const prefix = `test:${crypto.randomUUID()}` + const adapter = redis({ redis: mockClient(), prefix }) + await adapter.save( + { threadId: 's', userId: 'u', tenantId: 'tenant-a' }, + { + user: 'tenant-scoped only', + assistant: 'ok', + }, + ) + const withoutTenant = await adapter.recall( + { threadId: 's', userId: 'u' }, + 'tenant-scoped', + ) + expect(withoutTenant.systemPrompt).toBe('') + }) +}) + describe('redis scope-key hardening', () => { it('escapes the delimiter so scope values containing ":" cannot collide', async () => { const prefix = `test:${crypto.randomUUID()}` @@ -70,6 +114,27 @@ describe('redis scope-key hardening', () => { expect(other.systemPrompt).toBe('') expect(other.fragments ?? []).toHaveLength(0) }) + + it('escapes ":" inside tenantId so 3-segment keys cannot collide', async () => { + const prefix = `test:${crypto.randomUUID()}` + const adapter = redis({ redis: mockClient(), prefix }) + // Without escaping, { tenantId: 'a:b', userId: 'c', threadId: 'd' } and + // { tenantId: 'a', userId: 'b:c', threadId: 'd' } both serialize to + // `a:b:c:d`. + await adapter.save( + { tenantId: 'a:b', userId: 'c', threadId: 'd' }, + { + user: 'escape-tenant-a secret', + assistant: 'ok', + }, + ) + const other = await adapter.recall( + { tenantId: 'a', userId: 'b:c', threadId: 'd' }, + 'escape-tenant', + ) + expect(other.systemPrompt).toBe('') + expect(other.fragments ?? []).toHaveLength(0) + }) }) describe('fromNodeRedis', () => { diff --git a/packages/ai-memory/tests/same-scope.test.ts b/packages/ai-memory/tests/same-scope.test.ts new file mode 100644 index 000000000..06d9daeb0 --- /dev/null +++ b/packages/ai-memory/tests/same-scope.test.ts @@ -0,0 +1,55 @@ +import { describe, expect, it } from 'vitest' +import { sameScope } from '../src/internal/store' + +describe('sameScope', () => { + const full = { + threadId: 's', + userId: 'u', + tenantId: 'a', + } + + it('matches when threadId and present dims agree', () => { + expect(sameScope(full, full)).toBe(true) + expect( + sameScope(full, { threadId: 's', userId: 'u', tenantId: 'a' }), + ).toBe(true) + }) + + it('rejects different threadId', () => { + expect(sameScope(full, { ...full, threadId: 'other' })).toBe(false) + }) + + it('rejects different userId when query supplies userId', () => { + expect(sameScope(full, { threadId: 's', userId: 'other' })).toBe(false) + }) + + it('rejects different tenantId when query supplies tenantId', () => { + expect(sameScope(full, { ...full, tenantId: 'b' })).toBe(false) + }) + + it('treats omitted / empty query dims as "do not filter"', () => { + // Filter-when-present: a query without tenantId still matches a + // tenant-scoped record. Callers must pass the full dims used at write. + expect(sameScope(full, { threadId: 's', userId: 'u' })).toBe(true) + expect(sameScope(full, { threadId: 's' })).toBe(true) + expect(sameScope(full, { threadId: 's', userId: '', tenantId: '' })).toBe( + true, + ) + }) + + it('ignores namespace (reserved — no subsystem keys on it yet)', () => { + expect( + sameScope( + { ...full, namespace: 'bank-a' }, + { ...full, namespace: 'bank-b' }, + ), + ).toBe(true) + }) + + it('matches records that also lack optional dims', () => { + expect( + sameScope({ threadId: 's' }, { threadId: 's', userId: 'u' }), + ).toBe(false) + expect(sameScope({ threadId: 's' }, { threadId: 's' })).toBe(true) + }) +}) diff --git a/testing/panel/src/lib/memory-store.ts b/testing/panel/src/lib/memory-store.ts index 947db5fff..05f3db961 100644 --- a/testing/panel/src/lib/memory-store.ts +++ b/testing/panel/src/lib/memory-store.ts @@ -21,4 +21,4 @@ export const memoryAdapter = inMemory() * 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() +export const lastRecallByThread = new Map() diff --git a/testing/panel/src/routes/api.memory-chat.ts b/testing/panel/src/routes/api.memory-chat.ts index f91a05b15..dfb3c3380 100644 --- a/testing/panel/src/routes/api.memory-chat.ts +++ b/testing/panel/src/routes/api.memory-chat.ts @@ -12,7 +12,7 @@ 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 { lastRecallByThread, 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. @@ -27,7 +27,8 @@ memory rather than saying you don't know.` * 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 `threadId`. + * from the client-supplied `threadId` (demo-only — production must derive + * scope from trusted server session state, not bare client input). */ export const Route = createFileRoute('/api/memory-chat')({ server: { @@ -86,7 +87,7 @@ export const Route = createFileRoute('/api/memory-chat')({ adapter: memoryAdapter, scope: { threadId }, onRecall: (info) => { - lastRecallBySession.set(threadId, info.result) + lastRecallByThread.set(threadId, info.result) }, }) diff --git a/testing/panel/src/routes/api.memory-inspect.ts b/testing/panel/src/routes/api.memory-inspect.ts index 921c7effc..0515f9abc 100644 --- a/testing/panel/src/routes/api.memory-inspect.ts +++ b/testing/panel/src/routes/api.memory-inspect.ts @@ -1,9 +1,9 @@ import { createFileRoute } from '@tanstack/react-router' -import { lastRecallBySession, memoryAdapter } from '@/lib/memory-store' +import { lastRecallByThread, 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: + * "what's in memory" for a thread, straight off the shared singleton adapter: * the full record snapshot, the flat fact list, and what the most recent * `recall` injected into the prompt. */ @@ -16,7 +16,7 @@ export const Route = createFileRoute('/api/memory-inspect')({ const snapshot = await memoryAdapter.inspect?.(scope) const facts = await memoryAdapter.listFacts?.(scope) - const lastRecall = lastRecallBySession.get(threadId) ?? null + const lastRecall = lastRecallByThread.get(threadId) ?? null return new Response( JSON.stringify({ diff --git a/testing/panel/src/routes/memory.tsx b/testing/panel/src/routes/memory.tsx index 1052a000a..5e5e9beab 100644 --- a/testing/panel/src/routes/memory.tsx +++ b/testing/panel/src/routes/memory.tsx @@ -110,7 +110,7 @@ function MemoryPage() { refreshInspect() }, [refreshInspect]) - const startNewSession = () => { + const startNewThread = () => { const next = crypto.randomUUID() localStorage.setItem(THREAD_STORAGE_KEY, next) setThreadId(next) @@ -227,11 +227,11 @@ function MemoryPage() { Refresh
From c5ba7a2f5448763e0f56c55137bd54426a143406 Mon Sep 17 00:00:00 2001 From: "autofix-ci[bot]" <114827586+autofix-ci[bot]@users.noreply.github.com> Date: Fri, 24 Jul 2026 02:39:45 +0000 Subject: [PATCH 4/9] ci: apply automated fixes --- .../ai-devtools/src/components/hooks/MemoryPanel.tsx | 8 ++++---- packages/ai-devtools/tests/memory-registry.test.ts | 4 +--- packages/ai-memory/tests/same-scope.test.ts | 12 ++++++------ 3 files changed, 11 insertions(+), 13 deletions(-) diff --git a/packages/ai-devtools/src/components/hooks/MemoryPanel.tsx b/packages/ai-devtools/src/components/hooks/MemoryPanel.tsx index b3e65c754..b900c95d8 100644 --- a/packages/ai-devtools/src/components/hooks/MemoryPanel.tsx +++ b/packages/ai-devtools/src/components/hooks/MemoryPanel.tsx @@ -138,13 +138,13 @@ export const MemoryPanel: Component = () => { {(key) => { const entry = state.memory.scopes[key] - const label = entry - ? memoryScopeLabel(entry) - : key + const label = entry ? memoryScopeLabel(entry) : key return ( ) }} diff --git a/packages/ai-devtools/tests/memory-registry.test.ts b/packages/ai-devtools/tests/memory-registry.test.ts index d1116e7fd..5a7d97c54 100644 --- a/packages/ai-devtools/tests/memory-registry.test.ts +++ b/packages/ai-devtools/tests/memory-registry.test.ts @@ -169,9 +169,7 @@ describe('memory registry', () => { tenantId: 'tenant-b', adapter: 'redis', }) - expect(memoryScopeLabel(state.scopes[keyA]!)).toBe( - 'tenant-a · u · shared', - ) + expect(memoryScopeLabel(state.scopes[keyA]!)).toBe('tenant-a · u · shared') }) it('clears the registry', () => { diff --git a/packages/ai-memory/tests/same-scope.test.ts b/packages/ai-memory/tests/same-scope.test.ts index 06d9daeb0..a338bd85a 100644 --- a/packages/ai-memory/tests/same-scope.test.ts +++ b/packages/ai-memory/tests/same-scope.test.ts @@ -10,9 +10,9 @@ describe('sameScope', () => { it('matches when threadId and present dims agree', () => { expect(sameScope(full, full)).toBe(true) - expect( - sameScope(full, { threadId: 's', userId: 'u', tenantId: 'a' }), - ).toBe(true) + expect(sameScope(full, { threadId: 's', userId: 'u', tenantId: 'a' })).toBe( + true, + ) }) it('rejects different threadId', () => { @@ -47,9 +47,9 @@ describe('sameScope', () => { }) it('matches records that also lack optional dims', () => { - expect( - sameScope({ threadId: 's' }, { threadId: 's', userId: 'u' }), - ).toBe(false) + expect(sameScope({ threadId: 's' }, { threadId: 's', userId: 'u' })).toBe( + false, + ) expect(sameScope({ threadId: 's' }, { threadId: 's' })).toBe(true) }) }) From 12d51a2986d967082c343078cee3365fcb01ce41 Mon Sep 17 00:00:00 2001 From: Tom Beckenham <34339192+tombeckenham@users.noreply.github.com> Date: Fri, 24 Jul 2026 12:59:25 +1000 Subject: [PATCH 5/9] fix(ai-memory): address CodeRabbit scope isolation feedback Exact-match optional dims in sameScope, tenant-qualify vendor keys, mem0 run_id=threadId, full DevTools Scope identity, panel server-trusted user/tenant, and custom-adapter tenant_id example. --- docs/memory/adapters.md | 25 ++++---- docs/memory/custom-adapter.md | 26 ++++++-- docs/memory/quickstart.md | 19 +++++- packages/ai-client/src/devtools.ts | 7 ++- .../ai-devtools/src/store/memory-registry.ts | 20 +++--- .../ai-devtools/tests/memory-registry.test.ts | 27 +++++++- packages/ai-event-client/src/index.ts | 9 ++- .../tanstack-ai-memory-hindsight/SKILL.md | 3 +- .../skills/tanstack-ai-memory-honcho/SKILL.md | 6 +- .../skills/tanstack-ai-memory-mem0/SKILL.md | 5 +- .../skills/tanstack-ai-memory/SKILL.md | 12 ++-- packages/ai-memory/src/internal/store.test.ts | 63 +++++++++++++++++++ packages/ai-memory/src/internal/store.ts | 27 ++++---- .../src/providers/hindsight/index.ts | 9 ++- .../ai-memory/src/providers/honcho/index.ts | 29 ++++++--- .../ai-memory/src/providers/mem0/index.ts | 16 ++++- .../ai-memory/src/providers/redis/index.ts | 4 +- .../ai-memory/tests/providers/mem0.test.ts | 17 +++++ packages/ai-memory/tests/same-scope.test.ts | 55 ---------------- packages/ai-memory/vite.config.ts | 2 +- testing/panel/src/lib/memory-store.ts | 35 +++++++++-- testing/panel/src/routes/api.memory-chat.ts | 24 +++++-- .../panel/src/routes/api.memory-inspect.ts | 19 ++++-- 23 files changed, 313 insertions(+), 146 deletions(-) create mode 100644 packages/ai-memory/src/internal/store.test.ts delete mode 100644 packages/ai-memory/tests/same-scope.test.ts diff --git a/docs/memory/adapters.md b/docs/memory/adapters.md index 41a701bb6..783cf4631 100644 --- a/docs/memory/adapters.md +++ b/docs/memory/adapters.md @@ -124,15 +124,14 @@ you previously wrote under a different index shape, reindex or wipe. | Adapter | `threadId` | `userId` | `tenantId` | `namespace` | |---------|------------|----------|------------|-------------| -| `inMemory()` | yes | yes (filter when present) | yes (filter when present) | ignored | +| `inMemory()` | yes (exact) | yes (exact) | yes (exact) | ignored | | `redis()` | yes (key segment) | yes (key segment) | yes (key segment) | ignored | -| `hindsight()` | yes (bank id) | yes (bank id / `user` option) | **no** | ignored | -| `mem0()` | **no** (user-scoped only) | yes (`user_id` / `user` option) | **no** | ignored | -| `honcho()` | yes (Honcho session id) | yes (peer id / `user` option) | **no** | ignored | +| `hindsight()` | yes (bank id) | yes (bank id / `user` option) | yes (bank prefix; unset → `_`) | ignored | +| `mem0()` | yes (`run_id`) | yes (`user_id` / `user` option) | **no** | ignored | +| `honcho()` | yes (session key) | yes (peer id / `user` option) | yes (session/peer prefix) | ignored | -Vendor backends that do not model tenants will share memory across tenants when -`userId`+`threadId` (or just `userId` for mem0) collide — use a tenant-aware adapter -or encode the tenant into `user`/`threadId` yourself. +Optional dims are exact-match (omit ≠ match any). mem0 does not model tenants — +encode multi-tenant isolation into `user` if needed. ## `hindsight()` @@ -142,7 +141,7 @@ is an optional peer, loaded lazily. | Option | Type | Default | Purpose | |--------|------|---------|---------| -| `user` | `string` | `scope.userId` | Durable user id used in the bank key (`{user}__{threadId}`). | +| `user` | `string` | `scope.userId` | Durable user id used in the bank key (`{tenant|_}__{user}__{threadId}`). | | `baseUrl` | `string` | `HINDSIGHT_URL` / `http://localhost:8888` | Server URL. | | `budget` | `'low' \| 'mid' \| 'high'` | `'mid'` | Recall budget. | | `onToolRetain` | `(receipt) => void` | none | Fired when the model calls `hindsight_retain`. | @@ -152,7 +151,7 @@ is an optional peer, loaded lazily. import { hindsight } from '@tanstack/ai-memory/hindsight' const memory = hindsight({ - user: 'alice', // bank = alice__{threadId} + user: 'alice', // bank = {_}__alice__{threadId} (or tenant__alice__{threadId}) baseUrl: 'https://hindsight.internal', // default: HINDSIGHT_URL budget: 'high', // deeper recall onToolRetain: (receipt) => console.log('model retained', receipt.ok), @@ -161,7 +160,7 @@ const memory = hindsight({ }) ``` -Bank id is `{user}__{threadId}`. `tenantId` is not part of the bank key. +Bank id is `{tenantId|_}__{user}__{threadId}`. ## `mem0()` @@ -188,7 +187,7 @@ const memory = mem0({ }) ``` -mem0 is **user-scoped only** — `threadId` and `tenantId` are not sent to the server. +mem0 requests send `user_id` and `run_id` (`threadId`). `tenantId` is not sent. ## `honcho()` @@ -216,8 +215,8 @@ const memory = honcho({ }) ``` -Honcho sessions are keyed by `threadId`; peers by `user` / `scope.userId`. `tenantId` is -not sent. +Honcho session key is `{tenantId|_}__{threadId}`; peers are `{tenantId}__{user}` when +`tenantId` is set, otherwise the bare user id. ## Where to go next diff --git a/docs/memory/custom-adapter.md b/docs/memory/custom-adapter.md index 4ce8cfe96..b73f86487 100644 --- a/docs/memory/custom-adapter.md +++ b/docs/memory/custom-adapter.md @@ -95,9 +95,16 @@ export function pgvectorMemory(options: { pool: Pool; embed: Embed }): MemoryAda for (const row of rows) { const vector = await embed(row.text) await pool.query( - `INSERT INTO memory (thread_id, user_id, role, text, embedding) - VALUES ($1, $2, $3, $4, $5)`, - [scope.threadId, scope.userId ?? null, row.role, row.text, JSON.stringify(vector)], + `INSERT INTO memory (thread_id, user_id, tenant_id, role, text, embedding) + VALUES ($1, $2, $3, $4, $5, $6)`, + [ + scope.threadId, + scope.userId ?? null, + scope.tenantId ?? null, + row.role, + row.text, + JSON.stringify(vector), + ], ) } return [{ ok: true }] @@ -105,13 +112,22 @@ export function pgvectorMemory(options: { pool: Pool; embed: Embed }): MemoryAda async recall(scope: MemoryScope, query: string): Promise { const q = await embed(query) + // Match every isolation dim exactly (including NULL). Omitted tenant/user + // must not match rows written with a tenant/user set. const { rows } = await pool.query( `SELECT text, 1 - (embedding <=> $1::vector) AS score FROM memory - WHERE thread_id = $2 AND ($3::text IS NULL OR user_id = $3) + WHERE thread_id = $2 + AND user_id IS NOT DISTINCT FROM $3::text + AND tenant_id IS NOT DISTINCT FROM $4::text ORDER BY score DESC LIMIT 6`, - [JSON.stringify(q), scope.threadId, scope.userId ?? null], + [ + JSON.stringify(q), + scope.threadId, + scope.userId ?? null, + scope.tenantId ?? null, + ], ) const fragments = rows.map((r) => ({ text: r.text, source: 'pgvector' })) const systemPrompt = fragments.length diff --git a/docs/memory/quickstart.md b/docs/memory/quickstart.md index cd6e38b44..6f29214ab 100644 --- a/docs/memory/quickstart.md +++ b/docs/memory/quickstart.md @@ -148,8 +148,23 @@ const stream = chat({ }) ``` -On the client, nothing changes. `useChat` (or your connection adapter) consumes the -stream exactly as before. Memory is entirely server-side. +On the client, nothing changes for memory wiring — consume the same stream as any +other `chat()` endpoint: + +```ts +import { useChat, fetchServerSentEvents } from '@tanstack/ai-react' + +function Chat() { + const { messages, sendMessage, isLoading } = useChat({ + connection: fetchServerSentEvents('/api/chat'), + }) + // Memory is entirely server-side; the client only sees the usual message stream. + return ( + // render messages, input, sendMessage, isLoading… + null + ) +} +``` ## Where to go next diff --git a/packages/ai-client/src/devtools.ts b/packages/ai-client/src/devtools.ts index 79acfada3..0e4a10cb7 100644 --- a/packages/ai-client/src/devtools.ts +++ b/packages/ai-client/src/devtools.ts @@ -6,7 +6,10 @@ import { import { convertSchemaToJsonSchema } from '@tanstack/ai/client' import { DefaultChatClientEventEmitter } from './events' import type { AnyClientTool, StreamChunk } from '@tanstack/ai/client' -import type { AIDevtoolsEventVisibility } from '@tanstack/ai-event-client' +import type { + AIDevtoolsEventVisibility, + MemoryScopeLite, +} from '@tanstack/ai-event-client' import type { ChatClientEventContext, ChatClientEventEmitter, @@ -31,7 +34,7 @@ export interface AIDevtoolsDisplayOptions { * depend on `ai-memory`; the memory middleware is the producer. */ interface MemoryStateEventValue { - scope: { threadId?: string; userId?: string; tenantId?: string } + scope: MemoryScopeLite adapter: string query?: string recall?: { diff --git a/packages/ai-devtools/src/store/memory-registry.ts b/packages/ai-devtools/src/store/memory-registry.ts index fb7ad0daa..4af880683 100644 --- a/packages/ai-devtools/src/store/memory-registry.ts +++ b/packages/ai-devtools/src/store/memory-registry.ts @@ -66,6 +66,7 @@ export interface MemoryScopeState { threadId: string userId?: string tenantId?: string + namespace?: string /** Most recent adapter id seen for this scope. */ adapter?: string events: Array @@ -82,11 +83,11 @@ export function createMemoryRegistryState(): MemoryRegistryState { } /** - * Escape `:` / `\` so composite keys cannot collide when a dim contains the - * separator (mirrors redis scope-key hardening). + * Escape `:` / `\` / `_` so composite keys cannot collide when a dim contains + * the separator or the unset sentinel (mirrors redis scope-key hardening). */ function escapeScopeDim(value: string): string { - return value.replace(/[\\:]/g, '\\$&') + return value.replace(/[\\:_]/g, '\\$&') } /** Unset optional dims serialize as `_` (same convention as the redis adapter). */ @@ -95,21 +96,22 @@ function scopeDim(value: string | undefined): string { } /** - * Stable scope key: `{tenantId|_}:{userId|_}:{threadId}`. Empty/absent - * `threadId` (e.g. error scope) buckets to `(unknown)` so broken events do not - * invent a fake conversation. + * Stable scope key: + * `{tenantId|_}:{userId|_}:{threadId}:{namespace|_}`. + * Empty/absent `threadId` (e.g. error scope) buckets to `(unknown)` so broken + * events do not invent a fake conversation. */ export function memoryScopeKey(scope: MemoryScopeLite | undefined): string { const threadId = scope?.threadId if (!threadId || threadId.length === 0) return '(unknown)' - return `${scopeDim(scope?.tenantId)}:${scopeDim(scope?.userId)}:${escapeScopeDim(threadId)}` + return `${scopeDim(scope?.tenantId)}:${scopeDim(scope?.userId)}:${escapeScopeDim(threadId)}:${scopeDim(scope?.namespace)}` } /** Human-readable label for the Memory panel scope picker. */ export function memoryScopeLabel(entry: MemoryScopeState): string { const thread = entry.threadId && entry.threadId.length > 0 ? entry.threadId : '(unknown)' - const parts = [entry.tenantId, entry.userId, thread].filter( + const parts = [entry.tenantId, entry.userId, thread, entry.namespace].filter( (p): p is string => p != null && p.length > 0, ) return parts.join(' · ') @@ -129,6 +131,7 @@ function ensureScope( threadId: scope?.threadId ?? '', userId: scope?.userId, tenantId: scope?.tenantId, + namespace: scope?.namespace, events: [], lastActivity: 0, } @@ -136,6 +139,7 @@ function ensureScope( } if (scope?.userId) entry.userId = scope.userId if (scope?.tenantId) entry.tenantId = scope.tenantId + if (scope?.namespace) entry.namespace = scope.namespace return entry } diff --git a/packages/ai-devtools/tests/memory-registry.test.ts b/packages/ai-devtools/tests/memory-registry.test.ts index 5a7d97c54..41ac6d2e9 100644 --- a/packages/ai-devtools/tests/memory-registry.test.ts +++ b/packages/ai-devtools/tests/memory-registry.test.ts @@ -129,17 +129,24 @@ describe('memory registry', () => { ]) }) - it('stores userId/tenantId and isolates composite scopes', () => { + it('stores full Scope identity and isolates composite scopes', () => { const state = createMemoryRegistryState() const tenantA = { threadId: 'shared', userId: 'u', tenantId: 'tenant-a', + namespace: 'bank-a', } const tenantB = { threadId: 'shared', userId: 'u', tenantId: 'tenant-b', + namespace: 'bank-a', + } + const sameThreadOtherUser = { + threadId: 'shared', + userId: 'other', + tenantId: 'tenant-a', } applyMemoryEvent(state, { type: 'persist:started', @@ -153,14 +160,22 @@ describe('memory registry', () => { adapter: 'redis', timestamp: 2, }) + applyMemoryEvent(state, { + type: 'persist:started', + scope: sameThreadOtherUser, + adapter: 'in-memory', + timestamp: 3, + }) const keyA = memoryScopeKey(tenantA) const keyB = memoryScopeKey(tenantB) - expect(keyA).not.toBe(keyB) + const keyOtherUser = memoryScopeKey(sameThreadOtherUser) + expect(new Set([keyA, keyB, keyOtherUser]).size).toBe(3) expect(state.scopes[keyA]).toMatchObject({ threadId: 'shared', userId: 'u', tenantId: 'tenant-a', + namespace: 'bank-a', adapter: 'in-memory', }) expect(state.scopes[keyB]).toMatchObject({ @@ -169,7 +184,13 @@ describe('memory registry', () => { tenantId: 'tenant-b', adapter: 'redis', }) - expect(memoryScopeLabel(state.scopes[keyA]!)).toBe('tenant-a · u · shared') + // Literal `_` must not collide with the unset sentinel. + expect(memoryScopeKey({ threadId: 't', userId: '_' })).not.toBe( + memoryScopeKey({ threadId: 't' }), + ) + expect(memoryScopeLabel(state.scopes[keyA]!)).toBe( + 'tenant-a · u · shared · bank-a', + ) }) it('clears the registry', () => { diff --git a/packages/ai-event-client/src/index.ts b/packages/ai-event-client/src/index.ts index 00fa6cfdd..5b9f27f48 100644 --- a/packages/ai-event-client/src/index.ts +++ b/packages/ai-event-client/src/index.ts @@ -825,14 +825,17 @@ export interface VideoUsageEvent extends BaseEventContext { // --------------------------------------------------------------------------- /** - * Lite scope for devtools payloads. Mirrors the shared `Scope` / - * `MemoryScope` contract (`threadId`-centric); kept structurally minimal so the - * event client stays decoupled from the memory package. + * Lite scope for devtools payloads. Structural mirror of shared `Scope` / + * `MemoryScope` field set; every field optional so error/telemetry events can + * carry a partial identity. Not an isolation authority — adapters own that. + * Kept structurally minimal so the event client stays decoupled from `@tanstack/ai`. */ export type MemoryScopeLite = { threadId?: string userId?: string tenantId?: string + /** Reserved on `Scope`; carried for display/identity parity. */ + namespace?: string } /** Emitted when the middleware begins a `recall` for the current turn. */ diff --git a/packages/ai-memory/skills/tanstack-ai-memory-hindsight/SKILL.md b/packages/ai-memory/skills/tanstack-ai-memory-hindsight/SKILL.md index d7fdc6a20..091256a36 100644 --- a/packages/ai-memory/skills/tanstack-ai-memory-hindsight/SKILL.md +++ b/packages/ai-memory/skills/tanstack-ai-memory-hindsight/SKILL.md @@ -31,8 +31,7 @@ first use — install it where you use `hindsight()`. - `budget` — recall budget: `'low' | 'mid' | 'high'` (default `'mid'`). - `onToolRetain` / `onToolRecall` — callbacks fired when the model uses the memory tools. -**Scope fields:** bank id is `{user}__{threadId}`. `tenantId` and `namespace` are not -part of the bank key — multi-tenant isolation must be encoded into `user` if needed. +**Scope fields:** bank id is `{tenantId|_}__{user}__{threadId}`. `namespace` is ignored. ## Tools diff --git a/packages/ai-memory/skills/tanstack-ai-memory-honcho/SKILL.md b/packages/ai-memory/skills/tanstack-ai-memory-honcho/SKILL.md index 250cba7dd..92c8d4296 100644 --- a/packages/ai-memory/skills/tanstack-ai-memory-honcho/SKILL.md +++ b/packages/ai-memory/skills/tanstack-ai-memory-honcho/SKILL.md @@ -32,9 +32,9 @@ it where you use `honcho()`. - `apiKey` — default `HONCHO_API_KEY`. - `assistantId` — assistant peer id (default `'assistant'`). -**Scope fields:** Honcho session id = `scope.threadId`; peer id = `user` / -`scope.userId`. `tenantId` and `namespace` are not sent — encode multi-tenant -isolation into `user` or `workspaceId` if needed. +**Scope fields:** session key = `{tenantId|_}__{threadId}`; peer id is +`{tenantId}__{user}` when `tenantId` is set, otherwise `user` / `scope.userId`. +`namespace` is ignored. `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-mem0/SKILL.md b/packages/ai-memory/skills/tanstack-ai-memory-mem0/SKILL.md index 05af4d8de..3a8d828bc 100644 --- a/packages/ai-memory/skills/tanstack-ai-memory-mem0/SKILL.md +++ b/packages/ai-memory/skills/tanstack-ai-memory-mem0/SKILL.md @@ -29,9 +29,8 @@ Requires a running mem0 server (self-hosted or hosted). Point it via `baseUrl` ( - `apiKey` — bearer token (default `MEM0_ADMIN_API_KEY`). - `rerank` (default `true`), `threshold` (default `0.1`) — search tuning. -**Scope fields:** mem0 is **user-scoped only** — only `user_id` is sent. `threadId`, -`tenantId`, and `namespace` are ignored. Encode tenant/thread isolation into `user` -if the backend must not share memory across those dims. +**Scope fields:** requests send `user_id` and `run_id` (`scope.threadId`). `tenantId` +and `namespace` are not sent — encode multi-tenant isolation into `user` if needed. `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/SKILL.md b/packages/ai-memory/skills/tanstack-ai-memory/SKILL.md index ce0750467..836d160eb 100644 --- a/packages/ai-memory/skills/tanstack-ai-memory/SKILL.md +++ b/packages/ai-memory/skills/tanstack-ai-memory/SKILL.md @@ -77,13 +77,11 @@ BEFORE using it. ## Adapters -- `inMemory()` from `@tanstack/ai-memory/in-memory` — dev, tests, single-process demos. - Honors `threadId` + optional `userId`/`tenantId` (`namespace` ignored). -- `redis({ redis })` from `@tanstack/ai-memory/redis` — production, plain Redis. Index - keys include `tenantId`/`userId`/`threadId`; no dual-read of older layouts. -- `hindsight()` — bank `{user}__{threadId}`; does **not** use `tenantId`. -- `mem0()` — **user-scoped only** (`user_id`); `threadId`/`tenantId` are not sent. -- `honcho()` — session = `threadId`, peer = user; does **not** use `tenantId`. +- `inMemory()` / `redis()` — exact match on `threadId` + optional `userId`/`tenantId` + (`namespace` ignored). Redis index keys include all three segments. +- `hindsight()` — bank `{tenant|_}__{user}__{threadId}`. +- `mem0()` — `user_id` + `run_id` (`threadId`); no `tenantId`. +- `honcho()` — session `{tenant|_}__{threadId}`; peer tenant-prefixed when set. - Custom — implement `recall`/`save` and run `@tanstack/ai-memory/tests/contract`. ## Failure modes diff --git a/packages/ai-memory/src/internal/store.test.ts b/packages/ai-memory/src/internal/store.test.ts new file mode 100644 index 000000000..ca899d3d8 --- /dev/null +++ b/packages/ai-memory/src/internal/store.test.ts @@ -0,0 +1,63 @@ +import { describe, expect, it } from 'vitest' +import { sameScope } from './store' + +describe('sameScope', () => { + const full = { + threadId: 's', + userId: 'u', + tenantId: 'a', + } + + it('matches when threadId and optional dims agree', () => { + expect(sameScope(full, full)).toBe(true) + expect( + sameScope(full, { threadId: 's', userId: 'u', tenantId: 'a' }), + ).toBe(true) + }) + + it('rejects different threadId', () => { + expect(sameScope(full, { ...full, threadId: 'other' })).toBe(false) + }) + + it('rejects different userId', () => { + expect(sameScope(full, { threadId: 's', userId: 'other', tenantId: 'a' })).toBe( + false, + ) + }) + + it('rejects different tenantId', () => { + expect(sameScope(full, { ...full, tenantId: 'b' })).toBe(false) + }) + + it('treats omitted optional dims as exact (not wildcards)', () => { + // A query without tenant/user must not match a record that has them — + // same isolation model as Redis composite index keys. + expect(sameScope(full, { threadId: 's', userId: 'u' })).toBe(false) + expect(sameScope(full, { threadId: 's' })).toBe(false) + expect(sameScope(full, { threadId: 's', userId: '', tenantId: '' })).toBe( + false, + ) + }) + + it('matches when both sides omit the same optional dims', () => { + expect(sameScope({ threadId: 's' }, { threadId: 's' })).toBe(true) + expect( + sameScope({ threadId: 's', userId: 'u' }, { threadId: 's', userId: 'u' }), + ).toBe(true) + }) + + it('treats empty string as unset for optional dims', () => { + expect( + sameScope({ threadId: 's', userId: '' }, { threadId: 's' }), + ).toBe(true) + }) + + it('ignores namespace (reserved — no subsystem keys on it yet)', () => { + expect( + sameScope( + { ...full, namespace: 'bank-a' }, + { ...full, namespace: 'bank-b' }, + ), + ).toBe(true) + }) +}) diff --git a/packages/ai-memory/src/internal/store.ts b/packages/ai-memory/src/internal/store.ts index 39e53f3c4..c56241e69 100644 --- a/packages/ai-memory/src/internal/store.ts +++ b/packages/ai-memory/src/internal/store.ts @@ -92,20 +92,25 @@ export interface RecordStore { // =========================== /** - * Scope match for built-in stores. `threadId` must always match. Optional - * `userId` / `tenantId` are filter-when-present: when the query supplies a - * non-empty value, the record must match it; when the query omits them, any - * record value is accepted. Callers must pass the same dims they wrote with - * (the middleware does). Redis keys unset dims as `_` instead — omit ≠ match - * any there. `namespace` is reserved and ignored until a subsystem keys on it. + * Normalize an optional scope dimension: empty string is treated as unset so + * `''` and `undefined` compare equal. + */ +function scopeDimValue(value: string | undefined): string | undefined { + return value != null && value !== '' ? value : undefined +} + +/** + * Exact scope match for built-in stores. `threadId` must match, and optional + * `userId` / `tenantId` must match exactly on both sides (including both + * unset). A query that omits `tenantId` does **not** match a record written + * with a tenant — same isolation model as Redis composite index keys. + * `namespace` is reserved and ignored until a subsystem keys on it. */ export function sameScope(record: MemoryScope, query: MemoryScope): boolean { if (record.threadId !== query.threadId) return false - if (query.userId != null && query.userId !== '') { - if (record.userId !== query.userId) return false - } - if (query.tenantId != null && query.tenantId !== '') { - if (record.tenantId !== query.tenantId) return false + if (scopeDimValue(record.userId) !== scopeDimValue(query.userId)) return false + if (scopeDimValue(record.tenantId) !== scopeDimValue(query.tenantId)) { + return false } return true } diff --git a/packages/ai-memory/src/providers/hindsight/index.ts b/packages/ai-memory/src/providers/hindsight/index.ts index 584460e80..a7c207316 100644 --- a/packages/ai-memory/src/providers/hindsight/index.ts +++ b/packages/ai-memory/src/providers/hindsight/index.ts @@ -1,6 +1,7 @@ /** * Hindsight memory adapter. Hindsight owns extraction/ranking server-side and - * buckets memory into per-conversation "banks" (`{userId}__{threadId}`). Recall + * buckets memory into per-conversation "banks" + * (`{tenantId|_}__{userId}__{threadId}`). Recall * returns a rendered prompt block AND a set of LLM tools (retain/recall/reflect) * that let the model take direct control of memory. * @@ -113,7 +114,11 @@ export function hindsight(options: HindsightOptions = {}): MemoryAdapter { function bankId(scope: MemoryScope): string { const user = options.user ?? scope.userId ?? 'demo-user' - return `${user}__${scope.threadId}` + // Include tenant so multi-tenant deploys cannot share banks when user+thread + // collide. Unset tenant uses `_` (same placeholder convention as redis). + const tenant = + scope.tenantId != null && scope.tenantId !== '' ? scope.tenantId : '_' + return `${tenant}__${user}__${scope.threadId}` } return { diff --git a/packages/ai-memory/src/providers/honcho/index.ts b/packages/ai-memory/src/providers/honcho/index.ts index a622a3c31..de72c83f5 100644 --- a/packages/ai-memory/src/providers/honcho/index.ts +++ b/packages/ai-memory/src/providers/honcho/index.ts @@ -133,14 +133,29 @@ export function honcho(options: HonchoOptions = {}): MemoryAdapter { } return assistantPeerPromise } - function getSession(threadId: string): Promise { - return cached(sessionCache, threadId, async () => - (await getClient()).session(threadId), + function getSession(sessionKey: string): Promise { + return cached(sessionCache, sessionKey, async () => + (await getClient()).session(sessionKey), ) } + /** Honcho session id — tenant-qualified so tenants cannot share sessions. */ + function sessionKeyFor(scope: MemoryScope): string { + const tenant = + scope.tenantId != null && scope.tenantId !== '' ? scope.tenantId : '_' + return `${tenant}__${scope.threadId}` + } + + /** + * Honcho peer id. When `tenantId` is set, prefix the durable user so peers + * cannot collide across tenants. + */ function userIdFor(scope: MemoryScope): string { - return options.user ?? scope.userId ?? 'demo-user' + const user = options.user ?? scope.userId ?? 'demo-user' + if (scope.tenantId != null && scope.tenantId !== '') { + return `${scope.tenantId}__${user}` + } + return user } return { @@ -151,7 +166,7 @@ export function honcho(options: HonchoOptions = {}): MemoryAdapter { const [userPeer, assistantPeer, session] = await Promise.all([ getUserPeer(userIdFor(scope)), getAssistantPeer(), - getSession(scope.threadId), + getSession(sessionKeyFor(scope)), ]) return session.addMessages([ userPeer.message(turn.user), @@ -172,7 +187,7 @@ export function honcho(options: HonchoOptions = {}): MemoryAdapter { const result = await timed(async () => { const [userPeer, session] = await Promise.all([ getUserPeer(userIdFor(scope)), - getSession(scope.threadId), + getSession(sessionKeyFor(scope)), ]) return userPeer.chat(query, { session }) }) @@ -184,7 +199,7 @@ export function honcho(options: HonchoOptions = {}): MemoryAdapter { }, async inspect(scope): Promise { - const session = await getSession(scope.threadId).catch(() => null) + const session = await getSession(sessionKeyFor(scope)).catch(() => null) if (!session) { return { takenAt: new Date().toISOString(), diff --git a/packages/ai-memory/src/providers/mem0/index.ts b/packages/ai-memory/src/providers/mem0/index.ts index f6aa3f1f2..108d17de1 100644 --- a/packages/ai-memory/src/providers/mem0/index.ts +++ b/packages/ai-memory/src/providers/mem0/index.ts @@ -72,6 +72,14 @@ export function mem0(options: Mem0Options = {}): MemoryAdapter { return options.user ?? scope.userId ?? 'demo-user' } + /** + * mem0 `run_id` — conversation/run isolation. Maps 1:1 to `scope.threadId` so + * same-user memories do not leak across threads. + */ + function runId(scope: MemoryScope): string { + return scope.threadId + } + async function safeJson(fn: () => Promise): Promise { const start = Date.now() try { @@ -97,7 +105,11 @@ export function mem0(options: Mem0Options = {}): MemoryAdapter { } async function loadMemories(scope: MemoryScope): Promise { - const url = `${baseUrl}/memories?user_id=${encodeURIComponent(userId(scope))}` + const params = new URLSearchParams({ + user_id: userId(scope), + run_id: runId(scope), + }) + const url = `${baseUrl}/memories?${params.toString()}` return safeJson(() => fetch(url, { method: 'GET', headers: headers() })) } @@ -115,6 +127,7 @@ export function mem0(options: Mem0Options = {}): MemoryAdapter { { role: 'assistant', content: turn.assistant }, ], user_id: userId(scope), + run_id: runId(scope), }), }), ) @@ -136,6 +149,7 @@ export function mem0(options: Mem0Options = {}): MemoryAdapter { body: JSON.stringify({ query, user_id: userId(scope), + run_id: runId(scope), rerank, threshold, }), diff --git a/packages/ai-memory/src/providers/redis/index.ts b/packages/ai-memory/src/providers/redis/index.ts index ddeeecfb6..2b5812649 100644 --- a/packages/ai-memory/src/providers/redis/index.ts +++ b/packages/ai-memory/src/providers/redis/index.ts @@ -104,8 +104,8 @@ function warnMalformedRow(id: string, err: unknown): void { * {prefix}:index:{tenantId or _}:{userId or _}:{threadId} -> Set * ``` * Segments are escaped (so `:`, `\\`, `_` in values cannot collide). Missing - * optional dims become `_` (omit ≠ match any). No dual-read of older index - * layouts. + * optional dims become `_` (omit ≠ match any — same exact-match model as the + * built-in `sameScope` helper). No dual-read of older index layouts. */ export function redis(options: RedisOptions): MemoryAdapter { const client = options.redis diff --git a/packages/ai-memory/tests/providers/mem0.test.ts b/packages/ai-memory/tests/providers/mem0.test.ts index 06569c293..ae9bb43ec 100644 --- a/packages/ai-memory/tests/providers/mem0.test.ts +++ b/packages/ai-memory/tests/providers/mem0.test.ts @@ -70,12 +70,28 @@ describe('mem0 save', () => { expect(calls[0]?.method).toBe('POST') expect(calls[0]?.body).toMatchObject({ user_id: 'u1', + run_id: 's1', messages: [ { role: 'user', content: 'I live in Berlin' }, { role: 'assistant', content: 'noted' }, ], }) }) + + it('scopes save by threadId (run_id) so same-user threads stay separate', async () => { + const calls = stubFetch(() => ({ data: { id: 'mem-1' } })) + const adapter = mem0({ baseUrl: 'http://mem0.test', user: 'u1' }) + await adapter.save( + { threadId: 'thread-a', userId: 'u1' }, + { user: 'secret for thread A', assistant: 'ok' }, + ) + await adapter.save( + { threadId: 'thread-b', userId: 'u1' }, + { user: 'secret for thread B', assistant: 'ok' }, + ) + expect(calls[0]?.body).toMatchObject({ user_id: 'u1', run_id: 'thread-a' }) + expect(calls[1]?.body).toMatchObject({ user_id: 'u1', run_id: 'thread-b' }) + }) }) describe('mem0 recall', () => { @@ -97,6 +113,7 @@ describe('mem0 recall', () => { expect(calls[0]?.body).toMatchObject({ query: 'where do I live', user_id: 'u1', + run_id: 's1', }) expect(result.fragments).toHaveLength(2) expect(result.fragments?.[0]).toMatchObject({ diff --git a/packages/ai-memory/tests/same-scope.test.ts b/packages/ai-memory/tests/same-scope.test.ts deleted file mode 100644 index a338bd85a..000000000 --- a/packages/ai-memory/tests/same-scope.test.ts +++ /dev/null @@ -1,55 +0,0 @@ -import { describe, expect, it } from 'vitest' -import { sameScope } from '../src/internal/store' - -describe('sameScope', () => { - const full = { - threadId: 's', - userId: 'u', - tenantId: 'a', - } - - it('matches when threadId and present dims agree', () => { - expect(sameScope(full, full)).toBe(true) - expect(sameScope(full, { threadId: 's', userId: 'u', tenantId: 'a' })).toBe( - true, - ) - }) - - it('rejects different threadId', () => { - expect(sameScope(full, { ...full, threadId: 'other' })).toBe(false) - }) - - it('rejects different userId when query supplies userId', () => { - expect(sameScope(full, { threadId: 's', userId: 'other' })).toBe(false) - }) - - it('rejects different tenantId when query supplies tenantId', () => { - expect(sameScope(full, { ...full, tenantId: 'b' })).toBe(false) - }) - - it('treats omitted / empty query dims as "do not filter"', () => { - // Filter-when-present: a query without tenantId still matches a - // tenant-scoped record. Callers must pass the full dims used at write. - expect(sameScope(full, { threadId: 's', userId: 'u' })).toBe(true) - expect(sameScope(full, { threadId: 's' })).toBe(true) - expect(sameScope(full, { threadId: 's', userId: '', tenantId: '' })).toBe( - true, - ) - }) - - it('ignores namespace (reserved — no subsystem keys on it yet)', () => { - expect( - sameScope( - { ...full, namespace: 'bank-a' }, - { ...full, namespace: 'bank-b' }, - ), - ).toBe(true) - }) - - it('matches records that also lack optional dims', () => { - expect(sameScope({ threadId: 's' }, { threadId: 's', userId: 'u' })).toBe( - false, - ) - expect(sameScope({ threadId: 's' }, { threadId: 's' })).toBe(true) - }) -}) diff --git a/packages/ai-memory/vite.config.ts b/packages/ai-memory/vite.config.ts index 2ecd6238e..4052e2880 100644 --- a/packages/ai-memory/vite.config.ts +++ b/packages/ai-memory/vite.config.ts @@ -9,7 +9,7 @@ const config = defineConfig({ watch: false, globals: true, environment: 'node', - include: ['tests/**/*.test.ts'], + include: ['src/**/*.test.ts', 'tests/**/*.test.ts'], coverage: { provider: 'v8', reporter: ['text', 'json', 'html', 'lcov'], diff --git a/testing/panel/src/lib/memory-store.ts b/testing/panel/src/lib/memory-store.ts index 05f3db961..1dc751ab8 100644 --- a/testing/panel/src/lib/memory-store.ts +++ b/testing/panel/src/lib/memory-store.ts @@ -1,5 +1,5 @@ import { inMemory } from '@tanstack/ai-memory/in-memory' -import type { RecallResult } from '@tanstack/ai-memory' +import type { MemoryScope, RecallResult } from '@tanstack/ai-memory' /** * Process-local memory backing the `/memory` demo page. @@ -17,8 +17,35 @@ import type { RecallResult } from '@tanstack/ai-memory' export const memoryAdapter = inMemory() /** - * Records what the last `recall` injected for each thread, 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. + * Server-trusted demo identity. Never accept `userId` / `tenantId` from the + * client — the panel is a local demo without real auth, so these constants are + * the isolation dims. Production apps must derive every Scope field from a + * validated session. + */ +export const PANEL_MEMORY_USER = 'panel-demo-user' +export const PANEL_MEMORY_TENANT = 'panel-demo' + +/** + * Build the middleware/inspect scope for a client-chosen thread. User and + * tenant come only from the server constants above. + */ +export function panelMemoryScope(threadId: string): MemoryScope { + return { + threadId, + userId: PANEL_MEMORY_USER, + tenantId: PANEL_MEMORY_TENANT, + } +} + +/** Composite key matching built-in adapter isolation dims (not threadId alone). */ +export function panelScopeKey(scope: MemoryScope): string { + return `${scope.tenantId ?? '_'}|${scope.userId ?? '_'}|${scope.threadId}` +} + +/** + * Records what the last `recall` injected for each composite scope, 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 lastRecallByThread = new Map() diff --git a/testing/panel/src/routes/api.memory-chat.ts b/testing/panel/src/routes/api.memory-chat.ts index dfb3c3380..a41b4161a 100644 --- a/testing/panel/src/routes/api.memory-chat.ts +++ b/testing/panel/src/routes/api.memory-chat.ts @@ -12,7 +12,12 @@ 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 { lastRecallByThread, memoryAdapter } from '@/lib/memory-store' +import { + lastRecallByThread, + memoryAdapter, + panelMemoryScope, + panelScopeKey, +} from '@/lib/memory-store' import type { Provider } from '@/lib/model-selection' const SYSTEM_PROMPT = `You are a helpful, friendly assistant with long-term memory. @@ -27,8 +32,9 @@ memory rather than saying you don't know.` * 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 `threadId` (demo-only — production must derive - * scope from trusted server session state, not bare client input). + * from a client-supplied `threadId` plus server-trusted user/tenant constants + * (demo-only — production must derive every Scope field from validated session + * state, never from the request body alone). */ export const Route = createFileRoute('/api/memory-chat')({ server: { @@ -46,7 +52,13 @@ export const Route = createFileRoute('/api/memory-chat')({ const provider: Provider = data.provider || 'openai' const model: string | undefined = data.model - const threadId: string = data.threadId || 'panel-default-thread' + // threadId may come from the client for multi-thread demo UX; user and + // tenant are always server-side constants (see panelMemoryScope). + const threadId: string = + typeof data.threadId === 'string' && data.threadId.length > 0 + ? data.threadId + : 'panel-default-thread' + const scope = panelMemoryScope(threadId) try { const adapterConfig = { @@ -85,9 +97,9 @@ export const Route = createFileRoute('/api/memory-chat')({ const memory = memoryMiddleware({ adapter: memoryAdapter, - scope: { threadId }, + scope, onRecall: (info) => { - lastRecallByThread.set(threadId, info.result) + lastRecallByThread.set(panelScopeKey(scope), info.result) }, }) diff --git a/testing/panel/src/routes/api.memory-inspect.ts b/testing/panel/src/routes/api.memory-inspect.ts index 0515f9abc..de226d889 100644 --- a/testing/panel/src/routes/api.memory-inspect.ts +++ b/testing/panel/src/routes/api.memory-inspect.ts @@ -1,22 +1,29 @@ import { createFileRoute } from '@tanstack/react-router' -import { lastRecallByThread, memoryAdapter } from '@/lib/memory-store' +import { + lastRecallByThread, + memoryAdapter, + panelMemoryScope, + panelScopeKey, +} from '@/lib/memory-store' /** * Read side of the `/memory` demo. Returns everything the panel needs to show - * "what's in memory" for a thread, straight off the shared singleton adapter: + * "what's in memory" for a scope, straight off the shared singleton adapter: * the full record snapshot, the flat fact list, and what the most recent - * `recall` injected into the prompt. + * `recall` injected into the prompt. User/tenant dims are server constants — + * only `threadId` is taken from the query string (demo multi-thread UX). */ export const Route = createFileRoute('/api/memory-inspect')({ server: { handlers: { GET: async ({ request }) => { - const threadId = new URL(request.url).searchParams.get('threadId') ?? '' - const scope = { threadId } + const threadId = + new URL(request.url).searchParams.get('threadId') ?? '' + const scope = panelMemoryScope(threadId) const snapshot = await memoryAdapter.inspect?.(scope) const facts = await memoryAdapter.listFacts?.(scope) - const lastRecall = lastRecallByThread.get(threadId) ?? null + const lastRecall = lastRecallByThread.get(panelScopeKey(scope)) ?? null return new Response( JSON.stringify({ From 38b1f6d2456cb2a763d6c33ec613e3017c3bf375 Mon Sep 17 00:00:00 2001 From: "autofix-ci[bot]" <114827586+autofix-ci[bot]@users.noreply.github.com> Date: Fri, 24 Jul 2026 03:00:46 +0000 Subject: [PATCH 6/9] ci: apply automated fixes --- packages/ai-memory/src/internal/store.test.ts | 18 +++++++++--------- testing/panel/src/routes/api.memory-inspect.ts | 3 +-- 2 files changed, 10 insertions(+), 11 deletions(-) diff --git a/packages/ai-memory/src/internal/store.test.ts b/packages/ai-memory/src/internal/store.test.ts index ca899d3d8..ea518abd7 100644 --- a/packages/ai-memory/src/internal/store.test.ts +++ b/packages/ai-memory/src/internal/store.test.ts @@ -10,9 +10,9 @@ describe('sameScope', () => { it('matches when threadId and optional dims agree', () => { expect(sameScope(full, full)).toBe(true) - expect( - sameScope(full, { threadId: 's', userId: 'u', tenantId: 'a' }), - ).toBe(true) + expect(sameScope(full, { threadId: 's', userId: 'u', tenantId: 'a' })).toBe( + true, + ) }) it('rejects different threadId', () => { @@ -20,9 +20,9 @@ describe('sameScope', () => { }) it('rejects different userId', () => { - expect(sameScope(full, { threadId: 's', userId: 'other', tenantId: 'a' })).toBe( - false, - ) + expect( + sameScope(full, { threadId: 's', userId: 'other', tenantId: 'a' }), + ).toBe(false) }) it('rejects different tenantId', () => { @@ -47,9 +47,9 @@ describe('sameScope', () => { }) it('treats empty string as unset for optional dims', () => { - expect( - sameScope({ threadId: 's', userId: '' }, { threadId: 's' }), - ).toBe(true) + expect(sameScope({ threadId: 's', userId: '' }, { threadId: 's' })).toBe( + true, + ) }) it('ignores namespace (reserved — no subsystem keys on it yet)', () => { diff --git a/testing/panel/src/routes/api.memory-inspect.ts b/testing/panel/src/routes/api.memory-inspect.ts index de226d889..6d4fc8d2f 100644 --- a/testing/panel/src/routes/api.memory-inspect.ts +++ b/testing/panel/src/routes/api.memory-inspect.ts @@ -17,8 +17,7 @@ export const Route = createFileRoute('/api/memory-inspect')({ server: { handlers: { GET: async ({ request }) => { - const threadId = - new URL(request.url).searchParams.get('threadId') ?? '' + const threadId = new URL(request.url).searchParams.get('threadId') ?? '' const scope = panelMemoryScope(threadId) const snapshot = await memoryAdapter.inspect?.(scope) From b4f9744226d446b98bb2cbc6f3693c1363978d48 Mon Sep 17 00:00:00 2001 From: Tom Beckenham <34339192+tombeckenham@users.noreply.github.com> Date: Fri, 24 Jul 2026 13:38:42 +1000 Subject: [PATCH 7/9] docs(ai-memory): fix hindsight bank-key docs consistency Escape table pipes in adapters.md and align the hindsight skill intro with the tenant-aware bank format. --- docs/memory/adapters.md | 2 +- packages/ai-memory/skills/tanstack-ai-memory-hindsight/SKILL.md | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/memory/adapters.md b/docs/memory/adapters.md index 783cf4631..460bdc4ab 100644 --- a/docs/memory/adapters.md +++ b/docs/memory/adapters.md @@ -141,7 +141,7 @@ is an optional peer, loaded lazily. | Option | Type | Default | Purpose | |--------|------|---------|---------| -| `user` | `string` | `scope.userId` | Durable user id used in the bank key (`{tenant|_}__{user}__{threadId}`). | +| `user` | `string` | `scope.userId` | Durable user id used in the bank key (`{tenant\|_}__{user}__{threadId}`). | | `baseUrl` | `string` | `HINDSIGHT_URL` / `http://localhost:8888` | Server URL. | | `budget` | `'low' \| 'mid' \| 'high'` | `'mid'` | Recall budget. | | `onToolRetain` | `(receipt) => void` | none | Fired when the model calls `hindsight_retain`. | diff --git a/packages/ai-memory/skills/tanstack-ai-memory-hindsight/SKILL.md b/packages/ai-memory/skills/tanstack-ai-memory-hindsight/SKILL.md index 091256a36..08c6cd411 100644 --- a/packages/ai-memory/skills/tanstack-ai-memory-hindsight/SKILL.md +++ b/packages/ai-memory/skills/tanstack-ai-memory-hindsight/SKILL.md @@ -7,7 +7,7 @@ description: Use when wiring hindsight() from @tanstack/ai-memory/hindsight — Hosted `recall`/`save` adapter backed by Hindsight. Hindsight owns extraction and ranking server-side, buckets memory into per-conversation "banks" -(`{userId}__{threadId}`), and — uniquely — exposes LLM **tools** through `recall` so the +(`{tenantId|_}__{user}__{threadId}`), and — uniquely — exposes LLM **tools** through `recall` so the model can retain/recall/reflect directly. ## Setup From adf2a54bfd64434fc1d727bd1ff721475e3a45bc Mon Sep 17 00:00:00 2001 From: Tom Beckenham <34339192+tombeckenham@users.noreply.github.com> Date: Fri, 24 Jul 2026 14:08:07 +1000 Subject: [PATCH 8/9] fix(ai-memory): make error event scope optional, not empty MemoryScopeLite requires threadId when present; memory:error omits scope when resolve never ran. Drop emptyScope() and DevTools unknown-bucket fakes. --- docs/memory/operating.md | 2 +- .../ai-devtools/src/store/memory-registry.ts | 29 ++++++++++--------- .../ai-devtools/tests/memory-registry.test.ts | 20 +++++++++++-- packages/ai-event-client/src/index.ts | 22 ++++++++++---- .../skills/tanstack-ai-memory/SKILL.md | 3 +- packages/ai-memory/src/middleware.ts | 10 ++----- 6 files changed, 56 insertions(+), 30 deletions(-) diff --git a/docs/memory/operating.md b/docs/memory/operating.md index de17056bd..586a0cfc6 100644 --- a/docs/memory/operating.md +++ b/docs/memory/operating.md @@ -84,7 +84,7 @@ Under the hood the middleware emits these events on `aiEventClient` (from | `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'`) | +| `memory:error` | A `recall` or `save` threw (`phase: 'recall'` or `'save'`). Carries `scope` only when it was already resolved; omitted if the resolver failed or never ran. | ## Failures are non-fatal diff --git a/packages/ai-devtools/src/store/memory-registry.ts b/packages/ai-devtools/src/store/memory-registry.ts index 4af880683..832fd4ea4 100644 --- a/packages/ai-devtools/src/store/memory-registry.ts +++ b/packages/ai-devtools/src/store/memory-registry.ts @@ -98,22 +98,22 @@ function scopeDim(value: string | undefined): string { /** * Stable scope key: * `{tenantId|_}:{userId|_}:{threadId}:{namespace|_}`. - * Empty/absent `threadId` (e.g. error scope) buckets to `(unknown)` so broken - * events do not invent a fake conversation. + * Absent scope (e.g. `memory:error` before resolve) buckets to `(unknown)`. */ export function memoryScopeKey(scope: MemoryScopeLite | undefined): string { - const threadId = scope?.threadId - if (!threadId || threadId.length === 0) return '(unknown)' - return `${scopeDim(scope?.tenantId)}:${scopeDim(scope?.userId)}:${escapeScopeDim(threadId)}:${scopeDim(scope?.namespace)}` + if (!scope) return '(unknown)' + return `${scopeDim(scope.tenantId)}:${scopeDim(scope.userId)}:${escapeScopeDim(scope.threadId)}:${scopeDim(scope.namespace)}` } /** Human-readable label for the Memory panel scope picker. */ export function memoryScopeLabel(entry: MemoryScopeState): string { - const thread = - entry.threadId && entry.threadId.length > 0 ? entry.threadId : '(unknown)' - const parts = [entry.tenantId, entry.userId, thread, entry.namespace].filter( - (p): p is string => p != null && p.length > 0, - ) + if (entry.key === '(unknown)' || !entry.threadId) return '(unknown)' + const parts = [ + entry.tenantId, + entry.userId, + entry.threadId, + entry.namespace, + ].filter((p): p is string => p != null && p.length > 0) return parts.join(' · ') } @@ -137,9 +137,12 @@ function ensureScope( } state.scopes[key] = entry } - if (scope?.userId) entry.userId = scope.userId - if (scope?.tenantId) entry.tenantId = scope.tenantId - if (scope?.namespace) entry.namespace = scope.namespace + // Only merge metadata when we have a real scope (not the unscoped error bucket). + if (scope) { + if (scope.userId) entry.userId = scope.userId + if (scope.tenantId) entry.tenantId = scope.tenantId + if (scope.namespace) entry.namespace = scope.namespace + } return entry } diff --git a/packages/ai-devtools/tests/memory-registry.test.ts b/packages/ai-devtools/tests/memory-registry.test.ts index 41ac6d2e9..f90b8fd59 100644 --- a/packages/ai-devtools/tests/memory-registry.test.ts +++ b/packages/ai-devtools/tests/memory-registry.test.ts @@ -107,7 +107,7 @@ describe('memory registry', () => { expect(state.scopes[memoryScopeKey(SCOPE)]!.lastActivity).toBe(40) }) - it('isolates scopes and buckets missing threadId to (unknown)', () => { + it('isolates scopes and buckets omitted error scope to (unknown)', () => { const state = createMemoryRegistryState() applyMemoryEvent(state, { type: 'persist:started', @@ -117,7 +117,7 @@ describe('memory registry', () => { }) applyMemoryEvent(state, { type: 'error', - scope: { threadId: '' }, + // Resolver never produced a scope — omit entirely (no empty-string fake). adapter: 'in-memory', phase: 'save', error: { name: 'Error', message: 'x' }, @@ -127,6 +127,22 @@ describe('memory registry', () => { '(unknown)', memoryScopeKey({ threadId: 'a' }), ]) + expect(state.scopes['(unknown)']!.events[0]).toMatchObject({ + type: 'error', + phase: 'save', + }) + // Errors that resolved a scope stay in that scope's timeline. + applyMemoryEvent(state, { + type: 'error', + scope: { threadId: 'a' }, + adapter: 'in-memory', + phase: 'recall', + error: { name: 'Error', message: 'adapter failed' }, + timestamp: 3, + }) + expect(state.scopes[memoryScopeKey({ threadId: 'a' })]!.events).toHaveLength( + 2, + ) }) it('stores full Scope identity and isolates composite scopes', () => { diff --git a/packages/ai-event-client/src/index.ts b/packages/ai-event-client/src/index.ts index 5b9f27f48..062faabdd 100644 --- a/packages/ai-event-client/src/index.ts +++ b/packages/ai-event-client/src/index.ts @@ -825,13 +825,19 @@ export interface VideoUsageEvent extends BaseEventContext { // --------------------------------------------------------------------------- /** - * Lite scope for devtools payloads. Structural mirror of shared `Scope` / - * `MemoryScope` field set; every field optional so error/telemetry events can - * carry a partial identity. Not an isolation authority — adapters own that. - * Kept structurally minimal so the event client stays decoupled from `@tanstack/ai`. + * Wire/devtools payload for a **known** memory scope. + * + * Structural mirror of `Scope` from `@tanstack/ai` (`threadId` required when + * present). Not an isolation authority — adapters use real `MemoryScope` / + * `Scope`. Lives here so `@tanstack/ai-event-client` does not import + * `@tanstack/ai` (dependency cycle). + * + * When identity is unknown (e.g. the scope resolver threw before producing a + * scope), omit the field entirely on {@link MemoryErrorEvent} — do not send a + * partial or empty-string scope. */ export type MemoryScopeLite = { - threadId?: string + threadId: string userId?: string tenantId?: string /** Reserved on `Scope`; carried for display/identity parity. */ @@ -879,7 +885,11 @@ export interface MemoryPersistCompletedEvent extends BaseEventContext { /** Emitted when a `recall` or `save` throws. Memory failures are non-fatal. */ export interface MemoryErrorEvent extends BaseEventContext { - scope: MemoryScopeLite + /** + * Scope when it was already resolved before the failure. Omitted when the + * resolver itself failed or never ran — there is no fake empty scope. + */ + scope?: MemoryScopeLite adapter: string phase: 'recall' | 'save' error: { name: string; message: string } diff --git a/packages/ai-memory/skills/tanstack-ai-memory/SKILL.md b/packages/ai-memory/skills/tanstack-ai-memory/SKILL.md index 836d160eb..2b386ec12 100644 --- a/packages/ai-memory/skills/tanstack-ai-memory/SKILL.md +++ b/packages/ai-memory/skills/tanstack-ai-memory/SKILL.md @@ -95,4 +95,5 @@ fails the turn. 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. +fragment/receipt counts, not full memory text. Error events include `scope` only +when it was already resolved; if the resolver threw, `scope` is omitted. diff --git a/packages/ai-memory/src/middleware.ts b/packages/ai-memory/src/middleware.ts index 30af0867f..cd6ce56fe 100644 --- a/packages/ai-memory/src/middleware.ts +++ b/packages/ai-memory/src/middleware.ts @@ -146,9 +146,10 @@ export function memoryMiddleware( }) result = await options.adapter.recall(scope, state.lastUserText) } catch (error) { - const errScope = state.resolvedScope ?? emptyScope() safeEmit('memory:error', { - scope: errScope, + // Only attach scope when resolve already succeeded; otherwise omit + // (no empty-string / partial fake identity). + ...(state.resolvedScope ? { scope: state.resolvedScope } : {}), adapter: options.adapter.id, phase: 'recall', error: errorInfo(error), @@ -232,7 +233,6 @@ export function memoryMiddleware( scope ?? (await resolveScope(ctx, { lastUserText: userText })) } catch (error) { safeEmit('memory:error', { - scope: emptyScope(), adapter: options.adapter.id, phase: 'save', error: errorInfo(error), @@ -281,10 +281,6 @@ export function memoryMiddleware( // Internals // =========================== -function emptyScope(): MemoryScope { - return { threadId: '' } -} - /** * Read the adapter's current stored state via the optional `inspect`/`listFacts` * introspection methods. Returns `undefined` for adapters that don't implement From 5dfc0eb05432ec6e6b51fcae947443f0826bce17 Mon Sep 17 00:00:00 2001 From: "autofix-ci[bot]" <114827586+autofix-ci[bot]@users.noreply.github.com> Date: Fri, 24 Jul 2026 04:09:32 +0000 Subject: [PATCH 9/9] ci: apply automated fixes --- packages/ai-devtools/tests/memory-registry.test.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/packages/ai-devtools/tests/memory-registry.test.ts b/packages/ai-devtools/tests/memory-registry.test.ts index f90b8fd59..d89bf6485 100644 --- a/packages/ai-devtools/tests/memory-registry.test.ts +++ b/packages/ai-devtools/tests/memory-registry.test.ts @@ -140,9 +140,9 @@ describe('memory registry', () => { error: { name: 'Error', message: 'adapter failed' }, timestamp: 3, }) - expect(state.scopes[memoryScopeKey({ threadId: 'a' })]!.events).toHaveLength( - 2, - ) + expect( + state.scopes[memoryScopeKey({ threadId: 'a' })]!.events, + ).toHaveLength(2) }) it('stores full Scope identity and isolates composite scopes', () => {