Refactor for a simpiler implementation

This commit is contained in:
wenzhengjiang 2025-09-15 20:02:45 +09:00
parent 2a9ac449c0
commit c836de32c1
6 changed files with 226 additions and 464 deletions

View file

@ -1,6 +1,6 @@
import { getSettings } from "@/settings/model";
import { ChainType } from "@/chainFactory";
import { logError, logInfo } from "@/logger";
import { logInfo } from "@/logger";
import { ChatMessage, MessageContext } from "@/types/message";
import { FileParserManager } from "@/tools/FileParserManager";
import ChainManager from "@/LLMProviders/chainManager";
@ -140,9 +140,6 @@ export class ChatManager {
// Update the processed content
currentRepo.updateProcessedText(messageId, processedContent);
// Create condensed message for user messages
this.createCondensedMessageAsync(message, messageId, displayText, currentRepo);
logInfo(`[ChatManager] Successfully sent message ${messageId}`);
return messageId;
} catch (error) {
@ -367,43 +364,6 @@ export class ChatManager {
return currentRepo.getLLMMessage(id);
}
/**
* Create condensed message asynchronously for user messages (fire and forget)
*/
private createCondensedMessageAsync(
message: ChatMessage,
messageId: string,
displayText: string,
currentRepo: MessageRepository
): void {
if (message.sender === USER_SENDER && this.plugin.userMemoryManager) {
try {
const settings = getSettings();
if (settings.enableMemory) {
const chainManager = this.plugin.projectManager.getCurrentChainManager();
const chatModel = chainManager.chatModelManager.getChatModel();
// Create condensed message asynchronously (fire and forget)
this.plugin.userMemoryManager
.createCondensedMessage(displayText, chatModel)
.then((condensedMessage) => {
if (condensedMessage) {
currentRepo.updateCondensedMessage(messageId, condensedMessage);
logInfo(
`[ChatManager] Created condensed message for ${messageId}: "${condensedMessage}"`
);
}
})
.catch((error) => {
logError(`[ChatManager] Failed to create condensed message for ${messageId}:`, error);
});
}
} catch (error) {
logError(`[ChatManager] Error setting up condensed message creation:`, error);
}
}
}
/**
* Update chain memory with current LLM messages
*/

View file

@ -88,7 +88,6 @@ export class MessageRepository {
id,
displayText: message.message,
processedText: message.originalMessage || message.message,
condensedUserMessage: message.condensedUserMessage,
sender: message.sender,
timestamp,
context: message.context,
@ -149,21 +148,6 @@ export class MessageRepository {
return true;
}
/**
* Update condensed message for a message
*/
updateCondensedMessage(id: string, condensedMessage: string): boolean {
const message = this.messages.find((msg) => msg.id === id);
if (!message) {
logInfo(`[MessageRepository] Message not found for condensed message update: ${id}`);
return false;
}
message.condensedUserMessage = condensedMessage;
logInfo(`[MessageRepository] Updated condensed message for message: ${id}`);
return true;
}
/**
* Delete a message
*/
@ -217,7 +201,6 @@ export class MessageRepository {
id: msg.id,
message: msg.displayText,
originalMessage: msg.displayText,
condensedMessage: msg.condensedUserMessage,
sender: msg.sender,
timestamp: msg.timestamp,
isVisible: true,
@ -240,7 +223,6 @@ export class MessageRepository {
id: msg.id,
message: msg.processedText,
originalMessage: msg.displayText,
condensedUserMessage: msg.condensedUserMessage,
sender: msg.sender,
timestamp: msg.timestamp,
isVisible: false, // LLM messages are not for display
@ -260,7 +242,6 @@ export class MessageRepository {
id: msg.id,
message: msg.processedText,
originalMessage: msg.displayText,
condensedMessage: msg.condensedUserMessage,
sender: msg.sender,
timestamp: msg.timestamp,
isVisible: false,
@ -282,7 +263,6 @@ export class MessageRepository {
id: msg.id,
message: msg.displayText,
originalMessage: msg.displayText,
condensedUserMessage: msg.condensedUserMessage,
sender: msg.sender,
timestamp: msg.timestamp,
isVisible: msg.isVisible,
@ -303,7 +283,6 @@ export class MessageRepository {
id: msg.id || this.generateId(),
displayText: msg.message,
processedText: msg.originalMessage || msg.message,
condensedUserMessage: msg.condensedUserMessage,
sender: msg.sender,
timestamp: msg.timestamp || formatDateTime(new Date()),
context: msg.context,

View file

@ -8,10 +8,6 @@ jest.mock("@/settings/model", () => ({
getSettings: jest.fn(),
}));
jest.mock("@/constants", () => ({
USER_SENDER: "user",
}));
jest.mock("@/utils", () => ({
ensureFolderExists: jest.fn(),
}));
@ -21,7 +17,6 @@ import { App, TFile, Vault } from "obsidian";
import { ChatMessage } from "@/types/message";
import { logInfo, logError } from "@/logger";
import { getSettings } from "@/settings/model";
import { USER_SENDER } from "@/constants";
import { ensureFolderExists } from "@/utils";
import { BaseChatModel } from "@langchain/core/language_models/chat_models";
import { AIMessageChunk } from "@langchain/core/messages";
@ -83,14 +78,13 @@ describe("UserMemoryManager", () => {
const createMockMessage = (
id: string,
message: string,
sender: string = USER_SENDER
sender: string = "user"
): ChatMessage => ({
id,
message,
sender,
timestamp: null,
isVisible: true,
condensedUserMessage: `Condensed: ${message}`,
});
it("should skip memory update when memory is disabled", () => {
@ -112,43 +106,33 @@ describe("UserMemoryManager", () => {
);
});
it("should complete end-to-end memory update with existing file", async () => {
// Setup: Create test messages simulating a real conversation with enough content for key conclusions
it("should complete end-to-end memory update with new simple format", async () => {
// Setup: Create test messages simulating a real conversation
const messages = [
createMockMessage(
"1",
"How do I create a daily note template in Obsidian with automatic date formatting? I want to have a template that automatically inserts today's date and creates sections for tasks, notes, and reflections."
"How do I create a daily note template in Obsidian with automatic date formatting?"
),
createMockMessage(
"2",
"I can help you create a daily note template with automatic date formatting. Here's how you can set this up: First, create a template file in your templates folder with variables like {{date}} for automatic date insertion. You can use format strings to customize the date display. For the sections, you can create headers for Tasks, Notes, and Reflections that will be included every time you create a new daily note.",
"I can help you create a daily note template with automatic date formatting...",
"ai"
),
createMockMessage(
"3",
"That's perfect! Can you also show me how to add tags automatically to these daily notes? I'd like them to be tagged with #daily-note and maybe the current month."
),
createMockMessage(
"4",
"Certainly! You can add automatic tags to your template by including tag syntax directly in the template file. Add #daily-note and #{{date:MMMM}} to automatically tag with the current month. This way every daily note will be consistently tagged and easy to find later.",
"ai"
"That's perfect! Can you also show me how to add tags automatically?"
),
createMockMessage("4", "Certainly! You can add automatic tags to your template...", "ai"),
];
// Mock existing memory file with previous conversations
const existingMemoryContent = `## Previous Conversation
**Time:** 2024-01-01T09:00:00Z
**User Messages:**
- Asked about plugin installation
**Key Conclusions:**
- Plugins enhance Obsidian functionality
**Summary:** User asked about plugin installation and learned that plugins enhance Obsidian functionality.
## Another Conversation
**Time:** 2024-01-01T10:00:00Z
**User Messages:**
- Inquired about linking notes
**Key Conclusions:**
- Backlinks create knowledge connections
**Summary:** User inquired about linking notes and discovered that backlinks create knowledge connections.
`;
const mockMemoryFile = createMockTFile("copilot/memory/Recent Conversations.md");
@ -162,15 +146,15 @@ describe("UserMemoryManager", () => {
// Mock reading existing file content
mockVault.read.mockResolvedValue(existingMemoryContent);
// Mock LLM responses for conversation processing
const mockTitleResponse = new AIMessageChunk({ content: "Daily Note Template Setup" });
const mockConclusionResponse = new AIMessageChunk({
content:
"- Templates can automatically insert dates and metadata\n- Tags can be added through template variables",
// Mock LLM response for title and summary
const mockResponse = new AIMessageChunk({
content: JSON.stringify({
title: "Daily Note Template Setup",
summary:
"User asked about creating daily note templates with automatic date formatting and tagging. Learned how to use template variables for dates and automatic tag insertion.",
}),
});
mockChatModel.invoke
.mockResolvedValueOnce(mockTitleResponse)
.mockResolvedValueOnce(mockConclusionResponse);
mockChatModel.invoke.mockResolvedValueOnce(mockResponse);
// Execute the updateMemory function directly to ensure proper awaiting
await (userMemoryManager as any).updateMemory(messages, mockChatModel);
@ -179,170 +163,183 @@ describe("UserMemoryManager", () => {
const modifyCall = mockVault.modify.mock.calls[0];
const actualContent = modifyCall[1];
// Check the full memory content structure as a whole - exact line-by-line verification
const expectedContentStructure = [
// Previous conversations should be preserved (no empty lines between conversations)
"## Previous Conversation",
"**Time:** 2024-01-01T09:00:00Z",
"**User Messages:**",
"- Asked about plugin installation",
"**Key Conclusions:**",
"- Plugins enhance Obsidian functionality",
"## Another Conversation",
"**Time:** 2024-01-01T10:00:00Z",
"**User Messages:**",
"- Inquired about linking notes",
"**Key Conclusions:**",
"- Backlinks create knowledge connections",
// New conversation should be added
"## Daily Note Template Setup",
// Dynamic timestamp pattern
/\*\*Time:\*\* \d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}Z/,
"**User Messages:**",
"- Condensed: How do I create a daily note template in Obsidian with automatic date formatting? I want to have a template that automatically inserts today's date and creates sections for tasks, notes, and reflections.",
"- Condensed: That's perfect! Can you also show me how to add tags automatically to these daily notes? I'd like them to be tagged with #daily-note and maybe the current month.",
"**Key Conclusions:**",
"- Templates can automatically insert dates and metadata",
"- Tags can be added through template variables",
"", // Empty line at end
"", // Second empty line at end
];
// Check that the new format is used
expect(actualContent).toContain("## Daily Note Template Setup");
expect(actualContent).toMatch(/\*\*Time:\*\* \d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}Z/);
expect(actualContent).toContain(
"**Summary:** User asked about creating daily note templates"
);
// Verify the complete content structure line by line
const contentLines = actualContent.split("\n");
// Verify previous conversations are preserved
expect(actualContent).toContain("## Previous Conversation");
expect(actualContent).toContain("## Another Conversation");
// Verify we have the expected number of lines
expect(contentLines).toHaveLength(expectedContentStructure.length);
// Verify that the title and summary were extracted via single LLM call
expect(mockChatModel.invoke).toHaveBeenCalledTimes(1);
// Verify each line matches the expected structure
for (let i = 0; i < expectedContentStructure.length; i++) {
const expectedItem = expectedContentStructure[i];
const actualLine = contentLines[i];
if (expectedItem instanceof RegExp) {
// Handle regex patterns for dynamic content like timestamps
expect(actualLine).toMatch(expectedItem);
} else {
// Handle exact string matches
expect(actualLine).toBe(expectedItem);
}
}
// Verify all conversations have the required sections using pattern matching
expect(actualContent.match(/## [^#\n]+/g)).toHaveLength(3); // 3 conversations
expect(actualContent.match(/\*\*Time:\*\*/g)).toHaveLength(3); // Each has a timestamp
expect(actualContent.match(/\*\*User Messages:\*\*/g)).toHaveLength(3); // Each has user messages
expect(actualContent.match(/\*\*Key Conclusions:\*\*/g)).toHaveLength(3); // Each has key conclusions
// Verify that the conversation title and key conclusions were extracted via LLM
expect(mockChatModel.invoke).toHaveBeenCalledTimes(2);
// Verify title extraction call
expect(mockChatModel.invoke).toHaveBeenNthCalledWith(
1,
// Verify the LLM call format
expect(mockChatModel.invoke).toHaveBeenCalledWith(
expect.arrayContaining([
expect.objectContaining({
content: expect.stringContaining("Generate a title for the conversation"),
content: expect.stringContaining("generate both a title and a summary"),
}),
])
);
// Verify key conclusions extraction call
expect(mockChatModel.invoke).toHaveBeenNthCalledWith(
2,
expect.arrayContaining([
expect.objectContaining({
content: expect.stringContaining("extract key conclusions"),
}),
])
);
// Verify no folder creation was needed since folder already exists
expect(mockVault.createFolder).not.toHaveBeenCalled();
// Verify no new file creation was needed since file already exists
expect(mockVault.create).not.toHaveBeenCalled();
});
it("should handle missing condensed messages by creating them inline (race condition fix)", async () => {
// Setup: Create messages without condensed messages to simulate race condition
const messages = [
createMockMessage("1", "How do I create daily notes?"),
createMockMessage("2", "AI response about daily notes", "ai"),
createMockMessage("3", "What about templates?"),
];
// Remove condensed messages to simulate race condition
delete messages[0].condensedUserMessage;
delete messages[2].condensedUserMessage;
it("should handle LLM JSON parsing errors gracefully", async () => {
const messages = [createMockMessage("1", "test message")];
const mockMemoryFile = createMockTFile("copilot/memory/Recent Conversations.md");
const existingContent = "";
// Mock ensureFolderExists and file operations
(ensureFolderExists as jest.Mock).mockResolvedValue(undefined);
mockVault.getAbstractFileByPath.mockReturnValue(mockMemoryFile);
mockVault.read.mockResolvedValue(existingContent);
mockVault.read.mockResolvedValue("");
// Mock LLM responses
const mockTitleResponse = new AIMessageChunk({ content: "Daily Notes Help" });
const mockConclusionResponse = new AIMessageChunk({
content: "- Daily notes can be automated with templates",
});
// Mock LLM response with invalid JSON
const mockResponse = new AIMessageChunk({ content: "Invalid JSON response" });
mockChatModel.invoke.mockResolvedValueOnce(mockResponse);
// Setup condensed message creation (called inline for missing entries)
const condensedMessage1 = "Asked about creating daily notes";
const condensedMessage2 = "Inquired about template usage";
// Mock createCondensedMessage to return condensed versions
const createCondensedMessageSpy = jest.spyOn(
userMemoryManager as any,
"createCondensedMessage"
);
createCondensedMessageSpy.mockImplementation(async (message, model) => {
if (message === "How do I create daily notes?") {
return condensedMessage1;
}
if (message === "What about templates?") {
return condensedMessage2;
}
return null;
});
mockChatModel.invoke
.mockResolvedValueOnce(mockTitleResponse)
.mockResolvedValueOnce(mockConclusionResponse);
// Execute the updateMemory function
await (userMemoryManager as any).updateMemory(messages, mockChatModel);
// Verify condensed messages were created inline for missing entries
expect(createCondensedMessageSpy).toHaveBeenCalledTimes(2);
expect(createCondensedMessageSpy).toHaveBeenCalledWith(
"How do I create daily notes?",
mockChatModel
);
expect(createCondensedMessageSpy).toHaveBeenCalledWith(
"What about templates?",
mockChatModel
);
// Verify the final content includes the inline-created condensed messages
// Should still create a conversation entry with fallback values
const modifyCall = mockVault.modify.mock.calls[0];
const actualContent = modifyCall[1];
expect(actualContent).toContain("Asked about creating daily notes");
expect(actualContent).toContain("Inquired about template usage");
expect(actualContent).toContain("## Untitled Conversation");
expect(actualContent).toContain("**Summary:** Summary generation failed");
expect(logError).toHaveBeenCalledWith(
"[UserMemoryManager] Failed to parse LLM response as JSON:",
expect.any(Error)
);
});
createCondensedMessageSpy.mockRestore();
it("should handle JSON wrapped in code blocks from Gemini", async () => {
const messages = [createMockMessage("1", "test message")];
const mockMemoryFile = createMockTFile("copilot/memory/Recent Conversations.md");
(ensureFolderExists as jest.Mock).mockResolvedValue(undefined);
mockVault.getAbstractFileByPath.mockReturnValue(mockMemoryFile);
mockVault.read.mockResolvedValue("");
// Mock LLM response with JSON wrapped in code blocks (typical Gemini behavior)
const mockResponse = new AIMessageChunk({
content: `Here's the title and summary for the conversation:
\`\`\`json
{
"title": "Code Block Test",
"summary": "This tests JSON extraction from code blocks."
}
\`\`\``,
});
mockChatModel.invoke.mockResolvedValueOnce(mockResponse);
await (userMemoryManager as any).updateMemory(messages, mockChatModel);
// Should successfully extract JSON from code block
const modifyCall = mockVault.modify.mock.calls[0];
const actualContent = modifyCall[1];
expect(actualContent).toContain("## Code Block Test");
expect(actualContent).toContain("**Summary:** This tests JSON extraction from code blocks.");
});
it("should handle JSON wrapped in unmarked code blocks", async () => {
const messages = [createMockMessage("1", "test message")];
const mockMemoryFile = createMockTFile("copilot/memory/Recent Conversations.md");
(ensureFolderExists as jest.Mock).mockResolvedValue(undefined);
mockVault.getAbstractFileByPath.mockReturnValue(mockMemoryFile);
mockVault.read.mockResolvedValue("");
// Mock LLM response with JSON in unmarked code blocks
const mockResponse = new AIMessageChunk({
content: `\`\`\`
{
"title": "Unmarked Block Test",
"summary": "This tests JSON extraction from unmarked code blocks."
}
\`\`\``,
});
mockChatModel.invoke.mockResolvedValueOnce(mockResponse);
await (userMemoryManager as any).updateMemory(messages, mockChatModel);
// Should successfully extract JSON from unmarked code block
const modifyCall = mockVault.modify.mock.calls[0];
const actualContent = modifyCall[1];
expect(actualContent).toContain("## Unmarked Block Test");
expect(actualContent).toContain(
"**Summary:** This tests JSON extraction from unmarked code blocks."
);
});
});
describe("extractJsonFromResponse", () => {
it("should extract JSON from markdown code blocks with json language tag", () => {
const content = `Here's the response:
\`\`\`json
{
"title": "Test Title",
"summary": "Test Summary"
}
\`\`\`
That's the JSON data.`;
const result = (userMemoryManager as any).extractJsonFromResponse(content);
expect(result).toBe('{\n "title": "Test Title",\n "summary": "Test Summary"\n}');
});
it("should extract JSON from unmarked code blocks", () => {
const content = `\`\`\`
{
"title": "Unmarked Block",
"summary": "No language specified"
}
\`\`\``;
const result = (userMemoryManager as any).extractJsonFromResponse(content);
expect(result).toBe(
'{\n "title": "Unmarked Block",\n "summary": "No language specified"\n}'
);
});
it("should extract JSON object when no code blocks present", () => {
const content = `Some text before {"title": "Inline JSON", "summary": "Direct JSON"} and after`;
const result = (userMemoryManager as any).extractJsonFromResponse(content);
expect(result).toBe('{"title": "Inline JSON", "summary": "Direct JSON"}');
});
it("should return original content when no JSON patterns found", () => {
const content = "No JSON here, just plain text";
const result = (userMemoryManager as any).extractJsonFromResponse(content);
expect(result).toBe(content);
});
it("should handle multiline JSON in code blocks", () => {
const content = `\`\`\`json
{
"title": "Multi-line Test",
"summary": "This is a test with\\nmultiple lines and special characters: äöü"
}
\`\`\``;
const result = (userMemoryManager as any).extractJsonFromResponse(content);
expect(result).toContain('"title": "Multi-line Test"');
expect(result).toContain("special characters: äöü");
});
});
describe("getUserMemoryPrompt", () => {
it("should return memory prompt when recent conversations exist", async () => {
const mockFile = createMockTFile("copilot/memory/Recent Conversations.md");
const mockContent = "## Test Conversation\n**Time:** 2024-01-01T10:00:00Z\n";
const mockContent =
"## Test Conversation\n**Time:** 2024-01-01T10:00:00Z\n**Summary:** Test summary";
mockVault.getAbstractFileByPath.mockReturnValue(mockFile);
mockVault.read.mockResolvedValue(mockContent);

View file

@ -1,7 +1,6 @@
import { App, TFile } from "obsidian";
import { ChatMessage } from "@/types/message";
import { logInfo, logError, logWarn } from "@/logger";
import { USER_SENDER } from "@/constants";
import { logInfo, logError } from "@/logger";
import { getSettings } from "@/settings/model";
import { ensureFolderExists } from "@/utils";
import { BaseChatModel } from "@langchain/core/language_models/chat_models";
@ -10,9 +9,7 @@ import { HumanMessage, SystemMessage } from "@langchain/core/messages";
/**
* User Memory Management Class
*
* Instance-based methods for building and managing user memory based on conversations.
* The UserMemoryManager has methods to add recent conversations to memory
* which can then be used to provide recent conversation context for LLM responses.
* Simple memory manager that creates conversation summaries for recent chat history.
*/
export class UserMemoryManager {
private app: App;
@ -23,18 +20,6 @@ export class UserMemoryManager {
this.app = app;
}
/**
* Check if a message is a user message with valid condensed content
*/
private hasValidCondensedUserMessage(message: ChatMessage): boolean {
return (
message.sender === USER_SENDER &&
!!message.condensedUserMessage &&
typeof message.condensedUserMessage === "string" &&
message.condensedUserMessage.trim().length > 0
);
}
/**
* Load memory data from files into class fields
*/
@ -76,97 +61,6 @@ export class UserMemoryManager {
});
}
/**
* Create a condensed version of a user message for memory purposes.
* Optimized for Obsidian note-taking context and knowledge management workflows.
*
* @param userMessage - The original user message to condense
* @param chatModel - The chat model to use for condensing (optional)
* @returns Promise<string | null> - The condensed message or null if failed/unnecessary
*
* Features:
* - Skips condensing for very short messages or simple commands
* - Validates that condensed message is actually shorter than original
* - Provides fallback truncation if AI condensing fails
* - Optimized prompts for Obsidian-specific use cases
*/
async createCondensedMessage(
userMessage: string,
chatModel?: BaseChatModel
): Promise<string | null> {
if (!chatModel) {
logError("[UserMemoryManager] No chat model available for condensed message creation");
return null;
}
// Remove newlines and other formatting
const formattedMessage = userMessage.replace(/\n/g, " ").replace(/\\n/g, " ").trim();
const trimmedMessage = formattedMessage.trim();
if (!trimmedMessage) {
return null;
}
const systemPrompt = `Your task is to condense user messages into concise one-line summaries while preserving user intent and important details.
The condensed message will be used as part of the recent conversation content for memory purposes.
CRITICAL RULES:
1. Keep it to ONE sentence maximum
2. Preserve the user's core intent and request
3. Include important details like note names, tags, search queries, or Obsidian features mentioned
4. Maintain the meaning and specificity of the original message
5. Use clear, direct language
6. Prioritize Obsidian-specific features (links, tags, graphs, plugins, etc.)
# OUTPUT FORMAT
* Return only the condensed message as plain text, no quotes or additional formatting.
* Use the same language as the original message.`;
const humanPrompt = `<user_message>
${trimmedMessage}
</user_message>
Condense the user message into a single concise sentence while preserving intent and important details`;
const messages_llm = [new SystemMessage(systemPrompt), new HumanMessage(humanPrompt)];
try {
const response = await chatModel.invoke(messages_llm);
if (!response || !response.content) {
logError("[UserMemoryManager] Empty response from chat model for condensed message");
return null;
}
const condensed = response.content.toString().trim();
// Validate the condensed message
if (!condensed) {
logError("[UserMemoryManager] Chat model returned empty condensed message");
return null;
}
// Ensure the condensed message is actually shorter than the original
if (condensed.length >= trimmedMessage.length) {
logInfo("[UserMemoryManager] Condensed message not shorter than original, using original");
return trimmedMessage;
}
// Remove any quotes or formatting that might have been added
const cleanedCondensed = condensed.replace(/^["']|["']$/g, "").trim();
return cleanedCondensed || null;
} catch (error) {
logError("[UserMemoryManager] Failed to create condensed message:", error);
// Fallback: return a truncated version of the original message if it's too long
if (trimmedMessage.length > 100) {
const fallback = trimmedMessage.substring(0, 97) + "...";
logInfo("[UserMemoryManager] Using fallback truncated message");
return fallback;
}
return null;
}
}
/**
* Get user memory prompt
*/
@ -194,55 +88,12 @@ Condense the user message into a single concise sentence while preserving intent
messages: ChatMessage[],
chatModel: BaseChatModel
): Promise<string> {
const conversationTitle = await this.extractConversationTitle(messages, chatModel);
const { title, summary } = await this.extractTitleAndSummary(messages, chatModel);
const timestamp = new Date().toISOString().split(".")[0] + "Z"; // Remove milliseconds but keep Z for UTC
// Process user messages and ensure condensed messages are available
const userMessages = messages.filter((message) => message.sender === USER_SENDER);
const userMessageTexts: string[] = [];
for (const message of userMessages) {
let condensedText = message.condensedUserMessage;
// If condensed message is missing or invalid, create it inline to handle race condition
if (
!condensedText ||
typeof condensedText !== "string" ||
condensedText.trim().length === 0
) {
try {
const newCondensedText = await this.createCondensedMessage(message.message, chatModel);
if (newCondensedText) {
condensedText = newCondensedText;
logWarn(
`[UserMemoryManager] Created inline condensed message for missing entry: "${condensedText}"`
);
}
} catch (error) {
logError(
`[UserMemoryManager] Failed to create inline condensed message for "${message.message}":`,
error
);
// Continue processing other messages even if one fails
}
}
// Only include if we have valid condensed text
if (condensedText && condensedText.trim().length > 0) {
userMessageTexts.push(`- ${condensedText}`);
}
}
// Generate key conclusions if conversation is substantial enough
const keyConclusionsText = await this.extractKeyConclusion(messages, chatModel);
let section = `## ${conversationTitle}\n`;
let section = `## ${title}\n`;
section += `**Time:** ${timestamp}\n`;
section += `**User Messages:**\n${userMessageTexts.join("\n")}\n`;
if (keyConclusionsText) {
section += `**Key Conclusions:**\n${keyConclusionsText}\n`;
}
section += `**Summary:** ${summary}\n`;
return section;
}
@ -272,11 +123,9 @@ Condense the user message into a single concise sentence while preserving intent
return;
}
// 1. Always extract and save conversation summary to recent conversations
// Extract and save conversation summary to recent conversations
const conversationSection = await this.createConversationSection(messages, chatModel);
await this.addToMemoryFile(this.getRecentConversationFilePath(), conversationSection);
// User insights functionality removed - only maintain recent conversations
} catch (error) {
logError("[UserMemoryManager] Error analyzing chat messages for user memory:", error);
} finally {
@ -375,96 +224,81 @@ Condense the user message into a single concise sentence while preserving intent
}
/**
* Extract key conclusions from conversation if it contains important insights
* Extract JSON content from LLM response, handling cases where JSON is wrapped in code blocks
*/
private async extractKeyConclusion(
messages: ChatMessage[],
chatModel: BaseChatModel
): Promise<string | null> {
// Only generate key conclusions for conversations with substantial content
const conversationText = messages.map((msg) => `${msg.sender}: ${msg.message}`).join("\n\n");
// Skip if conversation is too short or simple
if (conversationText.length < 300) {
return null;
private extractJsonFromResponse(content: string): string {
// First, try to extract JSON from markdown code blocks
const codeBlockMatch = content.match(/```(?:json)?\s*\n?([\s\S]*?)\n?```/);
if (codeBlockMatch) {
return codeBlockMatch[1].trim();
}
const systemPrompt = `You are an AI assistant that analyzes conversations and determines if they contain important conclusions worth remembering.
TASK: Analyze the conversation and extract key conclusions ONLY if the conversation contains:
- Important insights, decisions, or learnings
- Technical solutions or discoveries
- Significant planning or strategy discussions
- Important facts or knowledge gained
If the conversation is just casual chat, simple questions, or routine tasks, return "NONE".
# OUTPUT FORMAT
If there are key conclusions: Return each conclusion as a bullet point (use - for each point). Each conclusion should be concise (1-2 sentences). Use the same language as the conversation.
Example:
- First important insight or decision
- Second key learning or solution
- Third significant conclusion
If no important conclusions: Return exactly "NONE"`;
const humanPrompt = `Analyze this conversation and determine if there are key conclusions worth remembering:
${conversationText}`;
const messages_llm = [new SystemMessage(systemPrompt), new HumanMessage(humanPrompt)];
try {
const response = await chatModel.invoke(messages_llm);
const conclusion = response.content.toString().trim();
if (conclusion === "NONE" || !conclusion) {
return null;
}
return conclusion;
} catch (error) {
logError("[UserMemoryManager] Failed to extract key conclusion:", error);
return null;
// If no code block found, look for JSON object pattern
const jsonMatch = content.match(/\{[\s\S]*\}/);
if (jsonMatch) {
return jsonMatch[0];
}
// Return original content if no patterns match
return content;
}
/**
* Extract conversation title using LLM
* Extract conversation title and summary using a single LLM call
*/
private async extractConversationTitle(
private async extractTitleAndSummary(
messages: ChatMessage[],
chatModel: BaseChatModel
): Promise<string> {
): Promise<{ title: string; summary: string }> {
const conversationText = messages.map((msg) => `${msg.sender}: ${msg.message}`).join("\n\n");
const systemPrompt = `Your task is to generate a title for a conversation based on its content.
const systemPrompt = `Your task is to analyze a conversation and generate both a title and a summary.
Examples: "Travel Plan", "Tokyo Weather"
# OUTPUT FORMAT
You must return your response in the following JSON format:
{
"title": "Brief 2-8 word title capturing the main user intent",
"summary": "2-3 sentence summary at most including key details (e.g. user facts mentioned entities), and key conclusions if there are any."
}
# OUTPUT RULES
* Look at the conversation content and generate a title that captures the main *user intent* of the conversation.
* Return only the brief 2-8 word title as plain text, no JSON format needed.
* Use the same language as the conversation.`;
# RULES
* Use the same language as the conversation`;
const humanPrompt = `
<conversation_text>
const humanPrompt = `<conversation_text>
${conversationText}
</conversation_text>
Generate a title for the conversation:`;
Generate a title and summary for this conversation:`;
const messages_llm = [new SystemMessage(systemPrompt), new HumanMessage(humanPrompt)];
try {
const response = await chatModel.invoke(messages_llm);
const summary = response.content.toString().trim();
return summary || "Untitled Conversation";
const content = response.content.toString().trim();
// Extract JSON from content, handling code blocks
const jsonContent = this.extractJsonFromResponse(content);
// Try to parse JSON response
try {
const parsed = JSON.parse(jsonContent);
return {
title: parsed.title || "Untitled Conversation",
summary: parsed.summary || "No summary available",
};
} catch (parseError) {
logError("[UserMemoryManager] Failed to parse LLM response as JSON:", parseError);
return {
title: "Untitled Conversation",
summary: "Summary generation failed",
};
}
} catch (error) {
logError("[UserMemoryManager] Failed to extract conversation summary:", error);
return "Untitled Conversation";
logError("[UserMemoryManager] Failed to extract title and summary:", error);
return {
title: "Untitled Conversation",
summary: "Summary generation failed",
};
}
}
// extractUserInsights removed - user insights functionality removed
}

View file

@ -56,6 +56,7 @@ graph TD
- **Conversation Titles**: LLM-extracted titles that capture main user intent
- **Key Conclusions**: Only generated for conversations with substantial content (>300 chars) containing insights, decisions, or learnings
- **Obsidian-optimized**: Special handling for note names, tags, links, and Obsidian-specific features
- **Robust JSON Parsing**: Handles JSON responses wrapped in code blocks (common with Gemini and other LLMs) with fallback to plain JSON extraction
### Configuration (Current):
@ -75,5 +76,6 @@ graph TD
- Fallback mechanisms for AI processing failures
- Graceful handling of missing files and folders
- Validation of AI-generated content (e.g., ensures condensed messages are actually shorter)
- Robust JSON extraction from LLM responses with multiple parsing strategies (code blocks, inline JSON, fallback to raw content)
This simplified design focuses on providing recent conversation context without the complexity of long-term memory management, while maintaining robust AI-powered content processing and configurable retention policies.

View file

@ -44,15 +44,6 @@ export interface ChatMessage {
/** Original user input before processing (for LLM messages) */
originalMessage?: string;
/**
* AI-generated one-sentence summary of user messages for memory storage.
* Created asynchronously after user messages to reduce memory footprint while preserving
* core intent and Obsidian-specific features (notes, tags, links).
* Used in constructing "Recent Conversations" for user memory.
* Only applies to user messages when memory is enabled.
*/
condensedUserMessage?: string;
/** Message sender ("user", "AI", etc.) */
sender: string;
@ -91,7 +82,6 @@ export interface StoredMessage {
id: string;
displayText: string; // What user typed/what AI responded
processedText: string; // For user messages: with context added. For AI: same as display
condensedUserMessage?: string; // AI-generated condensed version for memory storage (user messages only) - see condensedUserMessage documentation above
sender: string;
timestamp: FormattedDateTime;
context?: MessageContext;