Skip to main content
Glama

MCP Memory Service

session-tracker.js17.6 kB
/** * Session Tracker Utility * Provides cross-session intelligence and conversation continuity * Phase 2: Intelligent Context Updates */ const fs = require('fs').promises; const path = require('path'); const crypto = require('crypto'); /** * Session tracking data structure */ class SessionTracker { constructor(options = {}) { this.options = { maxSessionHistory: 50, // Maximum sessions to track maxConversationDepth: 10, // Maximum conversation thread depth sessionExpiryDays: 30, // Days after which sessions are considered expired trackingDataPath: options.trackingDataPath || path.join(__dirname, '../session-tracking.json'), ...options }; this.sessions = new Map(); this.conversationThreads = new Map(); this.projectSessions = new Map(); this.loaded = false; } /** * Initialize session tracking system */ async initialize() { console.log('[Session Tracker] Initializing session tracking system...'); try { await this.loadTrackingData(); this.cleanupExpiredSessions(); this.loaded = true; console.log(`[Session Tracker] Loaded ${this.sessions.size} sessions, ${this.conversationThreads.size} threads`); } catch (error) { console.error('[Session Tracker] Failed to initialize:', error.message); this.loaded = false; } } /** * Start tracking a new session */ async startSession(sessionId, context = {}) { if (!this.loaded) { await this.initialize(); } const session = { id: sessionId, startTime: new Date().toISOString(), endTime: null, projectContext: context.projectContext || {}, workingDirectory: context.workingDirectory, initialTopics: [], finalTopics: [], memoriesLoaded: [], memoriesCreated: [], conversationSummary: null, outcome: null, threadId: null, parentSessionId: null, childSessionIds: [], status: 'active' }; // Try to link to existing conversation thread await this.linkToConversationThread(session, context); this.sessions.set(sessionId, session); // Track by project const projectName = context.projectContext?.name; if (projectName) { if (!this.projectSessions.has(projectName)) { this.projectSessions.set(projectName, []); } this.projectSessions.get(projectName).push(sessionId); } console.log(`[Session Tracker] Started session ${sessionId} for project: ${projectName || 'unknown'}`); await this.saveTrackingData(); return session; } /** * End session tracking and record outcomes */ async endSession(sessionId, outcome = {}) { const session = this.sessions.get(sessionId); if (!session) { console.warn(`[Session Tracker] Session ${sessionId} not found`); return null; } session.endTime = new Date().toISOString(); session.status = 'completed'; session.outcome = outcome; session.conversationSummary = outcome.summary; session.finalTopics = outcome.topics || []; // Update conversation thread with session outcome if (session.threadId) { await this.updateConversationThread(session.threadId, session); } console.log(`[Session Tracker] Ended session ${sessionId} with outcome: ${outcome.type || 'unknown'}`); await this.saveTrackingData(); return session; } /** * Link session to existing conversation thread or create new one */ async linkToConversationThread(session, context) { // Try to find related sessions based on project and recent activity const relatedSessions = await this.findRelatedSessions(session, context); if (relatedSessions.length > 0) { // Link to existing thread const parentSession = relatedSessions[0]; session.threadId = parentSession.threadId; session.parentSessionId = parentSession.id; // Update parent session if (this.sessions.has(parentSession.id)) { this.sessions.get(parentSession.id).childSessionIds.push(session.id); } console.log(`[Session Tracker] Linked session ${session.id} to thread ${session.threadId}`); } else { // Create new conversation thread const threadId = this.generateThreadId(); session.threadId = threadId; const thread = { id: threadId, createdAt: new Date().toISOString(), projectContext: session.projectContext, sessionIds: [session.id], topics: new Set(), outcomes: [], status: 'active' }; this.conversationThreads.set(threadId, thread); console.log(`[Session Tracker] Created new conversation thread ${threadId}`); } } /** * Find related sessions for conversation threading */ async findRelatedSessions(session, context) { const projectName = context.projectContext?.name; if (!projectName) { return []; } const projectSessionIds = this.projectSessions.get(projectName) || []; const relatedSessions = []; // Look for recent sessions in same project const cutoffTime = new Date(); cutoffTime.setHours(cutoffTime.getHours() - 24); // 24 hour window for (const sessionId of projectSessionIds.slice(-10)) { // Check last 10 sessions const session = this.sessions.get(sessionId); if (!session || session.status === 'active') continue; const sessionTime = new Date(session.endTime || session.startTime); if (sessionTime > cutoffTime) { // Calculate relatedness score const relatednessScore = this.calculateSessionRelatedness(session, context); if (relatednessScore > 0.3) { relatedSessions.push({ ...session, relatednessScore }); } } } // Sort by relatedness score return relatedSessions.sort((a, b) => b.relatednessScore - a.relatednessScore); } /** * Calculate how related two sessions are */ calculateSessionRelatedness(existingSession, newContext) { let score = 0; // Same project bonus if (existingSession.projectContext?.name === newContext.projectContext?.name) { score += 0.4; } // Same working directory bonus if (existingSession.workingDirectory === newContext.workingDirectory) { score += 0.3; } // Technology stack similarity const existingTech = [ ...(existingSession.projectContext?.languages || []), ...(existingSession.projectContext?.frameworks || []) ]; const newTech = [ ...(newContext.projectContext?.languages || []), ...(newContext.projectContext?.frameworks || []) ]; const techOverlap = existingTech.filter(tech => newTech.includes(tech)).length; if (existingTech.length > 0) { score += (techOverlap / existingTech.length) * 0.3; } return Math.min(score, 1.0); } /** * Update conversation thread with session information */ async updateConversationThread(threadId, session) { const thread = this.conversationThreads.get(threadId); if (!thread) { console.warn(`[Session Tracker] Thread ${threadId} not found`); return; } // Add session to thread if not already present if (!thread.sessionIds.includes(session.id)) { thread.sessionIds.push(session.id); } // Update thread topics if (session.finalTopics && session.finalTopics.length > 0) { session.finalTopics.forEach(topic => thread.topics.add(topic)); } // Add outcome to thread history if (session.outcome) { thread.outcomes.push({ sessionId: session.id, outcome: session.outcome, timestamp: session.endTime }); } thread.lastUpdated = new Date().toISOString(); } /** * Get conversation context for a new session */ async getConversationContext(projectContext, options = {}) { const { maxPreviousSessions = 3, maxDaysBack = 7 } = options; const projectName = projectContext?.name; if (!projectName) { return null; } const projectSessionIds = this.projectSessions.get(projectName) || []; if (projectSessionIds.length === 0) { return null; } // Get recent sessions const cutoffTime = new Date(); cutoffTime.setDate(cutoffTime.getDate() - maxDaysBack); const recentSessions = []; for (const sessionId of projectSessionIds.slice(-10)) { const session = this.sessions.get(sessionId); if (!session || session.status === 'active') continue; const sessionTime = new Date(session.endTime || session.startTime); if (sessionTime > cutoffTime) { recentSessions.push(session); } } // Sort by end time and take most recent const sortedSessions = recentSessions .sort((a, b) => new Date(b.endTime || b.startTime) - new Date(a.endTime || a.startTime)) .slice(0, maxPreviousSessions); if (sortedSessions.length === 0) { return null; } // Build conversation context const context = { projectName: projectName, recentSessions: sortedSessions.map(session => ({ id: session.id, endTime: session.endTime, outcome: session.outcome, topics: session.finalTopics, memoriesCreated: session.memoriesCreated?.length || 0 })), continuityInsights: this.extractContinuityInsights(sortedSessions), activeThreads: this.getActiveThreadsForProject(projectName) }; return context; } /** * Extract insights about conversation continuity */ extractContinuityInsights(sessions) { const insights = { recurringTopics: this.findRecurringTopics(sessions), progressionPatterns: this.analyzeProgressionPatterns(sessions), uncompletedTasks: this.findUncompletedTasks(sessions) }; return insights; } /** * Find topics that appear across multiple sessions */ findRecurringTopics(sessions) { const topicCounts = new Map(); sessions.forEach(session => { (session.finalTopics || []).forEach(topic => { topicCounts.set(topic, (topicCounts.get(topic) || 0) + 1); }); }); return Array.from(topicCounts.entries()) .filter(([topic, count]) => count > 1) .sort((a, b) => b[1] - a[1]) .slice(0, 5) .map(([topic, count]) => ({ topic, frequency: count })); } /** * Analyze how work progresses across sessions */ analyzeProgressionPatterns(sessions) { const patterns = []; // Look for planning -> implementation -> testing patterns const outcomePairs = []; for (let i = 0; i < sessions.length - 1; i++) { outcomePairs.push([ sessions[i].outcome?.type, sessions[i + 1].outcome?.type ]); } return patterns; } /** * Find tasks or decisions that weren't completed */ findUncompletedTasks(sessions) { const tasks = []; sessions.forEach(session => { if (session.outcome?.type === 'planning' || session.outcome?.type === 'partial') { tasks.push({ sessionId: session.id, description: session.outcome?.summary, timestamp: session.endTime }); } }); return tasks; } /** * Get active conversation threads for a project */ getActiveThreadsForProject(projectName) { const threads = []; this.conversationThreads.forEach((thread, threadId) => { if (thread.projectContext?.name === projectName && thread.status === 'active') { threads.push({ id: threadId, sessionCount: thread.sessionIds.length, topics: Array.from(thread.topics), lastUpdated: thread.lastUpdated }); } }); return threads; } /** * Cleanup expired sessions and threads */ cleanupExpiredSessions() { const cutoffTime = new Date(); cutoffTime.setDate(cutoffTime.getDate() - this.options.sessionExpiryDays); let cleanedCount = 0; // Cleanup sessions for (const [sessionId, session] of this.sessions.entries()) { const sessionTime = new Date(session.endTime || session.startTime); if (sessionTime < cutoffTime) { this.sessions.delete(sessionId); cleanedCount++; } } // Cleanup project session references this.projectSessions.forEach((sessionIds, projectName) => { const validSessions = sessionIds.filter(id => this.sessions.has(id)); if (validSessions.length !== sessionIds.length) { this.projectSessions.set(projectName, validSessions); } }); if (cleanedCount > 0) { console.log(`[Session Tracker] Cleaned up ${cleanedCount} expired sessions`); } } /** * Generate unique thread ID */ generateThreadId() { return 'thread-' + crypto.randomBytes(8).toString('hex'); } /** * Load tracking data from disk */ async loadTrackingData() { try { const data = await fs.readFile(this.options.trackingDataPath, 'utf8'); const parsed = JSON.parse(data); // Restore sessions if (parsed.sessions) { parsed.sessions.forEach(session => { this.sessions.set(session.id, session); }); } // Restore conversation threads (convert topics Set back from array) if (parsed.conversationThreads) { parsed.conversationThreads.forEach(thread => { thread.topics = new Set(thread.topics || []); this.conversationThreads.set(thread.id, thread); }); } // Restore project sessions if (parsed.projectSessions) { Object.entries(parsed.projectSessions).forEach(([project, sessionIds]) => { this.projectSessions.set(project, sessionIds); }); } } catch (error) { if (error.code !== 'ENOENT') { console.warn('[Session Tracker] Failed to load tracking data:', error.message); } // Initialize empty structures if file doesn't exist } } /** * Save tracking data to disk */ async saveTrackingData() { try { const data = { sessions: Array.from(this.sessions.values()), conversationThreads: Array.from(this.conversationThreads.values()).map(thread => ({ ...thread, topics: Array.from(thread.topics) // Convert Set to Array for JSON })), projectSessions: Object.fromEntries(this.projectSessions.entries()), lastSaved: new Date().toISOString() }; await fs.writeFile(this.options.trackingDataPath, JSON.stringify(data, null, 2)); } catch (error) { console.error('[Session Tracker] Failed to save tracking data:', error.message); } } /** * Get statistics about session tracking */ getStats() { return { totalSessions: this.sessions.size, activeSessions: Array.from(this.sessions.values()).filter(s => s.status === 'active').length, totalThreads: this.conversationThreads.size, trackedProjects: this.projectSessions.size, loaded: this.loaded }; } } // Create global session tracker instance let globalSessionTracker = null; /** * Get or create global session tracker instance */ function getSessionTracker(options = {}) { if (!globalSessionTracker) { globalSessionTracker = new SessionTracker(options); } return globalSessionTracker; } module.exports = { SessionTracker, getSessionTracker };

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/doobidoo/mcp-memory-service'

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