UNPKG

cui-server

Version:

Web UI Agent Platform based on Claude Code

534 lines 21.9 kB
import * as fs from 'fs/promises'; import * as path from 'path'; import * as os from 'os'; import { CUIError } from '../types/index.js'; import { createLogger } from './logger.js'; import { SessionInfoService } from './session-info-service.js'; import { ConversationCache } from './conversation-cache.js'; import { ToolMetricsService } from './ToolMetricsService.js'; import { MessageFilter } from './message-filter.js'; /** * Reads conversation history from Claude's local storage */ export class ClaudeHistoryReader { claudeHomePath; logger; sessionInfoService; conversationCache; toolMetricsService; messageFilter; constructor(sessionInfoService) { this.claudeHomePath = path.join(os.homedir(), '.claude'); this.logger = createLogger('ClaudeHistoryReader'); this.sessionInfoService = sessionInfoService || new SessionInfoService(); this.conversationCache = new ConversationCache(); this.toolMetricsService = new ToolMetricsService(); this.messageFilter = new MessageFilter(); } get homePath() { return this.claudeHomePath; } /** * Clear the conversation cache to force a refresh on next read */ clearCache() { this.conversationCache.clear(); } /** * List all conversations with optional filtering */ async listConversations(filter) { try { // Parse all conversations from all JSONL files const conversationChains = await this.parseAllConversations(); // Convert to ConversationSummary format and enhance with custom names const allConversations = await Promise.all(conversationChains.map(async (chain) => { // Get full session info from SessionInfoService let sessionInfo; try { sessionInfo = await this.sessionInfoService.getSessionInfo(chain.sessionId); } catch (error) { this.logger.warn('Failed to get session info for conversation', { sessionId: chain.sessionId, error: error instanceof Error ? error.message : String(error) }); // Continue with default session info on error sessionInfo = { custom_name: '', created_at: new Date().toISOString(), updated_at: new Date().toISOString(), version: 4, pinned: false, archived: false, continuation_session_id: '', initial_commit_head: '', permission_mode: 'default' }; } // Calculate tool metrics for this conversation const toolMetrics = this.toolMetricsService.calculateMetricsFromMessages(chain.messages); return { sessionId: chain.sessionId, projectPath: chain.projectPath, summary: chain.summary, sessionInfo: sessionInfo, createdAt: chain.createdAt, updatedAt: chain.updatedAt, messageCount: chain.messages.length, totalDuration: chain.totalDuration, model: chain.model, status: 'completed', // Default status, will be updated by server toolMetrics: toolMetrics }; })); // Apply filters and pagination const filtered = this.applyFilters(allConversations, filter); const paginated = this.applyPagination(filtered, filter); return { conversations: paginated, total: filtered.length }; } catch (error) { throw new CUIError('HISTORY_READ_FAILED', `Failed to read conversation history: ${error}`, 500); } } /** * Fetch full conversation details */ async fetchConversation(sessionId) { try { const conversationChains = await this.parseAllConversations(); const conversation = conversationChains.find(chain => chain.sessionId === sessionId); if (!conversation) { throw new CUIError('CONVERSATION_NOT_FOUND', `Conversation ${sessionId} not found`, 404); } // Apply message filter before returning return this.messageFilter.filterMessages(conversation.messages); } catch (error) { if (error instanceof CUIError) throw error; throw new CUIError('CONVERSATION_READ_FAILED', `Failed to read conversation: ${error}`, 500); } } /** * Get conversation metadata */ async getConversationMetadata(sessionId) { try { const conversationChains = await this.parseAllConversations(); const conversation = conversationChains.find(chain => chain.sessionId === sessionId); if (!conversation) { return null; } return { summary: conversation.summary, projectPath: conversation.projectPath, model: conversation.model, totalDuration: conversation.totalDuration }; } catch (error) { this.logger.error('Error getting metadata for conversation', error, { sessionId }); return null; } } /** * Get the working directory for a specific conversation session */ async getConversationWorkingDirectory(sessionId) { try { const conversationChains = await this.parseAllConversations(); const conversation = conversationChains.find(chain => chain.sessionId === sessionId); if (!conversation) { this.logger.warn('Conversation not found when getting working directory', { sessionId }); return null; } this.logger.debug('Found working directory for conversation', { sessionId, workingDirectory: conversation.projectPath }); return conversation.projectPath; } catch (error) { this.logger.error('Error getting working directory for conversation', error, { sessionId }); return null; } } /** * Get file modification times for all JSONL files */ async getFileModificationTimes() { const modTimes = new Map(); const projectsPath = path.join(this.claudeHomePath, 'projects'); this.logger.debug('Getting file modification times', { projectsPath }); try { const projects = await this.readDirectory(projectsPath); this.logger.debug('Found projects', { projectCount: projects.length }); for (const project of projects) { const projectPath = path.join(projectsPath, project); const stats = await fs.stat(projectPath); if (!stats.isDirectory()) continue; const files = await this.readDirectory(projectPath); const jsonlFiles = files.filter(f => f.endsWith('.jsonl')); for (const file of jsonlFiles) { const filePath = path.join(projectPath, file); try { const fileStats = await fs.stat(filePath); modTimes.set(filePath, fileStats.mtimeMs); } catch (error) { this.logger.warn('Failed to stat file', { filePath, error }); } } } this.logger.debug('File modification times collection complete', { totalFiles: modTimes.size, projects: projects.length }); } catch (error) { this.logger.error('Error getting file modification times', error); } return modTimes; } /** * Extract source project name from file path */ extractSourceProject(filePath) { const projectsPath = path.join(this.claudeHomePath, 'projects'); const relativePath = path.relative(projectsPath, filePath); const segments = relativePath.split(path.sep); return segments[0]; // First segment is the project directory name } /** * Process all entries into conversation chains (the cheap in-memory operations) */ processAllEntries(allEntries) { const startTime = Date.now(); this.logger.debug('Processing all entries into conversations', { totalEntries: allEntries.length }); // Group entries by sessionId const sessionGroups = this.groupEntriesBySession(allEntries); this.logger.debug('Entries grouped by session', { sessionCount: sessionGroups.size, totalEntries: allEntries.length }); // Process summaries const summaries = this.processSummaries(allEntries); this.logger.debug('Summaries processed', { summaryCount: summaries.size }); // Build conversation chains const conversationChains = []; for (const [sessionId, entries] of sessionGroups) { const chain = this.buildConversationChain(sessionId, entries, summaries); if (chain) { conversationChains.push(chain); } } const totalElapsed = Date.now() - startTime; this.logger.debug('Entry processing complete', { conversationCount: conversationChains.length, totalElapsedMs: totalElapsed, avgTimePerConversation: conversationChains.length > 0 ? totalElapsed / conversationChains.length : 0 }); return conversationChains; } /** * Parse all conversations from all JSONL files with file-level caching and concurrency protection */ async parseAllConversations() { const startTime = Date.now(); this.logger.debug('Starting parseAllConversations with file-level caching'); // Get current file modification times const currentModTimes = await this.getFileModificationTimes(); this.logger.debug('Retrieved file modification times', { fileCount: currentModTimes.size }); // Use the new file-level cache interface const conversations = await this.conversationCache.getOrParseConversations(currentModTimes, (filePath) => this.parseJsonlFile(filePath), // Parse single file (filePath) => this.extractSourceProject(filePath), // Get source project (allEntries) => this.processAllEntries(allEntries) // Process entries ); const totalElapsed = Date.now() - startTime; this.logger.debug('File-level cached conversation parsing completed', { conversationCount: conversations.length, totalElapsedMs: totalElapsed }); return conversations; } /** * Parse a single JSONL file and return all valid entries */ async parseJsonlFile(filePath) { try { const content = await fs.readFile(filePath, 'utf-8'); const lines = content.split('\n').filter(line => line.trim()); const entries = []; for (const line of lines) { try { const entry = JSON.parse(line); entries.push(entry); } catch (parseError) { this.logger.warn('Failed to parse line from JSONL file', { error: parseError, filePath, line: line.substring(0, 100) }); } } return entries; } catch (error) { this.logger.error('Failed to read JSONL file', error, { filePath }); return []; } } /** * Group entries by sessionId */ groupEntriesBySession(entries) { const sessionGroups = new Map(); for (const entry of entries) { // Only group user and assistant messages if ((entry.type === 'user' || entry.type === 'assistant') && entry.sessionId) { if (!sessionGroups.has(entry.sessionId)) { sessionGroups.set(entry.sessionId, []); } sessionGroups.get(entry.sessionId).push(entry); } } return sessionGroups; } /** * Process summary entries and create leafUuid mapping */ processSummaries(entries) { const summaries = new Map(); for (const entry of entries) { if (entry.type === 'summary' && entry.leafUuid && entry.summary) { summaries.set(entry.leafUuid, entry.summary); } } return summaries; } async readDirectory(dirPath) { try { return await fs.readdir(dirPath); } catch (error) { if (error.code === 'ENOENT') { return []; } throw error; } } /** * Build a conversation chain from session entries */ buildConversationChain(sessionId, entries, summaries) { try { // Convert entries to ConversationMessage format const messages = entries.map(entry => this.parseMessage(entry)); // Build message chain using parentUuid/uuid relationships const orderedMessages = this.buildMessageChain(messages); if (orderedMessages.length === 0) { return null; } // Apply message filter const filteredMessages = this.messageFilter.filterMessages(orderedMessages); // Check if we have any messages left after filtering if (filteredMessages.length === 0) { return null; } // Determine project path - use original first message for cwd before filtering const firstMessage = orderedMessages[0]; let projectPath = ''; if (firstMessage.cwd) { projectPath = firstMessage.cwd; } else { // Fallback to decoding directory name from source project const sourceProject = entries[0].sourceProject; projectPath = this.decodeProjectPath(sourceProject); } // Determine conversation summary const summary = this.determineConversationSummary(filteredMessages, summaries); // Calculate metadata from filtered messages const totalDuration = filteredMessages.reduce((sum, msg) => sum + (msg.durationMs || 0), 0); const model = this.extractModel(filteredMessages); // Get timestamps from filtered messages const timestamps = filteredMessages .map(msg => msg.timestamp) .filter(ts => ts) .sort(); const createdAt = timestamps[0] || new Date().toISOString(); const updatedAt = timestamps[timestamps.length - 1] || createdAt; return { sessionId, messages: filteredMessages, projectPath, summary, createdAt, updatedAt, totalDuration, model }; } catch (error) { this.logger.error('Error building conversation chain', error, { sessionId }); return null; } } /** * Build ordered message chain using parentUuid relationships */ buildMessageChain(messages) { // Create uuid to message mapping const messageMap = new Map(); messages.forEach(msg => messageMap.set(msg.uuid, msg)); // Find head message (parentUuid is null) const headMessage = messages.find(msg => !msg.parentUuid); if (!headMessage) { // If no head found, return messages sorted by timestamp return messages.sort((a, b) => new Date(a.timestamp || '').getTime() - new Date(b.timestamp || '').getTime()); } // Build chain from head const orderedMessages = []; const visited = new Set(); const traverse = (currentMessage) => { if (visited.has(currentMessage.uuid)) { return; // Avoid cycles } visited.add(currentMessage.uuid); orderedMessages.push(currentMessage); // Find children (messages with this message as parent) const children = messages.filter(msg => msg.parentUuid === currentMessage.uuid); // Sort children by timestamp to maintain order children.sort((a, b) => new Date(a.timestamp || '').getTime() - new Date(b.timestamp || '').getTime()); children.forEach(child => traverse(child)); }; traverse(headMessage); // Add any orphaned messages at the end const orphanedMessages = messages.filter(msg => !visited.has(msg.uuid)); orderedMessages.push(...orphanedMessages.sort((a, b) => new Date(a.timestamp || '').getTime() - new Date(b.timestamp || '').getTime())); return orderedMessages; } /** * Determine conversation summary from messages and summary map */ determineConversationSummary(messages, summaries) { // Walk through messages from latest to earliest to find last available summary for (let i = messages.length - 1; i >= 0; i--) { const message = messages[i]; if (summaries.has(message.uuid)) { return summaries.get(message.uuid); } } // Fallback to first user message content const firstUserMessage = messages.find(msg => msg.type === 'user'); if (firstUserMessage && firstUserMessage.message) { const content = this.extractMessageContent(firstUserMessage.message); return content.length > 100 ? content.substring(0, 100) + '...' : content; } return 'No summary available'; } /** * Extract text content from message object */ extractMessageContent(message) { if (typeof message === 'string') { return message; } if (message.content) { if (typeof message.content === 'string') { return message.content; } if (Array.isArray(message.content)) { // Find first text content block const textBlock = message.content.find((block) => block.type === 'text'); return textBlock && 'text' in textBlock ? textBlock.text : ''; } } return 'No content available'; } /** * Extract model information from messages */ extractModel(messages) { for (const message of messages) { if (message.message && typeof message.message === 'object') { const messageObj = message.message; if (messageObj.model) { return messageObj.model; } } } return 'Unknown'; } parseMessage(entry) { return { uuid: entry.uuid || '', type: entry.type, message: entry.message, // Non-null assertion since ConversationMessage requires it timestamp: entry.timestamp || '', sessionId: entry.sessionId || '', parentUuid: entry.parentUuid, isSidechain: entry.isSidechain, userType: entry.userType, cwd: entry.cwd, version: entry.version, durationMs: entry.durationMs }; } applyFilters(conversations, filter) { if (!filter) return conversations; let filtered = [...conversations]; // Filter by project path if (filter.projectPath) { filtered = filtered.filter(c => c.projectPath === filter.projectPath); } // Filter by continuation session if (filter.hasContinuation !== undefined) { filtered = filtered.filter(c => { const hasContinuation = c.sessionInfo.continuation_session_id !== ''; return filter.hasContinuation ? hasContinuation : !hasContinuation; }); } // Filter by archived status if (filter.archived !== undefined) { filtered = filtered.filter(c => c.sessionInfo.archived === filter.archived); } // Filter by pinned status if (filter.pinned !== undefined) { filtered = filtered.filter(c => c.sessionInfo.pinned === filter.pinned); } // Sort if (filter.sortBy) { filtered.sort((a, b) => { const field = filter.sortBy === 'created' ? 'createdAt' : 'updatedAt'; const aVal = new Date(a[field]).getTime(); const bVal = new Date(b[field]).getTime(); return filter.order === 'desc' ? bVal - aVal : aVal - bVal; }); } return filtered; } applyPagination(conversations, filter) { if (!filter) return conversations; const limit = filter.limit || 20; const offset = filter.offset || 0; return conversations.slice(offset, offset + limit); } decodeProjectPath(encoded) { // Claude encodes directory paths by replacing '/' with '-' return encoded.replace(/-/g, '/'); } } //# sourceMappingURL=claude-history-reader.js.map