feat: initial conversation memory system implementation

- Add comprehensive memory service architecture mirroring CodeIndexManager pattern
- Implement temporal intelligence with fact categorization (infrastructure, architecture, debugging, pattern)
- Add memory_search tool for retrieving relevant conversation context
- Create service factory, orchestrator, and search service stubs
- Add configuration management for memory system settings
- Implement cache manager for persistent memory storage
- Define interfaces for fact extraction, conflict resolution, and temporal management

Based on detailed planning documents:
- Temporal intelligence for managing fact lifecycle
- Battle-tested patterns from mem0, Graphiti, and Potpie
- Workspace isolation for project-specific memories
- Non-invasive integration with minimal codebase changes

This is an initial implementation that provides the foundation for the conversation memory feature as described in issue #7537.
This commit is contained in:
Roo Code 2025-08-29 21:04:10 +00:00
parent 01458f1646
commit 60bbf31428
12 changed files with 1482 additions and 0 deletions

View file

@ -33,6 +33,7 @@ export const toolNames = [
"new_task",
"fetch_instructions",
"codebase_search",
"memory_search",
"update_todo_list",
"generate_image",
] as const

View file

@ -26,7 +26,9 @@ 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"
import { ConversationMemoryManager } from "../../../services/conversation-memory/manager"
// Map of tool names to their description functions
const toolDescriptionMap: Record<string, (args: ToolArgs) => string | undefined> = {
@ -50,6 +52,7 @@ const toolDescriptionMap: Record<string, (args: ToolArgs) => string | undefined>
use_mcp_tool: (args) => getUseMcpToolDescription(args),
access_mcp_resource: (args) => getAccessMcpResourceDescription(args),
codebase_search: (args) => getCodebaseSearchDescription(args),
memory_search: (args) => getMemorySearchDescription(args),
switch_mode: () => getSwitchModeDescription(),
new_task: (args) => getNewTaskDescription(args),
insert_content: (args) => getInsertContentDescription(args),
@ -126,6 +129,12 @@ export function getToolDescriptionsForMode(
tools.delete("codebase_search")
}
// Conditionally exclude memory_search if feature is disabled or not configured
const memoryManager = ConversationMemoryManager.getCurrentWorkspaceManager()
if (!memoryManager || !memoryManager.isFeatureEnabled || !memoryManager.isInitialized) {
tools.delete("memory_search")
}
// Conditionally exclude update_todo_list if disabled in settings
if (settings?.todoListEnabled === false) {
tools.delete("update_todo_list")
@ -171,5 +180,6 @@ export {
getInsertContentDescription,
getSearchAndReplaceDescription,
getCodebaseSearchDescription,
getMemorySearchDescription,
getGenerateImageDescription,
}

View file

@ -0,0 +1,44 @@
import { ToolArgs } from "./types"
export function getMemorySearchDescription(args: ToolArgs): string {
return `## memory_search
Description: Search conversation memory for past technical decisions, patterns, and project context. Retrieves relevant memories about infrastructure, architecture, debugging issues, and learned patterns for the current workspace.
Parameters:
- query: (required) Natural language search query. Use the user's exact wording when possible.
- category: (optional) Filter by category: infrastructure, architecture, pattern, or debugging
- tags: (optional) Comma-separated tags to filter results (e.g., "auth,cookies")
- limit: (optional) Maximum number of results to return (default: 10)
Usage:
<memory_search>
<query>Your natural language query here</query>
<category>architecture</category>
<tags>auth,cookies</tags>
<limit>6</limit>
</memory_search>
Examples:
1. Search for authentication decisions:
<memory_search>
<query>authentication approach</query>
<category>architecture</category>
</memory_search>
2. Find debugging patterns:
<memory_search>
<query>CORS error fixes</query>
<category>pattern</category>
</memory_search>
3. General project context search:
<memory_search>
<query>database configuration</query>
</memory_search>
Output format:
ARCHITECTURE: Database access via dependency injection (replaces singleton) (2025-07-08)
PATTERN: Avoid N+1 queries in SQLAlchemy using selectinload (derived from incident) (2025-05-21)
`
}

View file

@ -0,0 +1,167 @@
import * as vscode from "vscode"
import * as path from "path"
import * as fs from "fs/promises"
import { ConversationFact } from "./interfaces"
export interface MemoryCacheData {
version: string
lastUpdated: string
facts: ConversationFact[]
metadata: {
workspacePath: string
totalFacts: number
categories: Record<string, number>
}
}
export class ConversationMemoryCacheManager {
private readonly cacheFileName = "conversation-memory-cache.json"
private readonly cacheVersion = "1.0.0"
private cacheFilePath: string
private memoryCache: Map<string, ConversationFact> = new Map()
constructor(
private readonly context: vscode.ExtensionContext,
private readonly workspacePath: string,
) {
// Store cache in VS Code's global storage
const storageUri = context.globalStorageUri
this.cacheFilePath = path.join(storageUri.fsPath, this.getCacheFileName())
}
private getCacheFileName(): string {
// Create unique cache file name per workspace
const workspaceHash = Buffer.from(this.workspacePath).toString("base64").replace(/[/+=]/g, "_")
return `${workspaceHash}-${this.cacheFileName}`
}
public async initialize(): Promise<void> {
// Ensure storage directory exists
const storageDir = path.dirname(this.cacheFilePath)
await fs.mkdir(storageDir, { recursive: true })
// Load existing cache if available
await this.loadCache()
}
public async loadCache(): Promise<void> {
try {
const cacheContent = await fs.readFile(this.cacheFilePath, "utf8")
const cacheData: MemoryCacheData = JSON.parse(cacheContent)
// Validate cache version
if (cacheData.version !== this.cacheVersion) {
console.log(
`Cache version mismatch. Expected ${this.cacheVersion}, got ${cacheData.version}. Clearing cache.`,
)
await this.clearCacheFile()
return
}
// Load facts into memory
this.memoryCache.clear()
for (const fact of cacheData.facts) {
// Convert date strings back to Date objects
fact.reference_time = new Date(fact.reference_time)
fact.ingestion_time = new Date(fact.ingestion_time)
if (fact.superseded_at) fact.superseded_at = new Date(fact.superseded_at)
if (fact.resolved_at) fact.resolved_at = new Date(fact.resolved_at)
if (fact.last_confirmed) fact.last_confirmed = new Date(fact.last_confirmed)
this.memoryCache.set(fact.id, fact)
}
console.log(`Loaded ${this.memoryCache.size} facts from cache`)
} catch (error) {
// Cache doesn't exist or is corrupted - start fresh
console.log("No valid cache found, starting with empty memory")
this.memoryCache.clear()
}
}
public async saveCache(): Promise<void> {
try {
const facts = Array.from(this.memoryCache.values())
// Calculate category statistics
const categories: Record<string, number> = {}
for (const fact of facts) {
categories[fact.category] = (categories[fact.category] || 0) + 1
}
const cacheData: MemoryCacheData = {
version: this.cacheVersion,
lastUpdated: new Date().toISOString(),
facts,
metadata: {
workspacePath: this.workspacePath,
totalFacts: facts.length,
categories,
},
}
await fs.writeFile(this.cacheFilePath, JSON.stringify(cacheData, null, 2), "utf8")
console.log(`Saved ${facts.length} facts to cache`)
} catch (error) {
console.error("Failed to save memory cache:", error)
}
}
public async clearCacheFile(): Promise<void> {
try {
await fs.unlink(this.cacheFilePath)
this.memoryCache.clear()
console.log("Memory cache cleared")
} catch (error) {
// File might not exist, which is fine
if ((error as any).code !== "ENOENT") {
console.error("Failed to clear memory cache:", error)
}
}
}
// Cache operations
public getFact(id: string): ConversationFact | undefined {
return this.memoryCache.get(id)
}
public setFact(fact: ConversationFact): void {
this.memoryCache.set(fact.id, fact)
}
public deleteFact(id: string): boolean {
return this.memoryCache.delete(id)
}
public getAllFacts(): ConversationFact[] {
return Array.from(this.memoryCache.values())
}
public getFactsByCategory(category: string): ConversationFact[] {
return Array.from(this.memoryCache.values()).filter((fact) => fact.category === category)
}
public getFactCount(): number {
return this.memoryCache.size
}
// Periodic save
private saveTimer: NodeJS.Timeout | undefined
public scheduleSave(delayMs: number = 5000): void {
if (this.saveTimer) {
clearTimeout(this.saveTimer)
}
this.saveTimer = setTimeout(() => {
this.saveCache()
}, delayMs)
}
public async dispose(): Promise<void> {
if (this.saveTimer) {
clearTimeout(this.saveTimer)
}
await this.saveCache()
}
}

View file

@ -0,0 +1,177 @@
import * as vscode from "vscode"
import { ContextProxy } from "../../core/config/ContextProxy"
export interface ConversationMemoryConfig {
// Basic enablement
enabled: boolean
provider: "inherit" | "openai" | "ollama" | "anthropic" | "custom"
// When provider !== 'inherit', these are used:
memoryLLMProvider?: string
memoryModelId?: string
memoryLLMOptions?: Record<string, any>
// Memory-specific settings
autoExtraction: boolean
processingMode: "realtime" | "background" | "manual"
factRetentionDays: number
maxFactsPerConversation: number
conflictResolutionMode: "aggressive" | "conservative" | "manual"
// Token and cost budgets
promptBudgetTokens: number
memoryToolDefaultLimit: number
dailyProcessingBudgetUSD: number
}
export class ConversationMemoryConfigManager {
private currentConfig: ConversationMemoryConfig | undefined
private contextProxy: ContextProxy
constructor(contextProxy: ContextProxy) {
this.contextProxy = contextProxy
}
public async loadConfiguration(): Promise<{ requiresRestart: boolean }> {
const previousConfig = this.currentConfig
// Load memory-specific configuration
const memoryConfig = vscode.workspace.getConfiguration("roo.conversationMemory")
const codeIndexConfig = vscode.workspace.getConfiguration("roo.codeIndex")
// Determine inheritance mode
const inheritanceMode = memoryConfig.get("provider", "inherit")
if (inheritanceMode === "inherit") {
// Inherit from code indexing settings
this.currentConfig = {
enabled: memoryConfig.get("enabled", false),
provider: "inherit",
// Inherit LLM settings from code indexing
memoryLLMProvider: codeIndexConfig.get("embedderProvider"),
memoryModelId: codeIndexConfig.get("modelId"),
memoryLLMOptions: {
...codeIndexConfig.get("openAiOptions", {}),
...codeIndexConfig.get("ollamaOptions", {}),
...codeIndexConfig.get("geminiOptions", {}),
...codeIndexConfig.get("mistralOptions", {}),
},
// Memory-specific settings
autoExtraction: memoryConfig.get("autoExtraction", true),
processingMode: memoryConfig.get("processingMode", "background"),
factRetentionDays: memoryConfig.get("retentionDays", 90),
maxFactsPerConversation: memoryConfig.get("maxFacts", 10),
conflictResolutionMode: memoryConfig.get("conflictResolutionMode", "conservative"),
// Budgets
promptBudgetTokens: memoryConfig.get("promptBudgetTokens", 400),
memoryToolDefaultLimit: memoryConfig.get("memoryToolDefaultLimit", 10),
dailyProcessingBudgetUSD: memoryConfig.get("dailyProcessingBudgetUSD", 1.0),
}
} else {
// Independent configuration
this.currentConfig = {
enabled: memoryConfig.get("enabled", false),
provider: memoryConfig.get("provider", "inherit"),
memoryLLMProvider: memoryConfig.get("memoryLLMProvider"),
memoryModelId: memoryConfig.get("memoryModelId"),
memoryLLMOptions: memoryConfig.get("memoryLLMOptions", {}),
// Memory-specific settings
autoExtraction: memoryConfig.get("autoExtraction", true),
processingMode: memoryConfig.get("processingMode", "background"),
factRetentionDays: memoryConfig.get("retentionDays", 90),
maxFactsPerConversation: memoryConfig.get("maxFacts", 10),
conflictResolutionMode: memoryConfig.get("conflictResolutionMode", "conservative"),
// Budgets
promptBudgetTokens: memoryConfig.get("promptBudgetTokens", 400),
memoryToolDefaultLimit: memoryConfig.get("memoryToolDefaultLimit", 10),
dailyProcessingBudgetUSD: memoryConfig.get("dailyProcessingBudgetUSD", 1.0),
}
}
// Determine if restart is required
const requiresRestart = this.hasSignificantChanges(previousConfig, this.currentConfig)
return { requiresRestart }
}
public getConfig(): ConversationMemoryConfig {
if (!this.currentConfig) {
throw new Error("Configuration not loaded. Call loadConfiguration() first.")
}
return this.currentConfig
}
public get isFeatureEnabled(): boolean {
return this.currentConfig?.enabled ?? false
}
public get isFeatureConfigured(): boolean {
if (!this.currentConfig?.enabled) {
return false
}
// Check if we have necessary LLM configuration
if (this.currentConfig.provider === "inherit") {
// Check if code indexing is configured
const codeIndexConfig = vscode.workspace.getConfiguration("roo.codeIndex")
return codeIndexConfig.get("enabled", false) && codeIndexConfig.get("embedderProvider") !== undefined
}
// For independent configuration, check if provider is set
return this.currentConfig.memoryLLMProvider !== undefined
}
public validateDependencies(): { isValid: boolean; errors: string[]; warnings: string[] } {
const codeIndexConfig = vscode.workspace.getConfiguration("roo.codeIndex")
const memoryConfig = vscode.workspace.getConfiguration("roo.conversationMemory")
const errors: string[] = []
const warnings: string[] = []
// Memory system can work independently or inherit from code indexing
if (memoryConfig.get("provider") === "inherit") {
// Check if code indexing is enabled when inheriting
if (!codeIndexConfig.get("enabled", false) && memoryConfig.get("enabled", false)) {
warnings.push(
"Conversation memory is set to inherit from code indexing, but code indexing is disabled. Consider enabling code indexing or using independent configuration.",
)
}
// Check embedder configuration
const provider = codeIndexConfig.get("embedderProvider")
if (!provider && memoryConfig.get("provider") === "inherit") {
errors.push(
"No embedder provider configured for code indexing. Configure code indexing first or set independent memory provider.",
)
}
}
return {
isValid: errors.length === 0,
errors,
warnings,
}
}
private hasSignificantChanges(
oldConfig: ConversationMemoryConfig | undefined,
newConfig: ConversationMemoryConfig,
): boolean {
if (!oldConfig) {
return true
}
// Check for changes that require restart
return (
oldConfig.provider !== newConfig.provider ||
oldConfig.memoryLLMProvider !== newConfig.memoryLLMProvider ||
oldConfig.memoryModelId !== newConfig.memoryModelId ||
JSON.stringify(oldConfig.memoryLLMOptions) !== JSON.stringify(newConfig.memoryLLMOptions)
)
}
}

View file

@ -0,0 +1,130 @@
// Core interfaces for the conversation memory system
export interface ConversationFact {
id: string
content: string
category: FactCategory
confidence: number
// Bi-temporal tracking (Graphiti pattern)
reference_time: Date // When the fact/decision happened
ingestion_time: Date // When we recorded it
// Lifecycle tracking (mem0 pattern)
superseded_by?: string // ID of fact that replaces this
superseded_at?: Date // When it was superseded
resolved?: boolean // For debugging facts
resolved_at?: Date // When it was resolved
derived_from?: string // For patterns derived from incidents
derived_pattern_created?: boolean // For debugging incidents after promotion
last_confirmed?: Date // Last validation
// Context
workspace_path: string
project_context: ProjectContext
conversation_context: string
// Vector storage
embedding: number[]
// Metadata
metadata: Record<string, any>
}
export enum FactCategory {
INFRASTRUCTURE = "infrastructure", // Core tech stack - persistent
ARCHITECTURE = "architecture", // Design decisions - evolving
DEBUGGING = "debugging", // Temporary problems - expire
PATTERN = "pattern", // Solution wisdom - persistent (including promoted incident lessons)
}
export interface ProjectContext {
language: "typescript" | "python" | "rust" | "go" | "java" | "unknown"
framework?: string
workspaceName: string
packageManager?: "npm" | "yarn" | "pnpm" | "pip" | "cargo" | "maven"
}
export interface MemoryAction {
type: "ADD" | "UPDATE" | "DELETE" | "NONE"
fact: CategorizedFact
target_id?: string // For UPDATE/DELETE
reasoning: string
}
export interface CategorizedFact {
content: string
category: FactCategory
confidence: number
embedding: number[]
reference_time?: Date
context_description?: string
reasoning?: string
tags?: string[]
subtype?: string
}
export interface ConversationEpisode {
messages: Message[]
reference_time: Date
workspace_path: string
context_description: string
}
export interface Message {
role: "user" | "assistant" | "system"
content: string
timestamp?: Date
}
export interface FactConflictGroup {
newFact: CategorizedFact
existingFacts: ConversationFact[]
}
export interface MemoryStatus {
systemState: string
systemMessage: string
processedEpisodes: number
totalEpisodes: number
}
export interface MemorySearchOptions {
limit?: number
category?: FactCategory
tags?: string[]
}
// Service interfaces
export interface IFactExtractor {
extractFacts(messages: Message[], projectContext: ProjectContext): Promise<CategorizedFact[]>
}
export interface IConflictResolver {
resolveConflicts(
newFacts: CategorizedFact[],
existingFacts: ConversationFact[],
context: ProjectContext,
): Promise<MemoryAction[]>
}
export interface ITemporalManager {
cleanupExpiredFacts(): Promise<void>
calculateTemporalScore(fact: ConversationFact): number
markFactResolved(factId: string): Promise<void>
supersedeFact(oldFactId: string, newFactId: string): Promise<void>
promoteResolvedDebuggingToPattern(fact: ConversationFact, episode?: ConversationEpisode): Promise<void>
}
export interface IConversationProcessor {
processEpisode(episode: ConversationEpisode): Promise<void>
}
export interface IMemoryVectorStore {
insert(embeddings: number[][], ids: string[], payloads: any[]): Promise<void>
search(query: string, embedding: number[], limit: number, filter?: any): Promise<any[]>
get(id: string): Promise<any | null>
update(id: string, vector: number[], payload: any): Promise<void>
delete(id: string): Promise<void>
clear(): Promise<void>
}

View file

@ -0,0 +1,452 @@
import * as vscode from "vscode"
import { createHash } from "crypto"
import { ContextProxy } from "../../core/config/ContextProxy"
import {
ConversationFact,
ConversationEpisode,
Message,
MemoryStatus,
MemorySearchOptions,
ProjectContext,
} from "./interfaces"
import { ConversationMemoryConfigManager } from "./config-manager"
import { ConversationMemoryStateManager } from "./state-manager"
import { ConversationMemoryServiceFactory } from "./service-factory"
import { ConversationMemoryOrchestrator } from "./orchestrator"
import { ConversationMemorySearchService } from "./search-service"
import { ConversationMemoryCacheManager } from "./cache-manager"
import path from "path"
import fs from "fs/promises"
export class ConversationMemoryManager {
// --- Singleton Implementation (mirrors CodeIndexManager) ---
private static instances = new Map<string, ConversationMemoryManager>()
// Service dependencies - following CodeIndex patterns
private _configManager: ConversationMemoryConfigManager | undefined
private readonly _stateManager: ConversationMemoryStateManager
private _serviceFactory: ConversationMemoryServiceFactory | undefined
private _orchestrator: ConversationMemoryOrchestrator | undefined
private _searchService: ConversationMemorySearchService | undefined
private _cacheManager: ConversationMemoryCacheManager | undefined
// Project context for this workspace
private _projectContext: ProjectContext | undefined
// Flag to prevent race conditions during error recovery
private _isRecoveringFromError = false
public static getInstance(
context: vscode.ExtensionContext,
workspacePath?: string,
): ConversationMemoryManager | undefined {
// Exact same workspace discovery logic as CodeIndexManager
if (!workspacePath) {
const activeEditor = vscode.window.activeTextEditor
if (activeEditor) {
const workspaceFolder = vscode.workspace.getWorkspaceFolder(activeEditor.document.uri)
workspacePath = workspaceFolder?.uri.fsPath
}
if (!workspacePath) {
const workspaceFolders = vscode.workspace.workspaceFolders
if (!workspaceFolders || workspaceFolders.length === 0) {
return undefined
}
workspacePath = workspaceFolders[0].uri.fsPath
}
}
if (!ConversationMemoryManager.instances.has(workspacePath)) {
ConversationMemoryManager.instances.set(
workspacePath,
new ConversationMemoryManager(workspacePath, context),
)
}
return ConversationMemoryManager.instances.get(workspacePath)!
}
public static async initializeAll(context: vscode.ExtensionContext): Promise<void> {
const workspaceFolders = vscode.workspace.workspaceFolders || []
for (const folder of workspaceFolders) {
try {
const manager = ConversationMemoryManager.getInstance(context, folder.uri.fsPath)
if (manager) {
const contextProxy = new ContextProxy(context)
await manager.initialize(contextProxy)
}
} catch (error) {
console.warn(`Memory manager initialization failed for ${folder.uri.fsPath}:`, error)
// Continue - don't break other workspaces
}
}
// Listen for workspace changes
vscode.workspace.onDidChangeWorkspaceFolders(async (event) => {
// Handle workspace additions
for (const added of event.added) {
try {
const manager = ConversationMemoryManager.getInstance(context, added.uri.fsPath)
if (manager) {
const contextProxy = new ContextProxy(context)
await manager.initialize(contextProxy)
}
} catch (error) {
console.warn(`Failed to initialize memory manager for ${added.uri.fsPath}:`, error)
}
}
// Handle workspace removals
for (const removed of event.removed) {
const manager = ConversationMemoryManager.instances.get(removed.uri.fsPath)
if (manager) {
manager.dispose()
ConversationMemoryManager.instances.delete(removed.uri.fsPath)
}
}
})
}
public static getCurrentWorkspaceManager(): ConversationMemoryManager | undefined {
const activeEditor = vscode.window.activeTextEditor
if (!activeEditor) return undefined
const workspaceFolder = vscode.workspace.getWorkspaceFolder(activeEditor.document.uri)
if (!workspaceFolder) return undefined
return this.instances.get(workspaceFolder.uri.fsPath)
}
public static disposeAll(): void {
for (const instance of ConversationMemoryManager.instances.values()) {
instance.dispose()
}
ConversationMemoryManager.instances.clear()
}
private readonly workspacePath: string
private readonly context: vscode.ExtensionContext
// Private constructor for singleton pattern
private constructor(workspacePath: string, context: vscode.ExtensionContext) {
this.workspacePath = workspacePath
this.context = context
this._stateManager = new ConversationMemoryStateManager()
}
// --- Public API ---
public get onProgressUpdate() {
return this._stateManager.onProgressUpdate
}
private assertInitialized() {
if (!this._configManager || !this._orchestrator || !this._searchService || !this._cacheManager) {
throw new Error("ConversationMemoryManager not initialized. Call initialize() first.")
}
}
public get isFeatureEnabled(): boolean {
return this._configManager?.isFeatureEnabled ?? false
}
public get isFeatureConfigured(): boolean {
return this._configManager?.isFeatureConfigured ?? false
}
public get isInitialized(): boolean {
try {
this.assertInitialized()
return true
} catch (error) {
return false
}
}
public async initialize(contextProxy: ContextProxy): Promise<{ requiresRestart: boolean }> {
// 1. ConfigManager Initialization and Configuration Loading
if (!this._configManager) {
this._configManager = new ConversationMemoryConfigManager(contextProxy)
}
const { requiresRestart } = await this._configManager.loadConfiguration()
// 2. Check if feature is enabled
if (!this.isFeatureEnabled) {
if (this._orchestrator) {
this._orchestrator.stopProcessing()
}
return { requiresRestart }
}
// 3. Detect project context
this._projectContext = await this.detectProjectContext()
// 4. CacheManager Initialization
if (!this._cacheManager) {
this._cacheManager = new ConversationMemoryCacheManager(this.context, this.workspacePath)
await this._cacheManager.initialize()
}
// 5. Determine if Core Services Need Recreation
const needsServiceRecreation = !this._serviceFactory || requiresRestart
if (needsServiceRecreation) {
await this._recreateServices()
}
return { requiresRestart }
}
public async searchMemory(query: string, options?: MemorySearchOptions): Promise<ConversationFact[]> {
if (!this.isFeatureEnabled) {
return []
}
try {
this.assertInitialized()
return await this._searchService!.searchMemory(query, options)
} catch (error) {
console.warn("Memory search failed:", error)
return []
}
}
public async processConversation(messages: Message[]): Promise<void> {
if (!this.isFeatureEnabled) {
return
}
try {
this.assertInitialized()
const episode: ConversationEpisode = {
messages,
reference_time: new Date(),
workspace_path: this.workspacePath,
context_description: "Interactive conversation",
}
await this._orchestrator!.processConversationEpisode(episode)
} catch (error) {
console.warn("Memory processing failed:", error)
// Never throw - don't break existing flows
}
}
public getCurrentMemoryStatus(): MemoryStatus {
return this._stateManager.getCurrentStatus()
}
public async recoverFromError(): Promise<void> {
// Prevent race conditions from multiple rapid recovery attempts
if (this._isRecoveringFromError) {
return
}
this._isRecoveringFromError = true
try {
// Clear error state
this._stateManager.setSystemState("Standby", "")
} catch (error) {
console.error("Failed to clear error state during recovery:", error)
} finally {
// Force re-initialization by clearing service instances
this._configManager = undefined
this._serviceFactory = undefined
this._orchestrator = undefined
this._searchService = undefined
this._isRecoveringFromError = false
}
}
public dispose(): void {
if (this._orchestrator) {
this._orchestrator.stopProcessing()
}
this._stateManager.dispose()
}
public async clearMemoryData(): Promise<void> {
if (!this.isFeatureEnabled) {
return
}
this.assertInitialized()
await this._orchestrator!.clearMemoryData()
await this._cacheManager!.clearCacheFile()
}
// --- Private Helpers ---
private async detectProjectContext(): Promise<ProjectContext> {
const workspaceFiles = await this.scanWorkspaceFiles()
let language: ProjectContext["language"] = "unknown"
let framework: string | undefined
let packageManager: ProjectContext["packageManager"]
if (workspaceFiles.includes("package.json")) {
language = "typescript"
packageManager = workspaceFiles.includes("yarn.lock")
? "yarn"
: workspaceFiles.includes("pnpm-lock.yaml")
? "pnpm"
: "npm"
// Framework detection from package.json
try {
const packageJsonPath = path.join(this.workspacePath, "package.json")
const packageJson = JSON.parse(await fs.readFile(packageJsonPath, "utf8"))
if (packageJson.dependencies?.react || packageJson.devDependencies?.react) {
framework = "react"
} else if (packageJson.dependencies?.next || packageJson.devDependencies?.next) {
framework = "nextjs"
} else if (packageJson.dependencies?.express || packageJson.devDependencies?.express) {
framework = "express"
} else if (packageJson.dependencies?.vue || packageJson.devDependencies?.vue) {
framework = "vue"
}
} catch (error) {
// Ignore errors reading package.json
}
} else if (workspaceFiles.includes("requirements.txt") || workspaceFiles.includes("pyproject.toml")) {
language = "python"
packageManager = "pip"
// Framework detection
try {
const requirementsPath = path.join(this.workspacePath, "requirements.txt")
const requirements = await fs.readFile(requirementsPath, "utf8")
if (requirements.includes("django")) framework = "django"
else if (requirements.includes("fastapi")) framework = "fastapi"
else if (requirements.includes("flask")) framework = "flask"
else if (requirements.includes("pandas")) framework = "data-science"
} catch (error) {
// Ignore errors reading requirements
}
} else if (workspaceFiles.includes("Cargo.toml")) {
language = "rust"
packageManager = "cargo"
} else if (workspaceFiles.includes("go.mod")) {
language = "go"
} else if (workspaceFiles.includes("pom.xml")) {
language = "java"
packageManager = "maven"
}
return {
language,
framework,
packageManager,
workspaceName: path.basename(this.workspacePath),
}
}
private async scanWorkspaceFiles(): Promise<string[]> {
try {
const files = await fs.readdir(this.workspacePath)
return files
} catch (error) {
console.error("Failed to scan workspace files:", error)
return []
}
}
private async _recreateServices(): Promise<void> {
// Stop processing if it exists
if (this._orchestrator) {
this._orchestrator.stopProcessing()
}
// Clear existing services to ensure clean state
this._orchestrator = undefined
this._searchService = undefined
// (Re)Initialize service factory
this._serviceFactory = new ConversationMemoryServiceFactory(
this._configManager!,
this.workspacePath,
this._cacheManager!,
this._projectContext!,
)
// Create service instances
const services = await this._serviceFactory.createServices(this.context)
// Validate configuration before proceeding
const validationResult = await this._serviceFactory.validateConfiguration()
if (!validationResult.valid) {
const errorMessage = validationResult.error || "Memory service configuration validation failed"
this._stateManager.setSystemState("Error", errorMessage)
throw new Error(errorMessage)
}
// (Re)Initialize orchestrator
this._orchestrator = new ConversationMemoryOrchestrator(
this._configManager!,
this._stateManager,
this.workspacePath,
this._cacheManager!,
services.vectorStore,
services.factExtractor,
services.conflictResolver,
services.temporalManager,
services.conversationProcessor,
)
// (Re)Initialize search service
this._searchService = new ConversationMemorySearchService(
this._configManager!,
this._stateManager,
services.embedder,
services.vectorStore,
services.temporalManager,
)
// Clear any error state after successful recreation
this._stateManager.setSystemState("Standby", "")
}
public async handleSettingsChange(): Promise<void> {
if (this._configManager) {
const { requiresRestart } = await this._configManager.loadConfiguration()
const isFeatureEnabled = this.isFeatureEnabled
const isFeatureConfigured = this.isFeatureConfigured
// If feature is disabled, stop the service
if (!isFeatureEnabled) {
if (this._orchestrator) {
this._orchestrator.stopProcessing()
}
this._stateManager.setSystemState("Standby", "Conversation memory is disabled")
return
}
if (requiresRestart && isFeatureEnabled && isFeatureConfigured) {
try {
// Ensure cacheManager is initialized before recreating services
if (!this._cacheManager) {
this._cacheManager = new ConversationMemoryCacheManager(this.context, this.workspacePath)
await this._cacheManager.initialize()
}
// Recreate services with new configuration
await this._recreateServices()
} catch (error) {
console.error("Failed to recreate memory services:", error)
throw error
}
}
}
}
// Generate unique collection name for this workspace
public getMemoryCollectionName(): string {
const hash = createHash("sha256").update(this.workspacePath).digest("hex")
return `ws-${hash.substring(0, 16)}-memory`
}
}

View file

@ -0,0 +1,118 @@
import { ConversationMemoryConfigManager } from "./config-manager"
import { ConversationMemoryStateManager } from "./state-manager"
import { ConversationMemoryCacheManager } from "./cache-manager"
import {
IMemoryVectorStore,
IFactExtractor,
IConflictResolver,
ITemporalManager,
IConversationProcessor,
ConversationEpisode,
ConversationFact,
FactCategory,
} from "./interfaces"
export class ConversationMemoryOrchestrator {
private isProcessing = false
constructor(
private readonly configManager: ConversationMemoryConfigManager,
private readonly stateManager: ConversationMemoryStateManager,
private readonly workspacePath: string,
private readonly cacheManager: ConversationMemoryCacheManager,
private readonly vectorStore: IMemoryVectorStore,
private readonly factExtractor: IFactExtractor,
private readonly conflictResolver: IConflictResolver,
private readonly temporalManager: ITemporalManager,
private readonly conversationProcessor: IConversationProcessor,
) {}
public get state(): string {
return this.isProcessing ? "Processing" : "Standby"
}
public async processConversationEpisode(episode: ConversationEpisode): Promise<void> {
if (this.isProcessing) {
console.log("Already processing a conversation episode")
return
}
this.isProcessing = true
this.stateManager.setProcessingState("Processing conversation episode")
try {
// 1. Extract facts from the conversation
const projectContext = {
language: "typescript" as const,
workspaceName: this.workspacePath,
framework: undefined,
packageManager: "npm" as const,
}
const newFacts = await this.factExtractor.extractFacts(episode.messages, projectContext)
if (newFacts.length === 0) {
console.log("No facts extracted from conversation")
return
}
// 2. Find conflicting facts via vector similarity
const existingFacts = this.cacheManager.getAllFacts()
// 3. Resolve conflicts
const memoryActions = await this.conflictResolver.resolveConflicts(newFacts, existingFacts, projectContext)
// 4. Apply memory actions
for (const action of memoryActions) {
if (action.type === "ADD") {
const fact: ConversationFact = {
id: this.generateFactId(),
content: action.fact.content,
category: action.fact.category,
confidence: action.fact.confidence,
reference_time: episode.reference_time,
ingestion_time: new Date(),
workspace_path: this.workspacePath,
project_context: projectContext,
conversation_context: episode.context_description,
embedding: action.fact.embedding,
metadata: {},
}
// Store in cache and vector store
this.cacheManager.setFact(fact)
await this.vectorStore.insert([fact.embedding], [fact.id], [fact])
}
// TODO: Handle UPDATE and DELETE actions
}
// 5. Cleanup expired facts
await this.temporalManager.cleanupExpiredFacts()
// 6. Save cache
await this.cacheManager.saveCache()
this.stateManager.setSystemState("Standby", "Processing complete")
} catch (error) {
console.error("Error processing conversation episode:", error)
this.stateManager.setError(`Processing failed: ${error}`)
} finally {
this.isProcessing = false
}
}
public stopProcessing(): void {
this.isProcessing = false
this.stateManager.setSystemState("Standby", "Processing stopped")
}
public async clearMemoryData(): Promise<void> {
await this.vectorStore.clear()
await this.cacheManager.clearCacheFile()
this.stateManager.setSystemState("Standby", "Memory data cleared")
}
private generateFactId(): string {
return `fact_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`
}
}

View file

@ -0,0 +1,135 @@
import { ConversationMemoryConfigManager } from "./config-manager"
import { ConversationMemoryStateManager } from "./state-manager"
import { IEmbedder } from "../code-index/interfaces"
import { IMemoryVectorStore, ITemporalManager, ConversationFact, MemorySearchOptions, FactCategory } from "./interfaces"
export class ConversationMemorySearchService {
constructor(
private readonly configManager: ConversationMemoryConfigManager,
private readonly stateManager: ConversationMemoryStateManager,
private readonly embedder: IEmbedder,
private readonly vectorStore: IMemoryVectorStore,
private readonly temporalManager: ITemporalManager,
) {}
public async searchMemory(query: string, options?: MemorySearchOptions): Promise<ConversationFact[]> {
try {
// 1. Generate query embedding
const embeddingResponse = await this.embedder.createEmbeddings([query])
const queryEmbedding = embeddingResponse.embeddings[0]
// 2. Build search filter
const filter: any = {}
if (options?.category) {
filter.category = options.category
}
if (options?.tags && options.tags.length > 0) {
filter.tags = { $in: options.tags }
}
// 3. Search vector store
const limit = options?.limit || 10
const rawResults = await this.vectorStore.search(
query,
queryEmbedding,
limit * 2, // Get more results for temporal filtering
filter,
)
// 4. Apply temporal scoring and filtering
const scoredResults = rawResults.map((result) => {
const fact = result.payload as ConversationFact
const temporalScore = this.temporalManager.calculateTemporalScore(fact)
return {
fact,
temporalScore,
similarityScore: result.score || 0,
}
})
// 5. Filter by temporal relevance and sort
const relevantResults = scoredResults
.filter((result) => result.temporalScore > 0.3) // Minimum relevance threshold
.sort((a, b) => {
// Combine similarity and temporal scores
const aScore = a.similarityScore * 0.7 + a.temporalScore * 0.3
const bScore = b.similarityScore * 0.7 + b.temporalScore * 0.3
return bScore - aScore
})
.slice(0, limit)
.map((result) => result.fact)
return relevantResults
} catch (error) {
this.stateManager.setError(`Memory search failed: ${error}`)
throw error
}
}
public async getRelevantMemoryForPrompt(userMessage: string, maxTokens: number = 400): Promise<string> {
// Heuristics for automatic memory retrieval
const messageLower = userMessage.toLowerCase()
let category: FactCategory | undefined
let searchQuery = userMessage
// Detect intent from message
if (messageLower.includes("error") || messageLower.includes("bug") || messageLower.includes("fix")) {
category = FactCategory.DEBUGGING
} else if (
messageLower.includes("architecture") ||
messageLower.includes("design") ||
messageLower.includes("approach")
) {
category = FactCategory.ARCHITECTURE
} else if (
messageLower.includes("database") ||
messageLower.includes("deploy") ||
messageLower.includes("setup")
) {
category = FactCategory.INFRASTRUCTURE
}
// Search for relevant memories
const memories = await this.searchMemory(searchQuery, {
category,
limit: 6,
})
if (memories.length === 0) {
return ""
}
// Format memories for prompt injection
const formattedMemories = memories.map((fact) => {
const date = fact.reference_time.toLocaleDateString()
const categoryLabel = fact.category.toUpperCase()
let annotation = ""
if (fact.superseded_by) {
annotation = " (superseded)"
} else if (fact.resolved) {
annotation = " (resolved)"
} else if (fact.derived_from) {
annotation = " (derived from incident)"
}
return `- ${categoryLabel}: ${fact.content}${annotation} (${date})`
})
const memorySection = `# Relevant Memory (auto)\n${formattedMemories.join("\n")}`
// Simple token estimation (rough approximation)
const estimatedTokens = memorySection.length / 4
if (estimatedTokens > maxTokens) {
// Truncate if too long
const truncatedMemories = formattedMemories.slice(
0,
Math.floor(formattedMemories.length * ((maxTokens * 4) / memorySection.length)),
)
return `# Relevant Memory (auto)\n${truncatedMemories.join("\n")}`
}
return memorySection
}
}

View file

@ -0,0 +1,164 @@
import * as vscode from "vscode"
import { ConversationMemoryConfigManager } from "./config-manager"
import { ConversationMemoryCacheManager } from "./cache-manager"
import {
ProjectContext,
IFactExtractor,
IConflictResolver,
ITemporalManager,
IConversationProcessor,
IMemoryVectorStore,
} from "./interfaces"
import { IEmbedder } from "../code-index/interfaces"
export interface ConversationMemoryServices {
embedder: IEmbedder
vectorStore: IMemoryVectorStore
factExtractor: IFactExtractor
conflictResolver: IConflictResolver
temporalManager: ITemporalManager
conversationProcessor: IConversationProcessor
}
export class ConversationMemoryServiceFactory {
constructor(
private readonly configManager: ConversationMemoryConfigManager,
private readonly workspacePath: string,
private readonly cacheManager: ConversationMemoryCacheManager,
private readonly projectContext: ProjectContext,
) {}
public async createServices(context: vscode.ExtensionContext): Promise<ConversationMemoryServices> {
// TODO: Implement actual service creation
// For now, return stub implementations
const embedder = this.createEmbedder()
const vectorStore = this.createVectorStore()
const factExtractor = this.createFactExtractor()
const conflictResolver = this.createConflictResolver()
const temporalManager = this.createTemporalManager()
const conversationProcessor = this.createConversationProcessor()
return {
embedder,
vectorStore,
factExtractor,
conflictResolver,
temporalManager,
conversationProcessor,
}
}
public async validateConfiguration(): Promise<{ valid: boolean; error?: string }> {
// TODO: Implement configuration validation
// Check if LLM provider is configured, embedder is available, etc.
const config = this.configManager.getConfig()
if (!config.enabled) {
return { valid: false, error: "Conversation memory is not enabled" }
}
// For now, assume configuration is valid if enabled
return { valid: true }
}
private createEmbedder(): IEmbedder {
// TODO: Create actual embedder based on configuration
// For now, return a stub
return {
embed: async (text: string) => {
// Return a dummy embedding vector
return new Array(384).fill(0).map(() => Math.random())
},
embedBatch: async (texts: string[]) => {
return texts.map(() => new Array(384).fill(0).map(() => Math.random()))
},
createEmbeddings: async (texts: string[]) => {
return texts.map(() => new Array(384).fill(0).map(() => Math.random()))
},
validateConfiguration: async () => {
return { valid: true }
},
embedderInfo: {
model: "stub",
dimensions: 384,
maxInputTokens: 8192,
},
} as IEmbedder
}
private createVectorStore(): IMemoryVectorStore {
// TODO: Create actual vector store (Qdrant integration)
// For now, return an in-memory stub
const store = new Map<string, { vector: number[]; payload: any }>()
return {
insert: async (embeddings: number[][], ids: string[], payloads: any[]) => {
for (let i = 0; i < ids.length; i++) {
store.set(ids[i], { vector: embeddings[i], payload: payloads[i] })
}
},
search: async (query: string, embedding: number[], limit: number, filter?: any) => {
// Return all items for now (no actual similarity search)
return Array.from(store.values()).slice(0, limit)
},
get: async (id: string) => {
return store.get(id) || null
},
update: async (id: string, vector: number[], payload: any) => {
store.set(id, { vector, payload })
},
delete: async (id: string) => {
store.delete(id)
},
clear: async () => {
store.clear()
},
}
}
private createFactExtractor(): IFactExtractor {
// TODO: Implement LLM-based fact extraction
return {
extractFacts: async (messages, projectContext) => {
// Stub implementation
return []
},
}
}
private createConflictResolver(): IConflictResolver {
// TODO: Implement LLM-based conflict resolution
return {
resolveConflicts: async (newFacts, existingFacts, context) => {
// Stub implementation - just add all new facts
return newFacts.map((fact) => ({
type: "ADD" as const,
fact,
reasoning: "No conflict detection implemented yet",
}))
},
}
}
private createTemporalManager(): ITemporalManager {
// TODO: Implement temporal lifecycle management
return {
cleanupExpiredFacts: async () => {},
calculateTemporalScore: (fact) => fact.confidence,
markFactResolved: async (factId) => {},
supersedeFact: async (oldFactId, newFactId) => {},
promoteResolvedDebuggingToPattern: async (fact, episode) => {},
}
}
private createConversationProcessor(): IConversationProcessor {
// TODO: Implement conversation processing pipeline
return {
processEpisode: async (episode) => {
console.log("Processing conversation episode:", episode.context_description)
},
}
}
}

View file

@ -0,0 +1,82 @@
import * as vscode from "vscode"
import { MemoryStatus } from "./interfaces"
export interface ProgressData {
processedEpisodes: number
totalEpisodes: number
}
export interface ProgressUpdate {
state: string
message: string
progress?: ProgressData
}
export class ConversationMemoryStateManager {
private _systemState: string = "Standby"
private _systemMessage: string = ""
private _progressData: ProgressData = { processedEpisodes: 0, totalEpisodes: 0 }
// Event emitter for progress updates - matches CodeIndex pattern
private _onProgressUpdate = new vscode.EventEmitter<ProgressUpdate>()
public readonly onProgressUpdate = this._onProgressUpdate.event
public getCurrentStatus(): MemoryStatus {
return {
systemState: this._systemState,
systemMessage: this._systemMessage,
processedEpisodes: this._progressData.processedEpisodes,
totalEpisodes: this._progressData.totalEpisodes,
}
}
public setSystemState(state: string, message: string): void {
this._systemState = state
this._systemMessage = message
this._onProgressUpdate.fire({
state: this._systemState,
message: this._systemMessage,
progress: this._progressData,
})
}
public setProcessingState(message: string, progress?: ProgressData): void {
this._systemState = "Processing"
this._systemMessage = message
if (progress) {
this._progressData = progress
}
this._onProgressUpdate.fire({
state: this._systemState,
message: this._systemMessage,
progress: this._progressData,
})
}
public setError(errorMessage: string): void {
this._systemState = "Error"
this._systemMessage = errorMessage
this._onProgressUpdate.fire({
state: this._systemState,
message: this._systemMessage,
progress: this._progressData,
})
}
public updateProgress(processedEpisodes: number, totalEpisodes: number): void {
this._progressData = { processedEpisodes, totalEpisodes }
this._onProgressUpdate.fire({
state: this._systemState,
message: this._systemMessage,
progress: this._progressData,
})
}
public dispose(): void {
this._onProgressUpdate.dispose()
}
}

View file

@ -196,6 +196,7 @@ export const TOOL_DISPLAY_NAMES: Record<ToolName, string> = {
insert_content: "insert content",
search_and_replace: "search and replace",
codebase_search: "codebase search",
memory_search: "memory search",
update_todo_list: "update todo list",
generate_image: "generate images",
} as const
@ -210,6 +211,7 @@ export const TOOL_GROUPS: Record<ToolGroup, ToolGroupConfig> = {
"list_files",
"list_code_definition_names",
"codebase_search",
"memory_search",
],
},
edit: {