feat: implement conversation memory persistence

- Add MemoryService for storing and retrieving conversation memories
- Create memory_search tool for searching relevant memories
- Implement automatic memory injection at conversation start
- Add automatic memory storage on conversation milestones
- Include memory search in read tool group
- Add tests for memory functionality

This addresses issue #7537 by providing memory persistence across sessions,
allowing Roo Code to maintain context and learn from previous conversations.
This commit is contained in:
Roo Code 2025-08-29 20:29:39 +00:00
parent 01458f1646
commit a4593658d0
9 changed files with 967 additions and 0 deletions

View file

@ -35,6 +35,7 @@ export const toolNames = [
"codebase_search",
"update_todo_list",
"generate_image",
"memory_search",
] as const
export const toolNamesSchema = z.enum(toolNames)

View file

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

View file

@ -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, (args: ToolArgs) => 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,
}

View file

@ -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:
<memory_search>
<query>Your search query here</query>
<project_context>Optional project context</project_context>
</memory_search>
Examples:
1. Search for memories about a specific feature:
<memory_search>
<query>authentication implementation OAuth2</query>
</memory_search>
2. Search within a specific project context:
<memory_search>
<query>database schema design decisions</query>
<project_context>/home/user/projects/myapp</project_context>
</memory_search>
3. Search for architectural decisions:
<memory_search>
<query>architecture patterns microservices API design</query>
</memory_search>
The tool returns relevant memories with their content, summary, timestamp, and relevance score. Memories are automatically filtered by recency and importance.`
}

View file

@ -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<TaskEvents> 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<TaskEvents> 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<TaskEvents> implements TaskLike {
}
}
/**
* Search for and inject relevant memories into the conversation
*/
private async injectRelevantMemories(userContent: Anthropic.Messages.ContentBlockParam[]): Promise<void> {
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 `<relevant_memories>
The following memories from previous conversations may be relevant to this task:
${formatted}
</relevant_memories>`
}
/**
* Store a memory for significant conversation milestones
*/
private async storeMemory(content: string, summary: string, importance?: "low" | "medium" | "high"): Promise<void> {
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<void> {
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() {

View file

@ -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<void> {
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}`
}

View file

@ -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<string, Memory> = 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<void> {
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<void> {
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<Memory> {
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<MemorySearchResult[]> {
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<Memory[]> {
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<boolean> {
await this.ensureInitialized()
const deleted = this.memories.delete(id)
if (deleted) {
await this.saveMemories()
}
return deleted
}
/**
* Clear all memories
*/
public async clearAllMemories(): Promise<void> {
await this.ensureInitialized()
this.memories.clear()
await this.saveMemories()
}
/**
* Get memory statistics
*/
public async getStats(): Promise<{
totalMemories: number
oldestMemory?: Date
newestMemory?: Date
memoryByProject: Map<string, number>
}> {
await this.ensureInitialized()
const memories = Array.from(this.memories.values())
const memoryByProject = new Map<string, number>()
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,
}
}
}

View file

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

View file

@ -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<Pick<Record<ToolParamName, string>, "prompt" | "path" | "image">>
}
export interface MemorySearchToolUse extends ToolUse {
name: "memory_search"
params: Partial<Pick<Record<ToolParamName, string>, "query" | "project_context">>
}
// Define tool group configuration
export type ToolGroupConfig = {
tools: readonly string[]
@ -198,6 +204,7 @@ export const TOOL_DISPLAY_NAMES: Record<ToolName, string> = {
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<ToolGroup, ToolGroupConfig> = {
"list_files",
"list_code_definition_names",
"codebase_search",
"memory_search",
],
},
edit: {