Add CRUD to Saved Memory (#1929)

This commit is contained in:
Wenzheng Jiang 2025-10-23 15:33:35 +09:00 committed by GitHub
parent b90e80f509
commit 40d1badada
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
8 changed files with 189 additions and 127 deletions

View file

@ -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,
},
});
}

View file

@ -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,

View file

@ -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: [

View file

@ -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" });
});
});
});

View file

@ -80,34 +80,37 @@ export class UserMemoryManager {
/**
* Adds a saved memory that the user explicitly asked to remember
*/
async addSavedMemory(memoryContent: string): Promise<boolean> {
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<void> {
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 = `<current_memories>
${existingContent.trim()}
</current_memories>
<new_statement>
${query.trim()}
</new_statement>
`;
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 };
}
/**

View file

@ -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: `<use_tool><name>memoryTool</name><memoryContent>...</memoryContent></use_tool>`
- Uses XML format: `<use_tool><name>updateMemoryTool</name><memoryContent>...</memoryContent></use_tool>`
- 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`)

View file

@ -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:
<use_tool>
<name>memoryTool</name>
<memoryContent>User's favorite programming language is Python and they prefer functional programming style</memoryContent>
</use_tool>`,
Example usage:
<use_tool>
<name>updateMemory</name>
<statement>I'm studying Japanese and I'm preparing for JLPT N3</statement>
</use_tool>`,
},
});
}
@ -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();

View file

@ -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<typeof memorySchema, { success: boolean; message: string }> =
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}`,
};
}
},
});