mirror of
https://github.com/RooVetGit/Roo-Code.git
synced 2026-08-28 05:27:24 +00:00
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:
parent
01458f1646
commit
a4593658d0
9 changed files with 967 additions and 0 deletions
|
|
@ -35,6 +35,7 @@ export const toolNames = [
|
|||
"codebase_search",
|
||||
"update_todo_list",
|
||||
"generate_image",
|
||||
"memory_search",
|
||||
] as const
|
||||
|
||||
export const toolNamesSchema = z.enum(toolNames)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
}
|
||||
|
|
|
|||
36
src/core/prompts/tools/memory-search.ts
Normal file
36
src/core/prompts/tools/memory-search.ts
Normal 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.`
|
||||
}
|
||||
|
|
@ -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() {
|
||||
|
|
|
|||
106
src/core/tools/memorySearchTool.ts
Normal file
106
src/core/tools/memorySearchTool.ts
Normal 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}`
|
||||
}
|
||||
258
src/services/memory/MemoryService.ts
Normal file
258
src/services/memory/MemoryService.ts
Normal 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,
|
||||
}
|
||||
}
|
||||
}
|
||||
362
src/services/memory/__tests__/MemoryService.test.ts
Normal file
362
src/services/memory/__tests__/MemoryService.test.ts
Normal 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)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
|
@ -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: {
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue