UNPKG

converse-mcp-server

Version:

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

261 lines (229 loc) 7.42 kB
/** * Conversation Exporter * * Exports chat conversations to disk with incremental file writes * and atomic metadata updates. Follows functional architecture patterns. */ import { promises as fs } from 'fs'; import path from 'path'; import { createLogger } from './logger.js'; const logger = createLogger('conversationExporter'); /** * Write file only if it doesn't exist (write-if-missing pattern) * @param {string} filePath - Path to file * @param {string} content - Content to write * @returns {Promise<boolean>} True if written, false if skipped */ async function writeIfMissing(filePath, content) { try { // Check if file exists await fs.access(filePath); // File exists, skip writing return false; } catch { // File doesn't exist, write it try { await fs.writeFile(filePath, content, 'utf8'); return true; } catch (error) { logger.warn(`Failed to write file ${filePath}`, { error }); return false; } } } /** * Write file atomically (write to temp, then rename) * @param {string} filePath - Target file path * @param {string} content - Content to write * @returns {Promise<void>} */ async function writeAtomic(filePath, content) { const tempPath = `${filePath}.tmp`; try { await fs.writeFile(tempPath, content, 'utf8'); await fs.rename(tempPath, filePath); } catch (error) { // Try to clean up temp file if rename failed try { await fs.unlink(tempPath); } catch { // Ignore cleanup error } throw error; } } /** * Extract turn pairs from conversation messages * @param {Array} messages - Conversation messages array * @returns {Array} Array of {user, assistant} turn pairs */ function extractTurns(messages) { const turns = []; let currentTurn = null; for (const msg of messages) { // Skip system messages if (msg.role === 'system') continue; if (msg.role === 'user') { // Start new turn if (currentTurn && !currentTurn.assistant) { // Previous turn incomplete, save it anyway turns.push(currentTurn); } currentTurn = { user: msg, assistant: null }; } else if (msg.role === 'assistant' && currentTurn) { // Complete current turn currentTurn.assistant = msg; turns.push(currentTurn); currentTurn = null; } } // Handle incomplete final turn if (currentTurn && currentTurn.user) { turns.push(currentTurn); } return turns; } /** * Extract content from message (handles both string and complex content) * @param {object} message - Message object * @returns {string} Extracted content */ function extractMessageContent(message) { if (!message) return ''; const content = message.content; // Handle simple string content if (typeof content === 'string') { return content; } // Handle complex content array (files/images + text) if (Array.isArray(content)) { const textParts = []; for (const part of content) { if (part.type === 'text' && part.text) { textParts.push(part.text); } else if (part.type === 'file' && part.file_content) { // Include file reference in export textParts.push(`[File: ${part.file_name || 'unknown'}]`); } else if (part.type === 'image' && part.image_url) { // Include image reference in export const imageName = part.image_url.startsWith('data:') ? 'embedded image' : path.basename(part.image_url); textParts.push(`[Image: ${imageName}]`); } } return textParts.join('\n\n'); } return ''; } /** * Generate metadata for the conversation * @param {object} conversationState - Current conversation state * @param {number} totalTurns - Total number of turns * @param {object} params - Additional parameters from chat tool * @returns {object} Metadata object */ function generateMetadata(conversationState, totalTurns, params) { const metadata = { continuation_id: params.continuation_id, mode: params.mode || conversationState.mode || 'chat', total_turns: totalTurns, created_at: conversationState.createdAt ? new Date(conversationState.createdAt).toISOString() : new Date().toISOString(), last_updated: new Date( conversationState.lastUpdated || Date.now(), ).toISOString(), }; // Add optional parameters if present if (params.models && params.models.length > 0) { metadata.models = params.models; } if (params.reasoning_effort) { metadata.reasoning_effort = params.reasoning_effort; } if (params.files && params.files.length > 0) { metadata.files = params.files; } if (params.images && params.images.length > 0) { // Don't store base64 data, just file paths or indicators metadata.images = params.images.map((img) => img.startsWith('data:') ? '[base64 image]' : img, ); } return metadata; } /** * Export conversation to disk * @param {object} conversationState - Conversation state from continuation store * @param {object} options - Export options * @returns {Promise<void>} */ export async function exportConversation(conversationState, options = {}) { const { clientCwd, continuation_id, mode, reasoning_effort, files, images, models, } = options; if (!continuation_id) { logger.warn('Export skipped: no continuation_id provided'); return; } try { // 1. Sanitize continuation ID for folder name const safeId = path.basename(continuation_id); const exportDir = path.resolve(clientCwd || process.cwd(), safeId); // 2. Ensure directory exists await fs.mkdir(exportDir, { recursive: true }); logger.debug(`Export directory created/verified: ${exportDir}`); // 3. Extract turns from conversation const turns = extractTurns(conversationState.messages); if (turns.length === 0) { logger.debug('No turns to export'); return; } // 4. Write request/response files (skip existing) let filesWritten = 0; for (const [index, turn] of turns.entries()) { const turnNum = index + 1; // Write request file if missing if (turn.user) { const requestPath = path.join(exportDir, `${turnNum}_request.txt`); const userContent = extractMessageContent(turn.user); const written = await writeIfMissing(requestPath, userContent); if (written) filesWritten++; } // Write response file if missing if (turn.assistant) { const responsePath = path.join(exportDir, `${turnNum}_response.txt`); const assistantContent = extractMessageContent(turn.assistant); const written = await writeIfMissing(responsePath, assistantContent); if (written) filesWritten++; } } // 5. Always update metadata atomically const metadata = generateMetadata(conversationState, turns.length, { continuation_id, mode, reasoning_effort, files, images, models, }); const metadataPath = path.join(exportDir, 'metadata.json'); await writeAtomic(metadataPath, JSON.stringify(metadata, null, 2)); logger.info('Conversation exported', { continuation_id, exportDir, totalTurns: turns.length, filesWritten, }); } catch (error) { // Log error but don't interrupt conversation logger.error('Export failed', { error, continuation_id }); } }