UNPKG

converse-mcp-server

Version:

Converse MCP Server - Converse with other LLMs with chat and consensus tools

341 lines (307 loc) 10.5 kB
/** * Continuation Store - State Management * * Manages conversation history and state for persistent conversations. * Pluggable implementation that can be swapped for different storage backends. * Provides a consistent interface (get/set/delete) for state management. */ import { randomUUID } from 'crypto'; import { debugLog, debugError } from './utils/console.js'; /** * Storage backend interface that all continuation stores must implement * This ensures pluggable backend replacement without changing the API */ export class ContinuationStoreInterface { /** * Store conversation state * @param {string} continuationId - Unique continuation identifier * @param {object} state - Conversation state * @returns {Promise<void>} */ async set(continuationId, state) { throw new Error('set() method must be implemented by storage backend'); } /** * Retrieve conversation state * @param {string} continuationId - Unique continuation identifier * @returns {Promise<object|null>} State or null if not found */ async get(continuationId) { throw new Error('get() method must be implemented by storage backend'); } /** * Delete conversation state * @param {string} continuationId - Unique continuation identifier * @returns {Promise<boolean>} True if deleted, false if not found */ async delete(continuationId) { throw new Error('delete() method must be implemented by storage backend'); } /** * Check if continuation exists * @param {string} continuationId - Unique continuation identifier * @returns {Promise<boolean>} True if exists */ async exists(continuationId) { const state = await this.get(continuationId); return state !== null; } /** * Get storage statistics * @returns {Promise<object>} Backend-specific statistics */ async getStats() { throw new Error('getStats() method must be implemented by storage backend'); } /** * Clean up old data * @param {number} maxAgeMs - Maximum age in milliseconds * @returns {Promise<number>} Number of items cleaned up */ async cleanup(maxAgeMs) { throw new Error('cleanup() method must be implemented by storage backend'); } } /** * Custom error class for continuation store operations */ export class ContinuationStoreError extends Error { constructor(message, code = 'CONTINUATION_ERROR') { super(message); this.name = 'ContinuationStoreError'; this.code = code; } } /** * In-memory continuation store implementation * Implements the ContinuationStoreInterface for pluggable backend replacement */ class MemoryContinuationStore extends ContinuationStoreInterface { constructor() { super(); // Call parent constructor this.conversations = new Map(); this.maxConversations = 1000; // Prevent memory leaks this.maxMessagesPerConversation = 100; } /** * Store conversation state * @param {string} continuationId - Unique continuation identifier * @param {object} state - Conversation state to store * @returns {Promise<void>} * @throws {ContinuationStoreError} If storage fails */ async set(continuationId, state) { try { // Validate continuation ID if (!continuationId || typeof continuationId !== 'string') { throw new ContinuationStoreError( 'Invalid continuation ID: must be a non-empty string', 'INVALID_CONTINUATION_ID' ); } // Validate state object if (!state || typeof state !== 'object') { throw new ContinuationStoreError( 'Invalid state: must be an object', 'INVALID_STATE' ); } // Cleanup old conversations if we hit the limit if (this.conversations.size >= this.maxConversations) { const oldestKey = this.conversations.keys().next().value; this.conversations.delete(oldestKey); } // Limit messages per conversation to prevent memory issues const sanitizedState = { ...state }; if (sanitizedState.messages && sanitizedState.messages.length > this.maxMessagesPerConversation) { sanitizedState.messages = sanitizedState.messages.slice(-this.maxMessagesPerConversation); } // Store with metadata this.conversations.set(continuationId, { ...sanitizedState, lastAccessed: Date.now(), createdAt: this.conversations.has(continuationId) ? this.conversations.get(continuationId).createdAt : Date.now(), }); } catch (error) { if (error instanceof ContinuationStoreError) { throw error; } throw new ContinuationStoreError( `Failed to store continuation: ${error.message}`, 'STORAGE_ERROR' ); } } /** * Retrieve conversation state * @param {string} continuationId - Unique continuation identifier * @returns {Promise<object|null>} Conversation state or null if not found * @throws {ContinuationStoreError} If retrieval fails */ async get(continuationId) { try { // Validate continuation ID if (!continuationId || typeof continuationId !== 'string') { throw new ContinuationStoreError( 'Invalid continuation ID: must be a non-empty string', 'INVALID_CONTINUATION_ID' ); } const state = this.conversations.get(continuationId); if (state) { // Update last accessed time state.lastAccessed = Date.now(); // Return copy without internal metadata const { createdAt, lastAccessed, ...cleanState } = state; return { ...cleanState, _metadata: { createdAt, lastAccessed } }; } return null; } catch (error) { if (error instanceof ContinuationStoreError) { throw error; } throw new ContinuationStoreError( `Failed to retrieve continuation: ${error.message}`, 'RETRIEVAL_ERROR' ); } } /** * Delete conversation state * @param {string} continuationId - Unique continuation identifier * @returns {Promise<boolean>} True if deleted, false if not found * @throws {ContinuationStoreError} If deletion fails */ async delete(continuationId) { try { // Validate continuation ID if (!continuationId || typeof continuationId !== 'string') { throw new ContinuationStoreError( 'Invalid continuation ID: must be a non-empty string', 'INVALID_CONTINUATION_ID' ); } const existed = this.conversations.has(continuationId); this.conversations.delete(continuationId); return existed; } catch (error) { if (error instanceof ContinuationStoreError) { throw error; } throw new ContinuationStoreError( `Failed to delete continuation: ${error.message}`, 'DELETION_ERROR' ); } } /** * Get storage statistics * @returns {Promise<object>} Store statistics */ async getStats() { return { backend: 'memory', totalConversations: this.conversations.size, maxConversations: this.maxConversations, maxMessagesPerConversation: this.maxMessagesPerConversation, memoryUsage: process.memoryUsage(), }; } /** * Clean up old conversations * @param {number} maxAgeMs - Maximum age in milliseconds (default: 24 hours) * @returns {Promise<number>} Number of conversations cleaned up */ async cleanup(maxAgeMs = 24 * 60 * 60 * 1000) { const now = Date.now(); let cleanedCount = 0; // Special case: if maxAgeMs is 0, clean up all conversations if (maxAgeMs === 0) { cleanedCount = this.conversations.size; this.conversations.clear(); return cleanedCount; } for (const [id, state] of this.conversations.entries()) { if (now - state.lastAccessed > maxAgeMs) { this.conversations.delete(id); cleanedCount++; } } return cleanedCount; } } // Singleton instance - can be replaced for different backends let continuationStore = null; /** * Get the continuation store instance * @returns {ContinuationStoreInterface} Continuation store instance */ export function getContinuationStore() { if (!continuationStore) { continuationStore = new MemoryContinuationStore(); // Set up periodic cleanup (runs every hour) setInterval(async () => { try { const cleaned = await continuationStore.cleanup(); if (cleaned > 0) { debugLog(`ContinuationStore: Cleaned up ${cleaned} old conversations`); } } catch (error) { debugError('ContinuationStore cleanup failed:', error); } }, 60 * 60 * 1000); } return continuationStore; } /** * Set a custom continuation store backend (for testing or different implementations) * @param {ContinuationStoreInterface} store - Custom store implementation */ export function setContinuationStore(store) { if (!(store instanceof ContinuationStoreInterface)) { throw new ContinuationStoreError( 'Store must extend ContinuationStoreInterface', 'INVALID_STORE' ); } continuationStore = store; } /** * Generate a new UUID-based continuation ID * @returns {string} Unique continuation ID */ export function generateContinuationId() { return `conv_${randomUUID()}`; } /** * Validate continuation ID format * @param {string} continuationId - ID to validate * @returns {boolean} True if valid format */ export function isValidContinuationId(continuationId) { if (!continuationId || typeof continuationId !== 'string') { return false; } // Check for conv_ prefix and UUID format const uuidPattern = /^conv_[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i; return uuidPattern.test(continuationId); } /** * Helper function to add a message to conversation history * @param {object} state - Current conversation state * @param {object} message - Message to add * @returns {object} Updated state */ export function addMessageToHistory(state, message) { const messages = state.messages || []; return { ...state, messages: [...messages, message], lastUpdated: Date.now(), }; }