claude-code-spy
Advanced tools
+155
| const fs = require('fs'); | ||
| const path = require('path'); | ||
| const colors = require('./colors'); | ||
| const { formatTimestamp, formatProjectPath, truncateText } = require('./utils'); | ||
| // Filter entries based on config criteria | ||
| function filterEntries(entries, config) { | ||
| let filtered = entries; | ||
| // Filter by project | ||
| if (config.project) { | ||
| filtered = filtered.filter(e => | ||
| e.project && e.project.toLowerCase().includes(config.project.toLowerCase()) | ||
| ); | ||
| } | ||
| // Filter by search term | ||
| if (config.search) { | ||
| filtered = filtered.filter(e => | ||
| e.display && e.display.toLowerCase().includes(config.search.toLowerCase()) | ||
| ); | ||
| } | ||
| // Filter by date range | ||
| if (config.after) { | ||
| filtered = filtered.filter(e => new Date(e.timestamp) > config.after); | ||
| } | ||
| if (config.before) { | ||
| filtered = filtered.filter(e => new Date(e.timestamp) < config.before); | ||
| } | ||
| // Sort order | ||
| if (!config.reverse) { | ||
| filtered = filtered.reverse(); // Show newest first by default | ||
| } | ||
| // Limit | ||
| if (config.limit) { | ||
| filtered = filtered.slice(0, config.limit); | ||
| } | ||
| return filtered; | ||
| } | ||
| // Find conversation for an entry | ||
| async function findConversationForEntry(entry, { readConversation, findSessionFile }) { | ||
| const sessionInfo = await findSessionFile(entry.project, entry.timestamp); | ||
| if (!sessionInfo) return null; | ||
| const conversation = await readConversation(sessionInfo.filePath); | ||
| // Find the user message that matches our entry | ||
| const entryTime = new Date(entry.timestamp); | ||
| let closestMatch = null; | ||
| let minDiff = Infinity; | ||
| for (let i = 0; i < conversation.length; i++) { | ||
| const conv = conversation[i]; | ||
| if (conv.type === 'user' && conv.message?.content) { | ||
| const convTime = new Date(conv.timestamp); | ||
| const diff = Math.abs(entryTime - convTime); | ||
| const userContent = typeof conv.message.content === 'string' | ||
| ? conv.message.content | ||
| : conv.message.content[0]?.text || ''; | ||
| const entryText = entry.display.substring(0, 100); | ||
| const convText = userContent.substring(0, 100); | ||
| if (diff < minDiff && (diff < 60000 || convText.includes(entryText.substring(0, 50)) || entryText.includes(convText.substring(0, 50)))) { | ||
| minDiff = diff; | ||
| closestMatch = i; | ||
| } | ||
| } | ||
| } | ||
| if (closestMatch !== null) { | ||
| const result = [conversation[closestMatch]]; | ||
| if (closestMatch + 1 < conversation.length && conversation[closestMatch + 1].type === 'assistant') { | ||
| result.push(conversation[closestMatch + 1]); | ||
| } | ||
| return result; | ||
| } | ||
| return null; | ||
| } | ||
| // Display a single entry | ||
| async function displayEntry(entry, index, total, config, helpers) { | ||
| const time = formatTimestamp(entry.timestamp); | ||
| const project = formatProjectPath(entry.project); | ||
| if (!config.outputOnly) { | ||
| console.log(`\n${colors.bright}${colors.cyan}━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━${colors.reset}`); | ||
| console.log(`${colors.bright}Entry #${total - index}${colors.reset} ${colors.gray}(${time.relative})${colors.reset}`); | ||
| console.log(`${colors.yellow}Time:${colors.reset} ${time.formatted}`); | ||
| console.log(`${colors.magenta}Project:${colors.reset} ${project}`); | ||
| console.log(`${colors.green}Input:${colors.reset}`); | ||
| const lines = entry.display.split('\n'); | ||
| lines.forEach(line => { | ||
| console.log(` ${colors.white}${line}${colors.reset}`); | ||
| }); | ||
| } | ||
| // If full conversation requested, try to get Claude's response | ||
| if (config.showFull || config.outputOnly) { | ||
| const conversation = await findConversationForEntry(entry, helpers); | ||
| if (conversation && conversation.length > 1) { | ||
| const assistant = conversation[1]; | ||
| if (!config.outputOnly) { | ||
| console.log(`\n${colors.blue}Claude's Response:${colors.reset}`); | ||
| } else { | ||
| console.log(`\n${colors.bright}${colors.cyan}━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━${colors.reset}`); | ||
| console.log(`${colors.gray}Response to: "${truncateText(entry.display, 80)}"${colors.reset}`); | ||
| } | ||
| const response = helpers.formatAssistantMessage(assistant.message); | ||
| const responseLines = response.split('\n'); | ||
| const maxLines = 50; | ||
| const displayLines = responseLines.slice(0, maxLines); | ||
| displayLines.forEach(line => { | ||
| console.log(` ${colors.gray}${line}${colors.reset}`); | ||
| }); | ||
| if (responseLines.length > maxLines) { | ||
| console.log(` ${colors.dim}... (${responseLines.length - maxLines} more lines)${colors.reset}`); | ||
| } | ||
| if (assistant.message?.usage) { | ||
| console.log(` ${colors.dim}[Tokens: ${assistant.message.usage.input_tokens} in, ${assistant.message.usage.output_tokens} out]${colors.reset}`); | ||
| } | ||
| } else if (config.showFull) { | ||
| console.log(`\n${colors.gray}(Claude's response not found or not available)${colors.reset}`); | ||
| } | ||
| } | ||
| // If there's pasted content, show it | ||
| if (!config.outputOnly && entry.pastedContents && Object.keys(entry.pastedContents).length > 0) { | ||
| console.log(`${colors.blue}Attachments:${colors.reset}`); | ||
| Object.entries(entry.pastedContents).forEach(([key, value]) => { | ||
| console.log(` ${colors.gray}• ${key}: ${truncateText(value.toString())}${colors.reset}`); | ||
| }); | ||
| } | ||
| } | ||
| module.exports = { | ||
| filterEntries, | ||
| findConversationForEntry, | ||
| displayEntry | ||
| }; |
+117
| const readline = require('readline'); | ||
| const { execFileSync } = require('child_process'); | ||
| const colors = require('./colors'); | ||
| // Setup keyboard input handling for watch mode | ||
| function setupKeyboardInput(options) { | ||
| const { | ||
| onExit, | ||
| onAnalyzer, | ||
| onSecurity, | ||
| onDependencies, | ||
| onBash, | ||
| onFiles, | ||
| onHelp, | ||
| onTime, | ||
| readConversation, | ||
| sessionFile, | ||
| sessionStartTime, | ||
| watchStartIndex | ||
| } = options; | ||
| if (!process.stdin.isTTY) { | ||
| return; | ||
| } | ||
| readline.emitKeypressEvents(process.stdin); | ||
| if (process.stdin.setRawMode) { | ||
| process.stdin.setRawMode(true); | ||
| } | ||
| process.stdin.on('keypress', async (str, key) => { | ||
| // Exit | ||
| if (key && (key.name === 'q' || (key.ctrl && key.name === 'c'))) { | ||
| onExit(); | ||
| return; | ||
| } | ||
| // Security analysis (s key) | ||
| if (key && key.name === 's') { | ||
| const conversation = await readConversation(sessionFile); | ||
| await onSecurity(conversation, watchStartIndex); | ||
| return; | ||
| } | ||
| // Archer analysis (a key) | ||
| if (key && key.name === 'a') { | ||
| const conversation = await readConversation(sessionFile); | ||
| await onAnalyzer(conversation, watchStartIndex); | ||
| return; | ||
| } | ||
| // Dependencies (d key) | ||
| if (key && key.name === 'd') { | ||
| const conversation = await readConversation(sessionFile); | ||
| await onDependencies(conversation, sessionStartTime, watchStartIndex); | ||
| return; | ||
| } | ||
| // Bash history (b key) | ||
| if (key && key.name === 'b') { | ||
| const conversation = await readConversation(sessionFile); | ||
| await onBash(conversation, watchStartIndex); | ||
| return; | ||
| } | ||
| // File views (f key - cycles through tracker, summary, sizes) | ||
| if (key && key.name === 'f') { | ||
| const conversation = await readConversation(sessionFile); | ||
| await onFiles(conversation, watchStartIndex); | ||
| return; | ||
| } | ||
| // Help (h key) | ||
| if (key && key.name === 'h') { | ||
| await onHelp(); | ||
| return; | ||
| } | ||
| // Time statistics (t key) | ||
| if (key && key.name === 't') { | ||
| const conversation = await readConversation(sessionFile); | ||
| await onTime(conversation, watchStartIndex, sessionStartTime); | ||
| return; | ||
| } | ||
| }); | ||
| // Handle Ctrl+C globally | ||
| process.on('SIGINT', () => { | ||
| onExit(); | ||
| }); | ||
| } | ||
| // Clean up keyboard input | ||
| function cleanupKeyboardInput() { | ||
| if (process.stdin.setRawMode) { | ||
| process.stdin.setRawMode(false); | ||
| } | ||
| } | ||
| // Get keyboard shortcuts help text | ||
| function getKeyboardShortcuts() { | ||
| return `${colors.bright}${colors.cyan}Keyboard Shortcuts:${colors.reset} | ||
| ${colors.yellow}a${colors.reset} - Run Archer (AI conversation analysis) | ||
| ${colors.yellow}s${colors.reset} - Run Security Analysis | ||
| ${colors.yellow}d${colors.reset} - Show dependency graph & recent commits | ||
| ${colors.yellow}b${colors.reset} - Show bash command history | ||
| ${colors.yellow}f${colors.reset} - Cycle through file views (tracker → summary → sizes) | ||
| ${colors.yellow}t${colors.reset} - Show time statistics | ||
| ${colors.yellow}h${colors.reset} - Show this help | ||
| ${colors.yellow}q${colors.reset} - Quit`; | ||
| } | ||
| module.exports = { | ||
| setupKeyboardInput, | ||
| cleanupKeyboardInput, | ||
| getKeyboardShortcuts | ||
| }; |
+207
| /** | ||
| * Models Module - OpenAI API and Analysis Functions | ||
| * | ||
| * This module handles all LLM interactions and analysis operations. | ||
| * Functions: Archer analysis, Security analysis, Dependency analysis | ||
| * | ||
| * Note: Analysis functions (runArcherAnalysisInline, runSecurityAnalysisInline) | ||
| * are currently exported from main CLI file but will be extracted here | ||
| * as part of ongoing refactoring. This module provides the interface | ||
| * and helper functions for AI-powered analysis. | ||
| */ | ||
| const colors = require('./colors'); | ||
| const { appendToMarkdownLog } = require('./logger'); | ||
| /** | ||
| * Extract interactions from conversation for analysis | ||
| * @param {Array} conversation - Conversation entries from session | ||
| * @param {number} startIndex - Start index for filtering | ||
| * @param {number} limit - Max interactions to return | ||
| * @returns {Array} Array of interaction objects | ||
| */ | ||
| function extractInteractions(conversation, startIndex = 0, limit = 10) { | ||
| const interactions = []; | ||
| let currentInteraction = null; | ||
| for (let i = startIndex; i < conversation.length; i++) { | ||
| const entry = conversation[i]; | ||
| if (entry.type === 'user') { | ||
| const content = entry.message?.content || ''; | ||
| // Skip tool results | ||
| if (Array.isArray(content) && content.length > 0 && content[0].type === 'tool_result') { | ||
| continue; | ||
| } | ||
| if (currentInteraction) { | ||
| interactions.push(currentInteraction); | ||
| } | ||
| currentInteraction = { | ||
| user: typeof content === 'string' ? content : content[0]?.text || '', | ||
| assistant: '', | ||
| tools: [] | ||
| }; | ||
| } else if (entry.type === 'assistant' && currentInteraction) { | ||
| const content = entry.message?.content || []; | ||
| if (Array.isArray(content)) { | ||
| content.forEach(item => { | ||
| if (item.type === 'text') { | ||
| currentInteraction.assistant += item.text + '\n'; | ||
| } else if (item.type === 'tool_use') { | ||
| currentInteraction.tools.push({ | ||
| name: item.name, | ||
| input: item.input | ||
| }); | ||
| } | ||
| }); | ||
| } else if (typeof content === 'string') { | ||
| currentInteraction.assistant = content; | ||
| } | ||
| } | ||
| } | ||
| if (currentInteraction) { | ||
| interactions.push(currentInteraction); | ||
| } | ||
| return interactions.slice(-limit); | ||
| } | ||
| /** | ||
| * Format interactions for LLM input | ||
| * @param {Array} interactions - Interaction objects | ||
| * @returns {string} Formatted conversation text | ||
| */ | ||
| function formatConversationForLLM(interactions) { | ||
| return interactions.map((interaction, idx) => { | ||
| let text = `\n## Interaction ${idx + 1}\n\n`; | ||
| text += `**User Input:**\n${interaction.user}\n\n`; | ||
| if (interaction.tools.length > 0) { | ||
| text += `**Tools Used:**\n`; | ||
| interaction.tools.forEach(tool => { | ||
| text += `- ${tool.name}: ${JSON.stringify(tool.input, null, 2)}\n`; | ||
| }); | ||
| text += `\n`; | ||
| } | ||
| text += `**Claude's Response:**\n${interaction.assistant}\n`; | ||
| return text; | ||
| }).join('\n---\n'); | ||
| } | ||
| /** | ||
| * Display analysis results with proper formatting | ||
| * @param {string} analysis - Analysis text from LLM | ||
| * @param {object} options - Display options | ||
| */ | ||
| function displayAnalysis(analysis, options = {}) { | ||
| const { | ||
| title = '🏹 ANALYSIS', | ||
| titleColor = colors.orange, | ||
| highlightTerms = [], | ||
| logFile = null | ||
| } = options; | ||
| console.log(`${colors.bright}${titleColor}${title}${colors.reset}\n`); | ||
| if (logFile) appendToMarkdownLog(logFile, `## ${title}\n`); | ||
| const paragraphs = analysis.split('\n\n'); | ||
| paragraphs.forEach((para, idx) => { | ||
| const lines = para.split('\n'); | ||
| lines.forEach(line => { | ||
| if (line.trim()) { | ||
| let displayLine = line; | ||
| let lineColor = titleColor; | ||
| // Apply highlight colors for specific terms | ||
| highlightTerms.forEach(({ term, color }) => { | ||
| if (line.includes(term)) { | ||
| lineColor = color; | ||
| } | ||
| }); | ||
| console.log(`${lineColor}${displayLine}${colors.reset}`); | ||
| if (logFile) appendToMarkdownLog(logFile, line); | ||
| } | ||
| }); | ||
| if (idx < paragraphs.length - 1) { | ||
| console.log(''); | ||
| if (logFile) appendToMarkdownLog(logFile, ''); | ||
| } | ||
| }); | ||
| } | ||
| /** | ||
| * Call OpenAI API with retry and error handling | ||
| * @param {object} options - API options | ||
| * @returns {Promise<string>} Analysis result | ||
| */ | ||
| async function callOpenAIAPI(options) { | ||
| const { | ||
| systemPrompt, | ||
| userMessage, | ||
| model = 'gpt-4o-mini', | ||
| apiKey = process.env.OPENAI_API_KEY, | ||
| timeout = 30000 | ||
| } = options; | ||
| if (!apiKey) { | ||
| throw new Error('OPENAI_API_KEY environment variable not set'); | ||
| } | ||
| const controller = new AbortController(); | ||
| const timeoutHandle = setTimeout(() => controller.abort(), timeout); | ||
| try { | ||
| const response = await fetch('https://api.openai.com/v1/chat/completions', { | ||
| method: 'POST', | ||
| headers: { | ||
| 'Content-Type': 'application/json', | ||
| 'Authorization': `Bearer ${apiKey}` | ||
| }, | ||
| signal: controller.signal, | ||
| body: JSON.stringify({ | ||
| model, | ||
| messages: [ | ||
| { role: 'system', content: systemPrompt }, | ||
| { role: 'user', content: userMessage } | ||
| ], | ||
| temperature: 0.3, | ||
| max_tokens: 1200 | ||
| }) | ||
| }); | ||
| clearTimeout(timeoutHandle); | ||
| if (!response.ok) { | ||
| const error = new Error(`OpenAI API Error: ${response.status}`); | ||
| error.status = response.status; | ||
| throw error; | ||
| } | ||
| const data = await response.json(); | ||
| return { | ||
| analysis: data.choices[0].message.content, | ||
| tokens: data.usage.total_tokens, | ||
| model | ||
| }; | ||
| } catch (err) { | ||
| clearTimeout(timeoutHandle); | ||
| if (err.name === 'AbortError') { | ||
| throw new Error('API request timeout'); | ||
| } | ||
| throw err; | ||
| } | ||
| } | ||
| module.exports = { | ||
| extractInteractions, | ||
| formatConversationForLLM, | ||
| displayAnalysis, | ||
| callOpenAIAPI | ||
| }; |
+230
| /** | ||
| * Watch Mode Module - Real-time conversation monitoring | ||
| * | ||
| * This module handles watch mode functionality including: | ||
| * - Polling for new messages | ||
| * - Idle-time tracking for auto-summaries | ||
| * - Token counting for analysis triggers | ||
| * - Auto-triggering Archer analysis | ||
| */ | ||
| const colors = require('./colors'); | ||
| const { appendToMarkdownLog } = require('./logger'); | ||
| /** | ||
| * Calculate tool usage statistics from conversation | ||
| * @param {Array} conversation - Conversation entries | ||
| * @returns {object} {totalTools, toolList, toolTypes} | ||
| */ | ||
| function getToolStats(conversation) { | ||
| const toolCounts = {}; | ||
| const toolTypes = new Set(); | ||
| for (const entry of conversation) { | ||
| if (entry.type === 'assistant') { | ||
| const content = entry.message?.content || []; | ||
| if (Array.isArray(content)) { | ||
| content.forEach(item => { | ||
| if (item.type === 'tool_use') { | ||
| const toolName = item.name || 'unknown'; | ||
| toolCounts[toolName] = (toolCounts[toolName] || 0) + 1; | ||
| toolTypes.add(toolName); | ||
| } | ||
| }); | ||
| } | ||
| } | ||
| } | ||
| const totalTools = Object.values(toolCounts).reduce((a, b) => a + b, 0); | ||
| const toolList = Object.entries(toolCounts) | ||
| .map(([name, count]) => `${name}(${count})`) | ||
| .join(', '); | ||
| return { totalTools, toolList, toolTypes: Array.from(toolTypes) }; | ||
| } | ||
| /** | ||
| * Generate initial watch mode stats display | ||
| * @param {Array} conversation - Initial conversation | ||
| * @param {number} startIndex - Starting index for new content | ||
| * @returns {string} Formatted stats display | ||
| */ | ||
| function generateStatsDisplay(conversation, startIndex = 0) { | ||
| const recentMessages = conversation.slice(startIndex); | ||
| const userMessages = recentMessages.filter(e => e.type === 'user'); | ||
| const assistantMessages = recentMessages.filter(e => e.type === 'assistant'); | ||
| // Count tokens (rough estimate) | ||
| let totalTokens = 0; | ||
| assistantMessages.forEach(entry => { | ||
| const content = entry.message?.content || []; | ||
| if (Array.isArray(content)) { | ||
| content.forEach(item => { | ||
| if (item.type === 'text') { | ||
| totalTokens += Math.ceil(item.text.length / 4); | ||
| } | ||
| }); | ||
| } | ||
| }); | ||
| const { totalTools, toolList } = getToolStats(recentMessages); | ||
| return `${colors.cyan}━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━${colors.reset} | ||
| ${colors.bright}${colors.cyan}Watch Mode Active${colors.reset} | ||
| ${colors.cyan}━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━${colors.reset} | ||
| ${colors.dim}Messages:${colors.reset} ${userMessages.length} user, ${assistantMessages.length} assistant | ||
| ${colors.dim}Tokens:${colors.reset} ~${totalTokens} | ||
| ${colors.dim}Tools used:${colors.reset} ${totalTools > 0 ? toolList : 'none'} | ||
| ${colors.dim}Press 'h' for help, 'q' to quit${colors.reset}`; | ||
| } | ||
| /** | ||
| * Count total tokens in conversation (rough estimate: 4 chars per token) | ||
| * @param {Array} conversation - Conversation entries | ||
| * @returns {number} Estimated total tokens | ||
| */ | ||
| function countConversationTokens(conversation) { | ||
| let totalTokens = 0; | ||
| for (const entry of conversation) { | ||
| if (entry.type === 'assistant') { | ||
| const content = entry.message?.content || []; | ||
| if (Array.isArray(content)) { | ||
| content.forEach(item => { | ||
| if (item.type === 'text') { | ||
| totalTokens += Math.ceil(item.text.length / 4); | ||
| } | ||
| }); | ||
| } | ||
| } | ||
| } | ||
| return totalTokens; | ||
| } | ||
| /** | ||
| * Handle polling loop for watch mode | ||
| * @param {object} options - Polling options | ||
| */ | ||
| async function startPolling(options) { | ||
| const { | ||
| sessionFile, | ||
| readConversation, | ||
| displayMessage, | ||
| runArcherAnalysis, | ||
| logFile, | ||
| onExit | ||
| } = options; | ||
| let lastMessageCount = 0; | ||
| let lastMessageTime = Date.now(); | ||
| let analysisPending = false; | ||
| let lastAnalysisTokenCount = 0; | ||
| let isExiting = false; | ||
| const apiKey = process.env.OPENAI_API_KEY; | ||
| // Initialize tracking | ||
| try { | ||
| const initialConversation = await readConversation(sessionFile); | ||
| lastMessageCount = initialConversation.length; | ||
| lastAnalysisTokenCount = countConversationTokens(initialConversation); | ||
| } catch (e) { | ||
| // File might not exist yet | ||
| } | ||
| // Poll for changes every 500ms | ||
| const pollInterval = setInterval(async () => { | ||
| if (isExiting) { | ||
| clearInterval(pollInterval); | ||
| return; | ||
| } | ||
| try { | ||
| const currentConversation = await readConversation(sessionFile); | ||
| if (currentConversation.length > lastMessageCount) { | ||
| // New messages arrived | ||
| const newMessages = currentConversation.slice(lastMessageCount); | ||
| console.log(`${colors.cyan}━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━${colors.reset}`); | ||
| console.log(`${colors.green}New message(s) received!${colors.reset}\n`); | ||
| appendToMarkdownLog(logFile, '━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━'); | ||
| appendToMarkdownLog(logFile, 'New message(s) received!'); | ||
| appendToMarkdownLog(logFile, ''); | ||
| for (const entry of newMessages) { | ||
| displayMessage(entry, false); | ||
| // Log entry to markdown | ||
| if (entry.type === 'user') { | ||
| appendToMarkdownLog(logFile, `**User:** ${entry.input || ''}`); | ||
| } else if (entry.type === 'assistant') { | ||
| const content = entry.message?.content || []; | ||
| let text = ''; | ||
| if (Array.isArray(content)) { | ||
| content.forEach(item => { | ||
| if (item.type === 'text') { | ||
| text += item.text || ''; | ||
| } | ||
| }); | ||
| } | ||
| appendToMarkdownLog(logFile, `**Assistant:** ${text.substring(0, 500)}${text.length > 500 ? '...' : ''}`); | ||
| } | ||
| appendToMarkdownLog(logFile, ''); | ||
| } | ||
| lastMessageCount = currentConversation.length; | ||
| lastMessageTime = Date.now(); | ||
| analysisPending = false; | ||
| } else { | ||
| // Check for idle timeout (15 seconds) with minimum token count | ||
| const idleTime = Date.now() - lastMessageTime; | ||
| const totalTokens = countConversationTokens(currentConversation); | ||
| const newTokens = totalTokens - lastAnalysisTokenCount; | ||
| const idleSeconds = Math.floor(idleTime / 1000); | ||
| // Auto-trigger analysis after 15 seconds idle and 1000+ new tokens | ||
| if (idleSeconds >= 15 && newTokens >= 1000 && !analysisPending) { | ||
| if (!apiKey) { | ||
| console.log(`\n${colors.yellow}⚠ Auto-summary needs OPENAI_API_KEY${colors.reset}\n`); | ||
| appendToMarkdownLog(logFile, 'Auto-summary skipped: OPENAI_API_KEY not set'); | ||
| } else { | ||
| analysisPending = true; | ||
| lastAnalysisTokenCount = totalTokens; | ||
| console.log(`\n${colors.dim}Idle for ${idleSeconds}s and ${newTokens} tokens...${colors.reset}\n`); | ||
| console.log(`${colors.orange}🏹 Archer is contemplating...${colors.reset}\n`); | ||
| appendToMarkdownLog(logFile, `Idle for ${idleSeconds}s and ${newTokens} tokens`); | ||
| appendToMarkdownLog(logFile, 'Archer is contemplating...'); | ||
| try { | ||
| await runArcherAnalysis(); | ||
| } catch (err) { | ||
| console.error(`${colors.red}Auto-summary error: ${err.message}${colors.reset}`); | ||
| appendToMarkdownLog(logFile, `Auto-summary error: ${err.message}`); | ||
| } | ||
| console.log(`${colors.cyan}━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━${colors.reset}`); | ||
| console.log(`${colors.dim}Resuming watch...${colors.reset}\n`); | ||
| appendToMarkdownLog(logFile, '━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━'); | ||
| appendToMarkdownLog(logFile, 'Resuming watch...'); | ||
| analysisPending = false; | ||
| } | ||
| } | ||
| } | ||
| } catch (e) { | ||
| // File might be in the middle of being written, ignore | ||
| } | ||
| }, 500); | ||
| // Return cleanup function | ||
| return () => { | ||
| isExiting = true; | ||
| clearInterval(pollInterval); | ||
| }; | ||
| } | ||
| module.exports = { | ||
| getToolStats, | ||
| generateStatsDisplay, | ||
| countConversationTokens, | ||
| startPolling | ||
| }; |
+5
-1
@@ -6,3 +6,7 @@ // Main library exports - ties together all modules | ||
| logger: require('./logger'), | ||
| config: require('./config') | ||
| config: require('./config'), | ||
| core: require('./core'), | ||
| input: require('./input'), | ||
| models: require('./models'), | ||
| watch: require('./watch') | ||
| }; |
+1
-1
| { | ||
| "name": "claude-code-spy", | ||
| "version": "2.5.0", | ||
| "version": "2.5.1", | ||
| "description": "Spy on your Claude Code conversations - Real-time monitoring with AI-powered summaries and full tool visibility", | ||
@@ -5,0 +5,0 @@ "main": "claude-history-cli.js", |
162331
19.07%14
40%2951
26.44%13
30%6
50%