UNPKG

@akiojin/claude-worktree

Version:

Interactive Git worktree manager for Claude Code with graphical branch selection

563 lines 25.1 kB
import { homedir } from 'node:os'; import { readdir, readFile, stat } from 'node:fs/promises'; import path from 'node:path'; /** * Claude Code history manager error */ export class ClaudeHistoryError extends Error { cause; constructor(message, cause) { super(message); this.cause = cause; this.name = 'ClaudeHistoryError'; } } /** * Get Claude Code configuration directory */ function getClaudeConfigDir() { return path.join(homedir(), '.claude'); } /** * Get Claude Code projects directory */ function getClaudeProjectsDir() { return path.join(getClaudeConfigDir(), 'projects'); } /** * Check if Claude Code is configured on this system */ export async function isClaudeHistoryAvailable() { try { const projectsDir = getClaudeProjectsDir(); const stats = await stat(projectsDir); return stats.isDirectory(); } catch { return false; } } /** * Parse a JSONL conversation file */ async function parseConversationFile(filePath) { try { const content = await readFile(filePath, 'utf-8'); const lines = content.trim().split('\n').filter(line => line.trim()); if (lines.length === 0) { return null; } // Parse messages to extract information const messages = lines.map(line => { try { return JSON.parse(line); } catch { return null; } }).filter(Boolean); if (messages.length === 0) { return null; } // Extract conversation metadata const firstMessage = messages[0]; const lastMessage = messages[messages.length - 1]; // Extract session ID from messages (look for session_id, id, or conversation_id fields) let sessionId; for (const message of messages) { if (message.session_id) { sessionId = message.session_id; break; } else if (message.conversation_id) { sessionId = message.conversation_id; break; } else if (message.id && typeof message.id === 'string' && message.id.length > 10) { // If ID looks like a session ID (longer string), use it sessionId = message.id; break; } } // If no session ID found in messages, try to extract from filename if (!sessionId) { const fileName = path.basename(filePath, '.jsonl'); // Look for UUID-like patterns in filename const uuidMatch = fileName.match(/([0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})/i); if (uuidMatch) { sessionId = uuidMatch[1]; } else if (fileName.length > 20) { // Use filename as session ID if it's long enough sessionId = fileName; } } // Generate conversation title from first user message or file name let title = 'Untitled Conversation'; // Debug: Log raw messages for investigation if (process.env.DEBUG_CLAUDE_HISTORY || process.env.CLAUDE_WORKTREE_DEBUG) { console.log(` [DEBUG] ===== Processing file: ${filePath} =====`); console.log(`[DEBUG] File basename: ${path.basename(filePath, '.jsonl')}`); console.log(`[DEBUG] Message count: ${messages.length}`); // Log first 3 messages in detail console.log(`[DEBUG] First 3 messages:`); messages.slice(0, 3).forEach((msg, idx) => { console.log(`[DEBUG] Message ${idx + 1}:`); console.log(` - Type: ${typeof msg}`); console.log(` - Keys: ${Object.keys(msg).join(', ')}`); console.log(` - Role: ${msg.role || 'undefined'}`); console.log(` - Content type: ${typeof msg.content}`); if (msg.content) { if (typeof msg.content === 'string') { console.log(` - Content preview: ${msg.content.substring(0, 100)}...`); } else if (Array.isArray(msg.content)) { console.log(` - Content is array with ${msg.content.length} items`); if (msg.content[0]) { console.log(` - First item type: ${typeof msg.content[0]}`); console.log(` - First item keys: ${typeof msg.content[0] === 'object' ? Object.keys(msg.content[0]).join(', ') : 'N/A'}`); } } else { console.log(` - Content is object with keys: ${Object.keys(msg.content).join(', ')}`); } } console.log(''); }); } // Find last user message - Claude Code uses different message structure const lastUserMessage = messages.slice().reverse().find(msg => // Claude Code format: type='message' + userType='user' (msg.type === 'message' && msg.userType === 'user') || // Nested format: type='user' with message.role='user' (msg.type === 'user' && msg.message && msg.message.role === 'user') || // Legacy format msg.role === 'user' || msg.role === 'human' || (msg.sender && msg.sender === 'human') || (!msg.role && msg.content) // fallback for messages without explicit role ); if (lastUserMessage) { let extractedContent = ''; // Extract content based on Claude Code's actual structure let messageContent = null; // For Claude Code format: msg.message.content if (lastUserMessage.message && lastUserMessage.message.content) { messageContent = lastUserMessage.message.content; } // For direct message field (string) else if (lastUserMessage.message && typeof lastUserMessage.message === 'string') { messageContent = lastUserMessage.message; } // For legacy content field else if (lastUserMessage.content) { messageContent = lastUserMessage.content; } // Handle different content formats that Claude Code might use if (typeof messageContent === 'string') { extractedContent = messageContent; } else if (Array.isArray(messageContent)) { // Handle array of content blocks for (const block of messageContent) { if (typeof block === 'string') { extractedContent = block; break; } else if (block && typeof block === 'object') { // Handle content blocks with type and text properties if (block.text && typeof block.text === 'string') { extractedContent = block.text; break; } else if (block.content && typeof block.content === 'string') { extractedContent = block.content; break; } } } } else if (messageContent && typeof messageContent === 'object') { // Handle single content object if (messageContent.text) { extractedContent = messageContent.text; } else if (messageContent.content) { extractedContent = messageContent.content; } } // Clean and format the extracted content if (extractedContent) { // Remove system prompts or meta information const cleanContent = extractedContent .replace(/^(<.*?>|System:|Assistant:|Human:|User:)/i, '') .replace(/<\/[^>]+>/g, '') // Remove closing tags like </local-command-stdout> .replace(/^(Result of calling|Error:|Warning:|DEBUG:|LOG:)/i, '') // Remove system messages .replace(/^(Tool executed|Command executed|Output:)/i, '') // Remove tool output indicators .replace(/^\s*[-#*•]\s*/gm, '') // Remove list markers .replace(/^https?:\/\/[^\s]+$/gm, '') // Remove standalone URLs .trim(); // Extract first meaningful line const firstLine = cleanContent.split('\n')[0]?.trim() || ''; // Validate that the content is meaningful if (firstLine.length > 5 && !firstLine.match(/^(no content|undefined|null|\(no content\))$/i) && !firstLine.includes('</') && // Avoid HTML-like content !firstLine.match(/^[^a-zA-Z]*$/) // Must contain some letters ) { title = firstLine.length > 60 ? firstLine.substring(0, 57) + '...' : firstLine; } // Debug: Log title extraction if (process.env.DEBUG_CLAUDE_HISTORY) { console.log(`[DEBUG] Extracted title: "${title}" from content: "${extractedContent.substring(0, 100)}..."`); console.log(`[DEBUG] lastUserMessage structure:`, JSON.stringify(lastUserMessage, null, 2).substring(0, 500)); } } } // If still no good title, try alternative extraction methods if (!title || title === 'Untitled Conversation') { // Try to extract from filename patterns const fileName = path.basename(filePath, '.jsonl'); // Remove timestamp patterns and use remaining text const cleanFileName = fileName.replace(/^\d{4}-\d{2}-\d{2}_\d{2}-\d{2}-\d{2}_/, ''); if (cleanFileName && cleanFileName.length > 0 && !cleanFileName.match(/^[0-9a-f-]+$/i)) { // Only use filename if it's not just a UUID title = cleanFileName.replace(/[-_]/g, ' ').trim(); title = title.charAt(0).toUpperCase() + title.slice(1); } else { // Fallback: try to extract from any message content let foundTitle = false; // Try last messages first - more relevant for current context for (const msg of messages.slice(-10).reverse()) { // Check last 10 messages in reverse order if (msg && msg.content) { let content = ''; // Extract content regardless of format if (typeof msg.content === 'string') { content = msg.content; } else if (Array.isArray(msg.content)) { for (const item of msg.content) { if (typeof item === 'string') { content = item; break; } else if (item && typeof item === 'object') { content = item.text || item.content || JSON.stringify(item).substring(0, 100); if (content) break; } } } else if (typeof msg.content === 'object') { content = msg.content.text || msg.content.content || JSON.stringify(msg.content).substring(0, 100); } // Clean and extract meaningful text if (content && content.length > 10) { // Remove common prefixes and clean up const cleaned = content .replace(/^(Human:|Assistant:|User:|System:|<.*?>|\[.*?\])/gi, '') .replace(/^\s*[-#*•]\s*/gm, '') // Remove list markers .trim(); if (cleaned.length > 10) { // Get first sentence or line const firstSentence = cleaned.match(/^[^.!?\n]{10,60}/)?.[0] || cleaned.substring(0, 50); if (firstSentence && firstSentence.length > 10) { title = firstSentence.trim() + (firstSentence.length === 50 ? '...' : ''); foundTitle = true; break; } } } } } // If still no title, use generic title if (!foundTitle) { // We'll use the file stats later for the date title = `Conversation (${messages.length} messages)`; } } } // Get file stats for last activity time const stats = await stat(filePath); // Extract project path from file path const projectsDir = getClaudeProjectsDir(); const relativePath = path.relative(projectsDir, filePath); const projectPath = path.dirname(relativePath); const result = { id: path.basename(filePath, '.jsonl'), title: title, lastActivity: stats.mtime.getTime(), messageCount: messages.length, projectPath: projectPath === '.' ? 'root' : projectPath, filePath: filePath, summary: generateSummary(messages) }; // Only add sessionId if it exists if (sessionId) { result.sessionId = sessionId; } return result; } catch (error) { console.error(`Failed to parse conversation file ${filePath}:`, error); return null; } } /** * Get detailed conversation with all messages */ export async function getDetailedConversation(conversation) { try { const content = await readFile(conversation.filePath, 'utf-8'); const lines = content.trim().split('\n').filter(line => line.trim()); if (lines.length === 0) { return null; } // Parse all messages const messages = lines.map(line => { try { const parsed = JSON.parse(line); // Extract role and content based on Claude Code's actual structure let role = 'user'; let content = ''; // Determine role based on Claude Code's structure if (parsed.type === 'message' && parsed.userType === 'user') { role = 'user'; } else if (parsed.type === 'user') { role = 'user'; } else if (parsed.type === 'assistant') { role = 'assistant'; } else if (parsed.message && parsed.message.role === 'user') { role = 'user'; } else if (parsed.message && parsed.message.role === 'assistant') { role = 'assistant'; } else if (parsed.role === 'user' || parsed.role === 'human') { role = 'user'; } else if (parsed.role === 'assistant') { role = 'assistant'; } else { // Default based on message structure role = 'assistant'; } // Extract content based on Claude Code's structure if (parsed.message && parsed.message.content) { // For Claude Code format: msg.message.content const messageContent = parsed.message.content; if (typeof messageContent === 'string') { content = messageContent; } else if (Array.isArray(messageContent)) { // Handle array of content blocks for (const block of messageContent) { if (typeof block === 'string') { content = block; break; } else if (block && typeof block === 'object') { // Claude Code format: {type: "text", text: "..."} if (block.type === 'text' && block.text && typeof block.text === 'string') { content = block.text; break; } else if (block.type === 'tool_use' && block.name) { // Display tool usage content = `🔧 Used tool: ${block.name}`; break; } else if (block.text && typeof block.text === 'string') { content = block.text; break; } else if (block.content && typeof block.content === 'string') { content = block.content; break; } } } } } else if (parsed.message && typeof parsed.message === 'string') { // For direct message field (string) content = parsed.message; } else if (parsed.content) { // For legacy content field if (typeof parsed.content === 'string') { content = parsed.content; } else if (Array.isArray(parsed.content)) { for (const block of parsed.content) { if (typeof block === 'string') { content = block; break; } else if (block && typeof block === 'object') { // Claude Code format: {type: "text", text: "..."} if (block.type === 'text' && block.text && typeof block.text === 'string') { content = block.text; break; } else if (block.type === 'tool_use' && block.name) { // Display tool usage content = `🔧 Used tool: ${block.name}`; break; } else if (block.text && typeof block.text === 'string') { content = block.text; break; } } } } } return { role, content, timestamp: parsed.timestamp || Date.now() }; } catch { return null; } }).filter(Boolean); if (messages.length === 0) { return null; } return { ...conversation, messages }; } catch (error) { console.error(`Failed to get detailed conversation:`, error); return null; } } /** * Generate a summary from conversation messages */ function generateSummary(messages) { // Find user messages with flexible role matching const userMessages = messages.filter(msg => msg.role === 'user' || msg.role === 'human' || (msg.sender && msg.sender === 'human') || (!msg.role && msg.content)).slice(0, 3); const topics = userMessages.map(msg => { let content = ''; // Handle different content formats if (typeof msg.content === 'string') { content = msg.content; } else if (Array.isArray(msg.content)) { // Handle array of content blocks for (const block of msg.content) { if (typeof block === 'string') { content = block; break; } else if (block && typeof block === 'object') { if (block.text && typeof block.text === 'string') { content = block.text; break; } else if (block.content && typeof block.content === 'string') { content = block.content; break; } } } } else if (msg.content && typeof msg.content === 'object') { if (msg.content.text) { content = msg.content.text; } else if (msg.content.content) { content = msg.content.content; } } const firstLine = content.split('\n')[0]?.trim() || ''; return firstLine.length > 30 ? firstLine.substring(0, 27) + '...' : firstLine; }).filter(topic => topic.length > 0); return topics.length > 0 ? topics.join(' • ') : 'No summary available'; } /** * Get all Claude Code conversations */ export async function getAllClaudeConversations() { if (!(await isClaudeHistoryAvailable())) { throw new ClaudeHistoryError('Claude Code history is not available on this system'); } try { const conversations = []; const projectsDir = getClaudeProjectsDir(); // Recursively scan for .jsonl files await scanDirectoryForConversations(projectsDir, conversations); // Sort by last activity (most recent first) conversations.sort((a, b) => b.lastActivity - a.lastActivity); return conversations; } catch (error) { throw new ClaudeHistoryError('Failed to scan Claude Code conversations', error); } } /** * Recursively scan directory for conversation files */ async function scanDirectoryForConversations(dirPath, conversations) { try { const entries = await readdir(dirPath, { withFileTypes: true }); for (const entry of entries) { const fullPath = path.join(dirPath, entry.name); if (entry.isDirectory()) { // Recursively scan subdirectories await scanDirectoryForConversations(fullPath, conversations); } else if (entry.isFile() && entry.name.endsWith('.jsonl')) { // Parse conversation file const conversation = await parseConversationFile(fullPath); if (conversation) { conversations.push(conversation); } } } } catch (error) { // Continue scanning even if one directory fails console.error(`Failed to scan directory ${dirPath}:`, error); } } /** * Get conversations filtered by project/worktree path */ export async function getConversationsForProject(worktreePath) { const allConversations = await getAllClaudeConversations(); // Extract project name from worktree path const projectName = path.basename(worktreePath); return allConversations.filter(conversation => { // Match by project path or conversation mentions the project return conversation.projectPath.includes(projectName) || conversation.title.toLowerCase().includes(projectName.toLowerCase()) || conversation.summary?.toLowerCase().includes(projectName.toLowerCase()); }); } /** * Launch Claude Code with a specific conversation */ export async function launchClaudeWithConversation(worktreePath, conversation, options = {}) { const { launchClaudeCode } = await import('./claude.js'); // Launch Claude Code in the worktree with the conversation file // Note: This might need adjustment based on how Claude Code handles specific conversation files // For now, we'll use the standard launch and let Claude Code handle the session await launchClaudeCode(worktreePath, { mode: 'resume', skipPermissions: options.skipPermissions ?? false }); } //# sourceMappingURL=claude-history.js.map