diff --git a/packages/types/src/tool.ts b/packages/types/src/tool.ts index c31f63df76..9440e72fe7 100644 --- a/packages/types/src/tool.ts +++ b/packages/types/src/tool.ts @@ -33,6 +33,7 @@ export const toolNames = [ "new_task", "fetch_instructions", "codebase_search", + "memory_search", "update_todo_list", "generate_image", ] as const diff --git a/src/core/prompts/tools/index.ts b/src/core/prompts/tools/index.ts index 8b4e90733c..70322b3a87 100644 --- a/src/core/prompts/tools/index.ts +++ b/src/core/prompts/tools/index.ts @@ -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 | undefined> = { @@ -50,6 +52,7 @@ const toolDescriptionMap: Record 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, } diff --git a/src/core/prompts/tools/memory-search.ts b/src/core/prompts/tools/memory-search.ts new file mode 100644 index 0000000000..f5d7c099a6 --- /dev/null +++ b/src/core/prompts/tools/memory-search.ts @@ -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: + +Your natural language query here +architecture +auth,cookies +6 + + +Examples: + +1. Search for authentication decisions: + +authentication approach +architecture + + +2. Find debugging patterns: + +CORS error fixes +pattern + + +3. General project context search: + +database configuration + + +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) +` +} diff --git a/src/services/conversation-memory/cache-manager.ts b/src/services/conversation-memory/cache-manager.ts new file mode 100644 index 0000000000..bfaeba94ef --- /dev/null +++ b/src/services/conversation-memory/cache-manager.ts @@ -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 + } +} + +export class ConversationMemoryCacheManager { + private readonly cacheFileName = "conversation-memory-cache.json" + private readonly cacheVersion = "1.0.0" + private cacheFilePath: string + private memoryCache: Map = 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 { + // 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 { + 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 { + try { + const facts = Array.from(this.memoryCache.values()) + + // Calculate category statistics + const categories: Record = {} + 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 { + 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 { + if (this.saveTimer) { + clearTimeout(this.saveTimer) + } + await this.saveCache() + } +} diff --git a/src/services/conversation-memory/config-manager.ts b/src/services/conversation-memory/config-manager.ts new file mode 100644 index 0000000000..5c8e1259d2 --- /dev/null +++ b/src/services/conversation-memory/config-manager.ts @@ -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 + + // 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) + ) + } +} diff --git a/src/services/conversation-memory/interfaces/index.ts b/src/services/conversation-memory/interfaces/index.ts new file mode 100644 index 0000000000..7b008c01f9 --- /dev/null +++ b/src/services/conversation-memory/interfaces/index.ts @@ -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 +} + +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 +} + +export interface IConflictResolver { + resolveConflicts( + newFacts: CategorizedFact[], + existingFacts: ConversationFact[], + context: ProjectContext, + ): Promise +} + +export interface ITemporalManager { + cleanupExpiredFacts(): Promise + calculateTemporalScore(fact: ConversationFact): number + markFactResolved(factId: string): Promise + supersedeFact(oldFactId: string, newFactId: string): Promise + promoteResolvedDebuggingToPattern(fact: ConversationFact, episode?: ConversationEpisode): Promise +} + +export interface IConversationProcessor { + processEpisode(episode: ConversationEpisode): Promise +} + +export interface IMemoryVectorStore { + insert(embeddings: number[][], ids: string[], payloads: any[]): Promise + search(query: string, embedding: number[], limit: number, filter?: any): Promise + get(id: string): Promise + update(id: string, vector: number[], payload: any): Promise + delete(id: string): Promise + clear(): Promise +} diff --git a/src/services/conversation-memory/manager.ts b/src/services/conversation-memory/manager.ts new file mode 100644 index 0000000000..dc85f4c24e --- /dev/null +++ b/src/services/conversation-memory/manager.ts @@ -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() + + // 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 { + 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 { + 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 { + 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 { + // 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 { + if (!this.isFeatureEnabled) { + return + } + this.assertInitialized() + await this._orchestrator!.clearMemoryData() + await this._cacheManager!.clearCacheFile() + } + + // --- Private Helpers --- + + private async detectProjectContext(): Promise { + 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 { + 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 { + // 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 { + 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` + } +} diff --git a/src/services/conversation-memory/orchestrator.ts b/src/services/conversation-memory/orchestrator.ts new file mode 100644 index 0000000000..0ebc7f7c26 --- /dev/null +++ b/src/services/conversation-memory/orchestrator.ts @@ -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 { + 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 { + 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)}` + } +} diff --git a/src/services/conversation-memory/search-service.ts b/src/services/conversation-memory/search-service.ts new file mode 100644 index 0000000000..69b1409afc --- /dev/null +++ b/src/services/conversation-memory/search-service.ts @@ -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 { + 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 { + // 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 + } +} diff --git a/src/services/conversation-memory/service-factory.ts b/src/services/conversation-memory/service-factory.ts new file mode 100644 index 0000000000..35f3b0bc37 --- /dev/null +++ b/src/services/conversation-memory/service-factory.ts @@ -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 { + // 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() + + 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) + }, + } + } +} diff --git a/src/services/conversation-memory/state-manager.ts b/src/services/conversation-memory/state-manager.ts new file mode 100644 index 0000000000..9ee3801a01 --- /dev/null +++ b/src/services/conversation-memory/state-manager.ts @@ -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() + 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() + } +} diff --git a/src/shared/tools.ts b/src/shared/tools.ts index 8a8776764e..4126fe9154 100644 --- a/src/shared/tools.ts +++ b/src/shared/tools.ts @@ -196,6 +196,7 @@ export const TOOL_DISPLAY_NAMES: Record = { 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 = { "list_files", "list_code_definition_names", "codebase_search", + "memory_search", ], }, edit: {