@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.
Files changed (46) hide show
  1. package/dist/esm/index.d.ts +2 -0
  2. package/dist/esm/index.js +2 -0
  3. package/dist/esm/internal/store.d.ts +95 -0
  4. package/dist/esm/internal/store.js +201 -0
  5. package/dist/esm/internal/store.js.map +1 -0
  6. package/dist/esm/internal/store.test.d.ts +1 -0
  7. package/dist/esm/middleware.d.ts +73 -0
  8. package/dist/esm/middleware.js +250 -0
  9. package/dist/esm/middleware.js.map +1 -0
  10. package/dist/esm/providers/hindsight/index.d.ts +50 -0
  11. package/dist/esm/providers/hindsight/index.js +152 -0
  12. package/dist/esm/providers/hindsight/index.js.map +1 -0
  13. package/dist/esm/providers/hindsight/tools.d.ts +16 -0
  14. package/dist/esm/providers/hindsight/tools.js +119 -0
  15. package/dist/esm/providers/hindsight/tools.js.map +1 -0
  16. package/dist/esm/providers/honcho/index.d.ts +16 -0
  17. package/dist/esm/providers/honcho/index.js +150 -0
  18. package/dist/esm/providers/honcho/index.js.map +1 -0
  19. package/dist/esm/providers/in-memory/index.d.ts +19 -0
  20. package/dist/esm/providers/in-memory/index.js +46 -0
  21. package/dist/esm/providers/in-memory/index.js.map +1 -0
  22. package/dist/esm/providers/mem0/index.d.ts +14 -0
  23. package/dist/esm/providers/mem0/index.js +150 -0
  24. package/dist/esm/providers/mem0/index.js.map +1 -0
  25. package/dist/esm/providers/redis/index.d.ts +54 -0
  26. package/dist/esm/providers/redis/index.js +118 -0
  27. package/dist/esm/providers/redis/index.js.map +1 -0
  28. package/dist/esm/types.d.ts +112 -0
  29. package/package.json +100 -0
  30. package/skills/tanstack-ai-memory/SKILL.md +99 -0
  31. package/skills/tanstack-ai-memory-hindsight/SKILL.md +40 -0
  32. package/skills/tanstack-ai-memory-honcho/SKILL.md +40 -0
  33. package/skills/tanstack-ai-memory-in-memory/SKILL.md +51 -0
  34. package/skills/tanstack-ai-memory-mem0/SKILL.md +36 -0
  35. package/skills/tanstack-ai-memory-redis/SKILL.md +83 -0
  36. package/src/index.ts +20 -0
  37. package/src/internal/store.test.ts +63 -0
  38. package/src/internal/store.ts +378 -0
  39. package/src/middleware.ts +386 -0
  40. package/src/providers/hindsight/index.ts +237 -0
  41. package/src/providers/hindsight/tools.ts +139 -0
  42. package/src/providers/honcho/index.ts +238 -0
  43. package/src/providers/in-memory/index.ts +63 -0
  44. package/src/providers/mem0/index.ts +199 -0
  45. package/src/providers/redis/index.ts +179 -0
  46. package/src/types.ts +159 -0
