@tanstack/ai-memory 0.0.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/index.d.ts +2 -0
- package/dist/esm/index.js +2 -0
- package/dist/esm/internal/store.d.ts +95 -0
- package/dist/esm/internal/store.js +201 -0
- package/dist/esm/internal/store.js.map +1 -0
- package/dist/esm/internal/store.test.d.ts +1 -0
- package/dist/esm/middleware.d.ts +73 -0
- package/dist/esm/middleware.js +250 -0
- package/dist/esm/middleware.js.map +1 -0
- package/dist/esm/providers/hindsight/index.d.ts +50 -0
- package/dist/esm/providers/hindsight/index.js +152 -0
- package/dist/esm/providers/hindsight/index.js.map +1 -0
- package/dist/esm/providers/hindsight/tools.d.ts +16 -0
- package/dist/esm/providers/hindsight/tools.js +119 -0
- package/dist/esm/providers/hindsight/tools.js.map +1 -0
- package/dist/esm/providers/honcho/index.d.ts +16 -0
- package/dist/esm/providers/honcho/index.js +150 -0
- package/dist/esm/providers/honcho/index.js.map +1 -0
- package/dist/esm/providers/in-memory/index.d.ts +19 -0
- package/dist/esm/providers/in-memory/index.js +46 -0
- package/dist/esm/providers/in-memory/index.js.map +1 -0
- package/dist/esm/providers/mem0/index.d.ts +14 -0
- package/dist/esm/providers/mem0/index.js +150 -0
- package/dist/esm/providers/mem0/index.js.map +1 -0
- package/dist/esm/providers/redis/index.d.ts +54 -0
- package/dist/esm/providers/redis/index.js +118 -0
- package/dist/esm/providers/redis/index.js.map +1 -0
- package/dist/esm/types.d.ts +112 -0
- package/package.json +100 -0
- package/skills/tanstack-ai-memory/SKILL.md +99 -0
- package/skills/tanstack-ai-memory-hindsight/SKILL.md +40 -0
- package/skills/tanstack-ai-memory-honcho/SKILL.md +40 -0
- package/skills/tanstack-ai-memory-in-memory/SKILL.md +51 -0
- package/skills/tanstack-ai-memory-mem0/SKILL.md +36 -0
- package/skills/tanstack-ai-memory-redis/SKILL.md +83 -0
- package/src/index.ts +20 -0
- package/src/internal/store.test.ts +63 -0
- package/src/internal/store.ts +378 -0
- package/src/middleware.ts +386 -0
- package/src/providers/hindsight/index.ts +237 -0
- package/src/providers/hindsight/tools.ts +139 -0
- package/src/providers/honcho/index.ts +238 -0
- package/src/providers/in-memory/index.ts +63 -0
- package/src/providers/mem0/index.ts +199 -0
- package/src/providers/redis/index.ts +179 -0
- package/src/types.ts +159 -0
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
import type { Tool } from '@tanstack/ai'
|
|
2
|
+
import type { MemoryFragment, RecallResult, SaveReceipt } from '../../types'
|
|
3
|
+
import type { HindsightRuntime } from './index'
|
|
4
|
+
|
|
5
|
+
export interface HindsightToolDeps {
|
|
6
|
+
getRuntime: () => Promise<HindsightRuntime>
|
|
7
|
+
bankId: string
|
|
8
|
+
budget: string
|
|
9
|
+
onToolRetain?: (receipt: SaveReceipt) => void
|
|
10
|
+
onToolRecall?: (query: string, result: RecallResult) => void
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
function stringField(args: unknown, key: string): string {
|
|
14
|
+
if (args && typeof args === 'object' && key in args) {
|
|
15
|
+
const value = (args as Record<string, unknown>)[key]
|
|
16
|
+
if (typeof value === 'string') return value
|
|
17
|
+
}
|
|
18
|
+
return ''
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Build the hindsight LLM tools (retain / recall / reflect). These let the model
|
|
23
|
+
* take direct control of long-term memory beyond the automatic recall/save the
|
|
24
|
+
* middleware performs. Returned in `RecallResult.tools` and merged into the run.
|
|
25
|
+
*/
|
|
26
|
+
export function makeHindsightTools(deps: HindsightToolDeps): Array<Tool> {
|
|
27
|
+
const retainTool: Tool = {
|
|
28
|
+
name: 'hindsight_retain',
|
|
29
|
+
description:
|
|
30
|
+
'Explicitly store a fact, decision, or piece of context to remember in future sessions. Call this when the user shares something important about themselves, their preferences, their work, or any detail that should persist beyond this conversation.',
|
|
31
|
+
inputSchema: {
|
|
32
|
+
type: 'object',
|
|
33
|
+
properties: {
|
|
34
|
+
content: {
|
|
35
|
+
type: 'string',
|
|
36
|
+
description:
|
|
37
|
+
'The exact fact, decision, or piece of context to store. Write it as a self-contained statement that will still make sense out of conversation context.',
|
|
38
|
+
},
|
|
39
|
+
},
|
|
40
|
+
required: ['content'],
|
|
41
|
+
additionalProperties: false,
|
|
42
|
+
},
|
|
43
|
+
async execute(args) {
|
|
44
|
+
const content = stringField(args, 'content')
|
|
45
|
+
const start = Date.now()
|
|
46
|
+
try {
|
|
47
|
+
const { client } = await deps.getRuntime()
|
|
48
|
+
const data = await client.retain(deps.bankId, content, {
|
|
49
|
+
context: 'chat:tool',
|
|
50
|
+
timestamp: new Date(),
|
|
51
|
+
})
|
|
52
|
+
deps.onToolRetain?.({
|
|
53
|
+
ok: true,
|
|
54
|
+
latencyMs: Date.now() - start,
|
|
55
|
+
raw: data,
|
|
56
|
+
})
|
|
57
|
+
return { ok: true }
|
|
58
|
+
} catch (err) {
|
|
59
|
+
const error = err instanceof Error ? err.message : String(err)
|
|
60
|
+
deps.onToolRetain?.({ ok: false, latencyMs: Date.now() - start, error })
|
|
61
|
+
return { ok: false, error }
|
|
62
|
+
}
|
|
63
|
+
},
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
const recallTool: Tool = {
|
|
67
|
+
name: 'hindsight_recall',
|
|
68
|
+
description:
|
|
69
|
+
"Query memory directly with a specific question. Use this when you need context that may not have surfaced in the automatic recall — for example, to look up a different topic than the user's last message, or to find facts about an entity mentioned in passing.",
|
|
70
|
+
inputSchema: {
|
|
71
|
+
type: 'object',
|
|
72
|
+
properties: {
|
|
73
|
+
query: {
|
|
74
|
+
type: 'string',
|
|
75
|
+
description: 'Natural-language question or topic to look up.',
|
|
76
|
+
},
|
|
77
|
+
},
|
|
78
|
+
required: ['query'],
|
|
79
|
+
additionalProperties: false,
|
|
80
|
+
},
|
|
81
|
+
async execute(args) {
|
|
82
|
+
const query = stringField(args, 'query')
|
|
83
|
+
const start = Date.now()
|
|
84
|
+
try {
|
|
85
|
+
const { client, recallToPrompt } = await deps.getRuntime()
|
|
86
|
+
const data = await client.recall(deps.bankId, query, {
|
|
87
|
+
budget: deps.budget,
|
|
88
|
+
})
|
|
89
|
+
const systemPrompt = recallToPrompt(data)
|
|
90
|
+
const fragments: Array<MemoryFragment> = (data.results ?? []).map(
|
|
91
|
+
(r) => ({
|
|
92
|
+
text: r.text,
|
|
93
|
+
source: r.type ?? r.id,
|
|
94
|
+
}),
|
|
95
|
+
)
|
|
96
|
+
deps.onToolRecall?.(query, {
|
|
97
|
+
systemPrompt,
|
|
98
|
+
fragments,
|
|
99
|
+
latencyMs: Date.now() - start,
|
|
100
|
+
raw: data,
|
|
101
|
+
} as RecallResult)
|
|
102
|
+
return systemPrompt || '(no relevant memories found)'
|
|
103
|
+
} catch (err) {
|
|
104
|
+
const error = err instanceof Error ? err.message : String(err)
|
|
105
|
+
return `(no memory available: ${error})`
|
|
106
|
+
}
|
|
107
|
+
},
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
const reflectTool: Tool = {
|
|
111
|
+
name: 'hindsight_reflect',
|
|
112
|
+
description:
|
|
113
|
+
'Synthesize across many memories to answer questions that require reasoning over accumulated knowledge, rather than retrieving specific facts. Use this for questions like "what do I know about this user\'s stack?" or "what has the user been working on lately?"',
|
|
114
|
+
inputSchema: {
|
|
115
|
+
type: 'object',
|
|
116
|
+
properties: {
|
|
117
|
+
query: {
|
|
118
|
+
type: 'string',
|
|
119
|
+
description:
|
|
120
|
+
'The synthesis question to reflect on, e.g. "what do I know about the user\'s preferences?"',
|
|
121
|
+
},
|
|
122
|
+
},
|
|
123
|
+
required: ['query'],
|
|
124
|
+
additionalProperties: false,
|
|
125
|
+
},
|
|
126
|
+
async execute(args) {
|
|
127
|
+
const query = stringField(args, 'query')
|
|
128
|
+
try {
|
|
129
|
+
const { client } = await deps.getRuntime()
|
|
130
|
+
const data = await client.reflect(deps.bankId, query)
|
|
131
|
+
return data.text ?? '(no reflection)'
|
|
132
|
+
} catch (err) {
|
|
133
|
+
return `(reflection failed: ${err instanceof Error ? err.message : String(err)})`
|
|
134
|
+
}
|
|
135
|
+
},
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
return [retainTool, recallTool, reflectTool]
|
|
139
|
+
}
|
|
@@ -0,0 +1,238 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Honcho memory adapter. Honcho models memory as peers exchanging messages in a
|
|
3
|
+
* session and answers recall via a "dialectic" query over the user peer's
|
|
4
|
+
* representation — so `recall` returns a synthesized answer (no discrete
|
|
5
|
+
* fragments) and `save` appends the turn's messages to the session.
|
|
6
|
+
*
|
|
7
|
+
* `@honcho-ai/sdk` is an OPTIONAL peer dependency, loaded lazily on first use.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import type { Peer, Session } from '@honcho-ai/sdk'
|
|
11
|
+
import type {
|
|
12
|
+
MemoryAdapter,
|
|
13
|
+
MemoryFact,
|
|
14
|
+
MemoryScope,
|
|
15
|
+
MemorySnapshot,
|
|
16
|
+
MemoryTurn,
|
|
17
|
+
RecallResult,
|
|
18
|
+
SaveReceipt,
|
|
19
|
+
} from '../../types'
|
|
20
|
+
|
|
21
|
+
export interface HonchoOptions {
|
|
22
|
+
/** Durable user id. Falls back to `scope.userId`, then `'demo-user'`. */
|
|
23
|
+
user?: string
|
|
24
|
+
/** Honcho server URL. Defaults to `HONCHO_URL` or `http://localhost:8001`. */
|
|
25
|
+
baseURL?: string
|
|
26
|
+
/** Workspace id. Defaults to `HONCHO_APP_NAME` or `'ai-memory'`. */
|
|
27
|
+
workspaceId?: string
|
|
28
|
+
/** API key. Defaults to `HONCHO_API_KEY` (or `'dev-no-auth'`). */
|
|
29
|
+
apiKey?: string
|
|
30
|
+
/** Assistant peer id. Defaults to `'assistant'`. */
|
|
31
|
+
assistantId?: string
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
type Timed<T> =
|
|
35
|
+
| { ok: true; latencyMs: number; data: T }
|
|
36
|
+
| { ok: false; latencyMs: number; error: string }
|
|
37
|
+
|
|
38
|
+
async function timed<T>(fn: () => Promise<T>): Promise<Timed<T>> {
|
|
39
|
+
const start = Date.now()
|
|
40
|
+
try {
|
|
41
|
+
const data = await fn()
|
|
42
|
+
return { ok: true, latencyMs: Date.now() - start, data }
|
|
43
|
+
} catch (err) {
|
|
44
|
+
return {
|
|
45
|
+
ok: false,
|
|
46
|
+
latencyMs: Date.now() - start,
|
|
47
|
+
error: err instanceof Error ? err.message : String(err),
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
const HONCHO_LINE_RE = /^\[(?<ts>[^\]]+)\]\s+(?<text>.+)$/
|
|
53
|
+
|
|
54
|
+
/** Parse a Honcho `peer.representation()` text blob into flat fact rows. */
|
|
55
|
+
export function parseHonchoRepresentation(raw: string): Array<MemoryFact> {
|
|
56
|
+
return raw
|
|
57
|
+
.split('\n')
|
|
58
|
+
.map((line) => line.trim())
|
|
59
|
+
.filter(
|
|
60
|
+
(line) =>
|
|
61
|
+
line.length > 0 &&
|
|
62
|
+
!line.startsWith('##') &&
|
|
63
|
+
!line.startsWith('Explicit Observations'),
|
|
64
|
+
)
|
|
65
|
+
.map((line, i): MemoryFact => {
|
|
66
|
+
const m = line.match(HONCHO_LINE_RE)
|
|
67
|
+
if (m?.groups?.ts && m.groups.text) {
|
|
68
|
+
return {
|
|
69
|
+
id: `honcho-${m.groups.ts}-${i}`,
|
|
70
|
+
text: m.groups.text,
|
|
71
|
+
source: 'observation',
|
|
72
|
+
createdAt: m.groups.ts,
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
return { id: `honcho-${i}`, text: line, source: 'representation' }
|
|
76
|
+
})
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
export function honcho(options: HonchoOptions = {}): MemoryAdapter {
|
|
80
|
+
const assistantId = options.assistantId ?? 'assistant'
|
|
81
|
+
|
|
82
|
+
// Client + entity caches live in this factory's closure — each honcho()
|
|
83
|
+
// instance owns its own.
|
|
84
|
+
type Client = Awaited<ReturnType<typeof loadClient>>
|
|
85
|
+
let clientPromise: Promise<Client> | null = null
|
|
86
|
+
const sessionCache = new Map<string, Promise<Session>>()
|
|
87
|
+
const userPeerCache = new Map<string, Promise<Peer>>()
|
|
88
|
+
let assistantPeerPromise: Promise<Peer> | null = null
|
|
89
|
+
|
|
90
|
+
async function loadClient() {
|
|
91
|
+
const mod = await import('@honcho-ai/sdk')
|
|
92
|
+
return new mod.Honcho({
|
|
93
|
+
baseURL:
|
|
94
|
+
options.baseURL ?? process.env.HONCHO_URL ?? 'http://localhost:8001',
|
|
95
|
+
workspaceId:
|
|
96
|
+
options.workspaceId ?? process.env.HONCHO_APP_NAME ?? 'ai-memory',
|
|
97
|
+
apiKey: options.apiKey ?? process.env.HONCHO_API_KEY ?? 'dev-no-auth',
|
|
98
|
+
})
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
function getClient(): Promise<Client> {
|
|
102
|
+
if (!clientPromise) clientPromise = loadClient()
|
|
103
|
+
return clientPromise
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
function cached<T>(
|
|
107
|
+
cache: Map<string, Promise<T>>,
|
|
108
|
+
key: string,
|
|
109
|
+
create: () => Promise<T>,
|
|
110
|
+
): Promise<T> {
|
|
111
|
+
const existing = cache.get(key)
|
|
112
|
+
if (existing) return existing
|
|
113
|
+
const created = create().catch((err) => {
|
|
114
|
+
if (cache.get(key) === created) cache.delete(key)
|
|
115
|
+
throw err
|
|
116
|
+
})
|
|
117
|
+
cache.set(key, created)
|
|
118
|
+
return created
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
function getUserPeer(userId: string): Promise<Peer> {
|
|
122
|
+
return cached(userPeerCache, userId, async () =>
|
|
123
|
+
(await getClient()).peer(userId),
|
|
124
|
+
)
|
|
125
|
+
}
|
|
126
|
+
function getAssistantPeer(): Promise<Peer> {
|
|
127
|
+
if (!assistantPeerPromise) {
|
|
128
|
+
assistantPeerPromise = (async () =>
|
|
129
|
+
(await getClient()).peer(assistantId))().catch((err) => {
|
|
130
|
+
assistantPeerPromise = null
|
|
131
|
+
throw err
|
|
132
|
+
})
|
|
133
|
+
}
|
|
134
|
+
return assistantPeerPromise
|
|
135
|
+
}
|
|
136
|
+
function getSession(sessionKey: string): Promise<Session> {
|
|
137
|
+
return cached(sessionCache, sessionKey, async () =>
|
|
138
|
+
(await getClient()).session(sessionKey),
|
|
139
|
+
)
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/** Honcho session id — tenant-qualified so tenants cannot share sessions. */
|
|
143
|
+
function sessionKeyFor(scope: MemoryScope): string {
|
|
144
|
+
const tenant =
|
|
145
|
+
scope.tenantId != null && scope.tenantId !== '' ? scope.tenantId : '_'
|
|
146
|
+
return `${tenant}__${scope.threadId}`
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/**
|
|
150
|
+
* Honcho peer id. When `tenantId` is set, prefix the durable user so peers
|
|
151
|
+
* cannot collide across tenants.
|
|
152
|
+
*/
|
|
153
|
+
function userIdFor(scope: MemoryScope): string {
|
|
154
|
+
const user = options.user ?? scope.userId ?? 'demo-user'
|
|
155
|
+
if (scope.tenantId != null && scope.tenantId !== '') {
|
|
156
|
+
return `${scope.tenantId}__${user}`
|
|
157
|
+
}
|
|
158
|
+
return user
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
return {
|
|
162
|
+
id: 'honcho',
|
|
163
|
+
|
|
164
|
+
async save(scope, turn: MemoryTurn): Promise<Array<SaveReceipt>> {
|
|
165
|
+
const result = await timed(async () => {
|
|
166
|
+
const [userPeer, assistantPeer, session] = await Promise.all([
|
|
167
|
+
getUserPeer(userIdFor(scope)),
|
|
168
|
+
getAssistantPeer(),
|
|
169
|
+
getSession(sessionKeyFor(scope)),
|
|
170
|
+
])
|
|
171
|
+
return session.addMessages([
|
|
172
|
+
userPeer.message(turn.user),
|
|
173
|
+
assistantPeer.message(turn.assistant),
|
|
174
|
+
])
|
|
175
|
+
})
|
|
176
|
+
return [
|
|
177
|
+
{
|
|
178
|
+
ok: result.ok,
|
|
179
|
+
latencyMs: result.latencyMs,
|
|
180
|
+
raw: result.ok ? result.data : undefined,
|
|
181
|
+
error: result.ok ? undefined : result.error,
|
|
182
|
+
},
|
|
183
|
+
]
|
|
184
|
+
},
|
|
185
|
+
|
|
186
|
+
async recall(scope, query): Promise<RecallResult> {
|
|
187
|
+
const result = await timed(async () => {
|
|
188
|
+
const [userPeer, session] = await Promise.all([
|
|
189
|
+
getUserPeer(userIdFor(scope)),
|
|
190
|
+
getSession(sessionKeyFor(scope)),
|
|
191
|
+
])
|
|
192
|
+
return userPeer.chat(query, { session })
|
|
193
|
+
})
|
|
194
|
+
if (!result.ok) {
|
|
195
|
+
return { systemPrompt: '', raw: { error: result.error } }
|
|
196
|
+
}
|
|
197
|
+
const text = typeof result.data === 'string' ? result.data : ''
|
|
198
|
+
return { systemPrompt: text, raw: { dialectic: text } }
|
|
199
|
+
},
|
|
200
|
+
|
|
201
|
+
async inspect(scope): Promise<MemorySnapshot> {
|
|
202
|
+
const session = await getSession(sessionKeyFor(scope)).catch(() => null)
|
|
203
|
+
if (!session) {
|
|
204
|
+
return {
|
|
205
|
+
takenAt: new Date().toISOString(),
|
|
206
|
+
data: { error: 'failed to get session' },
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
const [messages, summaries] = await Promise.all([
|
|
210
|
+
timed(() => session.messages({ size: 50 })),
|
|
211
|
+
timed(() => session.summaries()),
|
|
212
|
+
])
|
|
213
|
+
return {
|
|
214
|
+
takenAt: new Date().toISOString(),
|
|
215
|
+
data: {
|
|
216
|
+
messages: messages.ok ? messages.data : { error: messages.error },
|
|
217
|
+
summaries: summaries.ok ? summaries.data : { error: summaries.error },
|
|
218
|
+
},
|
|
219
|
+
}
|
|
220
|
+
},
|
|
221
|
+
|
|
222
|
+
async listFacts(scope): Promise<Array<MemoryFact>> {
|
|
223
|
+
const result = await timed(async () => {
|
|
224
|
+
const userPeer = await getUserPeer(userIdFor(scope))
|
|
225
|
+
return userPeer.representation()
|
|
226
|
+
})
|
|
227
|
+
if (!result.ok) return []
|
|
228
|
+
const raw =
|
|
229
|
+
typeof result.data === 'string'
|
|
230
|
+
? result.data
|
|
231
|
+
: String(
|
|
232
|
+
(result.data as { representation?: unknown }).representation ??
|
|
233
|
+
'',
|
|
234
|
+
)
|
|
235
|
+
return parseHonchoRepresentation(raw)
|
|
236
|
+
},
|
|
237
|
+
}
|
|
238
|
+
}
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
import {
|
|
2
|
+
inspectRecords,
|
|
3
|
+
isExpired,
|
|
4
|
+
listRecordFacts,
|
|
5
|
+
recallRecords,
|
|
6
|
+
sameScope,
|
|
7
|
+
saveTurn,
|
|
8
|
+
} from '../../internal/store'
|
|
9
|
+
import type {
|
|
10
|
+
BuiltinOptions,
|
|
11
|
+
MemoryRecord,
|
|
12
|
+
RecordStore,
|
|
13
|
+
} from '../../internal/store'
|
|
14
|
+
import type { MemoryAdapter, MemoryScope } from '../../types'
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Options for {@link inMemory}. Retrieval/extraction knobs that used to live on
|
|
18
|
+
* the middleware are adapter options here.
|
|
19
|
+
*/
|
|
20
|
+
export interface InMemoryOptions extends BuiltinOptions {}
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Zero-dependency memory adapter backed by a `Map`. Records vanish on process
|
|
24
|
+
* restart, so this is for local development, tests, and single-process demos —
|
|
25
|
+
* not multi-process production (each worker gets its own Map). For production,
|
|
26
|
+
* use {@link redis} from `@tanstack/ai-memory/redis`.
|
|
27
|
+
*
|
|
28
|
+
* By default `save` stores the raw user/assistant turn and `recall` scores it
|
|
29
|
+
* lexically + by recency. Pass an `embedder` for semantic scoring and/or an
|
|
30
|
+
* `extract` function to persist derived facts.
|
|
31
|
+
*/
|
|
32
|
+
export function inMemory(options: InMemoryOptions = {}): MemoryAdapter {
|
|
33
|
+
const records = new Map<string, MemoryRecord>()
|
|
34
|
+
|
|
35
|
+
function sweep(): Array<MemoryRecord> {
|
|
36
|
+
const now = Date.now()
|
|
37
|
+
const live: Array<MemoryRecord> = []
|
|
38
|
+
for (const r of records.values()) {
|
|
39
|
+
if (isExpired(r, now)) records.delete(r.id)
|
|
40
|
+
else live.push(r)
|
|
41
|
+
}
|
|
42
|
+
return live
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
const store: RecordStore = {
|
|
46
|
+
async add(batch) {
|
|
47
|
+
const now = Date.now()
|
|
48
|
+
for (const r of batch) records.set(r.id, { ...r, updatedAt: now })
|
|
49
|
+
sweep()
|
|
50
|
+
},
|
|
51
|
+
async loadScope(scope: MemoryScope) {
|
|
52
|
+
return sweep().filter((r) => sameScope(r.scope, scope))
|
|
53
|
+
},
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
return {
|
|
57
|
+
id: 'in-memory',
|
|
58
|
+
recall: (scope, query) => recallRecords(store, scope, query, options),
|
|
59
|
+
save: (scope, turn) => saveTurn(store, scope, turn, options),
|
|
60
|
+
inspect: (scope) => inspectRecords(store, scope),
|
|
61
|
+
listFacts: (scope) => listRecordFacts(store, scope),
|
|
62
|
+
}
|
|
63
|
+
}
|
|
@@ -0,0 +1,199 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* mem0 memory adapter — talks to a mem0 server over plain HTTP (no SDK, so no
|
|
3
|
+
* peer dependency). mem0 owns extraction and ranking server-side; this adapter
|
|
4
|
+
* maps the `recall`/`save` contract onto its `/memories` and `/search` endpoints.
|
|
5
|
+
*
|
|
6
|
+
* Requires a running mem0 server. Point it at one via `baseUrl` (or the
|
|
7
|
+
* `MEM0_URL` env var); pass `apiKey` (or `MEM0_ADMIN_API_KEY`) when it's secured.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import type {
|
|
11
|
+
MemoryAdapter,
|
|
12
|
+
MemoryFact,
|
|
13
|
+
MemoryFragment,
|
|
14
|
+
MemoryScope,
|
|
15
|
+
MemorySnapshot,
|
|
16
|
+
MemoryTurn,
|
|
17
|
+
RecallResult,
|
|
18
|
+
SaveReceipt,
|
|
19
|
+
} from '../../types'
|
|
20
|
+
|
|
21
|
+
export interface Mem0Options {
|
|
22
|
+
/** Durable user id. Falls back to `scope.userId`, then `'demo-user'`. */
|
|
23
|
+
user?: string
|
|
24
|
+
/** mem0 server URL. Defaults to `MEM0_URL` or `http://localhost:8000`. */
|
|
25
|
+
baseUrl?: string
|
|
26
|
+
/** Bearer token. Defaults to `MEM0_ADMIN_API_KEY`. */
|
|
27
|
+
apiKey?: string
|
|
28
|
+
/** Ask mem0 to rerank search results. Defaults to `true`. */
|
|
29
|
+
rerank?: boolean
|
|
30
|
+
/** Minimum search score. Defaults to `0.1`. */
|
|
31
|
+
threshold?: number
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
type JsonResult =
|
|
35
|
+
| { ok: true; latencyMs: number; data: unknown }
|
|
36
|
+
| { ok: false; latencyMs: number; error: string }
|
|
37
|
+
|
|
38
|
+
function asRecord(value: unknown): Record<string, unknown> | undefined {
|
|
39
|
+
return value && typeof value === 'object'
|
|
40
|
+
? (value as Record<string, unknown>)
|
|
41
|
+
: undefined
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function asString(value: unknown): string | undefined {
|
|
45
|
+
return typeof value === 'string' ? value : undefined
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** Pull the array of items out of a mem0 response (`{results: []}` or a bare array). */
|
|
49
|
+
function itemsOf(data: unknown): Array<Record<string, unknown>> {
|
|
50
|
+
const rec = asRecord(data)
|
|
51
|
+
const candidate = rec && 'results' in rec ? rec.results : data
|
|
52
|
+
if (!Array.isArray(candidate)) return []
|
|
53
|
+
return candidate.filter(
|
|
54
|
+
(m): m is Record<string, unknown> => !!m && typeof m === 'object',
|
|
55
|
+
)
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export function mem0(options: Mem0Options = {}): MemoryAdapter {
|
|
59
|
+
const baseUrl =
|
|
60
|
+
options.baseUrl ?? process.env.MEM0_URL ?? 'http://localhost:8000'
|
|
61
|
+
const apiKey = options.apiKey ?? process.env.MEM0_ADMIN_API_KEY ?? ''
|
|
62
|
+
const rerank = options.rerank ?? true
|
|
63
|
+
const threshold = options.threshold ?? 0.1
|
|
64
|
+
|
|
65
|
+
function headers(): Record<string, string> {
|
|
66
|
+
const h: Record<string, string> = { 'Content-Type': 'application/json' }
|
|
67
|
+
if (apiKey) h.Authorization = `Bearer ${apiKey}`
|
|
68
|
+
return h
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function userId(scope: MemoryScope): string {
|
|
72
|
+
return options.user ?? scope.userId ?? 'demo-user'
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* mem0 `run_id` — conversation/run isolation. Maps 1:1 to `scope.threadId` so
|
|
77
|
+
* same-user memories do not leak across threads.
|
|
78
|
+
*/
|
|
79
|
+
function runId(scope: MemoryScope): string {
|
|
80
|
+
return scope.threadId
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
async function safeJson(fn: () => Promise<Response>): Promise<JsonResult> {
|
|
84
|
+
const start = Date.now()
|
|
85
|
+
try {
|
|
86
|
+
const res = await fn()
|
|
87
|
+
const latencyMs = Date.now() - start
|
|
88
|
+
if (!res.ok) {
|
|
89
|
+
const text = await res.text().catch(() => '')
|
|
90
|
+
return {
|
|
91
|
+
ok: false,
|
|
92
|
+
latencyMs,
|
|
93
|
+
error: `HTTP ${res.status}: ${text.slice(0, 300)}`,
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
const data = await res.json().catch(() => null)
|
|
97
|
+
return { ok: true, latencyMs, data }
|
|
98
|
+
} catch (err) {
|
|
99
|
+
return {
|
|
100
|
+
ok: false,
|
|
101
|
+
latencyMs: Date.now() - start,
|
|
102
|
+
error: err instanceof Error ? err.message : String(err),
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
async function loadMemories(scope: MemoryScope): Promise<JsonResult> {
|
|
108
|
+
const params = new URLSearchParams({
|
|
109
|
+
user_id: userId(scope),
|
|
110
|
+
run_id: runId(scope),
|
|
111
|
+
})
|
|
112
|
+
const url = `${baseUrl}/memories?${params.toString()}`
|
|
113
|
+
return safeJson(() => fetch(url, { method: 'GET', headers: headers() }))
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
return {
|
|
117
|
+
id: 'mem0',
|
|
118
|
+
|
|
119
|
+
async save(scope, turn: MemoryTurn): Promise<Array<SaveReceipt>> {
|
|
120
|
+
const result = await safeJson(() =>
|
|
121
|
+
fetch(`${baseUrl}/memories`, {
|
|
122
|
+
method: 'POST',
|
|
123
|
+
headers: headers(),
|
|
124
|
+
body: JSON.stringify({
|
|
125
|
+
messages: [
|
|
126
|
+
{ role: 'user', content: turn.user },
|
|
127
|
+
{ role: 'assistant', content: turn.assistant },
|
|
128
|
+
],
|
|
129
|
+
user_id: userId(scope),
|
|
130
|
+
run_id: runId(scope),
|
|
131
|
+
}),
|
|
132
|
+
}),
|
|
133
|
+
)
|
|
134
|
+
return [
|
|
135
|
+
{
|
|
136
|
+
ok: result.ok,
|
|
137
|
+
latencyMs: result.latencyMs,
|
|
138
|
+
raw: result.ok ? result.data : undefined,
|
|
139
|
+
error: result.ok ? undefined : result.error,
|
|
140
|
+
},
|
|
141
|
+
]
|
|
142
|
+
},
|
|
143
|
+
|
|
144
|
+
async recall(scope, query): Promise<RecallResult> {
|
|
145
|
+
const result = await safeJson(() =>
|
|
146
|
+
fetch(`${baseUrl}/search`, {
|
|
147
|
+
method: 'POST',
|
|
148
|
+
headers: headers(),
|
|
149
|
+
body: JSON.stringify({
|
|
150
|
+
query,
|
|
151
|
+
user_id: userId(scope),
|
|
152
|
+
run_id: runId(scope),
|
|
153
|
+
rerank,
|
|
154
|
+
threshold,
|
|
155
|
+
}),
|
|
156
|
+
}),
|
|
157
|
+
)
|
|
158
|
+
if (!result.ok) {
|
|
159
|
+
return { systemPrompt: '', fragments: [], raw: { error: result.error } }
|
|
160
|
+
}
|
|
161
|
+
const fragments: Array<MemoryFragment> = itemsOf(result.data).map(
|
|
162
|
+
(m) => ({
|
|
163
|
+
text: asString(m.memory) ?? asString(m.text) ?? JSON.stringify(m),
|
|
164
|
+
source: asString(m.id) ?? 'mem0',
|
|
165
|
+
}),
|
|
166
|
+
)
|
|
167
|
+
const systemPrompt =
|
|
168
|
+
fragments.length === 0
|
|
169
|
+
? ''
|
|
170
|
+
: `Recalled memory:\n${fragments.map((f) => `- (${f.source}) ${f.text}`).join('\n')}`
|
|
171
|
+
return { systemPrompt, fragments, raw: result.data }
|
|
172
|
+
},
|
|
173
|
+
|
|
174
|
+
async inspect(scope): Promise<MemorySnapshot> {
|
|
175
|
+
const result = await loadMemories(scope)
|
|
176
|
+
return {
|
|
177
|
+
takenAt: new Date().toISOString(),
|
|
178
|
+
data: result.ok ? result.data : { error: result.error },
|
|
179
|
+
}
|
|
180
|
+
},
|
|
181
|
+
|
|
182
|
+
async listFacts(scope): Promise<Array<MemoryFact>> {
|
|
183
|
+
const result = await loadMemories(scope)
|
|
184
|
+
if (!result.ok) return []
|
|
185
|
+
return itemsOf(result.data)
|
|
186
|
+
.map((m): MemoryFact | null => {
|
|
187
|
+
const text = asString(m.memory)
|
|
188
|
+
if (!text) return null
|
|
189
|
+
return {
|
|
190
|
+
id: asString(m.id) ?? 'mem0',
|
|
191
|
+
text,
|
|
192
|
+
source: 'memory',
|
|
193
|
+
createdAt: asString(m.updated_at) ?? asString(m.created_at),
|
|
194
|
+
}
|
|
195
|
+
})
|
|
196
|
+
.filter((f): f is MemoryFact => f !== null)
|
|
197
|
+
},
|
|
198
|
+
}
|
|
199
|
+
}
|