Skip to main content
Glama

mcp-adr-analysis-server

by tosin2013
conversation-memory-manager.ts17.6 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 { ConversationSession, ConversationTurn, ConversationMemoryConfig, ConversationContextSnapshot, MemoryExpansionRequest, MemoryExpansionResponse, SessionQuery, ConversationMemoryStats, } from '../types/conversation-memory.js'; import type { ExpandableContent } from '../types/tiered-response.js'; import { KnowledgeGraphManager } from './knowledge-graph-manager.js'; import { createLogger, loadConfig } from './config.js'; /** * Default configuration */ const DEFAULT_CONFIG: ConversationMemoryConfig = { maxSessionsInMemory: 10, persistAfterTurns: 5, sessionMaxAgeHours: 24, autoCleanup: true, archivedRetentionDays: 30, }; export class ConversationMemoryManager { private memoryDir: string; private sessionsDir: string; private expandableContentDir: string; private archiveDir: string; private config: ConversationMemoryConfig; private kgManager: KnowledgeGraphManager; private logger: ReturnType<typeof createLogger>; // In-memory cache private activeSession: ConversationSession | null = null; private expandableContentCache: Map<string, ExpandableContent> = new Map(); constructor(kgManager: KnowledgeGraphManager, config: Partial<ConversationMemoryConfig> = {}) { 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(): Promise<void> { 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: string): Promise<string> { 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: ConversationTurn['request'], response: ConversationTurn['response'], metadata: Partial<ConversationTurn['metadata']> = {} ): Promise<string> { if (!this.activeSession) { const serverConfig = loadConfig(); await this.startNewSession(serverConfig.projectPath); } const turnId = this.generateTurnId(); const turnNumber = this.activeSession!.turns.length + 1; const turn: ConversationTurn = { 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: string, content: ExpandableContent): Promise<void> { 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: MemoryExpansionRequest): Promise<MemoryExpansionResponse> { 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) as ExpandableContent; this.expandableContentCache.set(request.expandableId, content); } // Get related turns if context requested let relatedTurns: ConversationTurn[] | undefined; 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: number = 5 ): Promise<ConversationContextSnapshot | null> { 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: ConversationContextSnapshot['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: SessionQuery): Promise<ConversationSession[]> { try { const sessionFiles = await fs.readdir(this.sessionsDir); const sessions: ConversationSession[] = []; 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) as ConversationSession; // 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(): Promise<ConversationMemoryStats> { 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) as ConversationSession; totalTurns += session.turns.length; const sessionAge = now - new Date(session.lastActivityAt).getTime(); if (sessionAge < maxAge) { activeSessions++; } } // Calculate storage size const getDirectorySize = async (dir: string): Promise<number> => { 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 */ private async persistSession(): Promise<void> { 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 */ private async loadMostRecentSession(): Promise<void> { 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) as ConversationSession; 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) as ConversationSession; 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 */ private scheduleCleanup(): void { // 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 */ private async cleanupOldSessions(): Promise<void> { 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) as ConversationSession; 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 */ private generateSessionId(): string { const timestamp = Date.now().toString(36); const random = crypto.randomBytes(6).toString('hex'); return `session-${timestamp}-${random}`; } /** * Generate unique turn ID */ private generateTurnId(): string { const timestamp = Date.now().toString(36); const random = crypto.randomBytes(4).toString('hex'); return `turn-${timestamp}-${random}`; } /** * Get current session ID */ getCurrentSessionId(): string | null { return this.activeSession?.sessionId ?? null; } /** * Get current session */ getCurrentSession(): ConversationSession | null { return this.activeSession; } }

MCP directory API

We provide all the information about MCP servers via our MCP API.

curl -X GET 'https://glama.ai/api/mcp/v1/servers/tosin2013/mcp-adr-analysis-server'

If you have feedback or need assistance with the MCP directory API, please join our Discord server