@mastra/memory 0.11.2 → 0.11.3-alpha.1

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 CHANGED
@@ -1,4 +1,4 @@
1
- import { deepMerge } from '@mastra/core';
1
+ import { generateEmptyFromSchema } from '@mastra/core';
2
2
  import type { CoreTool, MastraMessageV1 } from '@mastra/core';
3
3
  import { MessageList } from '@mastra/core/agent';
4
4
  import type { MastraMessageV2 } from '@mastra/core/agent';
@@ -7,10 +7,14 @@ import type { MemoryConfig, SharedMemoryConfig, StorageThreadType, WorkingMemory
7
7
  import type { StorageGetMessagesArg } from '@mastra/core/storage';
8
8
  import { embedMany } from 'ai';
9
9
  import type { CoreMessage, TextPart, UIMessage } from 'ai';
10
+ import { Mutex } from 'async-mutex';
11
+ import type { JSONSchema7 } from 'json-schema';
10
12
 
11
13
  import xxhash from 'xxhash-wasm';
14
+ import { ZodObject } from 'zod';
15
+ import type { ZodTypeAny } from 'zod';
12
16
  import zodToJsonSchema from 'zod-to-json-schema';
13
- import { updateWorkingMemoryTool } from './tools/working-memory';
17
+ import { updateWorkingMemoryTool, __experimental_updateWorkingMemoryToolVNext } from './tools/working-memory';
14
18
 
15
19
  // Average characters per token based on OpenAI's tokenization
16
20
  const CHARS_PER_TOKEN = 4;
@@ -18,6 +22,8 @@ const CHARS_PER_TOKEN = 4;
18
22
  const DEFAULT_MESSAGE_RANGE = { before: 2, after: 2 } as const;
19
23
  const DEFAULT_TOP_K = 2;
20
24
 
25
+ const isZodObject = (v: ZodTypeAny): v is ZodObject<any, any, any> => v instanceof ZodObject;
26
+
21
27
  /**
22
28
  * Concrete implementation of MastraMemory that adds support for thread configuration
23
29
  * and message injection.
@@ -247,52 +253,7 @@ export class Memory extends MastraMemory {
247
253
  return this.storage.getThreadsByResourceId({ resourceId });
248
254
  }
249
255
 
250
- async saveThread({
251
- thread,
252
- memoryConfig,
253
- }: {
254
- thread: StorageThreadType;
255
- memoryConfig?: MemoryConfig;
256
- }): Promise<StorageThreadType> {
257
- const config = this.getMergedThreadConfig(memoryConfig || {});
258
-
259
- if (config.workingMemory?.enabled) {
260
- const scope = config.workingMemory.scope || 'thread';
261
-
262
- if (scope === 'resource' && thread.resourceId) {
263
- // For resource scope, initialize working memory in resource table
264
- const existingResource = await this.storage.getResourceById({ resourceId: thread.resourceId });
265
-
266
- if (!existingResource?.workingMemory) {
267
- let workingMemory = config.workingMemory.template || this.defaultWorkingMemoryTemplate;
268
-
269
- if (config.workingMemory.schema) {
270
- workingMemory = JSON.stringify(zodToJsonSchema(config.workingMemory.schema));
271
- }
272
-
273
- await this.storage.updateResource({
274
- resourceId: thread.resourceId,
275
- workingMemory,
276
- });
277
- }
278
- } else if (scope === 'thread' && !thread?.metadata?.workingMemory) {
279
- // For thread scope, initialize working memory in thread metadata (existing behavior)
280
- let workingMemory = config.workingMemory.template || this.defaultWorkingMemoryTemplate;
281
-
282
- if (config.workingMemory.schema) {
283
- workingMemory = JSON.stringify(zodToJsonSchema(config.workingMemory.schema));
284
- }
285
-
286
- return this.storage.saveThread({
287
- thread: deepMerge(thread, {
288
- metadata: {
289
- workingMemory,
290
- },
291
- }),
292
- });
293
- }
294
- }
295
-
256
+ async saveThread({ thread }: { thread: StorageThreadType; memoryConfig?: MemoryConfig }): Promise<StorageThreadType> {
296
257
  return this.storage.saveThread({ thread });
297
258
  }
298
259
 
@@ -359,6 +320,116 @@ export class Memory extends MastraMemory {
359
320
  }
360
321
  }
361
322
 
323
+ private updateWorkingMemoryMutexes = new Map<string, Mutex>();
324
+ /**
325
+ * @warning experimental! can be removed or changed at any time
326
+ */
327
+ async __experimental_updateWorkingMemoryVNext({
328
+ threadId,
329
+ resourceId,
330
+ workingMemory,
331
+ searchString,
332
+ memoryConfig,
333
+ }: {
334
+ threadId: string;
335
+ resourceId?: string;
336
+ workingMemory: string;
337
+ searchString?: string;
338
+ memoryConfig?: MemoryConfig;
339
+ }): Promise<{ success: boolean; reason: string }> {
340
+ const config = this.getMergedThreadConfig(memoryConfig || {});
341
+
342
+ if (!config.workingMemory?.enabled) {
343
+ throw new Error('Working memory is not enabled for this memory instance');
344
+ }
345
+
346
+ // If the agent calls the update working memory tool multiple times simultaneously
347
+ // each call could overwrite the other call
348
+ // so get an in memory mutex to make sure this.getWorkingMemory() returns up to date data each time
349
+ const mutexKey =
350
+ memoryConfig?.workingMemory?.scope === `resource` ? `resource-${resourceId}` : `thread-${threadId}`;
351
+ const mutex = this.updateWorkingMemoryMutexes.has(mutexKey)
352
+ ? this.updateWorkingMemoryMutexes.get(mutexKey)!
353
+ : new Mutex();
354
+ this.updateWorkingMemoryMutexes.set(mutexKey, mutex);
355
+ const release = await mutex.acquire();
356
+
357
+ try {
358
+ const existingWorkingMemory = (await this.getWorkingMemory({ threadId, resourceId, memoryConfig })) || '';
359
+ const template = await this.getWorkingMemoryTemplate({ memoryConfig });
360
+
361
+ let reason = '';
362
+ if (existingWorkingMemory) {
363
+ if (searchString && existingWorkingMemory?.includes(searchString)) {
364
+ workingMemory = existingWorkingMemory.replace(searchString, workingMemory);
365
+ reason = `found and replaced searchString with newMemory`;
366
+ } else if (
367
+ existingWorkingMemory.includes(workingMemory) ||
368
+ template?.content?.trim() === workingMemory.trim()
369
+ ) {
370
+ return {
371
+ success: false,
372
+ reason: `attempted to insert duplicate data into working memory. this entry was skipped`,
373
+ };
374
+ } else {
375
+ if (searchString) {
376
+ reason = `attempted to replace working memory string that doesn't exist. Appending to working memory instead.`;
377
+ } else {
378
+ reason = `appended newMemory to end of working memory`;
379
+ }
380
+
381
+ workingMemory = existingWorkingMemory + `\n${workingMemory}`;
382
+ }
383
+ } else if (workingMemory === template?.content) {
384
+ return {
385
+ success: false,
386
+ reason: `try again when you have data to add. newMemory was equal to the working memory template`,
387
+ };
388
+ } else {
389
+ reason = `started new working memory`;
390
+ }
391
+
392
+ // remove empty template insertions which models sometimes duplicate
393
+ workingMemory = template?.content ? workingMemory.replaceAll(template?.content, '') : workingMemory;
394
+
395
+ const scope = config.workingMemory.scope || 'thread';
396
+
397
+ if (scope === 'resource' && resourceId) {
398
+ // Update working memory in resource table
399
+ await this.storage.updateResource({
400
+ resourceId,
401
+ workingMemory,
402
+ });
403
+
404
+ if (reason) {
405
+ return { success: true, reason };
406
+ }
407
+ } else {
408
+ // Update working memory in thread metadata (existing behavior)
409
+ const thread = await this.storage.getThreadById({ threadId });
410
+ if (!thread) {
411
+ throw new Error(`Thread ${threadId} not found`);
412
+ }
413
+
414
+ await this.storage.updateThread({
415
+ id: threadId,
416
+ title: thread.title || 'Untitled Thread',
417
+ metadata: {
418
+ ...thread.metadata,
419
+ workingMemory,
420
+ },
421
+ });
422
+ }
423
+
424
+ return { success: true, reason };
425
+ } catch (e) {
426
+ this.logger.error(e instanceof Error ? e.stack || e.message : JSON.stringify(e));
427
+ return { success: false, reason: 'Tool error.' };
428
+ } finally {
429
+ release();
430
+ }
431
+ }
432
+
362
433
  protected chunkText(text: string, tokenSize = 4096) {
363
434
  // Convert token size to character size with some buffer
364
435
  const charSize = tokenSize * CHARS_PER_TOKEN;
@@ -654,18 +725,39 @@ export class Memory extends MastraMemory {
654
725
  return workingMemoryData;
655
726
  }
656
727
 
657
- public async getWorkingMemoryTemplate(): Promise<WorkingMemoryTemplate | null> {
658
- if (!this.threadConfig.workingMemory?.enabled) {
728
+ /**
729
+ * Gets the working memory template for the current memory configuration.
730
+ * Supports both ZodObject and JSONSchema7 schemas.
731
+ *
732
+ * @param memoryConfig - The memory configuration containing the working memory settings
733
+ * @returns The working memory template with format and content, or null if working memory is disabled
734
+ */
735
+ public async getWorkingMemoryTemplate({
736
+ memoryConfig,
737
+ }: {
738
+ memoryConfig?: MemoryConfig;
739
+ }): Promise<WorkingMemoryTemplate | null> {
740
+ const config = this.getMergedThreadConfig(memoryConfig || {});
741
+
742
+ if (!config.workingMemory?.enabled) {
659
743
  return null;
660
744
  }
661
745
 
662
746
  // Get thread from storage
663
- if (this.threadConfig?.workingMemory?.schema) {
747
+ if (config.workingMemory?.schema) {
664
748
  try {
665
- const schema = this.threadConfig.workingMemory.schema;
666
- const convertedSchema = zodToJsonSchema(schema, {
667
- $refStrategy: 'none',
668
- });
749
+ const schema = config.workingMemory.schema;
750
+ let convertedSchema: JSONSchema7;
751
+
752
+ if (isZodObject(schema as ZodTypeAny)) {
753
+ // Convert ZodObject to JSON Schema
754
+ convertedSchema = zodToJsonSchema(schema as ZodTypeAny, {
755
+ $refStrategy: 'none',
756
+ }) as JSONSchema7;
757
+ } else {
758
+ // Already a JSON Schema
759
+ convertedSchema = schema as any as JSONSchema7;
760
+ }
669
761
 
670
762
  return { format: 'json', content: JSON.stringify(convertedSchema) };
671
763
  } catch (error) {
@@ -675,8 +767,7 @@ export class Memory extends MastraMemory {
675
767
  }
676
768
 
677
769
  // Return working memory from metadata
678
- const memory = this.threadConfig.workingMemory.template || this.defaultWorkingMemoryTemplate;
679
-
770
+ const memory = config.workingMemory.template || this.defaultWorkingMemoryTemplate;
680
771
  return { format: 'markdown', content: memory.trim() };
681
772
  }
682
773
 
@@ -694,17 +785,22 @@ export class Memory extends MastraMemory {
694
785
  return null;
695
786
  }
696
787
 
697
- const workingMemoryTemplate = await this.getWorkingMemoryTemplate();
788
+ const workingMemoryTemplate = await this.getWorkingMemoryTemplate({ memoryConfig: config });
698
789
  const workingMemoryData = await this.getWorkingMemory({ threadId, resourceId, memoryConfig: config });
699
790
 
700
791
  if (!workingMemoryTemplate) {
701
792
  return null;
702
793
  }
703
794
 
704
- return this.getWorkingMemoryToolInstruction({
705
- template: workingMemoryTemplate,
706
- data: workingMemoryData,
707
- });
795
+ return this.isVNextWorkingMemoryConfig(memoryConfig)
796
+ ? this.__experimental_getWorkingMemoryToolInstructionVNext({
797
+ template: workingMemoryTemplate,
798
+ data: workingMemoryData,
799
+ })
800
+ : this.getWorkingMemoryToolInstruction({
801
+ template: workingMemoryTemplate,
802
+ data: workingMemoryData,
803
+ });
708
804
  }
709
805
 
710
806
  public defaultWorkingMemoryTemplate = `
@@ -727,6 +823,11 @@ export class Memory extends MastraMemory {
727
823
  template: WorkingMemoryTemplate;
728
824
  data: string | null;
729
825
  }) {
826
+ const emptyWorkingMemoryTemplateObject =
827
+ template.format === 'json' ? generateEmptyFromSchema(template.content) : null;
828
+ const hasEmptyWorkingMemoryTemplateObject =
829
+ emptyWorkingMemoryTemplateObject && Object.keys(emptyWorkingMemoryTemplateObject).length > 0;
830
+
730
831
  return `WORKING_MEMORY_SYSTEM_INSTRUCTION:
731
832
  Store and update any conversation-relevant information by calling the updateWorkingMemory tool. If information might be referenced again - store it!
732
833
 
@@ -735,12 +836,20 @@ Guidelines:
735
836
  2. Update proactively when information changes, no matter how small
736
837
  3. Use ${template.format === 'json' ? 'JSON' : 'Markdown'} format for all data
737
838
  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"
839
+ 5. IMPORTANT: When calling updateWorkingMemory, the only valid parameter is the memory field.
840
+ 6. IMPORTANT: ALWAYS pass the data you want to store in the memory field as a string. DO NOT pass an object.
841
+ 7. IMPORTANT: Data must only be sent as a string no matter which format is used.
738
842
 
739
- WORKING MEMORY TEMPLATE:
843
+ <working_memory_template>
740
844
  ${template.content}
845
+ </working_memory_template>
741
846
 
742
- WORKING MEMORY DATA:
847
+ ${hasEmptyWorkingMemoryTemplateObject ? 'When working with json data, the object format below represents the template:' : ''}
848
+ ${hasEmptyWorkingMemoryTemplateObject ? JSON.stringify(emptyWorkingMemoryTemplateObject) : ''}
849
+
850
+ <working_memory_data>
743
851
  ${data}
852
+ </working_memory_data>
744
853
 
745
854
  Notes:
746
855
  - Update memory whenever referenced information changes
@@ -752,17 +861,66 @@ Notes:
752
861
  - IMPORTANT: Preserve the ${template.format === 'json' ? 'JSON' : 'Markdown'} formatting structure above while updating the content.`;
753
862
  }
754
863
 
864
+ protected __experimental_getWorkingMemoryToolInstructionVNext({
865
+ template,
866
+ data,
867
+ }: {
868
+ template: WorkingMemoryTemplate;
869
+ data: string | null;
870
+ }) {
871
+ return `WORKING_MEMORY_SYSTEM_INSTRUCTION:
872
+ Store and update any conversation-relevant information by calling the updateWorkingMemory tool.
873
+
874
+ Guidelines:
875
+ 1. Store anything that could be useful later in the conversation
876
+ 2. Update proactively when information changes, no matter how small
877
+ 3. Use ${template.format === 'json' ? 'JSON' : 'Markdown'} format for all data
878
+ 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"
879
+ 5. If your memory has not changed, you do not need to call the updateWorkingMemory tool. By default it will persist and be available for you in future interactions
880
+ 6. Information not being relevant to the current conversation is not a valid reason to replace or remove working memory information. Your working memory spans across multiple conversations and may be needed again later, even if it's not currently relevant.
881
+
882
+ <working_memory_template>
883
+ ${template.content}
884
+ </working_memory_template>
885
+
886
+ <working_memory_data>
887
+ ${data}
888
+ </working_memory_data>
889
+
890
+ Notes:
891
+ - Update memory whenever referenced information changes
892
+ ${
893
+ template.content !== this.defaultWorkingMemoryTemplate
894
+ ? `- Only store information if it's in the working memory template, do not store other information unless the user asks you to remember it, as that non-template information may be irrelevant`
895
+ : `- 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)
896
+ `
897
+ }
898
+ - 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
899
+ - REMEMBER: the way you update your working memory is by calling the updateWorkingMemory tool with the ${template.format === 'json' ? 'JSON' : 'Markdown'} content. The system will store it for you. The user will not see it.
900
+ - IMPORTANT: You MUST call updateWorkingMemory in every response to a prompt where you received relevant information if that information is not already stored.
901
+ - IMPORTANT: Preserve the ${template.format === 'json' ? 'JSON' : 'Markdown'} formatting structure above while updating the content.
902
+ `;
903
+ }
904
+
905
+ private isVNextWorkingMemoryConfig(config?: MemoryConfig): boolean {
906
+ if (!config?.workingMemory) return false;
907
+
908
+ const isMDWorkingMemory =
909
+ !(`schema` in config.workingMemory) &&
910
+ (typeof config.workingMemory.template === `string` || config.workingMemory.template) &&
911
+ config.workingMemory;
912
+
913
+ return Boolean(isMDWorkingMemory && isMDWorkingMemory.version === `vnext`);
914
+ }
915
+
755
916
  public getTools(config?: MemoryConfig): Record<string, CoreTool> {
756
917
  const mergedConfig = this.getMergedThreadConfig(config);
757
918
  if (mergedConfig.workingMemory?.enabled) {
758
- if (mergedConfig.workingMemory.schema) {
759
- return {
760
- updateWorkingMemory: updateWorkingMemoryTool({ format: 'json' }),
761
- };
762
- }
763
-
764
919
  return {
765
- updateWorkingMemory: updateWorkingMemoryTool({ format: 'markdown' }),
920
+ updateWorkingMemory: this.isVNextWorkingMemoryConfig(mergedConfig)
921
+ ? // use the new experimental tool
922
+ __experimental_updateWorkingMemoryToolVNext(mergedConfig)
923
+ : updateWorkingMemoryTool(mergedConfig),
766
924
  };
767
925
  }
768
926
  return {};
@@ -1,13 +1,15 @@
1
- import type { CoreTool } from '@mastra/core';
2
- import type { WorkingMemoryFormat } from '@mastra/core/memory';
1
+ import type { CoreTool, MemoryConfig } from '@mastra/core';
3
2
  import { z } from 'zod';
4
3
 
5
- export const updateWorkingMemoryTool = ({ format }: { format: WorkingMemoryFormat }): CoreTool => ({
6
- description: 'Update the working memory with new information',
4
+ export const updateWorkingMemoryTool = (memoryConfig?: MemoryConfig): CoreTool => ({
5
+ description:
6
+ 'Update the working memory with new information. Always pass data as string to the memory field. Never pass an object.',
7
7
  parameters: z.object({
8
8
  memory: z
9
9
  .string()
10
- .describe(`The ${format === 'json' ? 'JSON' : 'Markdown'} formatted working memory content to store`),
10
+ .describe(
11
+ `The ${!!memoryConfig?.workingMemory?.schema ? 'JSON' : 'Markdown'} formatted working memory content to store. This MUST be a string. Never pass an object.`,
12
+ ),
11
13
  }),
12
14
  execute: async (params: any) => {
13
15
  const { context, threadId, memory, resourceId } = params;
@@ -32,8 +34,92 @@ export const updateWorkingMemoryTool = ({ format }: { format: WorkingMemoryForma
32
34
  threadId,
33
35
  resourceId: resourceId || thread.resourceId,
34
36
  workingMemory: workingMemory,
37
+ memoryConfig,
35
38
  });
36
39
 
37
40
  return { success: true };
38
41
  },
39
42
  });
43
+
44
+ export const __experimental_updateWorkingMemoryToolVNext = (config: MemoryConfig): CoreTool => ({
45
+ description: 'Update the working memory with new information.',
46
+ parameters: z.object({
47
+ newMemory: z
48
+ .string()
49
+ .optional()
50
+ .nullable()
51
+ .describe(`The ${config.workingMemory?.schema ? 'JSON' : 'Markdown'} formatted working memory content to store`),
52
+ searchString: z
53
+ .string()
54
+ .optional()
55
+ .nullable()
56
+ .describe(
57
+ "The working memory string to find. Will be replaced with the newMemory string. If this is omitted or doesn't exist, the newMemory string will be appended to the end of your working memory. Replacing single lines at a time is encouraged for greater accuracy. If updateReason is not 'append-new-memory', this search string must be provided or the tool call will be rejected.",
58
+ ),
59
+ updateReason: z
60
+ .enum(['append-new-memory', 'clarify-existing-memory', 'replace-irrelevant-memory'])
61
+ .optional()
62
+ .nullable()
63
+ .describe(
64
+ "The reason you're updating working memory. Passing any value other than 'append-new-memory' requires a searchString to be provided. Defaults to append-new-memory",
65
+ ),
66
+ }),
67
+ execute: async (params: any) => {
68
+ const { context, threadId, memory, resourceId } = params;
69
+ if (!threadId || !memory) {
70
+ throw new Error('Thread ID and Memory instance are required for working memory updates');
71
+ }
72
+
73
+ const thread = await memory.getThreadById({ threadId });
74
+
75
+ if (!thread) {
76
+ throw new Error(`Thread ${threadId} not found`);
77
+ }
78
+
79
+ if (thread.resourceId && thread.resourceId !== resourceId) {
80
+ throw new Error(`Thread with id ${threadId} resourceId does not match the current resourceId ${resourceId}`);
81
+ }
82
+
83
+ const workingMemory = context.newMemory || '';
84
+ if (!context.updateReason) context.updateReason = `append-new-memory`;
85
+
86
+ if (
87
+ context.searchString &&
88
+ config.workingMemory?.scope === `resource` &&
89
+ context.updateReason === `replace-irrelevant-memory`
90
+ ) {
91
+ // don't allow replacements due to something not being relevant to the current conversation
92
+ // if there's no searchString, then we will append.
93
+ context.searchString = undefined;
94
+ }
95
+
96
+ if (context.updateReason === `append-new-memory` && context.searchString) {
97
+ // do not find/replace when append-new-memory is selected
98
+ // some models get confused and pass a search string even when they don't want to replace it.
99
+ // TODO: maybe they're trying to add new info after the search string?
100
+ context.searchString = undefined;
101
+ }
102
+
103
+ if (context.updateReason !== `append-new-memory` && !context.searchString) {
104
+ return {
105
+ success: false,
106
+ reason: `updateReason was ${context.updateReason} but no searchString was provided. Unable to replace undefined with "${context.newMemory}"`,
107
+ };
108
+ }
109
+
110
+ // Use the new updateWorkingMemory method which handles both thread and resource scope
111
+ const result = await memory.__experimental_updateWorkingMemoryVNext({
112
+ threadId,
113
+ resourceId: resourceId || thread.resourceId,
114
+ workingMemory: workingMemory,
115
+ searchString: context.searchString,
116
+ memoryConfig: config,
117
+ });
118
+
119
+ if (result) {
120
+ return result;
121
+ }
122
+
123
+ return { success: true };
124
+ },
125
+ });