Last active
July 29, 2025 08:47
-
-
Save gkio/9df080cd98674ca3f1cff7e73cf1a2f2 to your computer and use it in GitHub Desktop.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| import { spawn, ChildProcess } from 'child_process'; | |
| import { EventEmitter } from 'events'; | |
| import path from 'path'; | |
| import fs from 'fs'; | |
| import os from 'os'; | |
| import { v4 as uuidv4 } from 'uuid'; | |
| export interface ClaudeExecutorConfig { | |
| projectPath: string; | |
| permissionMode: 'acceptEdits' | 'bypassPermissions' | 'default' | 'plan'; | |
| model?: string; | |
| sessionId?: string; | |
| verbose?: boolean; | |
| } | |
| export interface ProcessMessage { | |
| id: string; | |
| type: 'assistant' | 'user' | 'system' | 'tool_use' | 'result' | 'thinking'; | |
| content: string; | |
| timestamp: number; | |
| toolName?: string; | |
| actionType?: string; | |
| thinkingContent?: string; | |
| } | |
| export interface ProcessUpdate { | |
| type: 'message' | 'status' | 'session' | 'error'; | |
| data: any; | |
| timestamp: number; | |
| } | |
| export class ClaudeExecutor extends EventEmitter { | |
| private process: ChildProcess | null = null; | |
| private config: ClaudeExecutorConfig; | |
| private sessionId: string | null = null; | |
| private isRunning: boolean = false; | |
| private messageBuffer: string = ''; | |
| private processId: string; | |
| constructor(config: ClaudeExecutorConfig) { | |
| super(); | |
| this.config = config; | |
| this.processId = uuidv4(); | |
| } | |
| /** | |
| * Start the Claude Code CLI process | |
| */ | |
| async start(prompt: string): Promise<void> { | |
| if (this.isRunning) { | |
| throw new Error('Claude executor is already running'); | |
| } | |
| try { | |
| await this.validateProjectPath(); | |
| const command = this.buildCommand(); | |
| // Debug logging for command execution | |
| console.log('=== CLAUDE EXECUTOR DEBUG ==='); | |
| console.log('Config sessionId:', this.config.sessionId); | |
| console.log('Built command:', command); | |
| console.log('Project path:', this.config.projectPath); | |
| console.log('==============================='); | |
| this.emit('update', { | |
| type: 'status', | |
| data: { status: 'starting', command }, | |
| timestamp: Date.now() | |
| }); | |
| this.process = spawn('bash', ['-c', command], { | |
| cwd: this.config.projectPath, | |
| stdio: ['pipe', 'pipe', 'pipe'], | |
| env: { | |
| ...process.env, | |
| NODE_NO_WARNINGS: '1' | |
| }, | |
| detached: false | |
| }); | |
| this.setupProcessHandlers(); | |
| this.isRunning = true; | |
| // Send the prompt via stdin | |
| if (this.process.stdin) { | |
| this.process.stdin.write(prompt + '\n'); | |
| this.process.stdin.end(); | |
| } | |
| this.emit('update', { | |
| type: 'status', | |
| data: { status: 'running', processId: this.processId }, | |
| timestamp: Date.now() | |
| }); | |
| } catch (error) { | |
| this.emit('update', { | |
| type: 'error', | |
| data: { error: error instanceof Error ? error.message : 'Unknown error' }, | |
| timestamp: Date.now() | |
| }); | |
| throw error; | |
| } | |
| } | |
| /** | |
| * Send a follow-up message to the running process | |
| */ | |
| async sendMessage(message: string): Promise<void> { | |
| if (!this.isRunning || !this.process || !this.process.stdin) { | |
| throw new Error('Claude executor is not running'); | |
| } | |
| this.process.stdin.write(message + '\n'); | |
| this.emit('update', { | |
| type: 'message', | |
| data: { | |
| id: uuidv4(), | |
| type: 'user', | |
| content: message, | |
| timestamp: Date.now() | |
| }, | |
| timestamp: Date.now() | |
| }); | |
| } | |
| /** | |
| * Stop the Claude Code process | |
| */ | |
| async stop(): Promise<void> { | |
| if (!this.isRunning || !this.process) { | |
| return; | |
| } | |
| return new Promise((resolve) => { | |
| if (this.process) { | |
| this.process.once('exit', () => { | |
| this.cleanup(); | |
| resolve(); | |
| }); | |
| // Try graceful shutdown first | |
| this.process.kill('SIGTERM'); | |
| // Force kill after 5 seconds | |
| setTimeout(() => { | |
| if (this.process && !this.process.killed) { | |
| this.process.kill('SIGKILL'); | |
| } | |
| }, 5000); | |
| } else { | |
| this.cleanup(); | |
| resolve(); | |
| } | |
| }); | |
| } | |
| /** | |
| * Get current session ID | |
| */ | |
| getSessionId(): string | null { | |
| return this.sessionId; | |
| } | |
| /** | |
| * Get process ID | |
| */ | |
| getProcessId(): string { | |
| return this.processId; | |
| } | |
| /** | |
| * Check if process is running | |
| */ | |
| isProcessRunning(): boolean { | |
| return this.isRunning; | |
| } | |
| private async validateProjectPath(): Promise<void> { | |
| if (!fs.existsSync(this.config.projectPath)) { | |
| throw new Error(`Project path does not exist: ${this.config.projectPath}`); | |
| } | |
| const stat = fs.statSync(this.config.projectPath); | |
| if (!stat.isDirectory()) { | |
| throw new Error(`Project path is not a directory: ${this.config.projectPath}`); | |
| } | |
| } | |
| private checkSessionExists(sessionId: string): boolean { | |
| try { | |
| // Get Claude's project directory path | |
| const sanitizedPath = this.config.projectPath.replace(/\//g, '-'); | |
| const claudeProjectPath = path.join(os.homedir(), '.claude', 'projects', sanitizedPath); | |
| const sessionFile = path.join(claudeProjectPath, `${sessionId}.jsonl`); | |
| console.log('Checking for session file:', sessionFile); | |
| const exists = fs.existsSync(sessionFile); | |
| console.log('Session file exists:', exists); | |
| return exists; | |
| } catch (error) { | |
| console.error('Error checking session file:', error); | |
| return false; | |
| } | |
| } | |
| private buildCommand(): string { | |
| const baseCommand = 'npx -y @anthropic-ai/claude-code@latest'; | |
| const args = ['-p']; | |
| // Add permission mode | |
| switch (this.config.permissionMode) { | |
| case 'plan': | |
| args.push('--permission-mode=plan'); | |
| break; | |
| case 'acceptEdits': | |
| args.push('--permission-mode=accept'); | |
| break; | |
| case 'bypassPermissions': | |
| args.push('--dangerously-skip-permissions'); | |
| break; | |
| default: | |
| // Default mode - no special flags | |
| break; | |
| } | |
| // Add model selection (convert 'default' to empty string for CLI) | |
| if (this.config.model && this.config.model !== 'default') { | |
| args.push(`--model=${this.config.model}`); | |
| } | |
| // Add session resumption if available and session file exists | |
| if (this.config.sessionId) { | |
| const sessionExists = this.checkSessionExists(this.config.sessionId); | |
| if (sessionExists) { | |
| console.log('Session file exists, resuming:', this.config.sessionId); | |
| args.push(`--resume=${this.config.sessionId}`); | |
| } else { | |
| console.log('Session file does not exist, starting new session for:', this.config.sessionId); | |
| // Don't add --resume flag, let Claude create the session with this ID | |
| } | |
| } | |
| // Add output format and verbosity | |
| args.push('--output-format=stream-json'); | |
| if (this.config.verbose) { | |
| args.push('--verbose'); | |
| } | |
| const fullCommand = `${baseCommand} ${args.join(' ')}`; | |
| // For plan mode, wrap with watchkill script | |
| if (this.config.permissionMode === 'plan') { | |
| return this.wrapWithWatchkill(fullCommand); | |
| } | |
| return fullCommand; | |
| } | |
| private wrapWithWatchkill(command: string): string { | |
| // Create the watchkill script inline | |
| const watchkillScript = ` | |
| #!/usr/bin/env bash | |
| set -euo pipefail | |
| word="Exit plan mode?" | |
| command="${command}" | |
| exit_code=0 | |
| while IFS= read -r line; do | |
| printf '%s\\n' "$line" | |
| if [[ $line == *"$word"* ]]; then | |
| exit 0 | |
| fi | |
| done < <($command <&0 2>&1) | |
| exit_code=\${PIPESTATUS[0]} | |
| exit "$exit_code" | |
| `.trim(); | |
| return `bash -c '${watchkillScript.replace(/'/g, "'\"'\"'")}'`; | |
| } | |
| private setupProcessHandlers(): void { | |
| if (!this.process) return; | |
| // Handle stdout (main output) | |
| this.process.stdout?.on('data', (data: Buffer) => { | |
| this.handleOutput(data.toString()); | |
| }); | |
| // Handle stderr (errors and logs) | |
| this.process.stderr?.on('data', (data: Buffer) => { | |
| this.handleError(data.toString()); | |
| }); | |
| // Handle process exit | |
| this.process.on('exit', (code: number | null, signal: string | null) => { | |
| this.emit('update', { | |
| type: 'status', | |
| data: { | |
| status: 'completed', | |
| exitCode: code, | |
| signal, | |
| sessionId: this.sessionId | |
| }, | |
| timestamp: Date.now() | |
| }); | |
| this.cleanup(); | |
| }); | |
| // Handle process errors | |
| this.process.on('error', (error: Error) => { | |
| this.emit('update', { | |
| type: 'error', | |
| data: { error: error.message }, | |
| timestamp: Date.now() | |
| }); | |
| this.cleanup(); | |
| }); | |
| } | |
| private handleOutput(data: string): void { | |
| this.messageBuffer += data; | |
| const lines = this.messageBuffer.split('\n'); | |
| // Keep the last incomplete line in buffer | |
| this.messageBuffer = lines.pop() || ''; | |
| for (const line of lines) { | |
| if (line.trim()) { | |
| this.processLine(line.trim()); | |
| } | |
| } | |
| } | |
| private handleError(data: string): void { | |
| this.emit('update', { | |
| type: 'message', | |
| data: { | |
| id: uuidv4(), | |
| type: 'system', | |
| content: data, | |
| timestamp: Date.now() | |
| }, | |
| timestamp: Date.now() | |
| }); | |
| } | |
| private processLine(line: string): void { | |
| try { | |
| // Try to parse as JSON (Claude's stream-json format) | |
| const jsonData = JSON.parse(line); | |
| this.processJsonMessage(jsonData); | |
| } catch { | |
| // If not JSON, treat as plain text output | |
| this.emit('update', { | |
| type: 'message', | |
| data: { | |
| id: uuidv4(), | |
| type: 'system', | |
| content: line, | |
| timestamp: Date.now() | |
| }, | |
| timestamp: Date.now() | |
| }); | |
| } | |
| } | |
| private processJsonMessage(data: any): void { | |
| // Extract session ID if present | |
| if (data.session_id && !this.sessionId) { | |
| this.sessionId = data.session_id; | |
| this.emit('update', { | |
| type: 'session', | |
| data: { sessionId: this.sessionId }, | |
| timestamp: Date.now() | |
| }); | |
| } | |
| // Process different message types | |
| if (data.type === 'message') { | |
| let content = data.content || data.text || ''; | |
| let thinkingContent: string | undefined; | |
| // Handle structured content that may contain thinking | |
| if (Array.isArray(data.content)) { | |
| const textParts: string[] = []; | |
| for (const part of data.content) { | |
| if (part.type === 'text') { | |
| textParts.push(part.text || ''); | |
| } else if (part.type === 'thinking') { | |
| thinkingContent = part.thinking || part.text || ''; | |
| } | |
| } | |
| content = textParts.join('\n\n').trim(); | |
| } | |
| // Check if this is a thinking-only message | |
| if (data.thinking || thinkingContent) { | |
| // For thinking-only content, create a thinking message | |
| if (!content.trim() && thinkingContent) { | |
| const thinkingMessage: ProcessMessage = { | |
| id: uuidv4(), | |
| type: 'thinking', | |
| content: thinkingContent, | |
| timestamp: Date.now() | |
| }; | |
| this.emit('update', { | |
| type: 'message', | |
| data: thinkingMessage, | |
| timestamp: Date.now() | |
| }); | |
| return; | |
| } | |
| } | |
| const message: ProcessMessage = { | |
| id: uuidv4(), | |
| type: this.mapMessageType(data.role || 'system'), | |
| content, | |
| timestamp: Date.now(), | |
| toolName: data.tool_name, | |
| actionType: this.extractActionType(data), | |
| thinkingContent | |
| }; | |
| this.emit('update', { | |
| type: 'message', | |
| data: message, | |
| timestamp: Date.now() | |
| }); | |
| } | |
| } | |
| private mapMessageType(role: string): ProcessMessage['type'] { | |
| switch (role) { | |
| case 'assistant': | |
| return 'assistant'; | |
| case 'user': | |
| return 'user'; | |
| case 'tool': | |
| case 'tool_use': | |
| return 'tool_use'; | |
| case 'result': | |
| return 'result'; | |
| case 'thinking': | |
| return 'thinking'; | |
| default: | |
| return 'system'; | |
| } | |
| } | |
| private extractActionType(data: any): string | undefined { | |
| if (data.tool_name) { | |
| const toolName = data.tool_name.toLowerCase(); | |
| // Map Claude's tools to action types | |
| const toolMapping: Record<string, string> = { | |
| 'read': 'file_read', | |
| 'edit': 'file_edit', | |
| 'write': 'file_write', | |
| 'multiedit': 'file_edit', | |
| 'bash': 'command_execution', | |
| 'grep': 'search', | |
| 'glob': 'search', | |
| 'webfetch': 'web_request', | |
| 'task': 'task_management', | |
| 'todowrite': 'task_management', | |
| 'todoread': 'task_management', | |
| 'exitplanmode': 'plan_presentation' | |
| }; | |
| return toolMapping[toolName] || 'other'; | |
| } | |
| return undefined; | |
| } | |
| private cleanup(): void { | |
| this.isRunning = false; | |
| this.process = null; | |
| this.messageBuffer = ''; | |
| } | |
| } | |
| // TRASNFORM JSONL | |
| import fs from 'fs'; | |
| import path from 'path'; | |
| import { DatabaseChatMessage } from './models'; | |
| // Claude Code history interfaces matching the JSONL format | |
| export interface ClaudeHistoryMessage { | |
| parentUuid: string | null; | |
| isSidechain: boolean; | |
| userType: string; | |
| cwd: string; | |
| sessionId: string; | |
| version: string; | |
| gitBranch: string; | |
| type: 'user' | 'assistant'; | |
| message: { | |
| role: 'user' | 'assistant'; | |
| content: string | Array<{ | |
| type: 'text' | 'tool_use' | 'tool_result' | 'thinking'; | |
| text?: string; | |
| id?: string; | |
| name?: string; | |
| input?: any; | |
| content?: string; | |
| tool_use_id?: string; | |
| thinking?: string; | |
| }>; | |
| model?: string; | |
| stop_reason?: string; | |
| usage?: { | |
| input_tokens: number; | |
| cache_creation_input_tokens?: number; | |
| cache_read_input_tokens?: number; | |
| output_tokens: number; | |
| service_tier?: string; | |
| }; | |
| }; | |
| uuid: string; | |
| timestamp: string; | |
| requestId?: string; | |
| toolUseResult?: any; | |
| } | |
| export interface ClaudeSession { | |
| sessionId: string; | |
| projectPath: string; | |
| gitBranch: string; | |
| messages: ClaudeHistoryMessage[]; | |
| startTime: Date; | |
| endTime?: Date; | |
| totalTokens: number; | |
| } | |
| export class ClaudeHistoryParser { | |
| private static claudeDir = path.join(process.env.HOME || '~', '.claude'); | |
| /** | |
| * Get the Claude projects directory for a specific project path | |
| */ | |
| private static getProjectClaudeDir(projectPath: string): string { | |
| // Convert project path to Claude directory format | |
| const normalizedPath = projectPath.replace(/\//g, '-').replace(/^-/, ''); | |
| return path.join(this.claudeDir, 'projects', `-${normalizedPath}`); | |
| } | |
| /** | |
| * Get all session files for a project | |
| */ | |
| static getProjectSessions(projectPath: string): string[] { | |
| try { | |
| const projectClaudeDir = this.getProjectClaudeDir(projectPath); | |
| if (!fs.existsSync(projectClaudeDir)) { | |
| return []; | |
| } | |
| return fs.readdirSync(projectClaudeDir) | |
| .filter(file => file.endsWith('.jsonl')) | |
| .map(file => path.join(projectClaudeDir, file)); | |
| } catch (error) { | |
| console.error('Error getting project sessions:', error); | |
| return []; | |
| } | |
| } | |
| /** | |
| * Parse a single JSONL session file | |
| */ | |
| static parseSessionFile(filePath: string): ClaudeSession | null { | |
| try { | |
| if (!fs.existsSync(filePath)) { | |
| return null; | |
| } | |
| const content = fs.readFileSync(filePath, 'utf-8'); | |
| const lines = content.trim().split('\n').filter(line => line.trim()); | |
| if (lines.length === 0) { | |
| return null; | |
| } | |
| const messages: ClaudeHistoryMessage[] = []; | |
| let sessionId = ''; | |
| let projectPath = ''; | |
| let gitBranch = ''; | |
| let startTime: Date = new Date(); | |
| let endTime: Date | undefined; | |
| let totalTokens = 0; | |
| for (const line of lines) { | |
| try { | |
| const message: ClaudeHistoryMessage = JSON.parse(line); | |
| messages.push(message); | |
| // Extract session metadata from first message | |
| if (!sessionId) { | |
| sessionId = message.sessionId; | |
| projectPath = message.cwd; | |
| gitBranch = message.gitBranch || 'main'; | |
| startTime = new Date(message.timestamp); | |
| } | |
| // Update end time with latest message | |
| endTime = new Date(message.timestamp); | |
| // Calculate token usage | |
| if (message.message && message.message.usage) { | |
| totalTokens += message.message.usage.input_tokens || 0; | |
| totalTokens += message.message.usage.output_tokens || 0; | |
| } | |
| } catch (parseError) { | |
| console.error('Error parsing line:', parseError); | |
| } | |
| } | |
| return { | |
| sessionId, | |
| projectPath, | |
| gitBranch, | |
| messages, | |
| startTime, | |
| endTime, | |
| totalTokens | |
| }; | |
| } catch (error) { | |
| console.error('Error parsing session file:', error); | |
| return null; | |
| } | |
| } | |
| /** | |
| * Get all sessions for a project | |
| */ | |
| static getProjectClaudeSessions(projectPath: string): ClaudeSession[] { | |
| const sessionFiles = this.getProjectSessions(projectPath); | |
| const sessions: ClaudeSession[] = []; | |
| for (const file of sessionFiles) { | |
| const session = this.parseSessionFile(file); | |
| if (session) { | |
| sessions.push(session); | |
| } | |
| } | |
| // Sort by start time, newest first | |
| return sessions.sort((a, b) => b.startTime.getTime() - a.startTime.getTime()); | |
| } | |
| /** | |
| * Convert Claude history messages to chat message format with pagination | |
| */ | |
| static convertToChatMessages( | |
| claudeMessages: ClaudeHistoryMessage[], | |
| limit?: number, | |
| offset?: number | |
| ): { | |
| messages: Array<{ | |
| id: string; | |
| role: 'user' | 'assistant'; | |
| content: string; | |
| timestamp: Date; | |
| uuid: string; | |
| model?: string; | |
| tokens?: number; | |
| toolUse?: boolean; | |
| toolDetails?: Array<{ | |
| name: string; | |
| action: string; | |
| path?: string; | |
| input?: any; | |
| result?: string; | |
| }>; | |
| thinkingContent?: string; | |
| }>; | |
| hasMore: boolean; | |
| total: number; | |
| } { | |
| const allMessages: Array<{ | |
| id: string; | |
| role: 'user' | 'assistant'; | |
| content: string; | |
| timestamp: Date; | |
| uuid: string; | |
| model?: string; | |
| tokens?: number; | |
| toolUse?: boolean; | |
| toolDetails?: Array<{ | |
| name: string; | |
| action: string; | |
| path?: string; | |
| input?: any; | |
| result?: string; | |
| }>; | |
| thinkingContent?: string; | |
| }> = []; | |
| for (const msg of claudeMessages) { | |
| // Skip messages without message property | |
| if (!msg.message) { | |
| continue; | |
| } | |
| let content = ''; | |
| let toolUse = false; | |
| let thinkingContent = ''; | |
| const toolDetails: Array<{ | |
| name: string; | |
| action: string; | |
| path?: string; | |
| input?: any; | |
| result?: string; | |
| }> = []; | |
| // Extract content from different message formats | |
| if (typeof msg.message.content === 'string') { | |
| content = msg.message.content; | |
| } else if (Array.isArray(msg.message.content)) { | |
| // Handle structured content (tool uses, etc.) | |
| const textParts: string[] = []; | |
| const pendingToolResults: Array<{tool_use_id: string, content: string}> = []; | |
| // First pass: collect text, thinking, and tool uses | |
| for (const part of msg.message.content) { | |
| if (part.type === 'text') { | |
| const text = part.text || ''; | |
| if (text.trim()) { | |
| textParts.push(text); | |
| } | |
| } else if (part.type === 'thinking') { | |
| // Extract thinking content | |
| const thinking = part.thinking || part.text || ''; | |
| if (thinking.trim()) { | |
| thinkingContent = thinking; | |
| } | |
| } else if (part.type === 'tool_use') { | |
| toolUse = true; | |
| // Extract tool details | |
| const toolDetail: any = { | |
| name: part.name || '', | |
| action: 'tool_use', | |
| input: part.input, | |
| id: part.id | |
| }; | |
| // Try to extract file path and action from tool input | |
| if (part.input) { | |
| if (part.input.file_path || part.input.path) { | |
| toolDetail.path = part.input.file_path || part.input.path; | |
| } | |
| // Determine action based on tool name | |
| const toolName = part.name?.toLowerCase() || ''; | |
| if (toolName.includes('todowrite')) { | |
| toolDetail.action = 'todo_write'; | |
| } else if (toolName.includes('edit') || toolName.includes('multiedit')) { | |
| toolDetail.action = 'file_edit'; | |
| } else if (toolName.includes('write') || toolName.includes('create')) { | |
| toolDetail.action = 'file_write'; | |
| } else if (toolName.includes('read')) { | |
| toolDetail.action = 'file_read'; | |
| } else if (toolName.includes('bash') || toolName.includes('command')) { | |
| toolDetail.action = 'command_run'; | |
| if (part.input.command) { | |
| toolDetail.command = part.input.command; | |
| } | |
| } else if (toolName.includes('search') || toolName.includes('grep')) { | |
| toolDetail.action = 'search'; | |
| } else if (toolName.includes('webfetch')) { | |
| toolDetail.action = 'web_fetch'; | |
| } else if (toolName.includes('websearch')) { | |
| toolDetail.action = 'web_search'; | |
| } else if (toolName.includes('glob')) { | |
| toolDetail.action = 'file_search'; | |
| } else if (toolName.includes('ls')) { | |
| toolDetail.action = 'list_directory'; | |
| } else if (toolName.includes('task')) { | |
| toolDetail.action = 'agent_task'; | |
| } | |
| } | |
| toolDetails.push(toolDetail); | |
| } else if (part.type === 'tool_result') { | |
| pendingToolResults.push({ | |
| tool_use_id: part.tool_use_id, | |
| content: part.content | |
| }); | |
| } | |
| } | |
| // Second pass: match results to tools | |
| for (const result of pendingToolResults) { | |
| const matchingTool = toolDetails.find(t => t.id === result.tool_use_id); | |
| if (matchingTool) { | |
| matchingTool.result = result.content; | |
| } | |
| } | |
| // Clean text processing - separate text from tool uses | |
| content = textParts.join('\n\n').trim(); | |
| // If there's no text content but there are tool uses, provide a clean indicator | |
| if (!content && toolDetails.length > 0) { | |
| // Create a more descriptive message based on tool types | |
| const toolTypes = [...new Set(toolDetails.map(t => t.action))]; | |
| if (toolTypes.length === 1) { | |
| const action = toolTypes[0]; | |
| const actionMap: Record<string, string> = { | |
| 'file_read': 'read files', | |
| 'file_edit': 'edited files', | |
| 'file_write': 'created files', | |
| 'command_run': 'ran commands', | |
| 'search': 'searched', | |
| 'file_search': 'searched files', | |
| 'list_directory': 'listed directories', | |
| 'todo_write': 'updated todos', | |
| 'web_fetch': 'fetched web content', | |
| 'web_search': 'searched web', | |
| 'agent_task': 'ran agent tasks' | |
| }; | |
| content = `[${actionMap[action] || action}]`; | |
| } else { | |
| content = `[Used ${toolDetails.length} tool${toolDetails.length > 1 ? 's' : ''}]`; | |
| } | |
| } | |
| } | |
| // Skip messages without content AND without tool details AND without thinking | |
| if (!content.trim() && toolDetails.length === 0 && !thinkingContent.trim()) { | |
| continue; | |
| } | |
| const tokens = msg.message && msg.message.usage | |
| ? (msg.message.usage.input_tokens || 0) + (msg.message.usage.output_tokens || 0) | |
| : undefined; | |
| // Handle interruption messages specially | |
| const trimmedContent = content.trim(); | |
| const isInterruption = trimmedContent.includes('[Request interrupted by user]'); | |
| // Ensure proper role assignment - don't override with 'system' unless actually an interruption | |
| const messageRole = isInterruption ? 'system' : (msg.message.role || msg.type); | |
| allMessages.push({ | |
| id: msg.uuid, | |
| role: messageRole as 'user' | 'assistant' | 'system', | |
| content: isInterruption ? '[Request interrupted by user]' : trimmedContent, | |
| timestamp: new Date(msg.timestamp), | |
| uuid: msg.uuid, | |
| model: msg.message.model, | |
| tokens, | |
| toolUse, | |
| toolDetails: toolDetails.length > 0 ? toolDetails : undefined, | |
| thinkingContent: thinkingContent || undefined | |
| }); | |
| } | |
| // Sort by timestamp (newest first for chat display) | |
| allMessages.sort((a, b) => b.timestamp.getTime() - a.timestamp.getTime()); | |
| // Apply pagination | |
| const total = allMessages.length; | |
| const startIndex = offset || 0; | |
| const endIndex = limit ? startIndex + limit : allMessages.length; | |
| const messages = allMessages.slice(startIndex, endIndex); | |
| const hasMore = endIndex < total; | |
| return { | |
| messages, | |
| hasMore, | |
| total | |
| }; | |
| } | |
| /** | |
| * Get a summary of a Claude session | |
| */ | |
| static getSessionSummary(session: ClaudeSession): { | |
| title: string; | |
| messageCount: number; | |
| duration: string; | |
| firstUserMessage: string; | |
| } { | |
| const firstUserMessage = session.messages.find(m => m.type === 'user')?.message.content; | |
| const title = typeof firstUserMessage === 'string' | |
| ? firstUserMessage.slice(0, 50) + (firstUserMessage.length > 50 ? '...' : '') | |
| : 'Claude Session'; | |
| const duration = session.endTime | |
| ? Math.round((session.endTime.getTime() - session.startTime.getTime()) / 1000 / 60) | |
| : 0; | |
| return { | |
| title, | |
| messageCount: session.messages.length, | |
| duration: duration > 0 ? `${duration}m` : 'Active', | |
| firstUserMessage: typeof firstUserMessage === 'string' ? firstUserMessage : '' | |
| }; | |
| } | |
| } |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment