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/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..460bdc4ab 100644 --- a/docs/memory/adapters.md +++ b/docs/memory/adapters.md @@ -114,6 +114,25 @@ 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 (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) | 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 | + +Optional dims are exact-match (omit ≠ match any). mem0 does not model tenants — +encode multi-tenant isolation into `user` if needed. + ## `hindsight()` Hosted adapter backed by Hindsight. Owns extraction/ranking server-side and exposes @@ -122,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}__{sessionId}`). | +| `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`. | @@ -132,7 +151,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} (or tenant__alice__{threadId}) baseUrl: 'https://hindsight.internal', // default: HINDSIGHT_URL budget: 'high', // deeper recall onToolRetain: (receipt) => console.log('model retained', receipt.ok), @@ -141,6 +160,8 @@ const memory = hindsight({ }) ``` +Bank id is `{tenantId|_}__{user}__{threadId}`. + ## `mem0()` Hosted adapter backed by a mem0 server, over plain HTTP (no SDK peer). Requires a running @@ -166,6 +187,8 @@ const memory = mem0({ }) ``` +mem0 requests send `user_id` and `run_id` (`threadId`). `tenantId` is not sent. + ## `honcho()` Hosted adapter backed by Honcho. `recall` returns a synthesized dialectic answer over the @@ -192,6 +215,9 @@ const memory = honcho({ }) ``` +Honcho session key is `{tenantId|_}__{threadId}`; peers are `{tenantId}__{user}` when +`tenantId` is set, otherwise the bare user id. + ## Where to go next - [Overview](./overview): the `recall`/`save` contract and how a turn flows diff --git a/docs/memory/custom-adapter.md b/docs/memory/custom-adapter.md index d11bc142f..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 (session_id, user_id, role, text, embedding) - VALUES ($1, $2, $3, $4, $5)`, - [scope.sessionId, scope.userId ?? null, row.role, row.text, JSON.stringify(vector)], + `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 session_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.sessionId, 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 @@ -179,7 +195,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..586a0cfc6 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) @@ -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/docs/memory/overview.md b/docs/memory/overview.md index 5f254a22f..6f6c53142 100644 --- a/docs/memory/overview.md +++ b/docs/memory/overview.md @@ -89,19 +89,23 @@ 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 is an alias of Scope from `@tanstack/ai`, exported by `@tanstack/ai-memory`: type MemoryScope = { - sessionId: string + threadId: string // required — same as ChatMiddlewareContext.threadId userId?: string + tenantId?: string + 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 @@ -115,7 +119,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..6f29214ab 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,15 +141,30 @@ 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 } }, }), ], }) ``` -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 eea282067..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: { sessionId?: string; userId?: string } + scope: MemoryScopeLite 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..b900c95d8 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 (sessionId), not per-hook, so this - * panel reads the whole `state.memory` registry and lets the user pick a scope - * (defaulting to the most recently active). It renders two things: + * 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 24c72290a..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 (sessionId). + // 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 4fd1c9138..832fd4ea4 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 (sessionId), NOT by hook — several hooks can share - * one session. The `MemoryPanel` reads a single `MemoryScopeState` by key; the - * per-hook tab just resolves which key to show. + * 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,11 +60,13 @@ export interface MemorySnapshotRecord { facts: Array } -/** Everything known about memory for a single scope (sessionId). */ +/** Everything known about memory for a single composite scope. */ export interface MemoryScopeState { key: string - sessionId: string + threadId: string userId?: string + tenantId?: string + namespace?: string /** Most recent adapter id seen for this scope. */ adapter?: string events: Array @@ -80,10 +82,39 @@ export function createMemoryRegistryState(): MemoryRegistryState { return { scopes: {} } } -/** Stable scope key. Empty/absent sessionId (e.g. error scope) buckets to `(unknown)`. */ +/** + * 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, '\\$&') +} + +/** 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}:{namespace|_}`. + * Absent scope (e.g. `memory:error` before resolve) buckets to `(unknown)`. + */ export function memoryScopeKey(scope: MemoryScopeLite | undefined): string { - const sessionId = scope?.sessionId - return sessionId && sessionId.length > 0 ? sessionId : '(unknown)' + 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 { + 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(' · ') } const MAX_EVENTS_PER_SCOPE = 200 @@ -97,14 +128,21 @@ function ensureScope( if (!entry) { entry = { key, - sessionId: scope?.sessionId ?? '', + threadId: scope?.threadId ?? '', userId: scope?.userId, + tenantId: scope?.tenantId, + namespace: scope?.namespace, events: [], lastActivity: 0, } state.scopes[key] = entry } - if (scope?.userId) entry.userId = scope.userId + // 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 d9d41c3ac..d89bf6485 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 = { sessionId: 'session-1' } +const SCOPE = { threadId: 'thread-1' } describe('memory registry', () => { it('accumulates the operation timeline per scope', () => { @@ -106,23 +107,106 @@ 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 omitted error scope 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: '' }, + // Resolver never produced a scope — omit entirely (no empty-string fake). adapter: 'in-memory', phase: 'save', 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' }), + ]) + 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', () => { + 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', + scope: tenantA, + adapter: 'in-memory', + timestamp: 1, + }) + applyMemoryEvent(state, { + type: 'persist:started', + scope: tenantB, + adapter: 'redis', + timestamp: 2, + }) + applyMemoryEvent(state, { + type: 'persist:started', + scope: sameThreadOtherUser, + adapter: 'in-memory', + timestamp: 3, + }) + + const keyA = memoryScopeKey(tenantA) + const keyB = memoryScopeKey(tenantB) + 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({ + threadId: 'shared', + userId: 'u', + tenantId: 'tenant-b', + adapter: 'redis', + }) + // 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 fa36b3d24..062faabdd 100644 --- a/packages/ai-event-client/src/index.ts +++ b/packages/ai-event-client/src/index.ts @@ -825,13 +825,23 @@ 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 - * event client stays decoupled from the memory package. + * 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 = { - sessionId?: string + 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. */ @@ -875,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-hindsight/SKILL.md b/packages/ai-memory/skills/tanstack-ai-memory-hindsight/SKILL.md index fa12efaa9..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}__{sessionId}`), 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 @@ -31,6 +31,8 @@ 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 `{tenantId|_}__{user}__{threadId}`. `namespace` is ignored. + ## 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..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,5 +32,9 @@ it where you use `honcho()`. - `apiKey` — default `HONCHO_API_KEY`. - `assistantId` — assistant peer id (default `'assistant'`). +**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 7f442f340..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,5 +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:** 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-redis/SKILL.md b/packages/ai-memory/skills/tanstack-ai-memory-redis/SKILL.md index 09c20eb0e..cb5ed2fbb 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,19 @@ 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 (`:`, `\`, 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 9721e3e09..2b386ec12 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,16 +68,20 @@ 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 -- `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). +- `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 @@ -91,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/internal/store.test.ts b/packages/ai-memory/src/internal/store.test.ts new file mode 100644 index 000000000..ea518abd7 --- /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 013e4cb06..c56241e69 100644 --- a/packages/ai-memory/src/internal/store.ts +++ b/packages/ai-memory/src/internal/store.ts @@ -92,15 +92,25 @@ 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. + * 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.sessionId !== query.sessionId) return false - if (query.userId != null && query.userId !== '') { - return record.userId === query.userId + if (record.threadId !== query.threadId) 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/middleware.ts b/packages/ai-memory/src/middleware.ts index ea6cb12eb..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 { sessionId: '' } -} - /** * Read the adapter's current stored state via the optional `inspect`/`listFacts` * introspection methods. Returns `undefined` for adapters that don't implement diff --git a/packages/ai-memory/src/providers/hindsight/index.ts b/packages/ai-memory/src/providers/hindsight/index.ts index 91b9d466f..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}__{sessionId}`). 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.sessionId}` + // 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 88cb1fd0e..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(sessionId: string): Promise { - return cached(sessionCache, sessionId, async () => - (await getClient()).session(sessionId), + 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.sessionId), + 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.sessionId), + 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.sessionId).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 a3b0961dd..2b5812649 100644 --- a/packages/ai-memory/src/providers/redis/index.ts +++ b/packages/ai-memory/src/providers/redis/index.ts @@ -100,16 +100,24 @@ 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 * ``` + * Segments are escaped (so `:`, `\\`, `_` in values cannot collide). Missing + * 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 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..5d2028d32 100644 --- a/packages/ai-memory/src/types.ts +++ b/packages/ai-memory/src/types.ts @@ -14,26 +14,25 @@ * 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`). * - * 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. + * 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). + * + * 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 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..1046b9922 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() @@ -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/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..a7b2fc7b3 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,38 @@ 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('') + + 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/mem0.test.ts b/packages/ai-memory/tests/providers/mem0.test.ts index 4bf938ea3..ae9bb43ec 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) @@ -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', () => { @@ -90,13 +106,14 @@ 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') 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({ @@ -110,7 +127,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 +135,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..775c698b3 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 @@ -47,28 +48,93 @@ 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()}` 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('') 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/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/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..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 session, so the page can - * show "what memory fed into this turn". Populated from the middleware's - * `onRecall` callback in the chat route; read by the inspect route. + * 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 lastRecallBySession = new Map() +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 be1146576..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 { lastRecallBySession, 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,7 +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 `sessionId`. + * 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: { @@ -45,7 +52,13 @@ 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' + // 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 = { @@ -79,14 +92,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, onRecall: (info) => { - lastRecallBySession.set(sessionId, 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 9a859487b..6d4fc8d2f 100644 --- a/testing/panel/src/routes/api.memory-inspect.ts +++ b/testing/panel/src/routes/api.memory-inspect.ts @@ -1,23 +1,28 @@ import { createFileRoute } from '@tanstack/react-router' -import { lastRecallBySession, 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 session, 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 sessionId = - new URL(request.url).searchParams.get('sessionId') ?? '' - const scope = { sessionId } + 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 = lastRecallBySession.get(sessionId) ?? null + const lastRecall = lastRecallByThread.get(panelScopeKey(scope)) ?? null return new Response( JSON.stringify({ diff --git a/testing/panel/src/routes/memory.tsx b/testing/panel/src/routes/memory.tsx index 34fec675f..5e5e9beab 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(() => { @@ -110,10 +110,10 @@ function MemoryPage() { refreshInspect() }, [refreshInspect]) - const startNewSession = () => { + const startNewThread = () => { 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) : '…'}

@@ -227,11 +227,11 @@ function MemoryPage() { Refresh