@tanstack/ai-memory 0.1.10 → 0.2.0
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/dist/esm/testkit/contract.d.ts +8 -0
- package/dist/esm/testkit/contract.js +118 -0
- package/dist/esm/testkit/contract.js.map +1 -0
- package/package.json +29 -4
- package/skills/tanstack-ai-memory/SKILL.md +36 -22
- package/skills/tanstack-ai-memory-hindsight/SKILL.md +9 -3
- package/skills/tanstack-ai-memory-honcho/SKILL.md +9 -3
- package/skills/tanstack-ai-memory-in-memory/SKILL.md +5 -1
- package/skills/tanstack-ai-memory-mem0/SKILL.md +9 -3
- package/skills/tanstack-ai-memory-redis/SKILL.md +14 -3
- package/src/testkit/contract.ts +117 -0
|
@@ -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.0",
|
|
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.11.
|
|
76
|
+
"@tanstack/ai-event-client": "^0.11.3"
|
|
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.55.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.55.0"
|
|
86
111
|
},
|
|
87
112
|
"scripts": {
|
|
88
113
|
"build": "vite build",
|
|
@@ -22,28 +22,33 @@ Memory is for cross-turn / cross-session recall, not within-turn history.
|
|
|
22
22
|
## Wire it up
|
|
23
23
|
|
|
24
24
|
```ts
|
|
25
|
-
import { chat } from '@tanstack/ai'
|
|
25
|
+
import { chat, toServerSentEventsResponse } from '@tanstack/ai'
|
|
26
26
|
import { openaiText } from '@tanstack/ai-openai'
|
|
27
27
|
import { memoryMiddleware } from '@tanstack/ai-memory'
|
|
28
28
|
import { inMemory } from '@tanstack/ai-memory/in-memory'
|
|
29
|
+
import { requireSession } from './auth'
|
|
29
30
|
|
|
30
31
|
const memory = inMemory() // dev/tests only — see the in-memory skill
|
|
31
32
|
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
})
|
|
33
|
+
export async function POST(request: Request) {
|
|
34
|
+
const { messages } = await request.json()
|
|
35
|
+
// Resolved by your auth layer from cookies/headers — never from the request body.
|
|
36
|
+
const session = await requireSession(request)
|
|
37
|
+
|
|
38
|
+
const stream = chat({
|
|
39
|
+
adapter: openaiText('gpt-5.5'),
|
|
40
|
+
messages,
|
|
41
|
+
context: { session },
|
|
42
|
+
middleware: [
|
|
43
|
+
memoryMiddleware({
|
|
44
|
+
adapter: memory,
|
|
45
|
+
// Derive scope server-side from trusted session state.
|
|
46
|
+
scope: () => ({ threadId: session.threadId, userId: session.userId }),
|
|
47
|
+
}),
|
|
48
|
+
],
|
|
49
|
+
})
|
|
50
|
+
return toServerSentEventsResponse(stream)
|
|
51
|
+
}
|
|
47
52
|
```
|
|
48
53
|
|
|
49
54
|
`memoryMiddleware` options: `adapter`, `scope` (static or a function of `ctx`),
|
|
@@ -53,12 +58,21 @@ callbacks.
|
|
|
53
58
|
## The contract
|
|
54
59
|
|
|
55
60
|
```ts
|
|
61
|
+
import type {
|
|
62
|
+
MemoryFact,
|
|
63
|
+
MemoryScope,
|
|
64
|
+
MemorySnapshot,
|
|
65
|
+
MemoryTurn,
|
|
66
|
+
RecallResult,
|
|
67
|
+
SaveReceipt,
|
|
68
|
+
} from '@tanstack/ai-memory'
|
|
69
|
+
|
|
56
70
|
interface MemoryAdapter {
|
|
57
|
-
id: string
|
|
58
|
-
recall(scope, query
|
|
59
|
-
save(scope, turn
|
|
60
|
-
inspect
|
|
61
|
-
listFacts
|
|
71
|
+
readonly id: string
|
|
72
|
+
recall: (scope: MemoryScope, query: string) => Promise<RecallResult> // { systemPrompt, fragments?, tools?, toolGuidance? }
|
|
73
|
+
save: (scope: MemoryScope, turn: MemoryTurn) => Promise<Array<SaveReceipt>> // turn = { user, assistant }; extraction lives HERE
|
|
74
|
+
inspect?: (scope: MemoryScope) => Promise<MemorySnapshot> // optional (devtools)
|
|
75
|
+
listFacts?: (scope: MemoryScope) => Promise<Array<MemoryFact>> // optional (devtools)
|
|
62
76
|
}
|
|
63
77
|
```
|
|
64
78
|
|
|
@@ -82,7 +96,7 @@ BEFORE using it.
|
|
|
82
96
|
- `hindsight()` — bank `{tenant|_}__{user}__{threadId}`.
|
|
83
97
|
- `mem0()` — `user_id` + `run_id` (`threadId`); no `tenantId`.
|
|
84
98
|
- `honcho()` — session `{tenant|_}__{threadId}`; peer tenant-prefixed when set.
|
|
85
|
-
- Custom — implement `recall`/`save` and run `@tanstack/ai-memory/
|
|
99
|
+
- Custom — implement `recall`/`save` and run `runMemoryAdapterContract` from `@tanstack/ai-memory/testkit`.
|
|
86
100
|
|
|
87
101
|
## Failure modes
|
|
88
102
|
|
|
@@ -16,9 +16,15 @@ model can retain/recall/reflect directly.
|
|
|
16
16
|
import { memoryMiddleware } from '@tanstack/ai-memory'
|
|
17
17
|
import { hindsight } from '@tanstack/ai-memory/hindsight'
|
|
18
18
|
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
19
|
+
// Build per request from the server-validated session — never from req.body.
|
|
20
|
+
function memoryFor(session: { userId: string; threadId: string }) {
|
|
21
|
+
const memory = hindsight({ user: session.userId }) // baseUrl defaults to HINDSIGHT_URL
|
|
22
|
+
|
|
23
|
+
return memoryMiddleware({
|
|
24
|
+
adapter: memory,
|
|
25
|
+
scope: { threadId: session.threadId, userId: session.userId },
|
|
26
|
+
})
|
|
27
|
+
}
|
|
22
28
|
```
|
|
23
29
|
|
|
24
30
|
`@vectorize-io/hindsight-client` is an **optional peer dependency**, loaded lazily on
|
|
@@ -16,9 +16,15 @@ appends the turn's messages to the session.
|
|
|
16
16
|
import { memoryMiddleware } from '@tanstack/ai-memory'
|
|
17
17
|
import { honcho } from '@tanstack/ai-memory/honcho'
|
|
18
18
|
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
19
|
+
// Build per request from the server-validated session — never from req.body.
|
|
20
|
+
function memoryFor(session: { userId: string; threadId: string }) {
|
|
21
|
+
const memory = honcho({ user: session.userId }) // baseURL defaults to HONCHO_URL
|
|
22
|
+
|
|
23
|
+
return memoryMiddleware({
|
|
24
|
+
adapter: memory,
|
|
25
|
+
scope: { threadId: session.threadId, userId: session.userId },
|
|
26
|
+
})
|
|
27
|
+
}
|
|
22
28
|
```
|
|
23
29
|
|
|
24
30
|
`@honcho-ai/sdk` is an **optional peer dependency**, loaded lazily on first use — install
|
|
@@ -30,7 +30,11 @@ import { inMemory } from '@tanstack/ai-memory/in-memory'
|
|
|
30
30
|
|
|
31
31
|
const memory = inMemory()
|
|
32
32
|
|
|
33
|
-
|
|
33
|
+
// A static scope is fine for dev/tests; derive it from the session in real apps.
|
|
34
|
+
memoryMiddleware({
|
|
35
|
+
adapter: memory,
|
|
36
|
+
scope: { threadId: 'demo-thread', userId: 'alice' },
|
|
37
|
+
})
|
|
34
38
|
```
|
|
35
39
|
|
|
36
40
|
## Options
|
|
@@ -14,9 +14,15 @@ server-side. Talks to the server over plain HTTP — **no SDK peer dependency**.
|
|
|
14
14
|
import { memoryMiddleware } from '@tanstack/ai-memory'
|
|
15
15
|
import { mem0 } from '@tanstack/ai-memory/mem0'
|
|
16
16
|
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
17
|
+
// Build per request from the server-validated session — never from req.body.
|
|
18
|
+
function memoryFor(session: { userId: string; threadId: string }) {
|
|
19
|
+
const memory = mem0({ user: session.userId }) // baseUrl defaults to MEM0_URL
|
|
20
|
+
|
|
21
|
+
return memoryMiddleware({
|
|
22
|
+
adapter: memory,
|
|
23
|
+
scope: { threadId: session.threadId, userId: session.userId },
|
|
24
|
+
})
|
|
25
|
+
}
|
|
20
26
|
```
|
|
21
27
|
|
|
22
28
|
Requires a running mem0 server (self-hosted or hosted). Point it via `baseUrl` (or
|
|
@@ -20,10 +20,16 @@ import Redis from 'ioredis'
|
|
|
20
20
|
import { memoryMiddleware } from '@tanstack/ai-memory'
|
|
21
21
|
import { redis } from '@tanstack/ai-memory/redis'
|
|
22
22
|
|
|
23
|
-
const client = new Redis(process.env.REDIS_URL)
|
|
23
|
+
const client = new Redis(process.env.REDIS_URL ?? 'redis://localhost:6379')
|
|
24
24
|
const memory = redis({ redis: client, prefix: 'myapp:memory' })
|
|
25
25
|
|
|
26
|
-
|
|
26
|
+
// Resolve scope per request from the server-validated session — never from req.body.
|
|
27
|
+
function memoryFor(session: { userId: string; threadId: string }) {
|
|
28
|
+
return memoryMiddleware({
|
|
29
|
+
adapter: memory,
|
|
30
|
+
scope: { threadId: session.threadId, userId: session.userId },
|
|
31
|
+
})
|
|
32
|
+
}
|
|
27
33
|
```
|
|
28
34
|
|
|
29
35
|
### Option B: `redis` (node-redis v4+)
|
|
@@ -41,7 +47,12 @@ const memory = redis({
|
|
|
41
47
|
prefix: 'myapp:memory',
|
|
42
48
|
})
|
|
43
49
|
|
|
44
|
-
|
|
50
|
+
function memoryFor(session: { userId: string; threadId: string }) {
|
|
51
|
+
return memoryMiddleware({
|
|
52
|
+
adapter: memory,
|
|
53
|
+
scope: { threadId: session.threadId, userId: session.userId },
|
|
54
|
+
})
|
|
55
|
+
}
|
|
45
56
|
```
|
|
46
57
|
|
|
47
58
|
node-redis exposes a camelCase API (`sAdd`, `mGet`); `fromNodeRedis` translates it
|
|
@@ -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
|
+
}
|