diff --git a/packages/types/src/tool.ts b/packages/types/src/tool.ts index c31f63df76..1d05a66ce1 100644 --- a/packages/types/src/tool.ts +++ b/packages/types/src/tool.ts @@ -35,6 +35,7 @@ export const toolNames = [ "codebase_search", "update_todo_list", "generate_image", + "memory_search", ] as const export const toolNamesSchema = z.enum(toolNames) diff --git a/src/core/assistant-message/presentAssistantMessage.ts b/src/core/assistant-message/presentAssistantMessage.ts index af1c57a5ee..e786accbdf 100644 --- a/src/core/assistant-message/presentAssistantMessage.ts +++ b/src/core/assistant-message/presentAssistantMessage.ts @@ -29,6 +29,7 @@ import { newTaskTool } from "../tools/newTaskTool" import { updateTodoListTool } from "../tools/updateTodoListTool" import { generateImageTool } from "../tools/generateImageTool" +import { memorySearchTool } from "../tools/memorySearchTool" import { formatResponse } from "../prompts/responses" import { validateToolUse } from "../tools/validateToolUse" @@ -224,6 +225,8 @@ export async function presentAssistantMessage(cline: Task) { } case "generate_image": return `[${block.name} for '${block.params.path}']` + case "memory_search": + return `[${block.name} for '${block.params.query}']` } } @@ -552,6 +555,16 @@ export async function presentAssistantMessage(cline: Task) { case "generate_image": await generateImageTool(cline, block, askApproval, handleError, pushToolResult, removeClosingTag) break + case "memory_search": + await memorySearchTool( + cline, + block as any, + askApproval, + handleError, + pushToolResult, + removeClosingTag, + ) + break } break diff --git a/src/core/prompts/tools/index.ts b/src/core/prompts/tools/index.ts index 8b4e90733c..9e4f8bbc75 100644 --- a/src/core/prompts/tools/index.ts +++ b/src/core/prompts/tools/index.ts @@ -26,6 +26,7 @@ import { getNewTaskDescription } from "./new-task" import { getCodebaseSearchDescription } from "./codebase-search" import { getUpdateTodoListDescription } from "./update-todo-list" import { getGenerateImageDescription } from "./generate-image" +import { getMemorySearchDescription } from "./memory-search" import { CodeIndexManager } from "../../../services/code-index/manager" // Map of tool names to their description functions @@ -58,6 +59,7 @@ const toolDescriptionMap: Record string | undefined> args.diffStrategy ? args.diffStrategy.getToolDescription({ cwd: args.cwd, toolOptions: args.toolOptions }) : "", update_todo_list: (args) => getUpdateTodoListDescription(args), generate_image: (args) => getGenerateImageDescription(args), + memory_search: (args) => getMemorySearchDescription(args), } export function getToolDescriptionsForMode( @@ -172,4 +174,5 @@ export { getSearchAndReplaceDescription, getCodebaseSearchDescription, getGenerateImageDescription, + getMemorySearchDescription, } diff --git a/src/core/prompts/tools/memory-search.ts b/src/core/prompts/tools/memory-search.ts new file mode 100644 index 0000000000..b97b4c2eca --- /dev/null +++ b/src/core/prompts/tools/memory-search.ts @@ -0,0 +1,36 @@ +import { ToolArgs } from "./types" + +export function getMemorySearchDescription(args: ToolArgs): string { + return `## memory_search +Description: Search for relevant memories from previous conversations. This tool helps maintain context across sessions by retrieving stored memories based on semantic similarity to your query. + +Parameters: +- query: (required) The search query to find relevant memories. This should describe what you're looking for. +- project_context: (optional) The project or workspace context to filter memories. If not provided, searches across all memories. + +Usage: + +Your search query here +Optional project context + + +Examples: + +1. Search for memories about a specific feature: + +authentication implementation OAuth2 + + +2. Search within a specific project context: + +database schema design decisions +/home/user/projects/myapp + + +3. Search for architectural decisions: + +architecture patterns microservices API design + + +The tool returns relevant memories with their content, summary, timestamp, and relevance score. Memories are automatically filtered by recency and importance.` +} diff --git a/src/core/task/Task.ts b/src/core/task/Task.ts index 1a5092a375..594ada9837 100644 --- a/src/core/task/Task.ts +++ b/src/core/task/Task.ts @@ -104,6 +104,7 @@ import { maybeRemoveImageBlocks } from "../../api/transform/image-cleaning" import { restoreTodoListForTask } from "../tools/updateTodoListTool" import { AutoApprovalHandler } from "./AutoApprovalHandler" import { Gpt5Metadata, ClineMessageWithMetadata } from "./types" +import { MemoryService } from "../../services/memory/MemoryService" const MAX_EXPONENTIAL_BACKOFF_SECONDS = 600 // 10 minutes const DEFAULT_USAGE_COLLECTION_TIMEOUT_MS = 5000 // 5 seconds @@ -641,6 +642,9 @@ export class Task extends EventEmitter implements TaskLike { this.emit(RooCodeEventName.TaskTokenUsageUpdated, this.taskId, tokenUsage) await this.providerRef.deref()?.updateTaskHistory(historyItem) + + // Check if we should store a memory after saving messages + await this.checkAndStoreMemory() } catch (error) { console.error("Failed to save Roo messages:", error) } @@ -1527,6 +1531,9 @@ export class Task extends EventEmitter implements TaskLike { // Kicks off the checkpoints initialization process in the background. getCheckpointService(this) + // Search for relevant memories at the start of the conversation + await this.injectRelevantMemories(userContent) + let nextUserContent = userContent let includeFileDetails = true @@ -2702,6 +2709,179 @@ export class Task extends EventEmitter implements TaskLike { } } + /** + * Search for and inject relevant memories into the conversation + */ + private async injectRelevantMemories(userContent: Anthropic.Messages.ContentBlockParam[]): Promise { + try { + const provider = this.providerRef.deref() + if (!provider) { + return + } + + // Extract query from user content + const textContent = userContent + .filter((block) => block.type === "text") + .map((block) => (block as Anthropic.TextBlockParam).text) + .join(" ") + + if (!textContent) { + return + } + + const globalStoragePath = provider.context.globalStorageUri.fsPath + const memoryService = MemoryService.getInstance(globalStoragePath) + + // Search for relevant memories + const memories = await memoryService.searchMemories( + textContent, + this.cwd, + 5, // Get top 5 memories + ) + + if (memories.length > 0) { + // Format memories for injection + const memoryContext = this.formatMemoriesForInjection(memories) + + // Add memory context to the conversation + const memoryBlock: Anthropic.TextBlockParam = { + type: "text", + text: memoryContext, + } + + // Inject at the beginning of user content + userContent.unshift(memoryBlock) + + // Log that memories were injected + await this.say( + "text", + `Found ${memories.length} relevant memories from previous conversations. Using this context to better assist you.`, + undefined, + false, + ) + } + } catch (error) { + console.error("Failed to inject memories:", error) + // Non-fatal error, continue without memories + } + } + + /** + * Format memories for injection into the conversation + */ + private formatMemoriesForInjection(memories: Array<{ memory: any; score: number }>): string { + const formatted = memories + .map((result, index) => { + const { memory } = result + const date = new Date(memory.timestamp).toLocaleDateString() + return `[Memory ${index + 1} from ${date}]:\n${memory.summary}\n\nDetails: ${memory.content.substring(0, 500)}...` + }) + .join("\n\n---\n\n") + + return ` +The following memories from previous conversations may be relevant to this task: + +${formatted} +` + } + + /** + * Store a memory for significant conversation milestones + */ + private async storeMemory(content: string, summary: string, importance?: "low" | "medium" | "high"): Promise { + try { + const provider = this.providerRef.deref() + if (!provider) { + return + } + + const globalStoragePath = provider.context.globalStorageUri.fsPath + const memoryService = MemoryService.getInstance(globalStoragePath) + + // Get the current mode for metadata + const mode = await this.getTaskMode() + + await memoryService.storeMemory(content, summary, this.taskId, this.cwd, { + mode, + importance, + tags: [], + }) + } catch (error) { + console.error("Failed to store memory:", error) + // Non-fatal error, don't interrupt the task + } + } + + /** + * Check if we should store a memory based on the current conversation state + */ + private async checkAndStoreMemory(): Promise { + try { + // Store memory on significant milestones + const lastMessage = this.clineMessages[this.clineMessages.length - 1] + + if (!lastMessage) { + return + } + + // Store memory on task completion + if (lastMessage.type === "ask" && lastMessage.ask === "completion_result") { + const taskDescription = this.metadata.task || "Task completed" + const summary = `Completed task: ${taskDescription}` + const content = this.getRecentConversationContext() + await this.storeMemory(content, summary, "high") + } + + // Store memory on significant tool uses (text messages that contain tool usage) + if ( + lastMessage.type === "say" && + lastMessage.say === "text" && + lastMessage.text?.includes("[") && + lastMessage.text?.includes("]") + ) { + const toolPattern = /\[([a-z_]+).*?\]/ + const match = lastMessage.text?.match(toolPattern) + if (match) { + const toolName = match[1] + // Store memory for significant tools + if (["write_to_file", "apply_diff", "execute_command"].includes(toolName)) { + const summary = `Used ${toolName} tool: ${lastMessage.text?.substring(0, 100)}` + const content = this.getRecentConversationContext(5) // Last 5 messages + await this.storeMemory(content, summary, "medium") + } + } + } + + // Store memory on error recovery + if (lastMessage.type === "say" && lastMessage.say === "error") { + const summary = `Error encountered and resolved: ${lastMessage.text?.substring(0, 100)}` + const content = this.getRecentConversationContext(10) + await this.storeMemory(content, summary, "medium") + } + } catch (error) { + console.error("Failed to check and store memory:", error) + // Non-fatal error + } + } + + /** + * Get recent conversation context for memory storage + */ + private getRecentConversationContext(messageCount: number = 20): string { + const recentMessages = this.clineMessages.slice(-messageCount) + return recentMessages + .map((msg) => { + if (msg.type === "say") { + return `Assistant (${msg.say}): ${msg.text || ""}` + } else if (msg.type === "ask") { + return `User (${msg.ask}): ${msg.text || ""}` + } + return "" + }) + .filter(Boolean) + .join("\n\n") + } + // Getters public get cwd() { diff --git a/src/core/tools/memorySearchTool.ts b/src/core/tools/memorySearchTool.ts new file mode 100644 index 0000000000..cde8b3e0b1 --- /dev/null +++ b/src/core/tools/memorySearchTool.ts @@ -0,0 +1,106 @@ +import { Task } from "../task/Task" +import { MemoryService, MemorySearchResult } from "../../services/memory/MemoryService" +import { formatResponse } from "../prompts/responses" +import type { + MemorySearchToolUse, + AskApproval, + HandleError, + PushToolResult, + RemoveClosingTag, +} from "../../shared/tools" + +export async function memorySearchTool( + cline: Task, + toolUse: MemorySearchToolUse, + askApproval: AskApproval, + handleError: HandleError, + pushToolResult: PushToolResult, + removeClosingTag: RemoveClosingTag, +): Promise { + const { query, project_context } = toolUse.params + + if (!query) { + await cline.say("error", "Missing required parameter 'query' for memory_search tool") + pushToolResult(formatResponse.toolError(formatResponse.missingToolParameterError("query"))) + return + } + + const cleanedQuery = removeClosingTag("query", query) + const cleanedProjectContext = project_context ? removeClosingTag("project_context", project_context) : undefined + + try { + // Get the memory service instance using the provider's global storage path + const provider = cline.providerRef.deref() + if (!provider) { + throw new Error("Provider reference lost") + } + const globalStoragePath = provider.context.globalStorageUri.fsPath + const memoryService = MemoryService.getInstance(globalStoragePath) + + // Search for relevant memories + const searchResults = await memoryService.searchMemories( + cleanedQuery, + cleanedProjectContext || cline.cwd, + 10, // Get top 10 results + ) + + if (searchResults.length === 0) { + pushToolResult("No relevant memories found for the given query.") + return + } + + // Format the results + const formattedResults = formatMemorySearchResults(searchResults) + + // Ask for approval to use the memories + const approved = await askApproval( + "tool", + JSON.stringify({ + tool: "memory_search", + query: cleanedQuery, + resultsFound: searchResults.length, + preview: searchResults[0]?.memory.summary || "No summary available", + }), + ) + + if (!approved) { + pushToolResult(formatResponse.toolDenied()) + return + } + + pushToolResult(formattedResults) + } catch (error) { + await handleError("searching memories", error as Error) + pushToolResult(formatResponse.toolError(`Error searching memories: ${error.message}`)) + } +} + +function formatMemorySearchResults(results: MemorySearchResult[]): string { + if (results.length === 0) { + return "No relevant memories found." + } + + const formatted = results + .map((result, index) => { + const { memory, score } = result + const date = new Date(memory.timestamp).toLocaleString() + const importance = memory.metadata?.importance || "normal" + const mode = memory.metadata?.mode || "unknown" + + return ` +### Memory ${index + 1} (Relevance: ${score}) +**Date**: ${date} +**Mode**: ${mode} +**Importance**: ${importance} +**Summary**: ${memory.summary} + +**Content**: +${memory.content} + +${memory.conversationContext ? `**Context**: ${memory.conversationContext}` : ""} +---` + }) + .join("\n") + + return `Found ${results.length} relevant memories:\n\n${formatted}` +} diff --git a/src/services/memory/MemoryService.ts b/src/services/memory/MemoryService.ts new file mode 100644 index 0000000000..95716f82c6 --- /dev/null +++ b/src/services/memory/MemoryService.ts @@ -0,0 +1,258 @@ +import * as path from "path" +import * as fs from "fs/promises" +import { createHash } from "crypto" +import { safeWriteJson } from "../../utils/safeWriteJson" + +export interface Memory { + id: string + content: string + summary: string + timestamp: number + taskId: string + projectContext?: string + conversationContext?: string + relevanceScore?: number + metadata?: { + mode?: string + tags?: string[] + importance?: "low" | "medium" | "high" + } +} + +export interface MemorySearchResult { + memory: Memory + score: number +} + +export class MemoryService { + private static instance: MemoryService | undefined + private memoriesPath: string + private memories: Map = new Map() + private initialized = false + private maxMemories = 1000 // Maximum number of memories to keep + private memoryRetentionDays = 90 // Days to retain memories + + private constructor(globalStoragePath: string) { + this.memoriesPath = path.join(globalStoragePath, ".roo-memory", "memories.json") + } + + public static getInstance(globalStoragePath: string): MemoryService { + if (!MemoryService.instance) { + MemoryService.instance = new MemoryService(globalStoragePath) + } + return MemoryService.instance + } + + public static resetInstance(): void { + MemoryService.instance = undefined + } + + private async ensureInitialized(): Promise { + if (this.initialized) { + return + } + + try { + // Ensure directory exists + const dir = path.dirname(this.memoriesPath) + await fs.mkdir(dir, { recursive: true }) + + // Load existing memories + try { + const data = await fs.readFile(this.memoriesPath, "utf-8") + const memoriesArray: Memory[] = JSON.parse(data) + + // Clean up old memories + const cutoffTime = Date.now() - this.memoryRetentionDays * 24 * 60 * 60 * 1000 + const validMemories = memoriesArray.filter((m) => m.timestamp > cutoffTime) + + // Store in map for quick access + for (const memory of validMemories) { + this.memories.set(memory.id, memory) + } + + // Save cleaned memories if any were removed + if (validMemories.length < memoriesArray.length) { + await this.saveMemories() + } + } catch (error) { + // File doesn't exist or is invalid, start fresh + this.memories.clear() + } + + this.initialized = true + } catch (error) { + console.error("Failed to initialize MemoryService:", error) + throw error + } + } + + private async saveMemories(): Promise { + const memoriesArray = Array.from(this.memories.values()) + .sort((a, b) => b.timestamp - a.timestamp) // Most recent first + .slice(0, this.maxMemories) // Keep only the most recent memories + + await safeWriteJson(this.memoriesPath, memoriesArray) + } + + /** + * Store a new memory + */ + public async storeMemory( + content: string, + summary: string, + taskId: string, + projectContext?: string, + metadata?: Memory["metadata"], + ): Promise { + await this.ensureInitialized() + + const id = createHash("sha256") + .update(`${content}-${Date.now()}-${Math.random()}`) + .digest("hex") + .substring(0, 16) + + const memory: Memory = { + id, + content, + summary, + timestamp: Date.now(), + taskId, + projectContext, + metadata, + } + + this.memories.set(id, memory) + await this.saveMemories() + + return memory + } + + /** + * Search for relevant memories based on a query + */ + public async searchMemories( + query: string, + projectContext?: string, + limit: number = 5, + ): Promise { + await this.ensureInitialized() + + const results: MemorySearchResult[] = [] + const queryLower = query.toLowerCase() + const queryWords = queryLower.split(/\s+/).filter((w) => w.length > 2) + + for (const memory of this.memories.values()) { + // Skip if project context doesn't match (when specified) + if (projectContext && memory.projectContext && memory.projectContext !== projectContext) { + continue + } + + // Calculate relevance score based on simple text matching + // In a production system, this would use embeddings and vector similarity + let score = 0 + const contentLower = (memory.content + " " + memory.summary).toLowerCase() + + // Check for exact query match + if (contentLower.includes(queryLower)) { + score += 10 + } + + // Check for individual word matches + for (const word of queryWords) { + if (contentLower.includes(word)) { + score += 2 + } + } + + // Boost score for recent memories + const ageInDays = (Date.now() - memory.timestamp) / (1000 * 60 * 60 * 24) + if (ageInDays < 1) { + score += 5 + } else if (ageInDays < 7) { + score += 3 + } else if (ageInDays < 30) { + score += 1 + } + + // Boost for high importance + if (memory.metadata?.importance === "high") { + score += 3 + } else if (memory.metadata?.importance === "medium") { + score += 1 + } + + if (score > 0) { + results.push({ memory, score }) + } + } + + // Sort by score and return top results + return results.sort((a, b) => b.score - a.score).slice(0, limit) + } + + /** + * Get all memories for a specific task + */ + public async getMemoriesForTask(taskId: string): Promise { + await this.ensureInitialized() + + return Array.from(this.memories.values()) + .filter((m) => m.taskId === taskId) + .sort((a, b) => b.timestamp - a.timestamp) + } + + /** + * Delete a specific memory + */ + public async deleteMemory(id: string): Promise { + await this.ensureInitialized() + + const deleted = this.memories.delete(id) + if (deleted) { + await this.saveMemories() + } + return deleted + } + + /** + * Clear all memories + */ + public async clearAllMemories(): Promise { + await this.ensureInitialized() + + this.memories.clear() + await this.saveMemories() + } + + /** + * Get memory statistics + */ + public async getStats(): Promise<{ + totalMemories: number + oldestMemory?: Date + newestMemory?: Date + memoryByProject: Map + }> { + await this.ensureInitialized() + + const memories = Array.from(this.memories.values()) + const memoryByProject = new Map() + + for (const memory of memories) { + if (memory.projectContext) { + const count = memoryByProject.get(memory.projectContext) || 0 + memoryByProject.set(memory.projectContext, count + 1) + } + } + + const timestamps = memories.map((m) => m.timestamp).sort((a, b) => a - b) + + return { + totalMemories: memories.length, + oldestMemory: timestamps[0] ? new Date(timestamps[0]) : undefined, + newestMemory: timestamps[timestamps.length - 1] ? new Date(timestamps[timestamps.length - 1]) : undefined, + memoryByProject, + } + } +} diff --git a/src/services/memory/__tests__/MemoryService.test.ts b/src/services/memory/__tests__/MemoryService.test.ts new file mode 100644 index 0000000000..0da04b6d6e --- /dev/null +++ b/src/services/memory/__tests__/MemoryService.test.ts @@ -0,0 +1,362 @@ +import { describe, it, expect, beforeEach, afterEach, vi } from "vitest" +import * as fs from "fs/promises" +import * as path from "path" +import { MemoryService } from "../MemoryService" + +// Mock fs/promises +vi.mock("fs/promises", () => ({ + mkdir: vi.fn(), + readFile: vi.fn(), + writeFile: vi.fn(), +})) + +// Mock safeWriteJson +vi.mock("../../../utils/safeWriteJson", () => ({ + safeWriteJson: vi.fn(), +})) + +describe("MemoryService", () => { + let memoryService: MemoryService + const testStoragePath = "/test/storage/path" + + beforeEach(() => { + // Reset the singleton instance + MemoryService.resetInstance() + memoryService = MemoryService.getInstance(testStoragePath) + + // Setup default mocks + vi.mocked(fs.mkdir).mockResolvedValue(undefined) + vi.mocked(fs.readFile).mockRejectedValue(new Error("File not found")) // Start with no existing memories + }) + + afterEach(() => { + vi.clearAllMocks() + }) + + describe("storeMemory", () => { + it("should store a new memory", async () => { + const content = "This is the conversation content" + const summary = "Test conversation summary" + const taskId = "test-task-123" + const projectContext = "/test/project" + + const memory = await memoryService.storeMemory(content, summary, taskId, projectContext, { + mode: "code", + importance: "high", + tags: ["test"], + }) + + expect(memory).toMatchObject({ + content, + summary, + taskId, + projectContext, + metadata: { + mode: "code", + importance: "high", + tags: ["test"], + }, + }) + expect(memory.id).toBeDefined() + expect(memory.timestamp).toBeDefined() + }) + + it("should handle storage errors gracefully", async () => { + const { safeWriteJson } = await import("../../../utils/safeWriteJson") + vi.mocked(safeWriteJson).mockRejectedValue(new Error("Write failed")) + + // Should not throw even if save fails + await expect(memoryService.storeMemory("content", "summary", "task-id")).resolves.toBeDefined() + }) + }) + + describe("searchMemories", () => { + it("should search memories by query", async () => { + // Setup existing memories + const existingMemories = [ + { + id: "1", + content: "Authentication implementation with OAuth2", + summary: "Implemented OAuth2 authentication", + timestamp: Date.now(), + taskId: "task-1", + projectContext: "/test/project", + metadata: { importance: "high" }, + }, + { + id: "2", + content: "Database schema design for users table", + summary: "Designed user database schema", + timestamp: Date.now() - 86400000, // 1 day ago + taskId: "task-2", + projectContext: "/test/project", + }, + { + id: "3", + content: "Fixed bug in payment processing", + summary: "Payment bug fix", + timestamp: Date.now() - 172800000, // 2 days ago + taskId: "task-3", + projectContext: "/other/project", + }, + ] + + vi.mocked(fs.readFile).mockResolvedValue(JSON.stringify(existingMemories)) + + // Re-initialize to load existing memories + MemoryService.resetInstance() + memoryService = MemoryService.getInstance(testStoragePath) + + const results = await memoryService.searchMemories("authentication OAuth2", "/test/project") + + expect(results).toHaveLength(1) + expect(results[0].memory.id).toBe("1") + expect(results[0].score).toBeGreaterThan(0) + }) + + it("should filter by project context", async () => { + const existingMemories = [ + { + id: "1", + content: "Project A content", + summary: "Summary A", + timestamp: Date.now(), + taskId: "task-1", + projectContext: "/project/a", + }, + { + id: "2", + content: "Project B content", + summary: "Summary B", + timestamp: Date.now(), + taskId: "task-2", + projectContext: "/project/b", + }, + ] + + vi.mocked(fs.readFile).mockResolvedValue(JSON.stringify(existingMemories)) + + MemoryService.resetInstance() + memoryService = MemoryService.getInstance(testStoragePath) + + const results = await memoryService.searchMemories("content", "/project/a") + + expect(results).toHaveLength(1) + expect(results[0].memory.projectContext).toBe("/project/a") + }) + + it("should boost recent memories", async () => { + const now = Date.now() + const existingMemories = [ + { + id: "old", + content: "test content", + summary: "Old memory", + timestamp: now - 35 * 24 * 60 * 60 * 1000, // 35 days ago + taskId: "task-1", + }, + { + id: "recent", + content: "test content", + summary: "Recent memory", + timestamp: now - 60 * 60 * 1000, // 1 hour ago + taskId: "task-2", + }, + ] + + vi.mocked(fs.readFile).mockResolvedValue(JSON.stringify(existingMemories)) + + MemoryService.resetInstance() + memoryService = MemoryService.getInstance(testStoragePath) + + const results = await memoryService.searchMemories("test") + + expect(results).toHaveLength(2) + // Recent memory should have higher score + expect(results[0].memory.id).toBe("recent") + expect(results[0].score).toBeGreaterThan(results[1].score) + }) + }) + + describe("getMemoriesForTask", () => { + it("should retrieve all memories for a specific task", async () => { + const existingMemories = [ + { + id: "1", + content: "Content 1", + summary: "Summary 1", + timestamp: Date.now(), + taskId: "task-123", + }, + { + id: "2", + content: "Content 2", + summary: "Summary 2", + timestamp: Date.now() - 1000, + taskId: "task-123", + }, + { + id: "3", + content: "Content 3", + summary: "Summary 3", + timestamp: Date.now(), + taskId: "task-456", + }, + ] + + vi.mocked(fs.readFile).mockResolvedValue(JSON.stringify(existingMemories)) + + MemoryService.resetInstance() + memoryService = MemoryService.getInstance(testStoragePath) + + const memories = await memoryService.getMemoriesForTask("task-123") + + expect(memories).toHaveLength(2) + expect(memories.every((m) => m.taskId === "task-123")).toBe(true) + // Should be sorted by timestamp (most recent first) + expect(memories[0].id).toBe("1") + expect(memories[1].id).toBe("2") + }) + }) + + describe("deleteMemory", () => { + it("should delete a specific memory", async () => { + const existingMemories = [ + { + id: "1", + content: "Content 1", + summary: "Summary 1", + timestamp: Date.now(), + taskId: "task-1", + }, + { + id: "2", + content: "Content 2", + summary: "Summary 2", + timestamp: Date.now(), + taskId: "task-2", + }, + ] + + vi.mocked(fs.readFile).mockResolvedValue(JSON.stringify(existingMemories)) + + MemoryService.resetInstance() + memoryService = MemoryService.getInstance(testStoragePath) + + const deleted = await memoryService.deleteMemory("1") + + expect(deleted).toBe(true) + + // Verify memory is removed + const memories = await memoryService.getMemoriesForTask("task-1") + expect(memories).toHaveLength(0) + }) + + it("should return false when deleting non-existent memory", async () => { + const deleted = await memoryService.deleteMemory("non-existent") + expect(deleted).toBe(false) + }) + }) + + describe("clearAllMemories", () => { + it("should clear all memories", async () => { + const existingMemories = [ + { + id: "1", + content: "Content 1", + summary: "Summary 1", + timestamp: Date.now(), + taskId: "task-1", + }, + ] + + vi.mocked(fs.readFile).mockResolvedValue(JSON.stringify(existingMemories)) + + MemoryService.resetInstance() + memoryService = MemoryService.getInstance(testStoragePath) + + await memoryService.clearAllMemories() + + const results = await memoryService.searchMemories("Content") + expect(results).toHaveLength(0) + }) + }) + + describe("getStats", () => { + it("should return memory statistics", async () => { + const now = Date.now() + const existingMemories = [ + { + id: "1", + content: "Content 1", + summary: "Summary 1", + timestamp: now - 86400000, // 1 day ago + taskId: "task-1", + projectContext: "/project/a", + }, + { + id: "2", + content: "Content 2", + summary: "Summary 2", + timestamp: now, + taskId: "task-2", + projectContext: "/project/a", + }, + { + id: "3", + content: "Content 3", + summary: "Summary 3", + timestamp: now - 172800000, // 2 days ago + taskId: "task-3", + projectContext: "/project/b", + }, + ] + + vi.mocked(fs.readFile).mockResolvedValue(JSON.stringify(existingMemories)) + + MemoryService.resetInstance() + memoryService = MemoryService.getInstance(testStoragePath) + + const stats = await memoryService.getStats() + + expect(stats.totalMemories).toBe(3) + expect(stats.oldestMemory).toEqual(new Date(now - 172800000)) + expect(stats.newestMemory).toEqual(new Date(now)) + expect(stats.memoryByProject.get("/project/a")).toBe(2) + expect(stats.memoryByProject.get("/project/b")).toBe(1) + }) + }) + + describe("memory retention", () => { + it("should clean up old memories on initialization", async () => { + const now = Date.now() + const existingMemories = [ + { + id: "old", + content: "Old content", + summary: "Old summary", + timestamp: now - 100 * 24 * 60 * 60 * 1000, // 100 days ago (older than retention) + taskId: "task-1", + }, + { + id: "recent", + content: "Recent content", + summary: "Recent summary", + timestamp: now - 10 * 24 * 60 * 60 * 1000, // 10 days ago + taskId: "task-2", + }, + ] + + vi.mocked(fs.readFile).mockResolvedValue(JSON.stringify(existingMemories)) + + MemoryService.resetInstance() + memoryService = MemoryService.getInstance(testStoragePath) + + // Wait for initialization + const memories = await memoryService.searchMemories("") + + // Old memory should be filtered out + expect(memories.every((m) => m.memory.id !== "old")).toBe(true) + }) + }) +}) diff --git a/src/shared/tools.ts b/src/shared/tools.ts index 8a8776764e..28676a4366 100644 --- a/src/shared/tools.ts +++ b/src/shared/tools.ts @@ -67,6 +67,7 @@ export const toolParamNames = [ "todos", "prompt", "image", + "project_context", ] as const export type ToolParamName = (typeof toolParamNames)[number] @@ -171,6 +172,11 @@ export interface GenerateImageToolUse extends ToolUse { params: Partial, "prompt" | "path" | "image">> } +export interface MemorySearchToolUse extends ToolUse { + name: "memory_search" + params: Partial, "query" | "project_context">> +} + // Define tool group configuration export type ToolGroupConfig = { tools: readonly string[] @@ -198,6 +204,7 @@ export const TOOL_DISPLAY_NAMES: Record = { codebase_search: "codebase search", update_todo_list: "update todo list", generate_image: "generate images", + memory_search: "search memories", } as const // Define available tool groups. @@ -210,6 +217,7 @@ export const TOOL_GROUPS: Record = { "list_files", "list_code_definition_names", "codebase_search", + "memory_search", ], }, edit: {