UNPKG

claude-chat-viewer

Version:

Interactive CLI tool to view and export Claude conversation histories

888 lines (772 loc) 37 kB
const fs = require('fs'); const path = require('path'); class DiaryGenerator { constructor() { this.gitCommitPattern = /git\s+commit.*-m\s+["']([^"']+)["']/i; this.gitPushPattern = /git\s+push/i; this.bashCommandPattern = /\$\s*git\s+(commit|push|add|status|diff)/i; } /** * Generate a diary from a conversation file * @param {string} conversationPath - Path to the conversation JSONL file * @param {object} options - Generation options * @returns {object} Generated diary with entries */ async generateDiary(conversationPath, options = {}) { const messages = this.parseConversationFile(conversationPath); const diaryEntries = []; const toolUseMap = new Map(); // Map tool_use_id to tool info // First pass: collect all tool uses from assistant messages for (const message of messages) { if (message.type === 'assistant' && message.message && Array.isArray(message.message.content)) { for (const item of message.message.content) { if (item.type === 'tool_use') { toolUseMap.set(item.id, { name: item.name, input: item.input, timestamp: message.timestamp }); } } } } // Store toolUseMap for use in other methods this.toolUseMap = toolUseMap; // Second pass: extract diary entries for (let i = 0; i < messages.length; i++) { const message = messages[i]; // Extract user questions if (message.type === 'user' && this.isSignificantUserMessage(message)) { const entry = { type: 'question', timestamp: message.timestamp, content: this.extractUserQuestion(message) }; // Look for the assistant's response const response = this.findAssistantResponse(messages, i); if (response) { entry.response = this.extractInformativeResponse(response); // Also check if the response has inline tool results const inlineTools = this.extractInlineToolResults(messages, i + 1); if (inlineTools.length > 0) { entry.inlineTools = inlineTools; } } diaryEntries.push(entry); } // Extract tool summaries from assistant messages if (message.type === 'assistant' && message.message && Array.isArray(message.message.content)) { for (const item of message.message.content) { if (item.type === 'tool_use') { const toolSummary = this.createToolSummaryEntry(item, message.timestamp); if (toolSummary) { diaryEntries.push(toolSummary); } } } } // Extract git operations const gitOps = this.extractGitOperations(message, toolUseMap); if (gitOps.length > 0) { diaryEntries.push(...gitOps); } // Extract file operations const fileOps = this.extractFileOperations(message, toolUseMap); if (fileOps.length > 0) { diaryEntries.push(...fileOps); } } // Post-process: consolidate consecutive file operations const consolidatedEntries = this.consolidateEntries(diaryEntries); return { projectPath: options.projectPath || process.cwd(), generatedAt: new Date().toISOString(), entries: consolidatedEntries }; } /** * Parse conversation JSONL file */ parseConversationFile(filePath) { const content = fs.readFileSync(filePath, 'utf-8'); const lines = content.trim().split('\n'); const messages = []; for (const line of lines) { try { const data = JSON.parse(line); if (!data.isMeta) { messages.push(data); } } catch (error) { continue; } } return messages; } /** * Check if a user message is significant (not meta/system) */ isSignificantUserMessage(message) { if (!message.message || !message.message.content) return false; const content = this.getMessageText(message); // Skip init messages and continuations if (content.includes('init is analyzing your codebase') || content.includes('This session is being continued from')) { return false; } // Skip single word responses or very short messages if (content.trim().split(/\s+/).length < 3) { return false; } return true; } /** * Extract user question text */ extractUserQuestion(message) { let text = this.getMessageText(message); // Don't process quotes - return as-is to preserve user's original formatting // The user's quotes should appear exactly as they typed them return text; } /** * Find all assistant response parts to a user message */ findAssistantResponse(messages, userIndex) { const responseParts = []; let foundFirstAssistant = false; for (let i = userIndex + 1; i < messages.length; i++) { const msg = messages[i]; // Collect all assistant messages until next user question if (msg.type === 'assistant') { foundFirstAssistant = true; responseParts.push(msg); } // Stop if we hit another significant user message if (msg.type === 'user' && this.isSignificantUserMessage(msg)) { break; } // Continue collecting if we've started and this is a non-significant user message if (foundFirstAssistant && msg.type === 'user') { // This is likely a tool result, continue continue; } } // Merge all response parts into one if (responseParts.length === 0) return null; // Create a merged message const mergedMessage = { type: 'assistant', timestamp: responseParts[0].timestamp, message: { content: [] } }; // Collect all content from all parts for (const part of responseParts) { if (part.message && Array.isArray(part.message.content)) { mergedMessage.message.content.push(...part.message.content); } else if (part.message && typeof part.message.content === 'string') { mergedMessage.message.content.push({ type: 'text', text: part.message.content }); } } return mergedMessage; } /** * Extract informative parts of assistant response */ extractInformativeResponse(message) { const parts = []; // Extract text content if (message.message && Array.isArray(message.message.content)) { for (const item of message.message.content) { if (item.type === 'text' && item.text) { // Keep more content, process code blocks let processedText = item.text.replace(/```[\s\S]*?```/g, (match) => { const lines = match.split('\n'); if (lines.length > 5) { return '```\n[código con ' + (lines.length - 2) + ' líneas]\n```'; } return match; }); // Format TODO blocks properly - including those with Update Todos header processedText = processedText.replace(/(⏺\s*Update Todos[^\n]*\n\s*⎿?\s*)?(☐\s+[^\n]+(?:\n\s+[^\n☐✅]+)*(?:\n\s*☐\s+[^\n]+(?:\n\s+[^\n☐✅]+)*)*)/g, (match, header, todos) => { // If we have Update Todos header or multiple TODOs, format in code block if (header || todos.split('☐').length > 2 || todos.length > 100) { const fullBlock = (header || '') + todos; return '\n\n```\n' + fullBlock.trim() + '\n```\n'; } return match; }); // Ensure proper spacing for lists processedText = processedText.replace(/\n(-|\*|\d+\.)\s/g, '\n\n$1 '); // Add spacing before emoji markers like ⏺ processedText = processedText.replace(/([.!?:])\s*(⏺|✻|●|◆)/g, '$1\n\n$2'); // Ensure proper spacing after quoted content processedText = processedText.replace(/(desarrollo\?|realized\?)\s*\n*(⏺|✻|●|◆)/g, '$1\n\n$2'); // Ensure emoji markers at start of response have proper spacing processedText = processedText.replace(/^(⏺|✻|●|◆)/gm, '\n$1'); parts.push(processedText); } // Include tool use summaries if (item.type === 'tool_use') { const toolSummary = this.summarizeToolUse(item); if (toolSummary) { // Add extra spacing before tool summaries parts.push('\n' + toolSummary); } } } } else { // Fallback for simple text messages const content = this.getMessageText(message); if (content) parts.push(content); } // Join all parts and limit length const fullResponse = parts.join('\n\n'); // For longer responses, try to preserve key information if (fullResponse.length > 1200) { // Keep first part and important sections const sections = fullResponse.split('\n\n'); let result = sections[0] + '\n\n'; let currentLength = result.length; // Add sections that contain key indicators for (let i = 1; i < sections.length; i++) { const section = sections[i]; if (section.includes('⚡') || section.includes('📄') || section.includes('✅') || section.includes('TODO') || section.includes('rama') || section.includes('branch')) { if (currentLength + section.length < 1500) { result += section + '\n\n'; currentLength += section.length; } } } return result + '...'; } return fullResponse; } /** * Consolidate consecutive entries of the same type */ consolidateEntries(entries) { const consolidated = []; let i = 0; while (i < entries.length) { const entry = entries[i]; // Check if this is a tool_summary that can be consolidated if (entry.type === 'tool_summary' && !this.hasUserQuestionBefore(entries, i)) { // Collect all consecutive tool summaries of the same tool const toolOps = [entry]; let j = i + 1; while (j < entries.length && entries[j].type === 'tool_summary' && entries[j].toolName === entry.toolName && !this.hasUserQuestionBefore(entries, j)) { toolOps.push(entries[j]); j++; } // If we have multiple operations of the same tool, consolidate if (toolOps.length > 1) { // For TodoWrite, keep only the last one if (entry.toolName === 'TodoWrite') { const lastOp = toolOps[toolOps.length - 1]; consolidated.push(lastOp); } else { // For other tools, create a consolidated entry consolidated.push({ type: 'consolidated_tools', timestamp: toolOps[0].timestamp, toolName: entry.toolName, count: toolOps.length, operations: toolOps }); } i = j; } else { consolidated.push(entry); i++; } } // Check if this is a file operation that can be consolidated else if ((entry.type === 'file_edited' || entry.type === 'file_created') && !this.hasUserQuestionBefore(entries, i)) { // Collect all consecutive file operations const fileOps = [entry]; let j = i + 1; while (j < entries.length && (entries[j].type === 'file_edited' || entries[j].type === 'file_created') && !this.hasUserQuestionBefore(entries, j)) { fileOps.push(entries[j]); j++; } // If we have multiple operations, consolidate them if (fileOps.length > 2) { consolidated.push({ type: 'consolidated_files', timestamp: fileOps[0].timestamp, operations: fileOps, summary: this.summarizeFileOperations(fileOps) }); i = j; } else { // Keep individual entries if only 1-2 operations consolidated.push(...fileOps); i = j; } } else { // Keep other entry types as-is consolidated.push(entry); i++; } } return consolidated; } /** * Check if there's a user question right before this entry */ hasUserQuestionBefore(entries, index) { if (index === 0) return false; const prevEntry = entries[index - 1]; return prevEntry.type === 'question'; } /** * Summarize multiple file operations */ summarizeFileOperations(operations) { const created = operations.filter(op => op.type === 'file_created').length; const edited = operations.filter(op => op.type === 'file_edited').length; const totalEdits = operations .filter(op => op.type === 'file_edited') .reduce((sum, op) => sum + parseInt(op.editCount || 1), 0); const parts = []; if (created > 0) parts.push(`${created} archivos creados`); if (edited > 0) parts.push(`${edited} archivos editados (${totalEdits} ediciones totales)`); return parts.join(', '); } /** * Extract git operations from messages */ extractGitOperations(message, toolUseMap) { const operations = []; // Check tool use messages if (message.type === 'user' && message.message && Array.isArray(message.message.content)) { for (const item of message.message.content) { if (item.type === 'tool_result' && item.tool_use_id) { const toolInfo = toolUseMap.get(item.tool_use_id); const toolName = toolInfo ? toolInfo.name : 'unknown'; const content = typeof item.content === 'string' ? item.content : (item.content && Array.isArray(item.content)) ? item.content.map(c => c.text || JSON.stringify(c)).join('\n') : JSON.stringify(item.content || ''); // Check for Bash tool if (toolName === 'Bash') { const command = toolInfo.input ? toolInfo.input.command : ''; // Check for git commit if (command.includes('git commit') || content.includes('[') && content.includes(']')) { let commitMessage = 'Commit realizado'; let commitHash = null; // Try to extract from command if (command) { const cmdMatch = command.match(/-m\s*["']([^"']+)["']/); if (cmdMatch) commitMessage = cmdMatch[1]; } // Extract from output const hashMatch = content.match(/\[([^\s]+)\s+([a-f0-9]{7,})\]/); if (hashMatch) { commitHash = hashMatch[2]; // Extract commit message after hash const msgMatch = content.match(/\]\s*(.+?)\n/); if (msgMatch) commitMessage = msgMatch[1]; } const statsMatch = content.match(/(\d+)\s+file.*changed/); const filesChanged = content.match(/(create mode|modified:|delete mode)\s+\d+\s+(.+)/g); operations.push({ type: 'commit', timestamp: message.timestamp, message: commitMessage, hash: commitHash, stats: statsMatch ? statsMatch[0] : null, files: filesChanged ? filesChanged.length : null }); } // Check for git push else if (command.includes('git push')) { operations.push({ type: 'push', timestamp: message.timestamp, content: this.extractGitInfo(content, 'push') }); } } } } } return operations; } /** * Extract git command info */ extractGitInfo(content, type) { const lines = content.split('\n'); const relevantLines = []; for (const line of lines) { if (type === 'commit') { if (line.includes('file') && line.includes('changed')) { relevantLines.push(line.trim()); } else if (line.includes('insertion') || line.includes('deletion')) { relevantLines.push(line.trim()); } else if (line.includes('create mode') || line.includes('modified:')) { relevantLines.push(line.trim()); } } if (type === 'push' && (line.includes('->') || line.includes('branch') || line.includes('pushed'))) { relevantLines.push(line.trim()); } } return relevantLines.length > 0 ? relevantLines.join('\n') : content.substring(0, 100) + '...'; } /** * Get text content from message */ getMessageText(message) { const content = message.message?.content; if (typeof content === 'string') { return content; } if (Array.isArray(content)) { const textParts = []; for (const item of content) { if (item.type === 'text' && item.text) { textParts.push(item.text); } } return textParts.join('\n'); } return ''; } /** * Extract file operations (create, edit, etc.) */ extractFileOperations(message, toolUseMap) { const operations = []; if (message.type === 'user' && message.message && Array.isArray(message.message.content)) { for (const item of message.message.content) { if (item.type === 'tool_result' && item.tool_use_id) { const toolInfo = toolUseMap.get(item.tool_use_id); const toolName = toolInfo ? toolInfo.name : 'unknown'; const content = typeof item.content === 'string' ? item.content : ''; // Detect file writes if (toolName === 'Write') { const fileMatch = content.match(/File created successfully at: (.+)/); if (fileMatch) { operations.push({ type: 'file_created', timestamp: message.timestamp, filename: path.basename(fileMatch[1]), path: fileMatch[1] }); } } // Detect multi-edits if (toolName === 'MultiEdit') { const editMatch = content.match(/Applied (\d+) edits? to (.+):/); if (editMatch) { operations.push({ type: 'file_edited', timestamp: message.timestamp, filename: path.basename(editMatch[2]), editCount: editMatch[1] }); } } // Detect single edits if (toolName === 'Edit') { const editMatch = content.match(/The file (.+) has been updated/); if (editMatch) { operations.push({ type: 'file_edited', timestamp: message.timestamp, filename: path.basename(editMatch[1]), editCount: '1' }); } } } } } return operations; } /** * Format diary as markdown */ formatAsMarkdown(diary) { let markdown = `# Diario de Conversación\n\n`; markdown += `**Proyecto:** ${diary.projectPath}\n`; markdown += `**Generado:** ${new Date(diary.generatedAt).toLocaleString('es-ES')}\n\n`; markdown += `---\n\n`; // Group entries by date const entriesByDate = this.groupEntriesByDate(diary.entries); for (const [date, entries] of Object.entries(entriesByDate)) { markdown += `\n# 📅 ${date}\n\n`; for (const entry of entries) { const time = new Date(entry.timestamp).toLocaleTimeString('es-ES', { hour: '2-digit', minute: '2-digit' }); if (entry.type === 'question') { markdown += `### ${time} - 🤔 Pregunta\n\n`; markdown += `> ${entry.content}\n\n`; if (entry.response) { markdown += `**💡 Respuesta:**\n\n`; markdown += `${entry.response}\n\n`; // Add inline tool results if any if (entry.inlineTools && entry.inlineTools.length > 0) { // Consolidate consecutive TodoWrite entries const consolidatedTools = []; let i = 0; while (i < entry.inlineTools.length) { const tool = entry.inlineTools[i]; if (tool.name === 'TodoWrite') { // Find all consecutive TodoWrite entries let j = i + 1; while (j < entry.inlineTools.length && entry.inlineTools[j].name === 'TodoWrite') { j++; } // Keep only the last TodoWrite consolidatedTools.push(entry.inlineTools[j - 1]); i = j; } else { consolidatedTools.push(tool); i++; } } markdown += `\n**🔧 Acciones realizadas:**\n\n`; for (const tool of consolidatedTools) { // Check if tool summary contains code blocks if (tool.summary.includes('```')) { markdown += `* **${tool.name}**:\n${tool.summary}\n\n`; } else { markdown += `* **${tool.name}**: ${tool.summary}\n`; } } markdown += `\n`; } } else { // If no response, add extra spacing before next entry markdown += `\n_(Sin respuesta registrada)_\n\n`; } } else if (entry.type === 'commit') { markdown += `### ${time} - 📝 Git Commit\n\n`; markdown += `* **Mensaje:** ${entry.message}\n`; if (entry.hash) { markdown += `* **Hash:** \`${entry.hash}\`\n`; } if (entry.stats) { markdown += `* **Cambios:** ${entry.stats}\n`; } if (entry.files) { markdown += `* **Archivos afectados:** ${entry.files}\n`; } markdown += `\n`; } else if (entry.type === 'push') { markdown += `### ${time} - 🚀 Git Push\n\n`; if (entry.content) { markdown += `\`\`\`\n${entry.content}\n\`\`\`\n\n`; } } else if (entry.type === 'file_created') { markdown += `### ${time} - 📄 Archivo Creado\n\n`; markdown += `* **Archivo:** \`${entry.filename}\`\n`; markdown += `* **Ruta:** \`${entry.path}\`\n\n`; } else if (entry.type === 'file_edited') { markdown += `### ${time} - ✏️ Archivo Editado\n\n`; markdown += `* **Archivo:** \`${entry.filename}\`\n`; markdown += `* **Ediciones:** ${entry.editCount}\n\n`; } else if (entry.type === 'consolidated_files') { markdown += `### ${time} - 📁 Múltiples operaciones de archivos\n\n`; markdown += `**Resumen:** ${entry.summary}\n\n`; // Show files in a compact format const fileList = []; for (const op of entry.operations) { if (op.type === 'file_created') { fileList.push(`🆕 \`${op.filename}\``); } else { fileList.push(`✏️ \`${op.filename}\` (${op.editCount} ediciones)`); } } markdown += fileList.join(', ') + '\n\n'; } else if (entry.type === 'tool_summary') { // Skip TodoWrite summaries as they should be in inline tools if (entry.toolName !== 'TodoWrite') { markdown += entry.summary + '\n\n'; } } else if (entry.type === 'consolidated_tools') { markdown += `### ${time} - 🔧 ${entry.count} operaciones ${entry.toolName}\n\n`; if (entry.toolName === 'Bash') { markdown += `${entry.count} comandos ejecutados\n\n`; } else if (entry.toolName === 'Read') { markdown += `${entry.count} archivos leídos\n\n`; } } // Don't add separator for tool summaries to avoid clutter if (!['tool_summary', 'consolidated_tools'].includes(entry.type)) { markdown += `---\n`; } } } return markdown; } /** * Group entries by date */ groupEntriesByDate(entries) { const groups = {}; for (const entry of entries) { const date = new Date(entry.timestamp).toLocaleDateString('es-ES', { weekday: 'long', year: 'numeric', month: 'long', day: 'numeric' }); if (!groups[date]) { groups[date] = []; } groups[date].push(entry); } return groups; } /** * Extract inline tool results that appear after assistant message */ extractInlineToolResults(messages, startIndex) { const results = []; // Look at the next few messages for tool results for (let i = startIndex; i < Math.min(startIndex + 5, messages.length); i++) { const msg = messages[i]; // Stop if we hit another user question if (msg.type === 'user' && this.isSignificantUserMessage(msg)) { break; } // Check for tool results if (msg.type === 'user' && msg.message && Array.isArray(msg.message.content)) { for (const item of msg.message.content) { if (item.type === 'tool_result' && item.tool_use_id) { const toolInfo = this.toolUseMap?.get(item.tool_use_id); if (toolInfo) { results.push({ name: toolInfo.name, summary: this.summarizeToolResult(toolInfo, item) }); } } } } } return results; } /** * Summarize tool result for inline display */ summarizeToolResult(toolInfo, result) { const name = toolInfo.name; const input = toolInfo.input || {}; const content = typeof result.content === 'string' ? result.content : ''; switch (name) { case 'Bash': const cmd = input.command || ''; if (content.includes('Switched to a new branch')) { const branchMatch = content.match(/'([^']+)'/); return `🌿 Nueva rama creada: \`${branchMatch ? branchMatch[1] : 'branch'}\``; } if (cmd.includes('git checkout -b')) { const branchName = cmd.match(/-b\s+(\S+)/); return `🌿 Crear rama: \`${branchName ? branchName[1] : 'new-branch'}\``; } return `Comando: \`${cmd.substring(0, 40)}${cmd.length > 40 ? '...' : ''}\``; case 'TodoWrite': const todos = input.todos || []; if (todos.length === 0) return null; const pending = todos.filter(t => t.status === 'pending').length; const completed = todos.filter(t => t.status === 'completed').length; // Format TODO list with proper line wrapping const todoList = todos.map(t => { const checkbox = t.status === 'completed' ? '✅' : t.status === 'in_progress' ? '⏳' : '☐'; const content = t.content || ''; // Wrap long content at word boundaries if (content.length > 60) { // Find last space before 60 chars let breakPoint = 60; const lastSpace = content.lastIndexOf(' ', 60); if (lastSpace > 40) { // Only break at space if it's not too far back breakPoint = lastSpace; } const firstLine = content.substring(0, breakPoint).trim(); const secondLine = content.substring(breakPoint).trim(); if (secondLine.length > 55) { // If second line is still too long, truncate with ellipsis return ` ${checkbox} ${firstLine}\n ${secondLine.substring(0, 52)}...`; } return ` ${checkbox} ${firstLine}\n ${secondLine}`; } return ` ${checkbox} ${content}`; }).join('\n'); return `📋 ${todos.length} TODOs:\n\n\`\`\`\n${todoList}\n\`\`\``; case 'Write': return `📄 Archivo creado: \`${path.basename(input.file_path || 'file')}\``; case 'MultiEdit': const edits = input.edits || []; return `✏️ ${edits.length} ediciones en \`${path.basename(input.file_path || 'file')}\``; case 'Read': return `👁️ Archivo leído: \`${path.basename(input.file_path || 'file')}\``; default: return content.substring(0, 50) + '...'; } } /** * Create a tool summary entry for consolidation */ createToolSummaryEntry(toolUse, timestamp) { const name = toolUse.name; const input = toolUse.input || {}; // Only create entries for tools we want to consolidate if (['TodoWrite', 'Read', 'Bash'].includes(name)) { return { type: 'tool_summary', timestamp: timestamp, toolName: name, summary: this.summarizeToolUse(toolUse), input: input }; } return null; } /** * Summarize tool use for display */ summarizeToolUse(toolUse) { const name = toolUse.name; const input = toolUse.input || {}; switch (name) { case 'Bash': return `⚡ **Comando ejecutado:** \`${input.command || 'bash command'}\``; case 'Write': return `📄 **Archivo creado:** \`${path.basename(input.file_path || 'file')}\``; case 'Edit': case 'MultiEdit': return `✏️ **Archivo editado:** \`${path.basename(input.file_path || 'file')}\``; case 'TodoWrite': const todos = input.todos || []; return `📋 **TODOs actualizados:** ${todos.length} tareas`; case 'Read': return `👁️ **Archivo leído:** \`${path.basename(input.file_path || 'file')}\``; default: return null; } } } module.exports = DiaryGenerator;