@@ -0,0 +1,378 @@
1
+ /**
2
+ * Shared internals for the built-in `inMemory()` and `redis()` adapters.
3
+ *
4
+ * NOT part of the public contract — nothing here is exported from the package
5
+ * root. Both built-in adapters keep a set of scored, optionally-embedded
6
+ * `MemoryRecord`s and expose only `recall`/`save`; this module holds the record
7
+ * model, the scoring/rendering helpers, and the extract→store→score→render
8
+ * pipeline they share. The only thing an adapter supplies is a {@link RecordStore}
9
+ * (a Map for in-memory, Redis keys for redis).
10
+ */
11
+
12
+ import type {
13
+ MemoryFact,
14
+ MemoryFragment,
15
+ MemoryScope,
16
+ MemorySnapshot,
17
+ MemoryTurn,
18
+ RecallResult,
19
+ SaveReceipt,
20
+ } from '../types'
21
+
22
+ export type MemoryKind = 'message' | 'summary' | 'fact' | 'preference'
23
+ export type MemoryRole = 'user' | 'assistant'
24
+
25
+ /** Internal stored record. Never crosses the public boundary. */
26
+ export interface MemoryRecord {
27
+ id: string
28
+ scope: MemoryScope
29
+ text: string
30
+ kind: MemoryKind
31
+ role?: MemoryRole
32
+ createdAt: number
33
+ updatedAt?: number
34
+ expiresAt?: number
35
+ importance?: number
36
+ embedding?: Array<number>
37
+ metadata?: Record<string, unknown>
38
+ }
39
+
40
+ /** Pluggable extractor: turn a completed turn into extra records to persist. */
41
+ export type ExtractFn = (
42
+ turn: MemoryTurn,
43
+ scope: MemoryScope,
44
+ ) =>
45
+ | Promise<Array<ExtractedFact> | undefined>
46
+ | Array<ExtractedFact>
47
+ | undefined
48
+
49
+ export interface ExtractedFact {
50
+ text: string
51
+ kind?: MemoryKind
52
+ importance?: number
53
+ metadata?: Record<string, unknown>
54
+ }
55
+
56
+ export interface Embedder {
57
+ embed: (text: string) => Promise<Array<number>>
58
+ }
59
+
60
+ /** Options common to the built-in adapters. */
61
+ export interface BuiltinOptions {
62
+ /** Max hits returned by recall. Defaults to 6. */
63
+ topK?: number
64
+ /** Drop hits scoring below this. Defaults to 0.15. */
65
+ minScore?: number
66
+ /** Restrict recall to these kinds. Defaults to all. */
67
+ kinds?: Array<MemoryKind>
68
+ /** Optional embedder for semantic scoring on both save and recall. */
69
+ embedder?: Embedder
70
+ /** Optional extractor run on `save` to persist derived facts/preferences. */
71
+ extract?: ExtractFn
72
+ /** Replace the built-in prompt renderer. */
73
+ render?: (hits: Array<MemoryHit>) => string
74
+ }
75
+
76
+ export interface MemoryHit {
77
+ record: MemoryRecord
78
+ score: number
79
+ }
80
+
81
+ /**
82
+ * Minimal storage backend the built-in adapters run on. `add` upserts by id;
83
+ * `loadScope` returns the live (non-expired) records for exactly this scope.
84
+ */
85
+ export interface RecordStore {
86
+ add: (records: Array<MemoryRecord>) => Promise<void>
87
+ loadScope: (scope: MemoryScope) => Promise<Array<MemoryRecord>>
88
+ }
89
+
90
+ // ===========================
91
+ // Scope
92
+ // ===========================
93
+
94
+ /**
95
+ * Normalize an optional scope dimension: empty string is treated as unset so
96
+ * `''` and `undefined` compare equal.
97
+ */
98
+ function scopeDimValue(value: string | undefined): string | undefined {
99
+ return value != null && value !== '' ? value : undefined
100
+ }
101
+
102
+ /**
103
+ * Exact scope match for built-in stores. `threadId` must match, and optional
104
+ * `userId` / `tenantId` must match exactly on both sides (including both
105
+ * unset). A query that omits `tenantId` does **not** match a record written
106
+ * with a tenant — same isolation model as Redis composite index keys.
107
+ * `namespace` is reserved and ignored until a subsystem keys on it.
108
+ */
109
+ export function sameScope(record: MemoryScope, query: MemoryScope): boolean {
110
+ if (record.threadId !== query.threadId) return false
111
+ if (scopeDimValue(record.userId) !== scopeDimValue(query.userId)) return false
112
+ if (scopeDimValue(record.tenantId) !== scopeDimValue(query.tenantId)) {
113
+ return false
114
+ }
115
+ return true
116
+ }
117
+
118
+ // ===========================
119
+ // Scoring helpers
120
+ // ===========================
121
+
122
+ const DEFAULT_HALF_LIFE_MS = 1000 * 60 * 60 * 24 * 30 // 30 days
123
+
124
+ export function cosine(a?: Array<number>, b?: Array<number>): number {
125
+ if (!a || !b || a.length !== b.length || a.length === 0) return 0
126
+ let dot = 0
127
+ let aMag = 0
128
+ let bMag = 0
129
+ for (let i = 0; i < a.length; i++) {
130
+ const av = a[i] as number
131
+ const bv = b[i] as number
132
+ dot += av * bv
133
+ aMag += av ** 2
134
+ bMag += bv ** 2
135
+ }
136
+ if (aMag === 0 || bMag === 0) return 0
137
+ return dot / (Math.sqrt(aMag) * Math.sqrt(bMag))
138
+ }
139
+
140
+ export function lexicalOverlap(query: string, text: string): number {
141
+ const queryTokens = new Set(query.toLowerCase().split(/\W+/).filter(Boolean))
142
+ if (queryTokens.size === 0) return 0
143
+ const textTokens = new Set(text.toLowerCase().split(/\W+/).filter(Boolean))
144
+ let overlap = 0
145
+ for (const token of queryTokens) {
146
+ if (textTokens.has(token)) overlap++
147
+ }
148
+ return overlap / queryTokens.size
149
+ }
150
+
151
+ export function recencyScore(
152
+ createdAt: number,
153
+ halfLifeMs: number = DEFAULT_HALF_LIFE_MS,
154
+ now: number = Date.now(),
155
+ ): number {
156
+ const age = Math.max(0, now - createdAt)
157
+ return Math.pow(0.5, age / halfLifeMs)
158
+ }
159
+
160
+ export function isExpired(
161
+ record: MemoryRecord,
162
+ now: number = Date.now(),
163
+ ): boolean {
164
+ return record.expiresAt !== undefined && record.expiresAt < now
165
+ }
166
+
167
+ /**
168
+ * Reference ranking: weighted sum of semantic (0.55), lexical (0.20), recency
169
+ * (0.15), and importance (0.10). Unset importance contributes 0 — no mid-range
170
+ * fallback, so recent records don't automatically clear the `minScore` floor.
171
+ */
172
+ export function defaultScoreHit(args: {
173
+ record: MemoryRecord
174
+ queryText: string
175
+ queryEmbedding?: Array<number>
176
+ now?: number
177
+ }): number {
178
+ const { record, queryText, queryEmbedding, now } = args
179
+ const semantic = cosine(queryEmbedding, record.embedding)
180
+ const lexical = lexicalOverlap(queryText, record.text)
181
+ const recency = recencyScore(record.createdAt, undefined, now)
182
+ const importance = record.importance ?? 0
183
+ return semantic * 0.55 + lexical * 0.2 + recency * 0.15 + importance * 0.1
184
+ }
185
+
186
+ export function defaultRenderMemory(hits: Array<MemoryHit>): string {
187
+ if (hits.length === 0) return ''
188
+ return [
189
+ 'Relevant memory:',
190
+ 'Use this information only when it is relevant to the current user request.',
191
+ 'Do not mention memory directly unless the user asks about it.',
192
+ 'If current conversation context contradicts memory, prefer the current conversation.',
193
+ '',
194
+ // JSON.stringify the text so persisted content with newlines or
195
+ // instruction-shaped text can't break out of the list and steer the turn.
196
+ ...hits.map(
197
+ (hit, index) =>
198
+ `${index + 1}. [${hit.record.kind}] ${JSON.stringify(hit.record.text)}`,
199
+ ),
200
+ ].join('\n')
201
+ }
202
+
203
+ // ===========================
204
+ // Shared recall / save pipeline
205
+ // ===========================
206
+
207
+ /** Portable record id — real UUID where available, deterministic fallback otherwise. */
208
+ export function newRecordId(): string {
209
+ try {
210
+ return crypto.randomUUID()
211
+ } catch {
212
+ return `mem-${Date.now()}-${Math.random().toString(36).slice(2, 10)}`
213
+ }
214
+ }
215
+
216
+ /**
217
+ * Build the records for a completed turn: the raw user/assistant messages
218
+ * (importance 0.4) plus anything the optional extractor returns, embedding each
219
+ * when an embedder is configured.
220
+ */
221
+ export async function buildTurnRecords(
222
+ scope: MemoryScope,
223
+ turn: MemoryTurn,
224
+ options: BuiltinOptions,
225
+ ): Promise<Array<MemoryRecord>> {
226
+ const now = Date.now()
227
+ const records: Array<MemoryRecord> = []
228
+
229
+ async function embed(text: string): Promise<Array<number> | undefined> {
230
+ if (!options.embedder) return undefined
231
+ return options.embedder.embed(text)
232
+ }
233
+
234
+ if (turn.user) {
235
+ records.push({
236
+ id: newRecordId(),
237
+ scope,
238
+ text: turn.user,
239
+ kind: 'message',
240
+ role: 'user',
241
+ createdAt: now,
242
+ importance: 0.4,
243
+ embedding: await embed(turn.user),
244
+ })
245
+ }
246
+ if (turn.assistant) {
247
+ records.push({
248
+ id: newRecordId(),
249
+ scope,
250
+ text: turn.assistant,
251
+ kind: 'message',
252
+ role: 'assistant',
253
+ createdAt: now,
254
+ importance: 0.4,
255
+ embedding: await embed(turn.assistant),
256
+ })
257
+ }
258
+
259
+ const extracted = await options.extract?.(turn, scope)
260
+ if (extracted) {
261
+ for (const fact of extracted) {
262
+ records.push({
263
+ id: newRecordId(),
264
+ scope,
265
+ text: fact.text,
266
+ kind: fact.kind ?? 'fact',
267
+ createdAt: now,
268
+ importance: fact.importance,
269
+ embedding: await embed(fact.text),
270
+ metadata: fact.metadata,
271
+ })
272
+ }
273
+ }
274
+ return records
275
+ }
276
+
277
+ /** Persist a turn to the store and return one receipt for the batch. */
278
+ export async function saveTurn(
279
+ store: RecordStore,
280
+ scope: MemoryScope,
281
+ turn: MemoryTurn,
282
+ options: BuiltinOptions,
283
+ ): Promise<Array<SaveReceipt>> {
284
+ const startedAt = Date.now()
285
+ try {
286
+ const records = await buildTurnRecords(scope, turn, options)
287
+ if (records.length > 0) await store.add(records)
288
+ return [
289
+ {
290
+ ok: true,
291
+ latencyMs: Date.now() - startedAt,
292
+ raw: { addedIds: records.map((r) => r.id) },
293
+ },
294
+ ]
295
+ } catch (error) {
296
+ return [
297
+ {
298
+ ok: false,
299
+ latencyMs: Date.now() - startedAt,
300
+ error: error instanceof Error ? error.message : String(error),
301
+ },
302
+ ]
303
+ }
304
+ }
305
+
306
+ /** Score the scoped records against the query and render a recall result. */
307
+ export async function recallRecords(
308
+ store: RecordStore,
309
+ scope: MemoryScope,
310
+ query: string,
311
+ options: BuiltinOptions,
312
+ ): Promise<RecallResult> {
313
+ const topK = options.topK ?? 6
314
+ const minScore = options.minScore ?? 0.15
315
+ const now = Date.now()
316
+
317
+ const queryEmbedding = options.embedder
318
+ ? await options.embedder.embed(query)
319
+ : undefined
320
+
321
+ const records = await store.loadScope(scope)
322
+ const kinds = options.kinds
323
+ const candidates =
324
+ kinds && kinds.length > 0
325
+ ? records.filter((r) => kinds.includes(r.kind))
326
+ : records
327
+
328
+ const hits = candidates
329
+ .map((record) => ({
330
+ record,
331
+ score: defaultScoreHit({ record, queryText: query, queryEmbedding, now }),
332
+ }))
333
+ .filter((h) => h.score >= minScore)
334
+ .sort((a, b) => b.score - a.score)
335
+ .slice(0, topK)
336
+
337
+ const systemPrompt = (options.render ?? defaultRenderMemory)(hits)
338
+ const fragments: Array<MemoryFragment> = hits.map((h) => ({
339
+ text: h.record.text,
340
+ source: h.record.id,
341
+ }))
342
+ return { systemPrompt, fragments }
343
+ }
344
+
345
+ /** Devtools inspect over a scope's live records. */
346
+ export async function inspectRecords(
347
+ store: RecordStore,
348
+ scope: MemoryScope,
349
+ ): Promise<MemorySnapshot> {
350
+ const records = await store.loadScope(scope)
351
+ return {
352
+ takenAt: new Date().toISOString(),
353
+ data: {
354
+ records: records.map((r) => ({
355
+ id: r.id,
356
+ text: r.text,
357
+ kind: r.kind,
358
+ role: r.role,
359
+ createdAt: r.createdAt,
360
+ importance: r.importance,
361
+ })),
362
+ },
363
+ }
364
+ }
365
+
366
+ /** Devtools flat fact list over a scope's live records. */
367
+ export async function listRecordFacts(
368
+ store: RecordStore,
369
+ scope: MemoryScope,
370
+ ): Promise<Array<MemoryFact>> {
371
+ const records = await store.loadScope(scope)
372
+ return records.map((r) => ({
373
+ id: r.id,
374
+ text: r.text,
375
+ source: r.role ?? r.kind,
376
+ createdAt: new Date(r.createdAt).toISOString(),
377
+ }))
378
+ }