From 40d1badada02c031e3a37bb7d09d598e24980cfe Mon Sep 17 00:00:00 2001 From: Wenzheng Jiang Date: Thu, 23 Oct 2025 15:33:35 +0900 Subject: [PATCH] Add CRUD to Saved Memory (#1929) --- src/LLMProviders/intentAnalyzer.ts | 6 +- src/constants.ts | 2 +- src/integration_tests/composerPrompt.test.ts | 2 +- src/memory/UserMemoryManager.test.ts | 57 +++++----- src/memory/UserMemoryManager.ts | 111 +++++++++++++------ src/memory/memory-design.md | 40 ++++--- src/tools/builtinTools.ts | 32 +++--- src/tools/memoryTools.ts | 66 +++++------ 8 files changed, 189 insertions(+), 127 deletions(-) diff --git a/src/LLMProviders/intentAnalyzer.ts b/src/LLMProviders/intentAnalyzer.ts index e8d8b915..c1c0b95d 100644 --- a/src/LLMProviders/intentAnalyzer.ts +++ b/src/LLMProviders/intentAnalyzer.ts @@ -13,7 +13,7 @@ import { ToolManager } from "@/tools/toolManager"; import { extractChatHistory } from "@/utils"; import { Vault } from "obsidian"; import { BrevilabsClient } from "./brevilabsClient"; -import { memoryTool } from "@/tools/memoryTools"; +import { updateMemoryTool } from "@/tools/memoryTools"; import { AVAILABLE_TOOLS } from "@/components/chat-components/constants/tools"; type ToolCall = { @@ -136,9 +136,9 @@ export class IntentAnalyzer { const cleanQuery = this.removeAtCommands(originalMessage); processedToolCalls.push({ - tool: memoryTool, + tool: updateMemoryTool, args: { - memoryContent: cleanQuery, + statement: cleanQuery, }, }); } diff --git a/src/constants.ts b/src/constants.ts index 1e44abb2..a796d6af 100644 --- a/src/constants.ts +++ b/src/constants.ts @@ -796,7 +796,7 @@ export const DEFAULT_SETTINGS: CopilotSettings = { "youtubeTranscription", "writeToFile", "replaceInFile", - "memoryTool", + "updateMemory", ], reasoningEffort: DEFAULT_MODEL_SETTING.REASONING_EFFORT, verbosity: DEFAULT_MODEL_SETTING.VERBOSITY, diff --git a/src/integration_tests/composerPrompt.test.ts b/src/integration_tests/composerPrompt.test.ts index f3f4ecc3..b2eebfe1 100644 --- a/src/integration_tests/composerPrompt.test.ts +++ b/src/integration_tests/composerPrompt.test.ts @@ -53,7 +53,7 @@ describe("Composer Instructions - Integration Tests", () => { model: "gemini-2.5-flash-lite", generationConfig: { temperature: 0.1, - maxOutputTokens: 1000, + maxOutputTokens: 5000, }, systemInstruction: DEFAULT_SYSTEM_PROMPT, safetySettings: [ diff --git a/src/memory/UserMemoryManager.test.ts b/src/memory/UserMemoryManager.test.ts index b36fc875..b79eaa6a 100644 --- a/src/memory/UserMemoryManager.test.ts +++ b/src/memory/UserMemoryManager.test.ts @@ -16,7 +16,7 @@ jest.mock("@/utils", () => ({ import { UserMemoryManager } from "./UserMemoryManager"; import { App, TFile, Vault } from "obsidian"; import { ChatMessage } from "@/types/message"; -import { logInfo, logError, logWarn } from "@/logger"; +import { logError, logWarn } from "@/logger"; import { getSettings } from "@/settings/model"; import { ensureFolderExists } from "@/utils"; import { BaseChatModel } from "@langchain/core/language_models/chat_models"; @@ -461,23 +461,20 @@ The conversation covered advanced features and included code examples.`, it("should skip saving when saved memory is disabled", async () => { mockSettings.enableSavedMemory = false; - const result = await userMemoryManager.addSavedMemory("Test memory content"); - - expect(result).toBe(false); - expect(logWarn).toHaveBeenCalledWith( - "[UserMemoryManager] Saved memory is disabled, skipping save" + const result = await userMemoryManager.updateSavedMemory( + "Test memory content", + mockChatModel ); + + expect(result).toEqual({ error: "Saved memory is disabled, skipping save" }); }); it("should skip saving when no content provided", async () => { mockSettings.enableSavedMemory = true; - const result = await userMemoryManager.addSavedMemory(""); + const result = await userMemoryManager.updateSavedMemory("", mockChatModel); - expect(result).toBe(false); - expect(logWarn).toHaveBeenCalledWith( - "[UserMemoryManager] No content provided for saved memory" - ); + expect(result).toEqual({ error: "No content provided for saved memory" }); }); it("should save memory content to Saved Memories file", async () => { @@ -493,8 +490,15 @@ The conversation covered advanced features and included code examples.`, const mockNewFile = createMockTFile("copilot/memory/Saved Memories.md"); mockVault.create.mockResolvedValue(mockNewFile); - const result = await userMemoryManager.addSavedMemory( - "Important user preference: I prefer dark mode" + // Mock LLM merge result content + const llmMergedContent = `- The user prefers concise responses`; + (mockChatModel.invoke as jest.Mock).mockResolvedValue( + new AIMessageChunk({ content: llmMergedContent }) + ); + + const result = await userMemoryManager.updateSavedMemory( + "I prefer concise responses", + mockChatModel ); // Verify folder creation was called @@ -503,14 +507,13 @@ The conversation covered advanced features and included code examples.`, // Verify file creation was called with proper content expect(mockVault.create).toHaveBeenCalledWith( "copilot/memory/Saved Memories.md", - expect.stringContaining("- Important user preference: I prefer dark mode") + expect.stringContaining("- The user prefers concise responses") ); const createdContent = mockVault.create.mock.calls[0][1]; expect(createdContent).not.toContain("**"); - expect(result).toBe(true); - expect(logInfo).toHaveBeenCalledWith("[UserMemoryManager] Saved memory added successfully"); + expect(result).toEqual({ content: llmMergedContent }); }); it("should append to existing Saved Memories file", async () => { @@ -529,7 +532,16 @@ The conversation covered advanced features and included code examples.`, mockVault.getAbstractFileByPath.mockReturnValue(mockMemoryFile); mockVault.read.mockResolvedValue(existingContent); - const result = await userMemoryManager.addSavedMemory("New important information"); + // Mock LLM to return merged full list + const mergedContent = `- Previous memory content\n- Another important fact\n- New important information`; + (mockChatModel.invoke as jest.Mock).mockResolvedValue( + new AIMessageChunk({ content: mergedContent }) + ); + + const result = await userMemoryManager.updateSavedMemory( + "New important information", + mockChatModel + ); // Verify file modification was called with appended content expect(mockVault.modify).toHaveBeenCalledWith( @@ -544,8 +556,7 @@ The conversation covered advanced features and included code examples.`, const modifiedContent = mockVault.modify.mock.calls[0][1]; expect(modifiedContent).not.toContain("**"); - expect(result).toBe(true); - expect(logInfo).toHaveBeenCalledWith("[UserMemoryManager] Saved memory added successfully"); + expect(result).toEqual({ content: mergedContent }); }); it("should handle errors during save operation", async () => { @@ -554,13 +565,9 @@ The conversation covered advanced features and included code examples.`, // Mock ensureFolderExists to reject (ensureFolderExists as jest.Mock).mockRejectedValue(new Error("Folder creation failed")); - const result = await userMemoryManager.addSavedMemory("Test content"); + const result = await userMemoryManager.updateSavedMemory("Test content", mockChatModel); - expect(result).toBe(false); - expect(logError).toHaveBeenCalledWith( - "[UserMemoryManager] Error saving memory:", - expect.any(Error) - ); + expect(result).toEqual({ error: "Error saving memory: Folder creation failed" }); }); }); }); diff --git a/src/memory/UserMemoryManager.ts b/src/memory/UserMemoryManager.ts index 538afe18..04e514a2 100644 --- a/src/memory/UserMemoryManager.ts +++ b/src/memory/UserMemoryManager.ts @@ -80,34 +80,37 @@ export class UserMemoryManager { /** * Adds a saved memory that the user explicitly asked to remember */ - async addSavedMemory(memoryContent: string): Promise { + async updateSavedMemory( + query: string, + chatModel: BaseChatModel + ): Promise<{ content?: string; error?: string }> { const settings = getSettings(); // Only proceed if saved memory is enabled if (!settings.enableSavedMemory) { - logWarn("[UserMemoryManager] Saved memory is disabled, skipping save"); - return false; + return { error: "Saved memory is disabled, skipping save" }; } - if (!memoryContent || memoryContent.trim() === "") { - logWarn("[UserMemoryManager] No content provided for saved memory"); - return false; + if (!query || query.trim() === "") { + return { error: "No content provided for saved memory" }; + } + + if (!chatModel) { + return { error: "No chat model available, skipping save" }; } try { // Ensure user memory folder exists await this.ensureMemoryFolderExists(); - // Create memory entry as a bullet point without timestamp metadata - const memoryEntry = `- ${memoryContent.trim()}`; - // Add to saved memories file - await this.addToSavedMemoryFile(this.getSavedMemoriesFilePath(), memoryEntry); - - logInfo("[UserMemoryManager] Saved memory added successfully"); - return true; + const result = await this.updateSavedMemoryFile( + this.getSavedMemoriesFilePath(), + query, + chatModel + ); + return result; } catch (error) { - logError("[UserMemoryManager] Error saving memory:", error); - return false; + return { error: "Error saving memory: " + error.message }; } } @@ -237,30 +240,74 @@ export class UserMemoryManager { } /** - * Save content to saved memory file by appending new entry (no max limit) + * Update content to saved memory file with ChatModel */ - private async addToSavedMemoryFile(filePath: string, newMemoryEntry: string): Promise { + private async updateSavedMemoryFile( + filePath: string, + query: string, + chatModel: BaseChatModel + ): Promise<{ content?: string; error?: string }> { const existingFile = this.app.vault.getAbstractFileByPath(filePath); + // Load existing saved memories (may be empty) + const existingContent = + existingFile instanceof TFile ? await this.app.vault.read(existingFile) : ""; + + // Fast path: if no model available for some reason, append a bullet safely + if (!chatModel) { + return { error: "No chat model available, skipping memory update" }; + } + + // Ask the model to produce a deduplicated/merged/conflict-free full list + const systemPrompt = `You maintain a user's long-term personal memory list as concise bullet points. + + You task is to update the user's memory list with the new statement. +Rules: +- Keep only stable, evergreen facts or preferences that will help future conversations. +- Remove duplicates and near-duplicates by merging them into one concise statement. +- If the new statement conflicts with older ones, keep the most recent truth and remove obsolete/conflicting entries. +- Prefer short, specific, and unambiguous phrasing. +- Preserve the language used in the input memories. +- Output only the memory content with each as a bullet point. + + +# OUTPUT FORMAT +Return the updated memory list with each as a bullet point. +- memory item 1 +- memory item 2 +- memory item 3 +... +`; + + const humanPrompt = ` +${existingContent.trim()} + + + +${query.trim()} + +`; + + const messages_llm = [new SystemMessage(systemPrompt), new HumanMessage(humanPrompt)]; + + let updatedContent: string | null = null; + try { + const response = await chatModel.invoke(messages_llm); + updatedContent = response.text ?? ""; + } catch (error) { + return { error: "LLM call failed while updating saved memories: " + error.message }; + } + if (updatedContent == null || updatedContent.trim() === "") { + return { error: "Empty content returned from LLM" }; + } + if (existingFile instanceof TFile) { - // Read existing content and append new entry - const fileContent = await this.app.vault.read(existingFile); - - let updatedContent: string; - if (fileContent.trim() === "") { - // Create new file with the entry - updatedContent = `${newMemoryEntry}\n`; - } else { - // Append to existing content - updatedContent = `${fileContent.trimEnd()}\n${newMemoryEntry}\n`; - } - await this.app.vault.modify(existingFile, updatedContent); } else { - // Create new file - const initialContent = `${newMemoryEntry}\n`; - await this.app.vault.create(filePath, initialContent); + await this.app.vault.create(filePath, updatedContent); } + + return { content: updatedContent }; } /** diff --git a/src/memory/memory-design.md b/src/memory/memory-design.md index 151d7d60..d84fdfae 100644 --- a/src/memory/memory-design.md +++ b/src/memory/memory-design.md @@ -27,21 +27,22 @@ graph TD K --> L[Recent Memory Update Complete] %% Saved Memory Flow - M[User Explicitly Asks to Remember] --> N[memoryTool called] + M[User Explicitly Asks to Remember] --> N[updateMemoryTool called] N --> O{enableSavedMemory?} - O -->|Yes| P[Extract Memory Content] + O -->|Yes| P[Ensure Memory Folder Exists] O -->|No| Z2[Skip Saved Memory] - P --> Q[Format as Bullet Point] - Q --> R[Append to Saved Memories.md] - R --> S[Saved Memory Complete] + P --> Q[Load Saved Memories] + Q --> R[Generate Merged Bullet List via LLM] + R --> S[Overwrite Saved Memories.md] + S --> T_saved[Saved Memory Complete] %% Memory Retrieval - T[LLM Request] --> U[getUserMemoryPrompt called] - U --> V[Load Recent Conversations] - U --> W[Load Saved Memories] - V --> X[Combine Memory Sections] - W --> X - X --> Y[Return Memory Context for LLM] + U[LLM Request] --> V[getUserMemoryPrompt called] + V --> W[Load Recent Conversations] + V --> X[Load Saved Memories] + W --> Y[Combine Memory Sections] + X --> Y + Y --> Z[Return Memory Context for LLM] ``` ## Key Points @@ -57,7 +58,7 @@ graph TD **Saved Memories:** -- **Trigger**: When user explicitly asks to remember something during chat and `memoryTool` is called +- **Trigger**: When user explicitly asks to remember something during chat and `updateMemoryTool` is called - **Guard**: Only if `enableSavedMemory` setting is enabled - **Immediate**: Saves directly to file when invoked - **User notification**: Shows success/failure notice to user @@ -76,19 +77,23 @@ graph TD ### Saved Memories: -- **When**: User explicitly asks to remember something via `memoryTool` +- **When**: User explicitly asks to remember something via `updateMemoryTool` - **Retention policy**: No limit - memories persist until manually deleted - **Content**: - Raw user-specified information to remember - Personal facts, preferences, important decisions, or context -- **Format**: Simple bullet-point list in markdown + - LLM-normalized bullet list that deduplicates and merges related statements + - Conflict resolution keeps the most recent truth and removes obsolete entries + - Preserves the language of the source statements +- **Format**: Bullet-point list in markdown maintained by the LLM - **Storage**: `Saved Memories.md` in the configured memory folder -- **File handling**: Appends new memories to existing file, creates if doesn't exist +- **File handling**: Loads existing memories, asks the LLM to produce a fully merged list, and overwrites the file (creates if it doesn't exist) ### Message Processing Features: - **Conversation Titles**: LLM-extracted titles that capture main user intent (2-8 words) - **Conversation Summaries**: AI-generated 2-3 sentence summaries with key details and conclusions +- **Saved Memory Normalization**: LLM produces a concise, deduplicated bullet list for long-term memories while resolving conflicts - **Memory Tool Integration**: Explicit memory saving via natural language commands - **Robust JSON Parsing**: Handles JSON responses wrapped in code blocks (common with Gemini and other LLMs) with fallback to plain JSON extraction - **Language-aware**: Uses the same language as the conversation for titles and summaries @@ -114,6 +119,7 @@ graph TD - Fallback mechanisms for AI processing failures - Graceful handling of missing files and folders - User notifications for saved memory operations (success/failure) +- Saved memory writes fall back with descriptive error messages if LLM responses are empty or unavailable - Robust JSON extraction from LLM responses with multiple parsing strategies (code blocks, inline JSON, fallback to raw content) - Race condition protection for concurrent memory updates @@ -133,10 +139,10 @@ The memory system behaves differently depending on which chat mode is active: ### Agent Mode (Autonomous Agent) - **Memory Retrieval**: ✅ Full access to both Recent Conversations and Saved Memories via system prompt -- **Memory Saving**: ✅ Direct access to `memoryTool` through XML-based tool calling +- **Memory Saving**: ✅ Direct access to `updateMemoryTool` through XML-based tool calling - **Behavior**: - AI autonomously decides when to save memories based on user requests - - Uses XML format: `memoryTool...` + - Uses XML format: `updateMemoryTool...` - Can reason step-by-step about whether something should be remembered - Shows user notifications when memories are saved - Access controlled by tool enablement settings (`autonomousAgentEnabledToolIds`) diff --git a/src/tools/builtinTools.ts b/src/tools/builtinTools.ts index a6fa445f..b33eff69 100644 --- a/src/tools/builtinTools.ts +++ b/src/tools/builtinTools.ts @@ -3,7 +3,7 @@ import { Vault } from "obsidian"; import { replaceInFileTool, writeToFileTool } from "./ComposerTools"; import { createGetFileTreeTool } from "./FileTreeTools"; import { createGetTagListTool } from "./TagTools"; -import { memoryTool } from "./memoryTools"; +import { updateMemoryTool } from "./memoryTools"; import { readNoteTool } from "./NoteTools"; import { localSearchTool, webSearchTool } from "./SearchTools"; import { @@ -373,24 +373,24 @@ export function registerMemoryTool(): void { const registry = ToolRegistry.getInstance(); registry.register({ - tool: memoryTool, + tool: updateMemoryTool, metadata: { - id: "memoryTool", - displayName: "Save Memory", - description: "Save information to user memory when explicitly asked to remember something", + id: "updateMemory", + displayName: "Update Memory", + description: + "Save information to user memory when the user explicitly asks to remember something or update the memory", category: "memory", copilotCommands: ["@memory"], isAlwaysEnabled: true, - customPromptInstructions: `For memoryTool: -- Use ONLY when the user explicitly asks you to remember something (phrases like "remember that", "don't forget", etc.) -- DO NOT use for general information - only for personal facts, preferences, or specific things the user wants stored -- Extract the key information to remember from the user's message + customPromptInstructions: `For updateMemory: + - Use this tool to update the memory when the user explicitly asks to update the memory + - DO NOT use for general information - only for personal facts, preferences, or specific things the user wants stored -Example usage: - -memoryTool -User's favorite programming language is Python and they prefer functional programming style -`, + Example usage: + + updateMemory + I'm studying Japanese and I'm preparing for JLPT N3 + `, }, }); } @@ -409,13 +409,13 @@ export function initializeBuiltinTools(vault?: Vault): void { // Only reinitialize if tools have changed or vault/memory status has changed const hasFileTree = registry.getToolMetadata("getFileTree") !== undefined; const shouldHaveFileTree = vault !== undefined; - const hasMemoryTool = registry.getToolMetadata("memoryTool") !== undefined; + const hasUpdateMemoryTool = registry.getToolMetadata("updateMemory") !== undefined; const shouldHaveMemoryTool = settings.enableSavedMemory; if ( registry.getAllTools().length === 0 || hasFileTree !== shouldHaveFileTree || - hasMemoryTool !== shouldHaveMemoryTool + hasUpdateMemoryTool !== shouldHaveMemoryTool ) { // Clear any existing tools registry.clear(); diff --git a/src/tools/memoryTools.ts b/src/tools/memoryTools.ts index d4513906..775b7a07 100644 --- a/src/tools/memoryTools.ts +++ b/src/tools/memoryTools.ts @@ -2,49 +2,51 @@ import { z } from "zod"; import { createTool, SimpleTool } from "./SimpleTool"; import { UserMemoryManager } from "@/memory/UserMemoryManager"; import { logError } from "@/logger"; +import ChatModelManager from "@/LLMProviders/chatModelManager"; -// Define Zod schema for memoryTool +// Define Zod schema for updateMemoryTool const memorySchema = z.object({ - memoryContent: z + statement: z .string() .min(1) - .describe( - "The content to save to user's memory (information the user explicitly asked to remember)" - ), + .describe("The user statement for explicitly updating saved memories"), }); /** * Memory tool for saving information that the user explicitly asks the assistant to remember */ -export const memoryTool: SimpleTool = - createTool({ - name: "memoryTool", - description: - "Save information to user memory when the user explicitly asks to remember something", - schema: memorySchema, - handler: async ({ memoryContent }) => { - try { - const memoryManager = new UserMemoryManager(app); - const success = await memoryManager.addSavedMemory(memoryContent); - if (!success) { - return { - success: false, - message: `Failed to save memory: ${memoryContent}`, - }; - } - const memoryFilePath = memoryManager.getSavedMemoriesFilePath(); - - return { - success: true, - message: `Memory saved successfully into ${memoryFilePath}: ${memoryContent}`, - }; - } catch (error) { - logError("[memoryTool] Error saving memory:", error); +export const updateMemoryTool: SimpleTool< + typeof memorySchema, + { success: boolean; message: string } +> = createTool({ + name: "updateMemory", + description: "Update the user memory when the user explicitly asks to update the memory", + schema: memorySchema, + handler: async ({ statement }) => { + try { + const memoryManager = new UserMemoryManager(app); + const chatModel = ChatModelManager.getInstance().getChatModel(); + const result = await memoryManager.updateSavedMemory(statement, chatModel); + if (result.error) { return { success: false, - message: `Failed to save memory: ${error.message}`, + message: result.error, }; } - }, - }); + + const memoryFilePath = memoryManager.getSavedMemoriesFilePath(); + return { + success: true, + message: `Memory updated successfully into ${memoryFilePath}: ${result.content}`, + }; + } catch (error) { + logError("[updateMemoryTool] Error updating memory:", error); + + return { + success: false, + message: `Failed to save memory: ${error.message}`, + }; + } + }, +});