UNPKG

runlog

Version:

CLI tool for uploading Claude Code conversations to runlog.io

387 lines 16.5 kB
import * as fs from 'fs/promises'; import * as path from 'path'; export class ConversationParser { claudeDir; constructor(claudeDir = path.join(process.env.HOME || '', '.claude', 'projects')) { this.claudeDir = claudeDir; } async getAllConversations(currentWorkingDir) { const conversations = []; const cwd = currentWorkingDir || process.cwd(); // Convert current working directory to Claude project format // e.g., /Users/x/Documents/code/projects/runlog -> -Users-x-Documents-code-projects-runlog const cwdSegments = cwd.split(path.sep).filter(s => s); const claudeProjectName = '-' + cwdSegments.join('-'); try { const projects = await fs.readdir(this.claudeDir); for (const project of projects) { // Only process the project that matches current working directory if (project !== claudeProjectName) { continue; } const projectPath = path.join(this.claudeDir, project); const stat = await fs.stat(projectPath); if (stat.isDirectory()) { const files = await fs.readdir(projectPath); const jsonlFiles = files.filter(f => f.endsWith('.jsonl')); for (const file of jsonlFiles) { const filePath = path.join(projectPath, file); const metadata = await this.parseConversation(filePath); if (metadata) { conversations.push(metadata); } } } } } catch (error) { if (error.code === 'ENOENT') { throw new Error(`Claude directory not found: ${this.claudeDir}`); } throw error; } // Sort by last message time, most recent first return conversations.sort((a, b) => { const timeA = a.lastMessageTime?.getTime() || 0; const timeB = b.lastMessageTime?.getTime() || 0; return timeB - timeA; }); } async parseConversation(filePath) { try { const content = await fs.readFile(filePath, 'utf-8'); const lines = content.trim().split('\n').filter(line => line.trim()); if (lines.length === 0) { return null; } const messages = []; let sessionId = null; for (const line of lines) { try { const data = JSON.parse(line); // Skip summary and meta messages if (data.type === 'summary' || data.isMeta === true) { continue; } if (data.sessionId && !sessionId) { sessionId = data.sessionId; } messages.push(data); } catch (err) { // Skip invalid JSON lines continue; } } if (messages.length === 0 || !sessionId) { return null; } // Extract project name from path const projectPath = path.dirname(filePath); const projectName = path.basename(projectPath) .replace(/^-Users-[^-]+-/, '') // Remove user prefix .replace(/-/g, '/'); // Convert dashes back to slashes // Get timestamps const timestamps = messages .map(msg => this.parseTimestamp(msg.timestamp)) .filter(ts => ts !== null); const firstMessageTime = timestamps.length > 0 ? new Date(Math.min(...timestamps.map(t => t.getTime()))) : null; const lastMessageTime = timestamps.length > 0 ? new Date(Math.max(...timestamps.map(t => t.getTime()))) : null; // Calculate active conversation time const activeTime = this.calculateActiveTime(messages); // Generate conversation summary const summary = this.generateConversationSummary(messages); return { filePath, projectName, sessionId, messageCount: messages.length, firstMessageTime, lastMessageTime, activeTime, summary }; } catch (error) { console.error(`Error parsing ${filePath}:`, error); return null; } } parseTimestamp(timestamp) { if (!timestamp) return null; try { if (typeof timestamp === 'string') { return new Date(timestamp); } else { // Unix timestamp (seconds or milliseconds) const ts = timestamp > 1e10 ? timestamp : timestamp * 1000; return new Date(ts); } } catch { return null; } } async getConversationContent(filePath) { return fs.readFile(filePath, 'utf-8'); } async getMessageCount(filePath) { try { const content = await fs.readFile(filePath, 'utf-8'); const lines = content.trim().split('\n').filter(line => line.trim()); let count = 0; for (const line of lines) { try { const data = JSON.parse(line); if (data.type !== 'summary' && data.isMeta !== true) { count++; } } catch { continue; } } return count; } catch { return 0; } } async getMessages(filePath, offset = 0, count = 10) { try { const content = await fs.readFile(filePath, 'utf-8'); const lines = content.trim().split('\n').filter(line => line.trim()); const allMessages = []; // First pass: collect all valid messages for (let i = 0; i < lines.length; i++) { try { const data = JSON.parse(lines[i]); // Skip summary and meta messages if (data.type === 'summary' || data.isMeta === true) { continue; } // Extract content based on message type let content = ''; let role = data.type; if (data.type === 'user' || data.type === 'assistant') { if (data.message?.content) { if (typeof data.message.content === 'string') { content = data.message.content; } else if (Array.isArray(data.message.content)) { // Handle array content (e.g., with images) content = data.message.content .map((item) => { if (typeof item === 'string') return item; if (item.type === 'text' && item.text) return item.text; if (item.type === 'image') return '[Image]'; return ''; }) .filter(Boolean) .join(' '); } } role = data.message?.role || data.type; } else if (data.type === 'thinking') { content = data.message?.content || data.thinkingBlock?.content || ''; } if (content) { // Truncate long messages for preview if (content.length > 200) { content = content.substring(0, 197) + '...'; } allMessages.push({ type: data.type, timestamp: this.parseTimestamp(data.timestamp) || new Date(), content: content.trim(), role }); } } catch (err) { // Skip invalid JSON lines continue; } } // Return requested slice return allMessages.slice(offset, offset + count); } catch (error) { console.error(`Error getting last messages from ${filePath}:`, error); return []; } } async searchConversations(searchTerm, currentWorkingDir) { const allConversations = await this.getAllConversations(currentWorkingDir); if (!searchTerm.trim()) { return allConversations.map(conv => ({ ...conv, matchCount: undefined })); } const lowerSearchTerm = searchTerm.toLowerCase(); const conversationsWithMatches = []; for (const conversation of allConversations) { try { const content = await fs.readFile(conversation.filePath, 'utf-8'); const lines = content.trim().split('\n').filter(line => line.trim()); let matchCount = 0; for (const line of lines) { try { const data = JSON.parse(line); // Skip summary and meta messages if (data.type === 'summary' || data.isMeta === true) { continue; } // Check message content let messageContent = ''; if (data.type === 'user' || data.type === 'assistant') { const content = data.message?.content; if (typeof content === 'string') { messageContent = content; } else if (Array.isArray(content)) { messageContent = content .map((item) => { if (typeof item === 'string') return item; if (item.type === 'text' && item.text) return item.text; return ''; }) .filter(Boolean) .join(' '); } } else if (data.type === 'thinking') { messageContent = data.message?.content || data.thinkingBlock?.content || ''; } if (messageContent.toLowerCase().includes(lowerSearchTerm)) { matchCount++; } } catch { continue; } } if (matchCount > 0) { conversationsWithMatches.push({ ...conversation, matchCount }); } } catch (error) { console.error(`Error searching ${conversation.filePath}:`, error); } } // Sort by match count (highest first), then by time return conversationsWithMatches.sort((a, b) => { const matchDiff = (b.matchCount || 0) - (a.matchCount || 0); if (matchDiff !== 0) return matchDiff; const timeA = a.lastMessageTime?.getTime() || 0; const timeB = b.lastMessageTime?.getTime() || 0; return timeB - timeA; }); } generateConversationSummary(messages) { if (messages.length === 0) return ''; // Get first user message for summary const firstUserMsg = messages.find(m => m.type === 'user'); if (firstUserMsg?.message?.content) { const userContent = this.extractTextContent(firstUserMsg.message.content); if (userContent) { // Take first 60 chars and capitalize let summary = userContent.substring(0, 60).trim(); // Capitalize first letter summary = summary.charAt(0).toUpperCase() + summary.slice(1); if (userContent.length > 60) { summary += '...'; } return summary; } } return 'Empty conversation'; } extractTextContent(content) { if (typeof content === 'string') { return content.replace(/\s+/g, ' ').trim(); } else if (Array.isArray(content)) { return content .map((item) => { if (typeof item === 'string') return item; if (item.type === 'text' && item.text) return item.text; if (item.type === 'image') return '[Image]'; return ''; }) .filter(Boolean) .join(' ') .replace(/\s+/g, ' ') .trim(); } return ''; } calculateActiveTime(messages) { if (messages.length < 2) return 0; // Sort by timestamp const sorted = [...messages].sort((a, b) => { const timeA = this.parseTimestamp(a.timestamp)?.getTime() || 0; const timeB = this.parseTimestamp(b.timestamp)?.getTime() || 0; return timeA - timeB; }); // First, collect AI→User intervals to determine threshold const aiToUserIntervals = []; for (let i = 1; i < sorted.length; i++) { const prev = sorted[i - 1]; const curr = sorted[i]; if (prev.type === 'assistant' && curr.type === 'user') { const prevTime = this.parseTimestamp(prev.timestamp); const currTime = this.parseTimestamp(curr.timestamp); if (prevTime && currTime) { aiToUserIntervals.push(currTime.getTime() - prevTime.getTime()); } } } // Calculate threshold (95th percentile of AI→User intervals) // Using 95th percentile to be more inclusive of user writing time let threshold = 10 * 60 * 1000; // 10 minutes default if (aiToUserIntervals.length > 0) { const sortedIntervals = [...aiToUserIntervals].sort((a, b) => a - b); const p95Index = Math.floor(sortedIntervals.length * 0.95); threshold = sortedIntervals[p95Index] || sortedIntervals[sortedIntervals.length - 1]; // Also apply a minimum threshold of 10 minutes to catch normal writing time threshold = Math.max(threshold, 10 * 60 * 1000); } // Now calculate total active time let totalTime = 0; for (let i = 1; i < sorted.length; i++) { const prev = sorted[i - 1]; const curr = sorted[i]; const prevTime = this.parseTimestamp(prev.timestamp); const currTime = this.parseTimestamp(curr.timestamp); if (prevTime && currTime) { const interval = currTime.getTime() - prevTime.getTime(); if (curr.type === 'assistant') { // Always include intervals ending with AI (AI working) totalTime += interval; } else if (curr.type === 'user' && interval < threshold) { // Include AI→User intervals only if under threshold (user actively engaged) totalTime += interval; } } } return totalTime; } } //# sourceMappingURL=parser.js.map