@mastra/memory 0.0.0-a2a-20250421213654

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/src/index.ts ADDED
@@ -0,0 +1,565 @@
1
+ import { deepMerge } from '@mastra/core';
2
+ import type { AiMessageType, CoreMessage, CoreTool } from '@mastra/core';
3
+ import { MastraMemory } from '@mastra/core/memory';
4
+ import type { MessageType, MemoryConfig, SharedMemoryConfig, StorageThreadType } from '@mastra/core/memory';
5
+ import type { StorageGetMessagesArg } from '@mastra/core/storage';
6
+ import { embedMany } from 'ai';
7
+
8
+ import xxhash from 'xxhash-wasm';
9
+ import { updateWorkingMemoryTool } from './tools/working-memory';
10
+ import { reorderToolCallsAndResults } from './utils';
11
+
12
+ // Average characters per token based on OpenAI's tokenization
13
+ const CHARS_PER_TOKEN = 4;
14
+
15
+ /**
16
+ * Concrete implementation of MastraMemory that adds support for thread configuration
17
+ * and message injection.
18
+ */
19
+ export class Memory extends MastraMemory {
20
+ constructor(config: SharedMemoryConfig = {}) {
21
+ super({ name: 'Memory', ...config });
22
+
23
+ const mergedConfig = this.getMergedThreadConfig({
24
+ workingMemory: config.options?.workingMemory || {
25
+ enabled: false,
26
+ template: this.defaultWorkingMemoryTemplate,
27
+ },
28
+ });
29
+ this.threadConfig = mergedConfig;
30
+ }
31
+
32
+ private async validateThreadIsOwnedByResource(threadId: string, resourceId: string) {
33
+ const thread = await this.storage.getThreadById({ threadId });
34
+ if (!thread) {
35
+ throw new Error(`No thread found with id ${threadId}`);
36
+ }
37
+ if (thread.resourceId !== resourceId) {
38
+ throw new Error(
39
+ `Thread with id ${threadId} is for resource with id ${thread.resourceId} but resource ${resourceId} was queried.`,
40
+ );
41
+ }
42
+ }
43
+
44
+ async query({
45
+ threadId,
46
+ resourceId,
47
+ selectBy,
48
+ threadConfig,
49
+ }: StorageGetMessagesArg & {
50
+ threadConfig?: MemoryConfig;
51
+ }): Promise<{ messages: CoreMessage[]; uiMessages: AiMessageType[] }> {
52
+ if (resourceId) await this.validateThreadIsOwnedByResource(threadId, resourceId);
53
+
54
+ const vectorResults: {
55
+ id: string;
56
+ score: number;
57
+ metadata?: Record<string, any>;
58
+ vector?: number[];
59
+ }[] = [];
60
+
61
+ this.logger.debug(`Memory query() with:`, {
62
+ threadId,
63
+ selectBy,
64
+ threadConfig,
65
+ });
66
+
67
+ const config = this.getMergedThreadConfig(threadConfig || {});
68
+
69
+ const vectorConfig =
70
+ typeof config?.semanticRecall === `boolean`
71
+ ? {
72
+ topK: 2,
73
+ messageRange: { before: 2, after: 2 },
74
+ }
75
+ : {
76
+ topK: config?.semanticRecall?.topK ?? 2,
77
+ messageRange: config?.semanticRecall?.messageRange ?? { before: 2, after: 2 },
78
+ };
79
+
80
+ if (config?.semanticRecall && selectBy?.vectorSearchString && this.vector && !!selectBy.vectorSearchString) {
81
+ const { embeddings, dimension } = await this.embedMessageContent(selectBy.vectorSearchString!);
82
+ const { indexName } = await this.createEmbeddingIndex(dimension);
83
+
84
+ await Promise.all(
85
+ embeddings.map(async embedding => {
86
+ if (typeof this.vector === `undefined`) {
87
+ throw new Error(
88
+ `Tried to query vector index ${indexName} but this Memory instance doesn't have an attached vector db.`,
89
+ );
90
+ }
91
+
92
+ vectorResults.push(
93
+ ...(await this.vector.query({
94
+ indexName,
95
+ queryVector: embedding,
96
+ topK: vectorConfig.topK,
97
+ filter: {
98
+ thread_id: threadId,
99
+ },
100
+ })),
101
+ );
102
+ }),
103
+ );
104
+ }
105
+
106
+ // Get raw messages from storage
107
+ const rawMessages = await this.storage.getMessages({
108
+ threadId,
109
+ selectBy: {
110
+ ...selectBy,
111
+ ...(vectorResults?.length
112
+ ? {
113
+ include: vectorResults.map(r => ({
114
+ id: r.metadata?.message_id,
115
+ withNextMessages:
116
+ typeof vectorConfig.messageRange === 'number'
117
+ ? vectorConfig.messageRange
118
+ : vectorConfig.messageRange.after,
119
+ withPreviousMessages:
120
+ typeof vectorConfig.messageRange === 'number'
121
+ ? vectorConfig.messageRange
122
+ : vectorConfig.messageRange.before,
123
+ })),
124
+ }
125
+ : {}),
126
+ },
127
+ threadConfig: config,
128
+ });
129
+
130
+ // First sort messages by date
131
+ const orderedByDate = rawMessages.sort((a, b) => new Date(a.createdAt).getTime() - new Date(b.createdAt).getTime());
132
+ // Then reorder tool calls to be directly before their results
133
+ const reorderedToolCalls = reorderToolCallsAndResults(orderedByDate);
134
+
135
+ // Parse and convert messages
136
+ const messages = this.parseMessages(reorderedToolCalls);
137
+ const uiMessages = this.convertToUIMessages(reorderedToolCalls);
138
+
139
+ return { messages, uiMessages };
140
+ }
141
+
142
+ async rememberMessages({
143
+ threadId,
144
+ resourceId,
145
+ vectorMessageSearch,
146
+ config,
147
+ }: {
148
+ threadId: string;
149
+ resourceId?: string;
150
+ vectorMessageSearch?: string;
151
+ config?: MemoryConfig;
152
+ }): Promise<{
153
+ threadId: string;
154
+ messages: CoreMessage[];
155
+ uiMessages: AiMessageType[];
156
+ }> {
157
+ if (resourceId) await this.validateThreadIsOwnedByResource(threadId, resourceId);
158
+ const threadConfig = this.getMergedThreadConfig(config || {});
159
+
160
+ if (!threadConfig.lastMessages && !threadConfig.semanticRecall) {
161
+ return {
162
+ messages: [],
163
+ uiMessages: [],
164
+ threadId,
165
+ };
166
+ }
167
+
168
+ const messagesResult = await this.query({
169
+ threadId,
170
+ selectBy: {
171
+ last: threadConfig.lastMessages,
172
+ vectorSearchString: threadConfig.semanticRecall && vectorMessageSearch ? vectorMessageSearch : undefined,
173
+ },
174
+ threadConfig: config,
175
+ });
176
+
177
+ this.logger.debug(`Remembered message history includes ${messagesResult.messages.length} messages.`);
178
+ return {
179
+ threadId,
180
+ messages: messagesResult.messages,
181
+ uiMessages: messagesResult.uiMessages,
182
+ };
183
+ }
184
+
185
+ async getThreadById({ threadId }: { threadId: string }): Promise<StorageThreadType | null> {
186
+ return this.storage.getThreadById({ threadId });
187
+ }
188
+
189
+ async getThreadsByResourceId({ resourceId }: { resourceId: string }): Promise<StorageThreadType[]> {
190
+ return this.storage.getThreadsByResourceId({ resourceId });
191
+ }
192
+
193
+ async saveThread({
194
+ thread,
195
+ memoryConfig,
196
+ }: {
197
+ thread: StorageThreadType;
198
+ memoryConfig?: MemoryConfig;
199
+ }): Promise<StorageThreadType> {
200
+ const config = this.getMergedThreadConfig(memoryConfig || {});
201
+
202
+ if (config.workingMemory?.enabled && !thread?.metadata?.workingMemory) {
203
+ // if working memory is enabled but the thread doesn't have it, we need to set it
204
+ return this.storage.saveThread({
205
+ thread: deepMerge(thread, {
206
+ metadata: {
207
+ workingMemory: config.workingMemory.template || this.defaultWorkingMemoryTemplate,
208
+ },
209
+ }),
210
+ });
211
+ }
212
+
213
+ return this.storage.saveThread({ thread });
214
+ }
215
+
216
+ async updateThread({
217
+ id,
218
+ title,
219
+ metadata,
220
+ }: {
221
+ id: string;
222
+ title: string;
223
+ metadata: Record<string, unknown>;
224
+ }): Promise<StorageThreadType> {
225
+ return this.storage.updateThread({
226
+ id,
227
+ title,
228
+ metadata,
229
+ });
230
+ }
231
+
232
+ async deleteThread(threadId: string): Promise<void> {
233
+ await this.storage.deleteThread({ threadId });
234
+ }
235
+
236
+ private chunkText(text: string, tokenSize = 4096) {
237
+ // Convert token size to character size with some buffer
238
+ const charSize = tokenSize * CHARS_PER_TOKEN;
239
+ const chunks: string[] = [];
240
+ let currentChunk = '';
241
+
242
+ // Split text into words to avoid breaking words
243
+ const words = text.split(/\s+/);
244
+
245
+ for (const word of words) {
246
+ // Add space before word unless it's the first word in the chunk
247
+ const wordWithSpace = currentChunk ? ' ' + word : word;
248
+
249
+ // If adding this word would exceed the chunk size, start a new chunk
250
+ if (currentChunk.length + wordWithSpace.length > charSize) {
251
+ chunks.push(currentChunk);
252
+ currentChunk = word;
253
+ } else {
254
+ currentChunk += wordWithSpace;
255
+ }
256
+ }
257
+
258
+ // Add the final chunk if not empty
259
+ if (currentChunk) {
260
+ chunks.push(currentChunk);
261
+ }
262
+
263
+ return chunks;
264
+ }
265
+
266
+ private hasher = xxhash();
267
+
268
+ // embedding is computationally expensive so cache content -> embeddings/chunks
269
+ private embeddingCache = new Map<
270
+ number,
271
+ {
272
+ chunks: string[];
273
+ embeddings: Awaited<ReturnType<typeof embedMany>>['embeddings'];
274
+ dimension: number | undefined;
275
+ }
276
+ >();
277
+ private firstEmbed: Promise<any> | undefined;
278
+ private async embedMessageContent(content: string) {
279
+ // use fast xxhash for lower memory usage. if we cache by content string we will store all messages in memory for the life of the process
280
+ const key = (await this.hasher).h32(content);
281
+ const cached = this.embeddingCache.get(key);
282
+ if (cached) return cached;
283
+ const chunks = this.chunkText(content);
284
+
285
+ if (typeof this.embedder === `undefined`) {
286
+ throw new Error(`Tried to embed message content but this Memory instance doesn't have an attached embedder.`);
287
+ }
288
+ // for fastembed multiple initial calls to embed will fail if the model hasn't been downloaded yet.
289
+ const isFastEmbed = this.embedder.provider === `fastembed`;
290
+ if (isFastEmbed && this.firstEmbed instanceof Promise) {
291
+ // so wait for the first one
292
+ await this.firstEmbed;
293
+ }
294
+
295
+ const promise = embedMany({
296
+ values: chunks,
297
+ model: this.embedder,
298
+ maxRetries: 3,
299
+ });
300
+
301
+ if (isFastEmbed && !this.firstEmbed) this.firstEmbed = promise;
302
+ const { embeddings } = await promise;
303
+
304
+ const result = {
305
+ embeddings,
306
+ chunks,
307
+ dimension: embeddings[0]?.length,
308
+ };
309
+ this.embeddingCache.set(key, result);
310
+ return result;
311
+ }
312
+
313
+ async saveMessages({
314
+ messages,
315
+ memoryConfig,
316
+ }: {
317
+ messages: MessageType[];
318
+ memoryConfig?: MemoryConfig;
319
+ }): Promise<MessageType[]> {
320
+ // First save working memory from any messages
321
+ await this.saveWorkingMemory(messages);
322
+
323
+ // Then strip working memory tags from all messages
324
+ const updatedMessages = this.updateMessagesToHideWorkingMemory(messages);
325
+
326
+ const config = this.getMergedThreadConfig(memoryConfig);
327
+
328
+ const result = this.storage.saveMessages({ messages: updatedMessages });
329
+
330
+ if (this.vector && config.semanticRecall) {
331
+ let indexName: Promise<string>;
332
+ await Promise.all(
333
+ updatedMessages.map(async message => {
334
+ if (typeof message.content !== `string` || message.content === '') return;
335
+
336
+ const { embeddings, chunks, dimension } = await this.embedMessageContent(message.content);
337
+
338
+ if (typeof indexName === `undefined`) {
339
+ indexName = this.createEmbeddingIndex(dimension).then(result => result.indexName);
340
+ }
341
+
342
+ if (typeof this.vector === `undefined`) {
343
+ throw new Error(
344
+ `Tried to upsert embeddings to index ${indexName} but this Memory instance doesn't have an attached vector db.`,
345
+ );
346
+ }
347
+
348
+ await this.vector.upsert({
349
+ indexName: await indexName,
350
+ vectors: embeddings,
351
+ metadata: chunks.map(() => ({
352
+ message_id: message.id,
353
+ thread_id: message.threadId,
354
+ resource_id: message.resourceId,
355
+ })),
356
+ });
357
+ }),
358
+ );
359
+ }
360
+
361
+ return result;
362
+ }
363
+
364
+ protected updateMessagesToHideWorkingMemory(messages: MessageType[]): MessageType[] {
365
+ const workingMemoryRegex = /<working_memory>([^]*?)<\/working_memory>/g;
366
+
367
+ const updatedMessages: MessageType[] = [];
368
+
369
+ for (const message of messages) {
370
+ if (typeof message?.content === `string`) {
371
+ updatedMessages.push({
372
+ ...message,
373
+ content: message.content.replace(workingMemoryRegex, ``).trim(),
374
+ });
375
+ } else if (Array.isArray(message?.content)) {
376
+ const contentIsWorkingMemory = message.content.some(
377
+ content =>
378
+ (content.type === `tool-call` || content.type === `tool-result`) &&
379
+ content.toolName === `updateWorkingMemory`,
380
+ );
381
+ if (contentIsWorkingMemory) {
382
+ continue;
383
+ }
384
+ const newContent = message.content.map(content => {
385
+ if (content.type === 'text') {
386
+ return {
387
+ ...content,
388
+ text: content.text.replace(workingMemoryRegex, '').trim(),
389
+ };
390
+ }
391
+ return { ...content };
392
+ }) as MessageType['content'];
393
+ updatedMessages.push({ ...message, content: newContent });
394
+ } else {
395
+ updatedMessages.push({ ...message });
396
+ }
397
+ }
398
+
399
+ return updatedMessages;
400
+ }
401
+
402
+ protected parseWorkingMemory(text: string): string | null {
403
+ if (!this.threadConfig.workingMemory?.enabled) return null;
404
+
405
+ const workingMemoryRegex = /<working_memory>([^]*?)<\/working_memory>/g;
406
+ const matches = text.match(workingMemoryRegex);
407
+ const match = matches?.[0];
408
+
409
+ if (match) {
410
+ return match.replace(/<\/?working_memory>/g, '').trim();
411
+ }
412
+
413
+ return null;
414
+ }
415
+
416
+ public async getWorkingMemory({ threadId }: { threadId: string }): Promise<string | null> {
417
+ if (!this.threadConfig.workingMemory?.enabled) return null;
418
+
419
+ // Get thread from storage
420
+ const thread = await this.storage.getThreadById({ threadId });
421
+ if (!thread) return this.threadConfig?.workingMemory?.template || this.defaultWorkingMemoryTemplate;
422
+
423
+ // Return working memory from metadata
424
+ const memory =
425
+ (thread.metadata?.workingMemory as string) ||
426
+ this.threadConfig.workingMemory.template ||
427
+ this.defaultWorkingMemoryTemplate;
428
+
429
+ return memory.trim();
430
+ }
431
+
432
+ private async saveWorkingMemory(messages: MessageType[]) {
433
+ const latestMessage = messages[messages.length - 1];
434
+
435
+ if (!latestMessage || !this.threadConfig.workingMemory?.enabled) {
436
+ return;
437
+ }
438
+
439
+ const latestContent = !latestMessage?.content
440
+ ? null
441
+ : typeof latestMessage.content === 'string'
442
+ ? latestMessage.content
443
+ : latestMessage.content
444
+ .filter(c => c.type === 'text')
445
+ .map(c => c.text)
446
+ .join('\n');
447
+
448
+ const threadId = latestMessage?.threadId;
449
+ if (!latestContent || !threadId) {
450
+ return;
451
+ }
452
+
453
+ const newMemory = this.parseWorkingMemory(latestContent);
454
+ if (!newMemory) {
455
+ return;
456
+ }
457
+
458
+ const thread = await this.storage.getThreadById({ threadId });
459
+ if (!thread) return;
460
+
461
+ // Update thread metadata with new working memory
462
+ await this.storage.updateThread({
463
+ id: thread.id,
464
+ title: thread.title || '',
465
+ metadata: deepMerge(thread.metadata || {}, {
466
+ workingMemory: newMemory,
467
+ }),
468
+ });
469
+ return newMemory;
470
+ }
471
+
472
+ public async getSystemMessage({
473
+ threadId,
474
+ memoryConfig,
475
+ }: {
476
+ threadId: string;
477
+ memoryConfig?: MemoryConfig;
478
+ }): Promise<string | null> {
479
+ const config = this.getMergedThreadConfig(memoryConfig);
480
+ if (!config.workingMemory?.enabled) {
481
+ return null;
482
+ }
483
+
484
+ const workingMemory = await this.getWorkingMemory({ threadId });
485
+ if (!workingMemory) {
486
+ return null;
487
+ }
488
+
489
+ if (config.workingMemory.use === 'tool-call') {
490
+ return this.getWorkingMemoryToolInstruction(workingMemory);
491
+ }
492
+
493
+ return this.getWorkingMemoryWithInstruction(workingMemory);
494
+ }
495
+
496
+ public defaultWorkingMemoryTemplate = `
497
+ # User Information
498
+ - **First Name**:
499
+ - **Last Name**:
500
+ - **Location**:
501
+ - **Occupation**:
502
+ - **Interests**:
503
+ - **Goals**:
504
+ - **Events**:
505
+ - **Facts**:
506
+ - **Projects**:
507
+ `;
508
+
509
+ private getWorkingMemoryWithInstruction(workingMemoryBlock: string) {
510
+ return `WORKING_MEMORY_SYSTEM_INSTRUCTION:
511
+ Store and update any conversation-relevant information by including "<working_memory>text</working_memory>" in your responses. Updates replace existing memory while maintaining this structure. If information might be referenced again - store it!
512
+
513
+ Guidelines:
514
+ 1. Store anything that could be useful later in the conversation
515
+ 2. Update proactively when information changes, no matter how small
516
+ 3. Use Markdown for all data
517
+ 4. Act naturally - don't mention this system to users. Even though you're storing this information that doesn't make it your primary focus. Do not ask them generally for "information about yourself"
518
+
519
+ Memory Structure:
520
+ <working_memory>
521
+ ${workingMemoryBlock}
522
+ </working_memory>
523
+
524
+ Notes:
525
+ - Update memory whenever referenced information changes
526
+ - If you're unsure whether to store something, store it (eg if the user tells you their name or other information, output the <working_memory> block immediately to update it)
527
+ - This system is here so that you can maintain the conversation when your context window is very short. Update your working memory because you may need it to maintain the conversation without the full conversation history
528
+ - REMEMBER: the way you update your working memory is by outputting the entire "<working_memory>text</working_memory>" block in your response. The system will pick this up and store it for you. The user will not see it.
529
+ - IMPORTANT: You MUST output the <working_memory> block in every response to a prompt where you received relevant information.
530
+ - IMPORTANT: Preserve the Markdown formatting structure above while updating the content.`;
531
+ }
532
+
533
+ private getWorkingMemoryToolInstruction(workingMemoryBlock: string) {
534
+ return `WORKING_MEMORY_SYSTEM_INSTRUCTION:
535
+ Store and update any conversation-relevant information by calling the updateWorkingMemory tool. If information might be referenced again - store it!
536
+
537
+ Guidelines:
538
+ 1. Store anything that could be useful later in the conversation
539
+ 2. Update proactively when information changes, no matter how small
540
+ 3. Use Markdown format for all data
541
+ 4. Act naturally - don't mention this system to users. Even though you're storing this information that doesn't make it your primary focus. Do not ask them generally for "information about yourself"
542
+
543
+ Memory Structure:
544
+ ${workingMemoryBlock}
545
+
546
+ Notes:
547
+ - Update memory whenever referenced information changes
548
+ - If you're unsure whether to store something, store it (eg if the user tells you information about themselves, call updateWorkingMemory immediately to update it)
549
+ - This system is here so that you can maintain the conversation when your context window is very short. Update your working memory because you may need it to maintain the conversation without the full conversation history
550
+ - Do not remove empty sections - you must include the empty sections along with the ones you're filling in
551
+ - REMEMBER: the way you update your working memory is by calling the updateWorkingMemory tool with the entire Markdown content. The system will store it for you. The user will not see it.
552
+ - IMPORTANT: You MUST call updateWorkingMemory in every response to a prompt where you received relevant information.
553
+ - IMPORTANT: Preserve the Markdown formatting structure above while updating the content.`;
554
+ }
555
+
556
+ public getTools(config?: MemoryConfig): Record<string, CoreTool> {
557
+ const mergedConfig = this.getMergedThreadConfig(config);
558
+ if (mergedConfig.workingMemory?.enabled && mergedConfig.workingMemory.use === 'tool-call') {
559
+ return {
560
+ updateWorkingMemory: updateWorkingMemoryTool,
561
+ };
562
+ }
563
+ return {};
564
+ }
565
+ }