claude-chat-viewer
Version:
Interactive CLI tool to view and export Claude conversation histories
888 lines (772 loc) • 37 kB
JavaScript
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;