UNPKG

mcp-adr-analysis-server

Version:

MCP server for analyzing Architectural Decision Records and project architecture

443 lines 17.9 kB
/** * Conversation Memory Manager * * Phase 3 of context decay mitigation: Structured external memory. * Handles conversation persistence, expandable content storage, and resumption. */ import * as fs from 'fs/promises'; import * as path from 'path'; import * as os from 'os'; import crypto from 'crypto'; import { createLogger, loadConfig } from './config.js'; /** * Default configuration */ const DEFAULT_CONFIG = { maxSessionsInMemory: 10, persistAfterTurns: 5, sessionMaxAgeHours: 24, autoCleanup: true, archivedRetentionDays: 30, }; export class ConversationMemoryManager { memoryDir; sessionsDir; expandableContentDir; archiveDir; config; kgManager; logger; // In-memory cache activeSession = null; expandableContentCache = new Map(); constructor(kgManager, config = {}) { this.config = { ...DEFAULT_CONFIG, ...config }; this.kgManager = kgManager; const serverConfig = loadConfig(); this.logger = createLogger(serverConfig); // Setup storage directories const projectName = path.basename(serverConfig.projectPath); const baseDir = path.join(os.tmpdir(), projectName, 'conversation-memory'); this.memoryDir = baseDir; this.sessionsDir = path.join(baseDir, 'sessions'); this.expandableContentDir = path.join(baseDir, 'expandable-content'); this.archiveDir = path.join(baseDir, 'archive'); } /** * Initialize the conversation memory system */ async initialize() { try { // Ensure directories exist await fs.mkdir(this.memoryDir, { recursive: true }); await fs.mkdir(this.sessionsDir, { recursive: true }); await fs.mkdir(this.expandableContentDir, { recursive: true }); await fs.mkdir(this.archiveDir, { recursive: true }); // Try to load the most recent active session await this.loadMostRecentSession(); this.logger.info('Conversation Memory Manager initialized', 'ConversationMemoryManager', { activeSession: this.activeSession?.sessionId ?? 'none', }); // Schedule cleanup if enabled if (this.config.autoCleanup) { this.scheduleCleanup(); } } catch (error) { this.logger.error('Failed to initialize conversation memory', 'ConversationMemoryManager', error instanceof Error ? error : undefined); throw error; } } /** * Start a new conversation session */ async startNewSession(projectPath) { const sessionId = this.generateSessionId(); const now = new Date().toISOString(); this.activeSession = { sessionId, projectPath, startedAt: now, lastActivityAt: now, turns: [], metadata: { totalTokensUsed: 0, averageResponseTime: 0, toolsUsed: [], knowledgeGraphIntents: [], }, }; await this.persistSession(); this.logger.info(`New conversation session started: ${sessionId}`, 'ConversationMemoryManager'); return sessionId; } /** * Record a conversation turn */ async recordTurn(request, response, metadata = {}) { if (!this.activeSession) { const serverConfig = loadConfig(); await this.startNewSession(serverConfig.projectPath); } const turnId = this.generateTurnId(); const turnNumber = this.activeSession.turns.length + 1; const turn = { id: turnId, turnNumber, timestamp: new Date().toISOString(), request, response, metadata: { ...(metadata.duration !== undefined ? { duration: metadata.duration } : {}), ...(metadata.model ? { model: metadata.model } : {}), cacheHit: metadata.cacheHit ?? false, errorOccurred: metadata.errorOccurred ?? false, }, }; this.activeSession.turns.push(turn); this.activeSession.lastActivityAt = turn.timestamp; // Update session metadata this.activeSession.metadata.totalTokensUsed += response.tokenCount; if (request.toolName && !this.activeSession.metadata.toolsUsed.includes(request.toolName)) { this.activeSession.metadata.toolsUsed.push(request.toolName); } // Persist if threshold reached if (this.activeSession.turns.length % this.config.persistAfterTurns === 0) { await this.persistSession(); } this.logger.debug(`Turn ${turnNumber} recorded for session ${this.activeSession.sessionId}`, 'ConversationMemoryManager'); return turnId; } /** * Store expandable content from tiered response */ async storeExpandableContent(expandableId, content) { try { const filePath = path.join(this.expandableContentDir, `${expandableId}.json`); await fs.writeFile(filePath, JSON.stringify(content, null, 2), 'utf-8'); // Cache in memory this.expandableContentCache.set(expandableId, content); this.logger.debug(`Expandable content stored: ${expandableId}`, 'ConversationMemoryManager'); } catch (error) { this.logger.error(`Failed to store expandable content: ${expandableId}`, 'ConversationMemoryManager', error instanceof Error ? error : undefined); throw error; } } /** * Retrieve and expand stored content */ async expandContent(request) { try { // Check cache first let content = this.expandableContentCache.get(request.expandableId); if (!content) { // Load from disk const filePath = path.join(this.expandableContentDir, `${request.expandableId}.json`); const data = await fs.readFile(filePath, 'utf-8'); content = JSON.parse(data); this.expandableContentCache.set(request.expandableId, content); } // Get related turns if context requested let relatedTurns; if (request.includeContext && this.activeSession) { relatedTurns = this.activeSession.turns.filter(turn => turn.response.expandableId === request.expandableId); } // Get knowledge graph context let knowledgeGraphContext; if (request.includeContext) { const intents = await this.kgManager.getActiveIntents(); knowledgeGraphContext = intents.slice(0, 3).map(intent => ({ intent: intent.humanRequest, outcome: intent.currentStatus, timestamp: intent.timestamp, })); } return { expandableId: request.expandableId, content, ...(relatedTurns ? { relatedTurns } : {}), ...(knowledgeGraphContext ? { knowledgeGraphContext } : {}), }; } catch (error) { this.logger.error(`Failed to expand content: ${request.expandableId}`, 'ConversationMemoryManager', error instanceof Error ? error : undefined); throw error; } } /** * Get conversation context snapshot for resumption */ async getContextSnapshot(recentTurnCount = 5) { if (!this.activeSession) { return null; } const recentTurns = this.activeSession.turns.slice(-recentTurnCount); // Get active KG intents const intents = await this.kgManager.getActiveIntents(); const activeIntents = intents.slice(0, 5).map(intent => ({ id: intent.intentId, intent: intent.humanRequest, status: intent.currentStatus, })); // Get recorded decisions (from KG or metadata) const decisionsRecorded = []; // This could be enhanced to query KG for ADRs created in this session return { sessionId: this.activeSession.sessionId, recentTurns, activeIntents, decisionsRecorded, // conversationFocus is optional and can be omitted }; } /** * Query conversation sessions */ async querySessions(query) { try { const sessionFiles = await fs.readdir(this.sessionsDir); const sessions = []; for (const file of sessionFiles) { if (!file.endsWith('.json')) continue; const filePath = path.join(this.sessionsDir, file); const data = await fs.readFile(filePath, 'utf-8'); const session = JSON.parse(data); // Apply filters if (query.projectPath && session.projectPath !== query.projectPath) continue; if (query.toolsUsed && !query.toolsUsed.some(tool => session.metadata.toolsUsed.includes(tool))) continue; if (query.dateRange) { const sessionDate = new Date(session.startedAt); const start = new Date(query.dateRange.start); const end = new Date(query.dateRange.end); if (sessionDate < start || sessionDate > end) continue; } sessions.push(session); } // Apply limit const limit = query.limit ?? 10; return sessions.slice(0, limit); } catch (error) { this.logger.error('Failed to query sessions', 'ConversationMemoryManager', error instanceof Error ? error : undefined); return []; } } /** * Get conversation memory statistics */ async getStats() { try { const sessionFiles = await fs.readdir(this.sessionsDir); const archivedFiles = await fs.readdir(this.archiveDir); const expandableFiles = await fs.readdir(this.expandableContentDir); let totalTurns = 0; let activeSessions = 0; const now = Date.now(); const maxAge = this.config.sessionMaxAgeHours * 60 * 60 * 1000; for (const file of sessionFiles) { if (!file.endsWith('.json')) continue; const filePath = path.join(this.sessionsDir, file); const data = await fs.readFile(filePath, 'utf-8'); const session = JSON.parse(data); totalTurns += session.turns.length; const sessionAge = now - new Date(session.lastActivityAt).getTime(); if (sessionAge < maxAge) { activeSessions++; } } // Calculate storage size const getDirectorySize = async (dir) => { let size = 0; const files = await fs.readdir(dir); for (const file of files) { const filePath = path.join(dir, file); const stat = await fs.stat(filePath); size += stat.size; } return size; }; const totalStorageBytes = (await getDirectorySize(this.sessionsDir)) + (await getDirectorySize(this.expandableContentDir)) + (await getDirectorySize(this.archiveDir)); return { totalSessions: sessionFiles.filter(f => f.endsWith('.json')).length, activeSessions, archivedSessions: archivedFiles.filter(f => f.endsWith('.json')).length, totalTurns, totalExpandableContent: expandableFiles.filter(f => f.endsWith('.json')).length, avgTurnsPerSession: sessionFiles.length > 0 ? totalTurns / sessionFiles.length : 0, totalStorageBytes, }; } catch (error) { this.logger.error('Failed to get conversation memory stats', 'ConversationMemoryManager', error instanceof Error ? error : undefined); return { totalSessions: 0, activeSessions: 0, archivedSessions: 0, totalTurns: 0, totalExpandableContent: 0, avgTurnsPerSession: 0, totalStorageBytes: 0, }; } } /** * Persist current session to disk */ async persistSession() { if (!this.activeSession) return; try { const filePath = path.join(this.sessionsDir, `${this.activeSession.sessionId}.json`); await fs.writeFile(filePath, JSON.stringify(this.activeSession, null, 2), 'utf-8'); this.logger.debug(`Session persisted: ${this.activeSession.sessionId}`, 'ConversationMemoryManager'); } catch (error) { this.logger.error('Failed to persist session', 'ConversationMemoryManager', error instanceof Error ? error : undefined); } } /** * Load most recent session */ async loadMostRecentSession() { try { const sessionFiles = await fs.readdir(this.sessionsDir); if (sessionFiles.length === 0) return; // Find most recent session let mostRecentFile = ''; let mostRecentTime = 0; for (const file of sessionFiles) { if (!file.endsWith('.json')) continue; const filePath = path.join(this.sessionsDir, file); const data = await fs.readFile(filePath, 'utf-8'); const session = JSON.parse(data); const sessionTime = new Date(session.lastActivityAt).getTime(); if (sessionTime > mostRecentTime) { mostRecentTime = sessionTime; mostRecentFile = file; } } if (mostRecentFile) { const filePath = path.join(this.sessionsDir, mostRecentFile); const data = await fs.readFile(filePath, 'utf-8'); this.activeSession = JSON.parse(data); this.logger.info(`Loaded most recent session: ${this.activeSession.sessionId}`, 'ConversationMemoryManager'); } } catch (error) { this.logger.error('Failed to load most recent session', 'ConversationMemoryManager', error instanceof Error ? error : undefined); } } /** * Schedule periodic cleanup of old sessions */ scheduleCleanup() { // Run cleanup every hour setInterval(() => { this.cleanupOldSessions().catch(error => { this.logger.error('Scheduled cleanup failed', 'ConversationMemoryManager', error instanceof Error ? error : undefined); }); }, 60 * 60 * 1000); } /** * Archive old sessions and cleanup expired archives */ async cleanupOldSessions() { try { const sessionFiles = await fs.readdir(this.sessionsDir); const now = Date.now(); const maxAge = this.config.sessionMaxAgeHours * 60 * 60 * 1000; const archiveAge = this.config.archivedRetentionDays * 24 * 60 * 60 * 1000; // Archive old sessions for (const file of sessionFiles) { if (!file.endsWith('.json')) continue; const filePath = path.join(this.sessionsDir, file); const data = await fs.readFile(filePath, 'utf-8'); const session = JSON.parse(data); const sessionAge = now - new Date(session.lastActivityAt).getTime(); if (sessionAge > maxAge) { // Move to archive const archivePath = path.join(this.archiveDir, file); await fs.rename(filePath, archivePath); this.logger.debug(`Session archived: ${session.sessionId}`, 'ConversationMemoryManager'); } } // Delete expired archives const archiveFiles = await fs.readdir(this.archiveDir); for (const file of archiveFiles) { if (!file.endsWith('.json')) continue; const filePath = path.join(this.archiveDir, file); const stat = await fs.stat(filePath); const fileAge = now - stat.mtimeMs; if (fileAge > archiveAge) { await fs.unlink(filePath); this.logger.debug(`Archived session deleted: ${file}`, 'ConversationMemoryManager'); } } } catch (error) { this.logger.error('Cleanup failed', 'ConversationMemoryManager', error instanceof Error ? error : undefined); } } /** * Generate unique session ID */ generateSessionId() { const timestamp = Date.now().toString(36); const random = crypto.randomBytes(6).toString('hex'); return `session-${timestamp}-${random}`; } /** * Generate unique turn ID */ generateTurnId() { const timestamp = Date.now().toString(36); const random = crypto.randomBytes(4).toString('hex'); return `turn-${timestamp}-${random}`; } /** * Get current session ID */ getCurrentSessionId() { return this.activeSession?.sessionId ?? null; } /** * Get current session */ getCurrentSession() { return this.activeSession; } } //# sourceMappingURL=conversation-memory-manager.js.map