UNPKG

claude-code-tamagotchi

Version:

A virtual pet that lives in your Claude Code statusline

642 lines (552 loc) 23.4 kB
#!/usr/bin/env bun /** * Background worker for analyzing transcript messages * Runs as a detached process to avoid blocking the main statusline */ import { FeedbackDatabase } from '../engine/feedback/FeedbackDatabase'; import { MessageProcessor } from '../engine/feedback/MessageProcessor'; import { GroqClient } from '../llm/GroqClient'; import { TranscriptMessage, MessageMetadata, Feedback, LLMAnalysisResult } from '../engine/feedback/types'; import * as fs from 'fs'; import * as path from 'path'; // Debug logging function debug(message: string): void { if (process.env.PET_FEEDBACK_DEBUG === 'true') { const timestamp = new Date().toISOString(); const logMessage = `[${timestamp}] [Worker:${process.pid}] ${message}\n`; // Log to console console.log(logMessage.trim()); // Log to file if specified const logDir = process.env.PET_FEEDBACK_LOG_DIR; if (logDir) { try { // Create log directory if it doesn't exist if (!fs.existsSync(logDir)) { fs.mkdirSync(logDir, { recursive: true }); } const logFile = path.join(logDir, 'feedback-worker.log'); fs.appendFileSync(logFile, logMessage); } catch { // Ignore logging errors } } } } // Parse command line arguments const [transcriptPath, sessionId, dbPath, petStateJson] = process.argv.slice(2); if (!transcriptPath || !sessionId || !dbPath) { console.error('Usage: analyze-transcript.ts <transcript_path> <session_id> <db_path> [pet_state_json]'); process.exit(1); } // Parse pet state if provided let petState: any = null; if (petStateJson) { try { petState = JSON.parse(petStateJson); debug(`Pet state: hunger=${petState.hunger}, energy=${petState.energy}, cleanliness=${petState.cleanliness}, happiness=${petState.happiness}`); } catch (error) { debug(`Failed to parse pet state: ${error}`); } } debug(`Worker started for session ${sessionId}`); // Configuration from environment const config = { groqApiKey: process.env.PET_GROQ_API_KEY, groqModel: process.env.PET_GROQ_MODEL || 'openai/gpt-oss-20b', groqTimeout: parseInt(process.env.PET_GROQ_TIMEOUT || '2000'), batchSize: parseInt(process.env.PET_FEEDBACK_BATCH_SIZE || '10'), staleLockTime: parseInt(process.env.PET_FEEDBACK_STALE_LOCK_TIME || '30000') }; // Initialize components const db = new FeedbackDatabase(dbPath); const processor = new MessageProcessor(db, config.staleLockTime); const groq = new GroqClient( config.groqApiKey, config.groqModel, config.groqTimeout, 2, // maxRetries (default) dbPath // Pass database path for violation storage ); // Get recent funny observations for this session to avoid repetition if (petState) { try { const recentObservations = db.getRecentFunnyObservations(sessionId, 10); petState.thoughtHistory = recentObservations; debug(`Loaded ${recentObservations.length} recent observations for session ${sessionId}`); } catch (error) { debug(`Failed to load recent observations: ${error}`); petState.thoughtHistory = []; } } /** * Main analysis function */ async function analyzeTranscript() { try { debug(`Starting analysis for transcript: ${transcriptPath}`); // Claim messages for processing const messages = await processor.claimMessagesForProcessing( transcriptPath, config.batchSize ); if (messages.length === 0) { debug('No new messages to process'); return; } debug(`Processing ${messages.length} messages...`); debug(`Messages to process: ${messages.map(m => `${m.uuid} (${m.type})`).join(', ')}`); // Get all messages for context const allMessages = await processor.readTranscript(transcriptPath); debug(`Total messages in transcript: ${allMessages.length}`); // Process each message for (let i = 0; i < messages.length; i++) { const message = messages[i]; debug(`Processing message ${i+1}/${messages.length}: ${message.uuid} (${message.type})`); try { await processMessage(message, allMessages, sessionId, transcriptPath); } catch (error) { debug(`Failed to process message ${message.uuid}: ${error}`); console.error(`Failed to process message ${message.uuid}:`, error); } } // Mark all messages as processed const uuids = messages.map(m => m.uuid); processor.markMessagesProcessed(uuids); debug(`Marked ${uuids.length} messages as processed`); // Cleanup old data if needed db.checkAndCleanup(); } catch (error) { debug(`Analysis failed: ${error}`); console.error('Analysis failed:', error); } finally { db.close(); debug('Worker completed'); } } /** * Process a single message */ async function processMessage( message: TranscriptMessage, allMessages: TranscriptMessage[], sessionId: string, transcriptPath: string ): Promise<void> { // Get workspace ID for isolation const workspaceId = FeedbackDatabase.extractWorkspaceId(transcriptPath); // Get message index for context const messageIndex = allMessages.findIndex(m => m.uuid === message.uuid); // Extract message content regardless of type const extracted = processor.extractMessageContent(message); // Process based on message type if (message.type === 'user') { debug(`Processing user message: ${message.uuid}`); const userContent = extracted.content || 'No content'; // Check if this is a tool result (which is technically a user message but contains tool output) // We'll analyze these differently to get better summaries const isToolResult = userContent.startsWith('[Tool Result:') || userContent.startsWith('[[Tool output]]') || userContent.includes('Tool ran without output') || userContent.includes('Applied ') && userContent.includes(' edits to ') || userContent.includes('The file ') && userContent.includes(' has been ') || (userContent.startsWith('```') && userContent.length < 100) || // Short code blocks are often tool outputs userContent === 'Processed request' || message.type === 'tool_result'; // Some transcripts mark these explicitly // Get session history for context - don't include current message const sessionMetadata = db.getSessionMetadata(sessionId); debug(`Retrieved ${sessionMetadata.length} total metadata entries for session`); const sessionHistory: string[] = []; // Build history from ALL previous messages (not stopping at current) let skippedCount = 0; for (const meta of sessionMetadata) { // Skip only if we've reached or passed the current message timestamp if (meta.timestamp && message.timestamp && meta.timestamp >= message.timestamp) { debug(`Skipping metadata entry ${meta.message_uuid} - timestamp ${meta.timestamp} >= current ${message.timestamp}`); skippedCount++; continue; } if (meta.summary) { debug(`Adding to history: type=${meta.type}, summary="${meta.summary.slice(0, 50)}..."`); if (meta.type === 'user') { sessionHistory.push(`User: ${meta.summary}`); } else if (meta.type === 'assistant') { sessionHistory.push(`Claude: ${meta.summary}`); } else if (meta.type === 'tool_call') { sessionHistory.push(`[${meta.summary}]`); } } else { debug(`Metadata entry ${meta.message_uuid} has no summary`); } } debug(`Session history for user message contains ${sessionHistory.length} entries (skipped ${skippedCount})`); if (sessionHistory.length > 0) { debug(`First history entry: "${sessionHistory[0]?.slice(0, 100)}..."`); debug(`Last history entry: "${sessionHistory[sessionHistory.length - 1]?.slice(0, 100)}..."`); } if (isToolResult) { // This is a tool result - find which tool was called debug(`Processing tool result message: ${message.uuid}`); debug(`Tool result content preview: "${userContent.slice(0, 50)}..."`); const toolCall = processor.findToolCallForResult(allMessages, message); let toolInfo = '[Tool output]'; if (toolCall) { // Format based on tool type const { toolName, toolInput } = toolCall; switch (toolName) { case 'Read': toolInfo = `Tool: Read - ${toolInput.file_path || 'unknown file'}`; if (toolInput.limit) toolInfo += ` (limit: ${toolInput.limit})`; if (toolInput.offset) toolInfo += ` (offset: ${toolInput.offset})`; break; case 'Edit': toolInfo = `Tool: Edit - ${toolInput.file_path || 'unknown file'}`; if (toolInput.old_string) { const preview = toolInput.old_string.slice(0, 30).replace(/\n/g, ' '); toolInfo += ` (replacing: "${preview}...")`; } break; case 'Write': toolInfo = `Tool: Write - ${toolInput.file_path || 'unknown file'}`; break; case 'MultiEdit': toolInfo = `Tool: MultiEdit - ${toolInput.file_path || 'unknown file'}`; if (toolInput.edits) toolInfo += ` (${toolInput.edits.length} edits)`; break; case 'Bash': const cmd = toolInput.command || 'unknown command'; const shortCmd = cmd.length > 50 ? cmd.slice(0, 47) + '...' : cmd; toolInfo = `Tool: Bash - ${shortCmd}`; break; case 'Glob': toolInfo = `Tool: Glob - pattern: ${toolInput.pattern || 'unknown'}`; if (toolInput.path) toolInfo += ` in ${toolInput.path}`; break; case 'Grep': toolInfo = `Tool: Grep - "${toolInput.pattern || 'unknown'}"`; if (toolInput.path) toolInfo += ` in ${toolInput.path}`; break; case 'LS': toolInfo = `Tool: LS - ${toolInput.path || 'unknown path'}`; break; case 'WebFetch': toolInfo = `Tool: WebFetch - ${toolInput.url || 'unknown URL'}`; break; case 'WebSearch': toolInfo = `Tool: WebSearch - "${toolInput.query || 'unknown query'}"`; break; case 'Task': toolInfo = `Tool: Task - ${toolInput.description || 'unknown task'}`; break; case 'TodoWrite': toolInfo = `Tool: TodoWrite`; if (toolInput.todos && Array.isArray(toolInput.todos)) { toolInfo += ` - ${toolInput.todos.length} tasks`; } break; default: toolInfo = `Tool: ${toolName}`; break; } debug(`Formatted tool call: ${toolInfo}`); } // Analyze tool result with LLM to understand what Claude did and why debug(`Analyzing tool result with LLM: ${message.uuid}`); debug(`Tool info: ${toolInfo}`); debug(`Session history size for tool result: ${sessionHistory.length}`); // Create a special prompt for tool results const toolResultPrompt = `This is a tool result from Claude Code. ${toolInfo} Output preview: ${userContent.slice(0, 500)}`; debug(`Calling analyzeUserMessage for tool result...`); const analysis = await groq.analyzeUserMessage( toolResultPrompt, sessionHistory ); debug(`Tool result analysis received - summary: "${analysis.summary?.slice(0, 50)}...", intent: "${analysis.intent}"`) // Save with analyzed summary const metadata: MessageMetadata = { workspace_id: workspaceId, session_id: sessionId, message_uuid: message.uuid, parent_uuid: message.parentUuid, timestamp: message.timestamp, type: 'tool_call', // Mark as tool_call for clarity role: 'system', summary: analysis.summary || toolInfo, intent: analysis.intent || 'Tool execution', created_at: Date.now() }; db.saveMessageMetadata(metadata); debug(`Saved analyzed tool result: "${metadata.summary}"`); return; } // This is a real user message - analyze it with LLM for proper summary debug(`Analyzing real user message with LLM: ${message.uuid}`); debug(`User message preview: "${userContent.slice(0, 100)}..."`); debug(`Session history size for user message: ${sessionHistory.length}`); // Analyze user message with full context (sessionHistory already built above) debug(`Calling analyzeUserMessage for real user message...`); const analysis = await groq.analyzeUserMessage( userContent, sessionHistory ); debug(`User message analysis received - summary: "${analysis.summary?.slice(0, 50)}...", intent: "${analysis.intent}"`); if (analysis.summary === userContent) { debug(`WARNING: Analysis returned full message as summary - API may have failed`); } // Save user message metadata with LLM-generated summary const metadata: MessageMetadata = { workspace_id: workspaceId, session_id: sessionId, message_uuid: message.uuid, parent_uuid: message.parentUuid, timestamp: message.timestamp, type: message.type, role: message.message?.role, summary: analysis.summary || userContent, intent: analysis.intent || 'User request', created_at: Date.now() }; db.saveMessageMetadata(metadata); debug(`Saved user message with LLM summary: "${metadata.summary}"`); return; } else if (message.type === 'system') { // Handle system messages (errors, reminders, file opens, etc) debug(`Processing system message: ${message.uuid}`); const systemContent = extracted.content || 'System message'; // Save system message metadata const metadata: MessageMetadata = { workspace_id: workspaceId, session_id: sessionId, message_uuid: message.uuid, parent_uuid: message.parentUuid, timestamp: message.timestamp, type: message.type, role: 'system', summary: `System: ${systemContent.slice(0, 100)}`, created_at: Date.now() }; db.saveMessageMetadata(metadata); return; } else if (message.type !== 'assistant') { // Handle other message types (tool results, etc) debug(`Processing ${message.type} message: ${message.uuid}`); const metadata: MessageMetadata = { workspace_id: workspaceId, session_id: sessionId, message_uuid: message.uuid, parent_uuid: message.parentUuid, timestamp: message.timestamp, type: message.type, role: message.message?.role, summary: `${message.type}: ${extracted.content?.slice(0, 100) || 'No content'}`, created_at: Date.now() }; db.saveMessageMetadata(metadata); return; } // Process assistant messages (existing logic) debug(`Analyzing assistant message: ${message.uuid}`); // Get context - look back up to 10 messages to find user request const context = await processor.getMessageContext(allMessages, messageIndex, 10); // Extract Claude's actions with specific details - keep FULL content up to 50k chars const claudeActions: string[] = []; if (extracted.content) { // Only slice if truly massive (over 50k chars) const content = extracted.content.length > 50000 ? extracted.content.slice(0, 50000) + '... [truncated]' : extracted.content; claudeActions.push(`Text: ${content}`); } // Include specific tool details for better feedback for (const tool of extracted.tools || []) { let toolDescription = `Tool: ${tool.name}`; // Add specific details based on tool type if (tool.input) { switch (tool.name) { case 'Read': if (tool.input.file_path) { toolDescription = `Tool: Read - ${tool.input.file_path}`; } break; case 'Edit': case 'Write': case 'MultiEdit': if (tool.input.file_path) { toolDescription = `Tool: ${tool.name} - ${tool.input.file_path}`; } break; case 'Bash': if (tool.input.command) { const cmd = tool.input.command.length > 50 ? tool.input.command.slice(0, 47) + '...' : tool.input.command; toolDescription = `Tool: Bash - "${cmd}"`; } break; case 'Grep': if (tool.input.pattern) { toolDescription = `Tool: Grep - pattern: "${tool.input.pattern}"`; if (tool.input.path) toolDescription += ` in ${tool.input.path}`; } break; case 'Glob': if (tool.input.pattern) { toolDescription = `Tool: Glob - pattern: "${tool.input.pattern}"`; if (tool.input.path) toolDescription += ` in ${tool.input.path}`; } break; } } claudeActions.push(toolDescription); } // Get FULL session history for complete context (NOTE: second param is ignored in current implementation) const sessionMetadata = db.getSessionMetadata(sessionId); debug(`Building session history for assistant message - found ${sessionMetadata.length} metadata entries`); // Build session narrative from all previous messages const sessionHistory: string[] = []; let skippedForTimestamp = 0; let noSummaryCount = 0; for (const meta of sessionMetadata) { // Skip only if we've reached or passed the current message timestamp if (meta.timestamp && message.timestamp && meta.timestamp >= message.timestamp) { debug(`Skipping future message: ${meta.message_uuid} (${meta.type}) - timestamp ${meta.timestamp} >= current ${message.timestamp}`); skippedForTimestamp++; continue; } // Build a narrative entry for each message with summary if (meta.summary) { debug(`Adding ${meta.type} to history: "${meta.summary.slice(0, 50)}..."`); if (meta.type === 'user') { sessionHistory.push(`User: ${meta.summary}`); } else if (meta.type === 'assistant') { sessionHistory.push(`Claude: ${meta.summary}`); } else if (meta.type === 'tool_call') { // Include all tool calls equally sessionHistory.push(`[${meta.summary}]`); } else if (meta.type === 'system' && meta.type !== 'tool_result') { // Include important system messages for context sessionHistory.push(`[${meta.summary}]`); } } else { debug(`No summary for ${meta.type} message: ${meta.message_uuid}`); noSummaryCount++; } } debug(`Session history contains ${sessionHistory.length} entries (skipped ${skippedForTimestamp} future, ${noSummaryCount} no summary)`); if (sessionHistory.length > 0) { debug(`First entry: "${sessionHistory[0]?.slice(0, 100)}..."`); debug(`Last entry: "${sessionHistory[sessionHistory.length - 1]?.slice(0, 100)}..."`); } else { debug(`WARNING: Empty session history for assistant message!`); } // Skip TodoWrite-only messages - they're not interesting for observations const isOnlyTodoWrite = extracted.tools && extracted.tools.length === 1 && extracted.tools[0].name === 'TodoWrite' && !extracted.content; // No text response, just todo if (isOnlyTodoWrite) { debug(`Skipping TodoWrite-only message - not interesting for observations`); // Still save metadata but with a simple summary const metadata: MessageMetadata = { workspace_id: workspaceId, session_id: sessionId, message_uuid: message.uuid, parent_uuid: message.parentUuid, timestamp: message.timestamp, type: message.type, role: message.message?.role, summary: 'Updated task list', intent: context.userRequest || 'Task management', created_at: Date.now() }; db.saveMessageMetadata(metadata); return; } // Analyze with LLM debug(`Calling Groq API for analysis...`); debug(`User request: "${context.userRequest || 'No specific request'}"`); debug(`Claude actions: ${claudeActions.join(', ') || 'None'}`); debug(`Session ID: ${sessionId}, Message UUID: ${message.uuid}, Transcript Path: ${transcriptPath}`); debug(`Workspace ID (already extracted): ${workspaceId}`); debug(`Calling analyzeExchange with: sessionId=${sessionId}, messageUuid=${message.uuid}, workspaceId=${workspaceId}`); const analysis = await groq.analyzeExchange( context.userRequest || 'No specific request', claudeActions, sessionHistory, undefined, // project context petState, // pass pet state for contextual remarks sessionId, // Pass session ID for violation storage message.uuid, // Pass message UUID for violation storage workspaceId // Pass workspace ID for violation storage ); debug(`Analysis result: ${analysis.feedback_type}/${analysis.severity} - Score: ${analysis.compliance_score}/10`); if (analysis.remark) { debug(`Remark: "${analysis.remark}"`); } // Save metadata with workspace ID const metadata: MessageMetadata = { workspace_id: workspaceId, session_id: sessionId, message_uuid: message.uuid, parent_uuid: message.parentUuid, timestamp: message.timestamp, type: message.type, role: message.message?.role, summary: analysis.summary, intent: analysis.intent, project_context: analysis.project_context, compliance_score: analysis.compliance_score, efficiency_score: analysis.efficiency_score, created_at: Date.now() }; db.saveMessageMetadata(metadata); // Save feedback if there's an issue or praise if (analysis.feedback_type !== 'none') { const feedback: Feedback = { workspace_id: workspaceId, session_id: sessionId, message_uuid: message.uuid, feedback_type: analysis.feedback_type, severity: analysis.severity, remark: analysis.remark, funny_observation: analysis.funny_observation, icon: getIconForFeedback(analysis), shown: false, expires_at: Date.now() + (60 * 60 * 1000), // Expire after 1 hour created_at: Date.now() }; db.saveFeedback(feedback); } } /** * Get appropriate icon for feedback type */ function getIconForFeedback(analysis: LLMAnalysisResult): string { if (analysis.severity === 'critical') { return '💢'; // Angry outburst } else if (analysis.severity === 'problematic') { return '🗯️'; // Annoyed remark } else if (analysis.severity === 'annoying') { return '⚡'; // Quick reaction } else if (analysis.feedback_type === 'good') { return '✨'; // Praise } else if (analysis.funny_observation) { return '🎯'; // Project observation } else { return '🔍'; // Analysis insight } } // Run the analysis analyzeTranscript().then(() => { console.log('Analysis complete'); process.exit(0); }).catch(error => { console.error('Analysis failed:', error); process.exit(1); });