UNPKG

claude-code-history-mcp

Version:

MCP server for Claude Code conversation history tracking and analysis

398 lines 19.1 kB
import * as fs from 'fs/promises'; import * as path from 'path'; import * as os from 'os'; import { createReadStream } from 'fs'; import { createInterface } from 'readline'; export class ClaudeCodeHistoryService { claudeDir; constructor() { this.claudeDir = path.join(os.homedir(), '.claude'); } /** * Normalize date string to ISO format for proper comparison with timezone support */ normalizeDate(dateString, isEndDate = false, timezone) { if (dateString.includes('T')) { return dateString; } const tz = timezone || Intl.DateTimeFormat().resolvedOptions().timeZone; try { if (tz === 'UTC') { const timeStr = isEndDate ? '23:59:59.999' : '00:00:00.000'; return `${dateString}T${timeStr}Z`; } // Correct approach: Create date in target timezone and convert to UTC const [year, month, day] = dateString.split('-').map(Number); const hour = isEndDate ? 23 : 0; const minute = isEndDate ? 59 : 0; const second = isEndDate ? 59 : 0; const millisecond = isEndDate ? 999 : 0; // Create a reference date to calculate offset const referenceDate = new Date(year, month - 1, day, 12, 0, 0); // Use noon for stable offset // Calculate timezone offset for this specific date (handles DST) const offsetMs = referenceDate.getTimezoneOffset() * 60000; // Create the target time in the specified timezone const localTime = new Date(year, month - 1, day, hour, minute, second, millisecond); // Get what this local time would be in the target timezone const targetTzTime = new Date(localTime.toLocaleString('en-CA', { timeZone: tz })); const utcTime = new Date(localTime.toLocaleString('en-CA', { timeZone: 'UTC' })); // Calculate the difference between target timezone and UTC const tzOffsetMs = targetTzTime.getTime() - utcTime.getTime(); // Adjust local time to get UTC equivalent const utcResult = new Date(localTime.getTime() + offsetMs - tzOffsetMs); const result = utcResult.toISOString(); console.log(`normalizeDate: ${dateString} (${isEndDate ? 'end' : 'start'}) in ${tz} -> ${result}`); return result; } catch (error) { console.warn(`Failed to process timezone ${tz}, falling back to simple conversion:`, error); const fallback = `${dateString}T${isEndDate ? '23:59:59.999' : '00:00:00.000'}Z`; console.log(`normalizeDate fallback: ${dateString} -> ${fallback}`); return fallback; } } async getConversationHistory(options = {}) { const { sessionId, startDate, endDate, limit = 20, offset = 0, timezone, messageTypes } = options; // Normalize date strings for proper comparison const normalizedStartDate = startDate ? this.normalizeDate(startDate, false, timezone) : undefined; const normalizedEndDate = endDate ? this.normalizeDate(endDate, true, timezone) : undefined; // Determine which message types to include (default to user only to reduce data volume) const allowedTypes = messageTypes && messageTypes.length > 0 ? messageTypes : ['user']; // Load history from Claude Code's .jsonl files with pre-filtering let allEntries = await this.loadClaudeHistoryEntries({ startDate: normalizedStartDate, endDate: normalizedEndDate }); // Filter by session ID if specified if (sessionId) { allEntries = allEntries.filter(entry => entry.sessionId === sessionId); } // Filter by message types (defaults to user only) allEntries = allEntries.filter(entry => allowedTypes.includes(entry.type)); // Filter by date range if specified (additional in-memory filtering for precision) if (normalizedStartDate) { allEntries = allEntries.filter(entry => entry.timestamp >= normalizedStartDate); } if (normalizedEndDate) { allEntries = allEntries.filter(entry => entry.timestamp <= normalizedEndDate); } // Sort by timestamp (newest first) allEntries.sort((a, b) => new Date(b.timestamp).getTime() - new Date(a.timestamp).getTime()); // Calculate pagination const totalCount = allEntries.length; const paginatedEntries = allEntries.slice(offset, offset + limit); const hasMore = offset + limit < totalCount; return { entries: paginatedEntries, pagination: { total_count: totalCount, limit, offset, has_more: hasMore } }; } async searchConversations(searchQuery, options = {}) { const { limit = 30, projectPath, startDate, endDate, timezone } = options; // Normalize date strings for proper comparison const normalizedStartDate = startDate ? this.normalizeDate(startDate, false, timezone) : undefined; const normalizedEndDate = endDate ? this.normalizeDate(endDate, true, timezone) : undefined; const allEntries = await this.loadClaudeHistoryEntries({ startDate: normalizedStartDate, endDate: normalizedEndDate }); const queryLower = searchQuery.toLowerCase(); let matchedEntries = allEntries.filter(entry => entry.content.toLowerCase().includes(queryLower)); // Filter by project path if specified if (projectPath) { matchedEntries = matchedEntries.filter(entry => entry.projectPath === projectPath); } // Filter by date range if specified (additional in-memory filtering for precision) if (normalizedStartDate) { matchedEntries = matchedEntries.filter(entry => entry.timestamp >= normalizedStartDate); } if (normalizedEndDate) { matchedEntries = matchedEntries.filter(entry => entry.timestamp <= normalizedEndDate); } matchedEntries.sort((a, b) => new Date(b.timestamp).getTime() - new Date(a.timestamp).getTime()); return matchedEntries.slice(0, limit); } async listProjects() { const projects = new Map(); try { const projectsDir = path.join(this.claudeDir, 'projects'); const projectDirs = await fs.readdir(projectsDir); for (const projectDir of projectDirs) { const projectPath = path.join(projectsDir, projectDir); const stats = await fs.stat(projectPath); if (stats.isDirectory()) { const files = await fs.readdir(projectPath); const decodedPath = this.decodeProjectPath(projectDir); if (!projects.has(decodedPath)) { projects.set(decodedPath, { sessionIds: new Set(), messageCount: 0, lastActivityTime: '1970-01-01T00:00:00.000Z' }); } const projectInfo = projects.get(decodedPath); if (!projectInfo) continue; for (const file of files) { if (file.endsWith('.jsonl')) { const sessionId = file.replace('.jsonl', ''); projectInfo.sessionIds.add(sessionId); const filePath = path.join(projectPath, file); const fileStats = await fs.stat(filePath); if (fileStats.mtime.toISOString() > projectInfo.lastActivityTime) { projectInfo.lastActivityTime = fileStats.mtime.toISOString(); } // Count messages in this session const entries = await this.parseJsonlFile(filePath, projectDir); projectInfo.messageCount += entries.length; } } } } } catch (error) { console.error('Error listing projects:', error); } return Array.from(projects.entries()).map(([projectPath, info]) => ({ projectPath, sessionCount: info.sessionIds.size, messageCount: info.messageCount, lastActivityTime: info.lastActivityTime })); } async listSessions(options = {}) { const { projectPath, startDate, endDate, timezone } = options; // Normalize date strings for proper comparison const normalizedStartDate = startDate ? this.normalizeDate(startDate, false, timezone) : undefined; const normalizedEndDate = endDate ? this.normalizeDate(endDate, true, timezone) : undefined; const sessions = []; try { const projectsDir = path.join(this.claudeDir, 'projects'); const projectDirs = await fs.readdir(projectsDir); for (const projectDir of projectDirs) { const decodedPath = this.decodeProjectPath(projectDir); // Filter by project path if specified if (projectPath && decodedPath !== projectPath) { continue; } const projectDirPath = path.join(projectsDir, projectDir); const stats = await fs.stat(projectDirPath); if (stats.isDirectory()) { const files = await fs.readdir(projectDirPath); for (const file of files) { if (file.endsWith('.jsonl')) { const sessionId = file.replace('.jsonl', ''); const filePath = path.join(projectDirPath, file); const entries = await this.parseJsonlFile(filePath, projectDir); if (entries.length === 0) continue; const sessionStart = entries[entries.length - 1].timestamp; const sessionEnd = entries[0].timestamp; // Filter by date range if specified if (normalizedStartDate && sessionEnd < normalizedStartDate) continue; if (normalizedEndDate && sessionStart > normalizedEndDate) continue; const userMessageCount = entries.filter(e => e.type === 'user').length; const assistantMessageCount = entries.filter(e => e.type === 'assistant').length; sessions.push({ sessionId, projectPath: decodedPath, startTime: sessionStart, endTime: sessionEnd, messageCount: entries.length, userMessageCount, assistantMessageCount }); } } } } } catch (error) { console.error('Error listing sessions:', error); } // Sort by start time (newest first) sessions.sort((a, b) => new Date(b.startTime).getTime() - new Date(a.startTime).getTime()); return sessions; } async loadClaudeHistoryEntries(options = {}) { const entries = []; const { startDate, endDate } = options; try { const projectsDir = path.join(this.claudeDir, 'projects'); const projectDirs = await fs.readdir(projectsDir); for (const projectDir of projectDirs) { const projectPath = path.join(projectsDir, projectDir); const stats = await fs.stat(projectPath); if (stats.isDirectory()) { const files = await fs.readdir(projectPath); for (const file of files) { if (file.endsWith('.jsonl')) { const filePath = path.join(projectPath, file); // Pre-filter files based on modification time if (await this.shouldSkipFile(filePath, startDate, endDate)) { continue; } const sessionEntries = await this.parseJsonlFile(filePath, projectDir, startDate, endDate); entries.push(...sessionEntries); } } } } } catch (error) { console.error('Error loading Claude history:', error); } return entries; } async parseJsonlFile(filePath, projectDir, startDate, endDate) { const entries = []; try { const fileStream = createReadStream(filePath); const rl = createInterface({ input: fileStream, crlfDelay: Infinity }); for await (const line of rl) { if (line.trim()) { try { const claudeMessage = JSON.parse(line); // Apply date filtering at message level for efficiency if (startDate && claudeMessage.timestamp < startDate) { continue; } if (endDate && claudeMessage.timestamp > endDate) { continue; } const entry = this.convertClaudeMessageToEntry(claudeMessage, projectDir); if (entry) { entries.push(entry); } } catch (parseError) { console.error('Error parsing line:', parseError); } } } } catch (error) { console.error('Error reading file:', filePath, error); } return entries; } convertClaudeMessageToEntry(claudeMessage, projectDir) { try { let content = ''; if (claudeMessage.message?.content) { if (typeof claudeMessage.message.content === 'string') { content = claudeMessage.message.content; } else if (Array.isArray(claudeMessage.message.content)) { // Handle array content (e.g., from assistant messages) content = claudeMessage.message.content .map(item => { if (typeof item === 'string') return item; if (item?.type === 'text' && item?.text) return item.text; return JSON.stringify(item); }) .join(' '); } } // Decode project path from directory name const projectPath = this.decodeProjectPath(projectDir); // Add enhanced time information const timestamp = claudeMessage.timestamp; const messageDate = new Date(timestamp); return { sessionId: claudeMessage.sessionId, timestamp, type: claudeMessage.type, content, projectPath, uuid: claudeMessage.uuid, formattedTime: messageDate.toLocaleString('en-US', { timeZone: 'Asia/Tokyo', year: 'numeric', month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit', second: '2-digit', hour12: false }), timeAgo: this.getTimeAgo(messageDate), localDate: messageDate.toLocaleDateString('sv-SE', { timeZone: 'Asia/Tokyo' }), metadata: { usage: claudeMessage.message?.usage, model: claudeMessage.message?.model, requestId: claudeMessage.requestId } }; } catch (error) { console.error('Error converting Claude message:', error); return null; } } getTimeAgo(date) { const now = new Date(); const diffMs = now.getTime() - date.getTime(); const diffMins = Math.floor(diffMs / (1000 * 60)); const diffHours = Math.floor(diffMs / (1000 * 60 * 60)); const diffDays = Math.floor(diffMs / (1000 * 60 * 60 * 24)); if (diffMins < 1) return 'just now'; if (diffMins < 60) return `${diffMins}m ago`; if (diffHours < 24) return `${diffHours}h ago`; if (diffDays < 7) return `${diffDays}d ago`; if (diffDays < 30) return `${Math.floor(diffDays / 7)}w ago`; if (diffDays < 365) return `${Math.floor(diffDays / 30)}mo ago`; return `${Math.floor(diffDays / 365)}y ago`; } decodeProjectPath(projectDir) { return projectDir.replace(/-/g, '/').replace(/^\//, ''); } /** * Determines whether to skip reading a file based on its modification time */ async shouldSkipFile(filePath, startDate, endDate) { if (!startDate && !endDate) { return false; // Don't skip if no date filters are specified } try { const fileStats = await fs.stat(filePath); const fileModTime = fileStats.mtime.toISOString(); const fileCreateTime = fileStats.birthtime.toISOString(); // Get the earliest and latest possible times for file content const oldestPossibleTime = fileCreateTime < fileModTime ? fileCreateTime : fileModTime; const newestPossibleTime = fileModTime; // If endDate is specified: skip if file's oldest time is after endDate if (endDate && oldestPossibleTime > endDate) { return true; // Skip } // If startDate is specified: skip if file's newest time is before startDate if (startDate && newestPossibleTime < startDate) { return true; // Skip } return false; // File might contain data in range, so read it } catch (error) { console.warn(`Failed to get file stats for ${filePath}:`, error); return false; // Safe fallback: read the file if stat fails } } } //# sourceMappingURL=history-service.js.map