@tanstack/ai-memory 0.1.11 → 0.2.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md
ADDED
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
<div align="center">
|
|
2
|
+
<picture>
|
|
3
|
+
<source
|
|
4
|
+
media="(prefers-color-scheme: dark)"
|
|
5
|
+
srcset="https://tanstack.com/api/readme/ai.png?theme=dark"
|
|
6
|
+
/>
|
|
7
|
+
<source
|
|
8
|
+
media="(prefers-color-scheme: light)"
|
|
9
|
+
srcset="https://tanstack.com/api/readme/ai.png"
|
|
10
|
+
/>
|
|
11
|
+
<img
|
|
12
|
+
src="https://tanstack.com/api/readme/ai.png"
|
|
13
|
+
alt="TanStack AI"
|
|
14
|
+
width="900"
|
|
15
|
+
/>
|
|
16
|
+
</picture>
|
|
17
|
+
</div>
|
|
18
|
+
|
|
19
|
+
<br />
|
|
20
|
+
|
|
21
|
+
# @tanstack/ai-memory
|
|
22
|
+
|
|
23
|
+
Pluggable memory adapters for TanStack AI's `memoryMiddleware`
|
|
24
|
+
|
|
25
|
+
`memoryMiddleware` gives a `chat()` call memory across turns and sessions: each turn it recalls relevant memory into the system prompt, then saves the finished user/assistant turn through an adapter. The package ships the middleware, the `MemoryAdapter` contract, the built-in adapters, and adapters for hosted memory services, each on its own subpath.
|
|
26
|
+
|
|
27
|
+
## Installation
|
|
28
|
+
|
|
29
|
+
```bash
|
|
30
|
+
npm install @tanstack/ai-memory
|
|
31
|
+
# or
|
|
32
|
+
pnpm add @tanstack/ai-memory
|
|
33
|
+
# or
|
|
34
|
+
yarn add @tanstack/ai-memory
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
## Usage
|
|
38
|
+
|
|
39
|
+
### Wire `memoryMiddleware` into `chat()`
|
|
40
|
+
|
|
41
|
+
Start with the in-memory adapter, then swap it for a persistent one without changing anything else:
|
|
42
|
+
|
|
43
|
+
```typescript
|
|
44
|
+
import { chat } from '@tanstack/ai'
|
|
45
|
+
import { openaiText } from '@tanstack/ai-openai'
|
|
46
|
+
import { memoryMiddleware } from '@tanstack/ai-memory'
|
|
47
|
+
import { inMemory } from '@tanstack/ai-memory/in-memory'
|
|
48
|
+
|
|
49
|
+
const memory = inMemory()
|
|
50
|
+
|
|
51
|
+
const stream = chat({
|
|
52
|
+
adapter: openaiText('gpt-5.5'),
|
|
53
|
+
messages: [{ role: 'user', content: 'Hello' }],
|
|
54
|
+
middleware: [
|
|
55
|
+
memoryMiddleware({
|
|
56
|
+
adapter: memory,
|
|
57
|
+
scope: { threadId: 'demo-thread', userId: 'alice' },
|
|
58
|
+
}),
|
|
59
|
+
],
|
|
60
|
+
})
|
|
61
|
+
```
|
|
62
|
+
|
|
63
|
+
### Derive scope server-side
|
|
64
|
+
|
|
65
|
+
`scope` is the isolation boundary. In a real app, derive it per request from server-validated session data, never from the request body:
|
|
66
|
+
|
|
67
|
+
```typescript
|
|
68
|
+
memoryMiddleware({
|
|
69
|
+
adapter: memory,
|
|
70
|
+
scope: (ctx) => {
|
|
71
|
+
const session = getSession(ctx)
|
|
72
|
+
return { threadId: session.threadId, userId: session.userId }
|
|
73
|
+
},
|
|
74
|
+
})
|
|
75
|
+
```
|
|
76
|
+
|
|
77
|
+
Memory is entirely server-side; the client consumes the same stream as any other `chat()` endpoint.
|
|
78
|
+
|
|
79
|
+
## Adapters
|
|
80
|
+
|
|
81
|
+
| Adapter | Import | Backing store |
|
|
82
|
+
| ------------- | ------------------------------- | ---------------------------------------------------------- |
|
|
83
|
+
| `inMemory()` | `@tanstack/ai-memory/in-memory` | A `Map` in the current process. Development, tests, demos. |
|
|
84
|
+
| `redis()` | `@tanstack/ai-memory/redis` | Redis, via your own `ioredis` or `redis` client. |
|
|
85
|
+
| `hindsight()` | `@tanstack/ai-memory/hindsight` | A hosted Hindsight server. |
|
|
86
|
+
| `mem0()` | `@tanstack/ai-memory/mem0` | A mem0 server, over plain HTTP. |
|
|
87
|
+
| `honcho()` | `@tanstack/ai-memory/honcho` | A hosted Honcho server. |
|
|
88
|
+
|
|
89
|
+
### Redis
|
|
90
|
+
|
|
91
|
+
```typescript
|
|
92
|
+
import Redis from 'ioredis'
|
|
93
|
+
import { redis } from '@tanstack/ai-memory/redis'
|
|
94
|
+
|
|
95
|
+
const memory = redis({ redis: new Redis(process.env.REDIS_URL) })
|
|
96
|
+
```
|
|
97
|
+
|
|
98
|
+
Using `redis` (node-redis) instead of `ioredis`? Wrap the client with `fromNodeRedis` from the same subpath.
|
|
99
|
+
|
|
100
|
+
### Semantic scoring
|
|
101
|
+
|
|
102
|
+
The built-in adapters score lexically by default. Pass an `embedder` for semantic recall when scopes grow large or queries don't share keywords with stored text:
|
|
103
|
+
|
|
104
|
+
```typescript
|
|
105
|
+
import OpenAI from 'openai'
|
|
106
|
+
import { inMemory } from '@tanstack/ai-memory/in-memory'
|
|
107
|
+
|
|
108
|
+
const openai = new OpenAI()
|
|
109
|
+
|
|
110
|
+
const memory = inMemory({
|
|
111
|
+
embedder: {
|
|
112
|
+
async embed(text) {
|
|
113
|
+
const result = await openai.embeddings.create({
|
|
114
|
+
model: 'text-embedding-3-small',
|
|
115
|
+
input: text,
|
|
116
|
+
})
|
|
117
|
+
const embedding = result.data[0]?.embedding
|
|
118
|
+
if (!embedding) throw new Error('embedding request returned no vector')
|
|
119
|
+
return embedding
|
|
120
|
+
},
|
|
121
|
+
},
|
|
122
|
+
})
|
|
123
|
+
```
|
|
124
|
+
|
|
125
|
+
### Hosted services
|
|
126
|
+
|
|
127
|
+
`hindsight()`, `mem0()`, and `honcho()` map `recall`/`save` onto the vendor API. `@vectorize-io/hindsight-client` and `@honcho-ai/sdk` are optional peer dependencies, loaded lazily by their adapters; `mem0()` needs no SDK.
|
|
128
|
+
|
|
129
|
+
## Custom adapters
|
|
130
|
+
|
|
131
|
+
Implement the `MemoryAdapter` contract — a stable `id` plus `recall` and `save` — then prove it with the same contract suite the built-in adapters run:
|
|
132
|
+
|
|
133
|
+
```typescript
|
|
134
|
+
import { runMemoryAdapterContract } from '@tanstack/ai-memory/testkit'
|
|
135
|
+
import { myAdapter } from './my-adapter'
|
|
136
|
+
|
|
137
|
+
runMemoryAdapterContract('myAdapter', () => myAdapter())
|
|
138
|
+
```
|
|
139
|
+
|
|
140
|
+
The testkit is a Vitest suite. `vitest` is an optional peer dependency, so install it in your project before importing `@tanstack/ai-memory/testkit`.
|
|
141
|
+
|
|
142
|
+
## Documentation
|
|
143
|
+
|
|
144
|
+
- [Overview](https://tanstack.com/ai/latest/docs/memory/overview): the `recall`/`save` contract, scope, and how a turn flows
|
|
145
|
+
- [Quickstart](https://tanstack.com/ai/latest/docs/memory/quickstart)
|
|
146
|
+
- [Adapters](https://tanstack.com/ai/latest/docs/memory/adapters): every adapter's options
|
|
147
|
+
- [Operating memory](https://tanstack.com/ai/latest/docs/memory/operating): options, telemetry, devtools events, and failures
|
|
148
|
+
- [Custom Adapter](https://tanstack.com/ai/latest/docs/memory/custom-adapter)
|
|
149
|
+
|
|
150
|
+
## License
|
|
151
|
+
|
|
152
|
+
MIT
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
import { MemoryAdapter } from '../types.js';
|
|
2
|
+
/**
|
|
3
|
+
* Shared contract suite for any `recall`/`save` {@link MemoryAdapter}. Point it
|
|
4
|
+
* at a factory that returns a fresh adapter and it verifies the round-trip,
|
|
5
|
+
* scope isolation, empty recall, receipt shape, and the optional introspection
|
|
6
|
+
* methods. If your adapter passes, the middleware works.
|
|
7
|
+
*/
|
|
8
|
+
export declare function runMemoryAdapterContract(label: string, factory: () => Promise<MemoryAdapter> | MemoryAdapter): void;
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
import { beforeEach, describe, expect, it } from "vitest";
|
|
2
|
+
//#region src/testkit/contract.ts
|
|
3
|
+
/**
|
|
4
|
+
* Shared contract suite for any `recall`/`save` {@link MemoryAdapter}. Point it
|
|
5
|
+
* at a factory that returns a fresh adapter and it verifies the round-trip,
|
|
6
|
+
* scope isolation, empty recall, receipt shape, and the optional introspection
|
|
7
|
+
* methods. If your adapter passes, the middleware works.
|
|
8
|
+
*/
|
|
9
|
+
function runMemoryAdapterContract(label, factory) {
|
|
10
|
+
describe(label, () => {
|
|
11
|
+
let adapter;
|
|
12
|
+
const scopeA = {
|
|
13
|
+
threadId: "s1",
|
|
14
|
+
userId: "u1"
|
|
15
|
+
};
|
|
16
|
+
const scopeB = {
|
|
17
|
+
threadId: "s2",
|
|
18
|
+
userId: "u2"
|
|
19
|
+
};
|
|
20
|
+
beforeEach(async () => {
|
|
21
|
+
adapter = await factory();
|
|
22
|
+
});
|
|
23
|
+
describe("save", () => {
|
|
24
|
+
it("returns a non-empty array of ok receipts", async () => {
|
|
25
|
+
const receipts = await adapter.save(scopeA, {
|
|
26
|
+
user: "I love hiking in the mountains",
|
|
27
|
+
assistant: "Noted — hiking it is."
|
|
28
|
+
});
|
|
29
|
+
expect(Array.isArray(receipts)).toBe(true);
|
|
30
|
+
expect(receipts.length).toBeGreaterThan(0);
|
|
31
|
+
expect(receipts.every((r) => typeof r.ok === "boolean")).toBe(true);
|
|
32
|
+
expect(receipts.some((r) => r.ok)).toBe(true);
|
|
33
|
+
});
|
|
34
|
+
});
|
|
35
|
+
describe("recall", () => {
|
|
36
|
+
it("round-trips: a saved turn surfaces in a later recall", async () => {
|
|
37
|
+
await adapter.save(scopeA, {
|
|
38
|
+
user: "My favorite programming language is TypeScript",
|
|
39
|
+
assistant: "Great choice."
|
|
40
|
+
});
|
|
41
|
+
const result = await adapter.recall(scopeA, "programming language");
|
|
42
|
+
expect(result.systemPrompt.toLowerCase()).toContain("typescript");
|
|
43
|
+
});
|
|
44
|
+
it("returns an empty result for a scope with nothing saved", async () => {
|
|
45
|
+
const result = await adapter.recall(scopeA, "anything at all");
|
|
46
|
+
expect(result.systemPrompt).toBe("");
|
|
47
|
+
expect(result.fragments ?? []).toHaveLength(0);
|
|
48
|
+
});
|
|
49
|
+
it("isolates scopes — recall never crosses into another scope", async () => {
|
|
50
|
+
await adapter.save(scopeA, {
|
|
51
|
+
user: "The secret code is alpha-bravo",
|
|
52
|
+
assistant: "Understood."
|
|
53
|
+
});
|
|
54
|
+
const other = await adapter.recall(scopeB, "secret code");
|
|
55
|
+
expect(other.systemPrompt).toBe("");
|
|
56
|
+
expect(other.fragments ?? []).toHaveLength(0);
|
|
57
|
+
});
|
|
58
|
+
it("isolates same threadId across different userId", async () => {
|
|
59
|
+
await adapter.save({
|
|
60
|
+
threadId: "shared-thread",
|
|
61
|
+
userId: "alice"
|
|
62
|
+
}, {
|
|
63
|
+
user: "Alice secret token is red-fox",
|
|
64
|
+
assistant: "Understood."
|
|
65
|
+
});
|
|
66
|
+
const otherUser = await adapter.recall({
|
|
67
|
+
threadId: "shared-thread",
|
|
68
|
+
userId: "bob"
|
|
69
|
+
}, "secret token");
|
|
70
|
+
expect(otherUser.systemPrompt).toBe("");
|
|
71
|
+
expect(otherUser.fragments ?? []).toHaveLength(0);
|
|
72
|
+
});
|
|
73
|
+
it("isolates same threadId+userId across different tenantId", async () => {
|
|
74
|
+
await adapter.save({
|
|
75
|
+
threadId: "shared-thread",
|
|
76
|
+
userId: "u",
|
|
77
|
+
tenantId: "tenant-a"
|
|
78
|
+
}, {
|
|
79
|
+
user: "Tenant A vault code is blue-jay",
|
|
80
|
+
assistant: "Understood."
|
|
81
|
+
});
|
|
82
|
+
const otherTenant = await adapter.recall({
|
|
83
|
+
threadId: "shared-thread",
|
|
84
|
+
userId: "u",
|
|
85
|
+
tenantId: "tenant-b"
|
|
86
|
+
}, "vault code");
|
|
87
|
+
expect(otherTenant.systemPrompt).toBe("");
|
|
88
|
+
expect(otherTenant.fragments ?? []).toHaveLength(0);
|
|
89
|
+
});
|
|
90
|
+
});
|
|
91
|
+
describe("optional introspection", () => {
|
|
92
|
+
it("inspect (when present) returns a well-formed snapshot after a save", async () => {
|
|
93
|
+
if (!adapter.inspect) return;
|
|
94
|
+
await adapter.save(scopeA, {
|
|
95
|
+
user: "hello world",
|
|
96
|
+
assistant: "hi"
|
|
97
|
+
});
|
|
98
|
+
const snap = await adapter.inspect(scopeA);
|
|
99
|
+
expect(typeof snap.takenAt).toBe("string");
|
|
100
|
+
expect(snap.data).toBeDefined();
|
|
101
|
+
});
|
|
102
|
+
it("listFacts (when present) returns rows after a save", async () => {
|
|
103
|
+
if (!adapter.listFacts) return;
|
|
104
|
+
await adapter.save(scopeA, {
|
|
105
|
+
user: "hello world",
|
|
106
|
+
assistant: "hi"
|
|
107
|
+
});
|
|
108
|
+
const facts = await adapter.listFacts(scopeA);
|
|
109
|
+
expect(Array.isArray(facts)).toBe(true);
|
|
110
|
+
expect(facts.every((f) => typeof f.id === "string" && typeof f.text === "string")).toBe(true);
|
|
111
|
+
});
|
|
112
|
+
});
|
|
113
|
+
});
|
|
114
|
+
}
|
|
115
|
+
//#endregion
|
|
116
|
+
export { runMemoryAdapterContract };
|
|
117
|
+
|
|
118
|
+
//# sourceMappingURL=contract.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"contract.js","names":[],"sources":["../../../src/testkit/contract.ts"],"sourcesContent":["import { beforeEach, describe, expect, it } from 'vitest'\nimport type { MemoryAdapter, MemoryScope } from '../types'\n\n/**\n * Shared contract suite for any `recall`/`save` {@link MemoryAdapter}. Point it\n * at a factory that returns a fresh adapter and it verifies the round-trip,\n * scope isolation, empty recall, receipt shape, and the optional introspection\n * methods. If your adapter passes, the middleware works.\n */\nexport function runMemoryAdapterContract(\n label: string,\n factory: () => Promise<MemoryAdapter> | MemoryAdapter,\n) {\n describe(label, () => {\n let adapter: MemoryAdapter\n const scopeA: MemoryScope = { threadId: 's1', userId: 'u1' }\n const scopeB: MemoryScope = { threadId: 's2', userId: 'u2' }\n\n beforeEach(async () => {\n adapter = await factory()\n })\n\n describe('save', () => {\n it('returns a non-empty array of ok receipts', async () => {\n const receipts = await adapter.save(scopeA, {\n user: 'I love hiking in the mountains',\n assistant: 'Noted — hiking it is.',\n })\n expect(Array.isArray(receipts)).toBe(true)\n expect(receipts.length).toBeGreaterThan(0)\n expect(receipts.every((r) => typeof r.ok === 'boolean')).toBe(true)\n expect(receipts.some((r) => r.ok)).toBe(true)\n })\n })\n\n describe('recall', () => {\n it('round-trips: a saved turn surfaces in a later recall', async () => {\n await adapter.save(scopeA, {\n user: 'My favorite programming language is TypeScript',\n assistant: 'Great choice.',\n })\n const result = await adapter.recall(scopeA, 'programming language')\n expect(result.systemPrompt.toLowerCase()).toContain('typescript')\n })\n\n it('returns an empty result for a scope with nothing saved', async () => {\n const result = await adapter.recall(scopeA, 'anything at all')\n expect(result.systemPrompt).toBe('')\n expect(result.fragments ?? []).toHaveLength(0)\n })\n\n it('isolates scopes — recall never crosses into another scope', async () => {\n await adapter.save(scopeA, {\n user: 'The secret code is alpha-bravo',\n assistant: 'Understood.',\n })\n const other = await adapter.recall(scopeB, 'secret code')\n expect(other.systemPrompt).toBe('')\n expect(other.fragments ?? []).toHaveLength(0)\n })\n\n it('isolates same threadId across different userId', async () => {\n await adapter.save(\n { threadId: 'shared-thread', userId: 'alice' },\n {\n user: 'Alice secret token is red-fox',\n assistant: 'Understood.',\n },\n )\n const otherUser = await adapter.recall(\n { threadId: 'shared-thread', userId: 'bob' },\n 'secret token',\n )\n expect(otherUser.systemPrompt).toBe('')\n expect(otherUser.fragments ?? []).toHaveLength(0)\n })\n\n it('isolates same threadId+userId across different tenantId', async () => {\n await adapter.save(\n { threadId: 'shared-thread', userId: 'u', tenantId: 'tenant-a' },\n {\n user: 'Tenant A vault code is blue-jay',\n assistant: 'Understood.',\n },\n )\n const otherTenant = await adapter.recall(\n { threadId: 'shared-thread', userId: 'u', tenantId: 'tenant-b' },\n 'vault code',\n )\n expect(otherTenant.systemPrompt).toBe('')\n expect(otherTenant.fragments ?? []).toHaveLength(0)\n })\n })\n\n describe('optional introspection', () => {\n it('inspect (when present) returns a well-formed snapshot after a save', async () => {\n if (!adapter.inspect) return\n await adapter.save(scopeA, { user: 'hello world', assistant: 'hi' })\n const snap = await adapter.inspect(scopeA)\n expect(typeof snap.takenAt).toBe('string')\n expect(snap.data).toBeDefined()\n })\n\n it('listFacts (when present) returns rows after a save', async () => {\n if (!adapter.listFacts) return\n await adapter.save(scopeA, { user: 'hello world', assistant: 'hi' })\n const facts = await adapter.listFacts(scopeA)\n expect(Array.isArray(facts)).toBe(true)\n expect(\n facts.every(\n (f) => typeof f.id === 'string' && typeof f.text === 'string',\n ),\n ).toBe(true)\n })\n })\n })\n}\n"],"mappings":"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"}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@tanstack/ai-memory",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.2.2",
|
|
4
4
|
"description": "Pluggable memory adapters for TanStack AI memoryMiddleware",
|
|
5
5
|
"author": "",
|
|
6
6
|
"license": "MIT",
|
|
@@ -17,6 +17,10 @@
|
|
|
17
17
|
"types": "./dist/esm/index.d.ts",
|
|
18
18
|
"import": "./dist/esm/index.js"
|
|
19
19
|
},
|
|
20
|
+
"./testkit": {
|
|
21
|
+
"types": "./dist/esm/testkit/contract.d.ts",
|
|
22
|
+
"import": "./dist/esm/testkit/contract.js"
|
|
23
|
+
},
|
|
20
24
|
"./in-memory": {
|
|
21
25
|
"types": "./dist/esm/providers/in-memory/index.d.ts",
|
|
22
26
|
"import": "./dist/esm/providers/in-memory/index.js"
|
|
@@ -44,6 +48,22 @@
|
|
|
44
48
|
"src",
|
|
45
49
|
"skills"
|
|
46
50
|
],
|
|
51
|
+
"nx": {
|
|
52
|
+
"targets": {
|
|
53
|
+
"test:lib": {
|
|
54
|
+
"dependsOn": [
|
|
55
|
+
"build",
|
|
56
|
+
"^build"
|
|
57
|
+
]
|
|
58
|
+
},
|
|
59
|
+
"test:types": {
|
|
60
|
+
"dependsOn": [
|
|
61
|
+
"build",
|
|
62
|
+
"^build"
|
|
63
|
+
]
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
},
|
|
47
67
|
"keywords": [
|
|
48
68
|
"ai",
|
|
49
69
|
"tanstack",
|
|
@@ -53,14 +73,15 @@
|
|
|
53
73
|
"tanstack-intent"
|
|
54
74
|
],
|
|
55
75
|
"dependencies": {
|
|
56
|
-
"@tanstack/ai-event-client": "^0.
|
|
76
|
+
"@tanstack/ai-event-client": "^0.12.0"
|
|
57
77
|
},
|
|
58
78
|
"peerDependencies": {
|
|
59
79
|
"@honcho-ai/sdk": ">=2.0.0",
|
|
60
80
|
"@vectorize-io/hindsight-client": ">=0.6.0",
|
|
61
81
|
"ioredis": ">=5.0.0",
|
|
62
82
|
"redis": ">=4.0.0",
|
|
63
|
-
"
|
|
83
|
+
"vitest": "^4.1.10",
|
|
84
|
+
"@tanstack/ai": "^0.57.0"
|
|
64
85
|
},
|
|
65
86
|
"peerDependenciesMeta": {
|
|
66
87
|
"ioredis": {
|
|
@@ -74,6 +95,9 @@
|
|
|
74
95
|
},
|
|
75
96
|
"@honcho-ai/sdk": {
|
|
76
97
|
"optional": true
|
|
98
|
+
},
|
|
99
|
+
"vitest": {
|
|
100
|
+
"optional": true
|
|
77
101
|
}
|
|
78
102
|
},
|
|
79
103
|
"devDependencies": {
|
|
@@ -82,7 +106,8 @@
|
|
|
82
106
|
"@vitest/coverage-v8": "4.1.10",
|
|
83
107
|
"ioredis-mock": "^8.9.0",
|
|
84
108
|
"redis": "^4.7.0",
|
|
85
|
-
"
|
|
109
|
+
"vitest": "^4.1.10",
|
|
110
|
+
"@tanstack/ai": "0.57.0"
|
|
86
111
|
},
|
|
87
112
|
"scripts": {
|
|
88
113
|
"build": "vite build",
|
|
@@ -96,7 +96,7 @@ BEFORE using it.
|
|
|
96
96
|
- `hindsight()` — bank `{tenant|_}__{user}__{threadId}`.
|
|
97
97
|
- `mem0()` — `user_id` + `run_id` (`threadId`); no `tenantId`.
|
|
98
98
|
- `honcho()` — session `{tenant|_}__{threadId}`; peer tenant-prefixed when set.
|
|
99
|
-
- Custom — implement `recall`/`save` and run `@tanstack/ai-memory/
|
|
99
|
+
- Custom — implement `recall`/`save` and run `runMemoryAdapterContract` from `@tanstack/ai-memory/testkit`.
|
|
100
100
|
|
|
101
101
|
## Failure modes
|
|
102
102
|
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
import { beforeEach, describe, expect, it } from 'vitest'
|
|
2
|
+
import type { MemoryAdapter, MemoryScope } from '../types'
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Shared contract suite for any `recall`/`save` {@link MemoryAdapter}. Point it
|
|
6
|
+
* at a factory that returns a fresh adapter and it verifies the round-trip,
|
|
7
|
+
* scope isolation, empty recall, receipt shape, and the optional introspection
|
|
8
|
+
* methods. If your adapter passes, the middleware works.
|
|
9
|
+
*/
|
|
10
|
+
export function runMemoryAdapterContract(
|
|
11
|
+
label: string,
|
|
12
|
+
factory: () => Promise<MemoryAdapter> | MemoryAdapter,
|
|
13
|
+
) {
|
|
14
|
+
describe(label, () => {
|
|
15
|
+
let adapter: MemoryAdapter
|
|
16
|
+
const scopeA: MemoryScope = { threadId: 's1', userId: 'u1' }
|
|
17
|
+
const scopeB: MemoryScope = { threadId: 's2', userId: 'u2' }
|
|
18
|
+
|
|
19
|
+
beforeEach(async () => {
|
|
20
|
+
adapter = await factory()
|
|
21
|
+
})
|
|
22
|
+
|
|
23
|
+
describe('save', () => {
|
|
24
|
+
it('returns a non-empty array of ok receipts', async () => {
|
|
25
|
+
const receipts = await adapter.save(scopeA, {
|
|
26
|
+
user: 'I love hiking in the mountains',
|
|
27
|
+
assistant: 'Noted — hiking it is.',
|
|
28
|
+
})
|
|
29
|
+
expect(Array.isArray(receipts)).toBe(true)
|
|
30
|
+
expect(receipts.length).toBeGreaterThan(0)
|
|
31
|
+
expect(receipts.every((r) => typeof r.ok === 'boolean')).toBe(true)
|
|
32
|
+
expect(receipts.some((r) => r.ok)).toBe(true)
|
|
33
|
+
})
|
|
34
|
+
})
|
|
35
|
+
|
|
36
|
+
describe('recall', () => {
|
|
37
|
+
it('round-trips: a saved turn surfaces in a later recall', async () => {
|
|
38
|
+
await adapter.save(scopeA, {
|
|
39
|
+
user: 'My favorite programming language is TypeScript',
|
|
40
|
+
assistant: 'Great choice.',
|
|
41
|
+
})
|
|
42
|
+
const result = await adapter.recall(scopeA, 'programming language')
|
|
43
|
+
expect(result.systemPrompt.toLowerCase()).toContain('typescript')
|
|
44
|
+
})
|
|
45
|
+
|
|
46
|
+
it('returns an empty result for a scope with nothing saved', async () => {
|
|
47
|
+
const result = await adapter.recall(scopeA, 'anything at all')
|
|
48
|
+
expect(result.systemPrompt).toBe('')
|
|
49
|
+
expect(result.fragments ?? []).toHaveLength(0)
|
|
50
|
+
})
|
|
51
|
+
|
|
52
|
+
it('isolates scopes — recall never crosses into another scope', async () => {
|
|
53
|
+
await adapter.save(scopeA, {
|
|
54
|
+
user: 'The secret code is alpha-bravo',
|
|
55
|
+
assistant: 'Understood.',
|
|
56
|
+
})
|
|
57
|
+
const other = await adapter.recall(scopeB, 'secret code')
|
|
58
|
+
expect(other.systemPrompt).toBe('')
|
|
59
|
+
expect(other.fragments ?? []).toHaveLength(0)
|
|
60
|
+
})
|
|
61
|
+
|
|
62
|
+
it('isolates same threadId across different userId', async () => {
|
|
63
|
+
await adapter.save(
|
|
64
|
+
{ threadId: 'shared-thread', userId: 'alice' },
|
|
65
|
+
{
|
|
66
|
+
user: 'Alice secret token is red-fox',
|
|
67
|
+
assistant: 'Understood.',
|
|
68
|
+
},
|
|
69
|
+
)
|
|
70
|
+
const otherUser = await adapter.recall(
|
|
71
|
+
{ threadId: 'shared-thread', userId: 'bob' },
|
|
72
|
+
'secret token',
|
|
73
|
+
)
|
|
74
|
+
expect(otherUser.systemPrompt).toBe('')
|
|
75
|
+
expect(otherUser.fragments ?? []).toHaveLength(0)
|
|
76
|
+
})
|
|
77
|
+
|
|
78
|
+
it('isolates same threadId+userId across different tenantId', async () => {
|
|
79
|
+
await adapter.save(
|
|
80
|
+
{ threadId: 'shared-thread', userId: 'u', tenantId: 'tenant-a' },
|
|
81
|
+
{
|
|
82
|
+
user: 'Tenant A vault code is blue-jay',
|
|
83
|
+
assistant: 'Understood.',
|
|
84
|
+
},
|
|
85
|
+
)
|
|
86
|
+
const otherTenant = await adapter.recall(
|
|
87
|
+
{ threadId: 'shared-thread', userId: 'u', tenantId: 'tenant-b' },
|
|
88
|
+
'vault code',
|
|
89
|
+
)
|
|
90
|
+
expect(otherTenant.systemPrompt).toBe('')
|
|
91
|
+
expect(otherTenant.fragments ?? []).toHaveLength(0)
|
|
92
|
+
})
|
|
93
|
+
})
|
|
94
|
+
|
|
95
|
+
describe('optional introspection', () => {
|
|
96
|
+
it('inspect (when present) returns a well-formed snapshot after a save', async () => {
|
|
97
|
+
if (!adapter.inspect) return
|
|
98
|
+
await adapter.save(scopeA, { user: 'hello world', assistant: 'hi' })
|
|
99
|
+
const snap = await adapter.inspect(scopeA)
|
|
100
|
+
expect(typeof snap.takenAt).toBe('string')
|
|
101
|
+
expect(snap.data).toBeDefined()
|
|
102
|
+
})
|
|
103
|
+
|
|
104
|
+
it('listFacts (when present) returns rows after a save', async () => {
|
|
105
|
+
if (!adapter.listFacts) return
|
|
106
|
+
await adapter.save(scopeA, { user: 'hello world', assistant: 'hi' })
|
|
107
|
+
const facts = await adapter.listFacts(scopeA)
|
|
108
|
+
expect(Array.isArray(facts)).toBe(true)
|
|
109
|
+
expect(
|
|
110
|
+
facts.every(
|
|
111
|
+
(f) => typeof f.id === 'string' && typeof f.text === 'string',
|
|
112
|
+
),
|
|
113
|
+
).toBe(true)
|
|
114
|
+
})
|
|
115
|
+
})
|
|
116
|
+
})
|
|
117
|
+
}
|