Skip to content
26 changes: 26 additions & 0 deletions .changeset/memory-scope-threadid.md
Original file line number Diff line number Diff line change
@@ -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.
6 changes: 3 additions & 3 deletions .changeset/shared-scope.md
Original file line number Diff line number Diff line change
Expand Up @@ -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).
11 changes: 6 additions & 5 deletions docs/config.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
}
]
},
Expand Down
30 changes: 28 additions & 2 deletions docs/memory/adapters.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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`. |
Expand All @@ -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),
Expand All @@ -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
Expand All @@ -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
Expand All @@ -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
Expand Down
29 changes: 23 additions & 6 deletions docs/memory/custom-adapter.md
Original file line number Diff line number Diff line change
Expand Up @@ -95,23 +95,39 @@ 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 }]
},

async recall(scope: MemoryScope, query: string): Promise<RecallResult> {
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
Expand Down Expand Up @@ -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.
Expand Down
4 changes: 2 additions & 2 deletions docs/memory/operating.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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

Expand Down
18 changes: 11 additions & 7 deletions docs/memory/overview.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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 }
},
})
```
Expand Down
23 changes: 19 additions & 4 deletions docs/memory/quickstart.md
Original file line number Diff line number Diff line change
Expand Up @@ -58,7 +58,7 @@ const stream = chat({
middleware: [
memoryMiddleware({
adapter: memory,
scope: { sessionId: 'demo-thread', userId: 'alice' },
scope: { threadId: 'demo-thread', userId: 'alice' },
}),
],
})
Expand Down Expand Up @@ -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

Expand Down
7 changes: 5 additions & 2 deletions packages/ai-client/src/devtools.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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?: {
Expand Down
4 changes: 2 additions & 2 deletions packages/ai-client/tests/devtools.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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: {
Expand Down Expand Up @@ -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?',
}),
Expand Down
28 changes: 17 additions & 11 deletions packages/ai-devtools/src/components/hooks/MemoryPanel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -134,14 +136,18 @@ export const MemoryPanel: Component = () => {
data-testid="ai-devtools-memory-scope-select"
>
<For each={scopeKeys()}>
{(key) => (
<option value={key}>
{state.memory.scopes[key]?.adapter
? `${state.memory.scopes[key].adapter} · `
: ''}
{key.slice(0, 12)}
</option>
)}
{(key) => {
const entry = state.memory.scopes[key]
const label = entry ? memoryScopeLabel(entry) : key
return (
<option value={key}>
{entry?.adapter ? `${entry.adapter} · ` : ''}
{label.length > 40
? `${label.slice(0, 40)}…`
: label}
</option>
)
}}
</For>
</select>
</Show>
Expand Down
3 changes: 2 additions & 1 deletion packages/ai-devtools/src/store/ai-context.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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<typeof applyMemoryEvent>[1]
const recordMemoryEvent = (
type: MemoryEventInput['type'],
Expand Down
Loading
Loading