| |
| |
| |
| |
|
|
| import type { |
| CursorStreamEvent, |
| CursorSystemEvent, |
| CursorAssistantEvent, |
| CursorToolCallEvent, |
| CursorResultEvent, |
| } from '@automaker/types'; |
|
|
| |
| |
| |
| |
| |
| function cleanFragmentedText(content: string): string { |
| |
| |
| let cleaned = content.replace(/([a-zA-Z])\n+([a-zA-Z])/g, '$1$2'); |
|
|
| |
| |
| cleaned = cleaned.replace(/<([a-zA-Z]+)\n*([a-zA-Z]*)\n*>/g, '<$1$2>'); |
| cleaned = cleaned.replace(/<\/([a-zA-Z]+)\n*([a-zA-Z]*)\n*>/g, '</$1$2>'); |
|
|
| return cleaned; |
| } |
|
|
| export type LogEntryType = |
| | 'prompt' |
| | 'tool_call' |
| | 'tool_result' |
| | 'phase' |
| | 'error' |
| | 'success' |
| | 'info' |
| | 'debug' |
| | 'warning' |
| | 'thinking'; |
|
|
| export type ToolCategory = |
| | 'read' |
| | 'edit' |
| | 'write' |
| | 'bash' |
| | 'search' |
| | 'todo' |
| | 'task' |
| | 'other'; |
|
|
| const TOOL_CATEGORIES: Record<string, ToolCategory> = { |
| Read: 'read', |
| Edit: 'edit', |
| Write: 'write', |
| Bash: 'bash', |
| Grep: 'search', |
| Glob: 'search', |
| Ls: 'read', |
| Delete: 'write', |
| WebSearch: 'search', |
| WebFetch: 'read', |
| TodoWrite: 'todo', |
| Task: 'task', |
| NotebookEdit: 'edit', |
| KillShell: 'bash', |
| SemanticSearch: 'search', |
| ReadLints: 'read', |
| }; |
|
|
| |
| |
| |
| export function categorizeToolName(toolName: string): ToolCategory { |
| return TOOL_CATEGORIES[toolName] || 'other'; |
| } |
|
|
| export interface LogEntryMetadata { |
| toolName?: string; |
| toolCategory?: ToolCategory; |
| filePath?: string; |
| summary?: string; |
| phase?: string; |
| } |
|
|
| export interface LogEntry { |
| id: string; |
| type: LogEntryType; |
| title: string; |
| content: string; |
| timestamp?: string; |
| collapsed?: boolean; |
| metadata?: LogEntryMetadata; |
| } |
|
|
| |
| |
| |
| |
| |
| |
| |
| |
| const generateDeterministicId = (content: string, lineIndex: number): string => { |
| |
| const stableContent = content.slice(0, 200); |
| |
| let hash = 0; |
| const str = stableContent + '|' + lineIndex.toString(); |
| for (let i = 0; i < str.length; i++) { |
| const char = str.charCodeAt(i); |
| hash = (hash << 5) - hash + char; |
| hash = hash & hash; |
| } |
| return 'log_' + Math.abs(hash).toString(36); |
| }; |
|
|
| |
| |
| |
| function detectEntryType(content: string): LogEntryType { |
| const trimmed = content.trim(); |
| |
| const cleaned = cleanFragmentedText(trimmed); |
|
|
| |
| if (trimmed.startsWith('🔧 Tool:') || trimmed.match(/^Tool:\s*/)) { |
| return 'tool_call'; |
| } |
|
|
| |
| if ( |
| trimmed.startsWith('Input:') || |
| trimmed.startsWith('Result:') || |
| trimmed.startsWith('Output:') |
| ) { |
| return 'tool_result'; |
| } |
|
|
| |
| if ( |
| trimmed.startsWith('📋') || |
| trimmed.startsWith('⚡') || |
| trimmed.startsWith('✅') || |
| trimmed.match(/^(Planning|Action|Verification)/i) || |
| trimmed.match(/\[Phase:\s*([^\]]+)\]/) || |
| trimmed.match(/Phase:\s*\w+/i) |
| ) { |
| return 'phase'; |
| } |
|
|
| |
| if ( |
| trimmed.match(/\[Feature Creation\]/i) || |
| trimmed.match(/Feature Creation/i) || |
| trimmed.match(/Creating feature/i) |
| ) { |
| return 'success'; |
| } |
|
|
| |
| if (trimmed.startsWith('❌') || trimmed.toLowerCase().includes('error:')) { |
| return 'error'; |
| } |
|
|
| |
| |
| if ( |
| trimmed.startsWith('✅') || |
| trimmed.toLowerCase().includes('success') || |
| trimmed.toLowerCase().includes('completed') || |
| |
| trimmed.startsWith('<summary>') || |
| cleaned.startsWith('<summary>') || |
| |
| trimmed.match(/^##\s+(Summary|Feature|Changes|Implementation)/i) || |
| cleaned.match(/^##\s+(Summary|Feature|Changes|Implementation)/i) || |
| trimmed.match(/^(I've|I have) (successfully |now )?(completed|finished|implemented)/i) |
| ) { |
| return 'success'; |
| } |
|
|
| |
| if (trimmed.startsWith('⚠️') || trimmed.toLowerCase().includes('warning:')) { |
| return 'warning'; |
| } |
|
|
| |
| if ( |
| trimmed.toLowerCase().includes('ultrathink') || |
| trimmed.match(/thinking level[:\s]*(low|medium|high|none|\d)/i) || |
| trimmed.match(/^thinking level\s*$/i) || |
| trimmed.toLowerCase().includes('estimated cost') || |
| trimmed.toLowerCase().includes('estimated time') || |
| trimmed.toLowerCase().includes('budget tokens') || |
| trimmed.match(/thinking.*preparation/i) |
| ) { |
| return 'thinking'; |
| } |
|
|
| |
| if ( |
| trimmed.startsWith('{') || |
| trimmed.startsWith('[') || |
| trimmed.includes('at ') || |
| trimmed.match(/^\s*\d+\s*\|/) |
| ) { |
| return 'debug'; |
| } |
|
|
| |
| return 'info'; |
| } |
|
|
| |
| |
| |
| |
| function extractToolName(content: string): string | undefined { |
| |
| const match = content.match(/(?:🔧\s*)?Tool:\s*(\S+)/); |
| return match?.[1]; |
| } |
|
|
| |
| |
| |
| function extractPhase(content: string): string | undefined { |
| if (content.includes('📋')) return 'planning'; |
| if (content.includes('⚡')) return 'action'; |
| if (content.includes('✅')) return 'verification'; |
|
|
| |
| const phaseMatch = content.match(/\[Phase:\s*([^\]]+)\]/); |
| if (phaseMatch) { |
| return phaseMatch[1].toLowerCase(); |
| } |
|
|
| const match = content.match(/^(Planning|Action|Verification)/i); |
| return match?.[1]?.toLowerCase(); |
| } |
|
|
| |
| |
| |
| function extractFilePath(content: string): string | undefined { |
| try { |
| const inputMatch = content.match(/Input:\s*([\s\S]*)/); |
| if (!inputMatch) return undefined; |
|
|
| const jsonStr = inputMatch[1].trim(); |
| const parsed = JSON.parse(jsonStr) as Record<string, unknown>; |
|
|
| if (typeof parsed.file_path === 'string') return parsed.file_path; |
| if (typeof parsed.path === 'string') return parsed.path; |
| if (typeof parsed.notebook_path === 'string') return parsed.notebook_path; |
|
|
| return undefined; |
| } catch { |
| return undefined; |
| } |
| } |
|
|
| |
| |
| |
| export function generateToolSummary(toolName: string, content: string): string | undefined { |
| try { |
| |
| const inputMatch = content.match(/Input:\s*([\s\S]*)/); |
| if (!inputMatch) return undefined; |
|
|
| const jsonStr = inputMatch[1].trim(); |
| const parsed = JSON.parse(jsonStr) as Record<string, unknown>; |
|
|
| switch (toolName) { |
| case 'Read': { |
| const filePath = parsed.file_path as string | undefined; |
| return `Reading ${filePath?.split('/').pop() || 'file'}`; |
| } |
| case 'Edit': { |
| const filePath = parsed.file_path as string | undefined; |
| const fileName = filePath?.split('/').pop() || 'file'; |
| return `Editing ${fileName}`; |
| } |
| case 'Write': { |
| const filePath = parsed.file_path as string | undefined; |
| return `Writing ${filePath?.split('/').pop() || 'file'}`; |
| } |
| case 'Bash': { |
| const command = parsed.command as string | undefined; |
| const cmd = command?.slice(0, 50) || ''; |
| return `Running: ${cmd}${(command?.length || 0) > 50 ? '...' : ''}`; |
| } |
| case 'Grep': { |
| const pattern = parsed.pattern as string | undefined; |
| return `Searching for "${pattern?.slice(0, 30) || ''}"`; |
| } |
| case 'Glob': { |
| const pattern = parsed.pattern as string | undefined; |
| return `Finding files: ${pattern || ''}`; |
| } |
| case 'TodoWrite': { |
| const todos = parsed.todos as unknown[] | undefined; |
| const todoCount = todos?.length || 0; |
| return `${todoCount} todo item${todoCount !== 1 ? 's' : ''}`; |
| } |
| case 'Task': { |
| const subagentType = parsed.subagent_type as string | undefined; |
| const description = parsed.description as string | undefined; |
| return `${subagentType || 'Agent'}: ${description || ''}`; |
| } |
| case 'WebSearch': { |
| const query = parsed.query as string | undefined; |
| return `Searching: "${query?.slice(0, 40) || ''}"`; |
| } |
| case 'WebFetch': { |
| const url = parsed.url as string | undefined; |
| return `Fetching: ${url?.slice(0, 40) || ''}`; |
| } |
| case 'NotebookEdit': { |
| const notebookPath = parsed.notebook_path as string | undefined; |
| return `Editing notebook: ${notebookPath?.split('/').pop() || 'notebook'}`; |
| } |
| case 'KillShell': { |
| return 'Terminating shell session'; |
| } |
| case 'SemanticSearch': { |
| const query = parsed.query as string | undefined; |
| return `Semantic search: "${query?.slice(0, 30) || ''}"`; |
| } |
| case 'ReadLints': { |
| const paths = parsed.paths as string[] | undefined; |
| const pathCount = paths?.length || 0; |
| return `Reading lints for ${pathCount} file(s)`; |
| } |
| default: |
| return undefined; |
| } |
| } catch { |
| return undefined; |
| } |
| } |
|
|
| |
| |
| |
|
|
| |
| |
| |
| function isCursorEvent(obj: unknown): obj is CursorStreamEvent { |
| return ( |
| obj !== null && |
| typeof obj === 'object' && |
| 'type' in obj && |
| 'session_id' in obj && |
| ['system', 'user', 'assistant', 'tool_call', 'result'].includes( |
| (obj as Record<string, unknown>).type as string |
| ) |
| ); |
| } |
|
|
| |
| |
| |
| function normalizeCursorToolCall( |
| event: CursorToolCallEvent, |
| baseEntry: { id: string; timestamp: string } |
| ): LogEntry | null { |
| const toolCall = event.tool_call; |
| const isStarted = event.subtype === 'started'; |
| const isCompleted = event.subtype === 'completed'; |
|
|
| |
| if (toolCall.readToolCall) { |
| const path = toolCall.readToolCall.args?.path || 'unknown'; |
| const result = toolCall.readToolCall.result?.success; |
|
|
| return { |
| ...baseEntry, |
| id: `${baseEntry.id}-${event.call_id}`, |
| type: 'tool_call' as LogEntryType, |
| title: isStarted ? `Reading ${path}` : `Read ${path}`, |
| content: |
| isCompleted && result |
| ? `${result.totalLines} lines, ${result.totalChars} chars` |
| : `Path: ${path}`, |
| collapsed: true, |
| metadata: { |
| toolName: 'Read', |
| toolCategory: 'read' as ToolCategory, |
| filePath: path, |
| summary: isCompleted ? `Read ${result?.totalLines || 0} lines` : `Reading file...`, |
| }, |
| }; |
| } |
|
|
| |
| if (toolCall.writeToolCall) { |
| const path = |
| toolCall.writeToolCall.args?.path || |
| toolCall.writeToolCall.result?.success?.path || |
| 'unknown'; |
| const result = toolCall.writeToolCall.result?.success; |
|
|
| return { |
| ...baseEntry, |
| id: `${baseEntry.id}-${event.call_id}`, |
| type: 'tool_call' as LogEntryType, |
| title: isStarted ? `Writing ${path}` : `Wrote ${path}`, |
| content: |
| isCompleted && result |
| ? `${result.linesCreated} lines, ${result.fileSize} bytes` |
| : `Path: ${path}`, |
| collapsed: true, |
| metadata: { |
| toolName: 'Write', |
| toolCategory: 'write' as ToolCategory, |
| filePath: path, |
| summary: isCompleted ? `Wrote ${result?.linesCreated || 0} lines` : `Writing file...`, |
| }, |
| }; |
| } |
|
|
| |
| if (toolCall.editToolCall) { |
| const path = toolCall.editToolCall.args?.path || 'unknown'; |
|
|
| return { |
| ...baseEntry, |
| id: `${baseEntry.id}-${event.call_id}`, |
| type: 'tool_call' as LogEntryType, |
| title: isStarted ? `Editing ${path}` : `Edited ${path}`, |
| content: `Path: ${path}`, |
| collapsed: true, |
| metadata: { |
| toolName: 'Edit', |
| toolCategory: 'edit' as ToolCategory, |
| filePath: path, |
| summary: isCompleted ? `Edited file` : `Editing file...`, |
| }, |
| }; |
| } |
|
|
| |
| if (toolCall.shellToolCall) { |
| const command = toolCall.shellToolCall.args?.command || ''; |
| const result = toolCall.shellToolCall.result; |
| const shortCmd = command.length > 50 ? command.slice(0, 50) + '...' : command; |
|
|
| let content = `Command: ${command}`; |
| if (isCompleted && result?.success) { |
| content += `\nExit code: ${result.success.exitCode}`; |
| } else if (isCompleted && result?.rejected) { |
| content += `\nRejected: ${result.rejected.reason}`; |
| } |
|
|
| return { |
| ...baseEntry, |
| id: `${baseEntry.id}-${event.call_id}`, |
| type: 'tool_call' as LogEntryType, |
| title: isStarted ? `Running: ${shortCmd}` : `Ran: ${shortCmd}`, |
| content, |
| collapsed: true, |
| metadata: { |
| toolName: 'Bash', |
| toolCategory: 'bash' as ToolCategory, |
| summary: isCompleted |
| ? result?.success |
| ? `Exit ${result.success.exitCode}` |
| : result?.rejected |
| ? 'Rejected' |
| : 'Completed' |
| : `Running...`, |
| }, |
| }; |
| } |
|
|
| |
| if (toolCall.deleteToolCall) { |
| const path = toolCall.deleteToolCall.args?.path || 'unknown'; |
| const result = toolCall.deleteToolCall.result; |
|
|
| let content = `Path: ${path}`; |
| if (isCompleted && result?.rejected) { |
| content += `\nRejected: ${result.rejected.reason}`; |
| } |
|
|
| return { |
| ...baseEntry, |
| id: `${baseEntry.id}-${event.call_id}`, |
| type: 'tool_call' as LogEntryType, |
| title: isStarted ? `Deleting ${path}` : `Deleted ${path}`, |
| content, |
| collapsed: true, |
| metadata: { |
| toolName: 'Delete', |
| toolCategory: 'write' as ToolCategory, |
| filePath: path, |
| summary: isCompleted ? (result?.rejected ? 'Rejected' : 'Deleted') : `Deleting...`, |
| }, |
| }; |
| } |
|
|
| |
| if (toolCall.grepToolCall) { |
| const pattern = toolCall.grepToolCall.args?.pattern || ''; |
| const searchPath = toolCall.grepToolCall.args?.path; |
| const result = toolCall.grepToolCall.result?.success; |
|
|
| return { |
| ...baseEntry, |
| id: `${baseEntry.id}-${event.call_id}`, |
| type: 'tool_call' as LogEntryType, |
| title: isStarted ? `Searching: "${pattern}"` : `Searched: "${pattern}"`, |
| content: `Pattern: ${pattern}${searchPath ? `\nPath: ${searchPath}` : ''}${ |
| isCompleted && result ? `\nMatched ${result.matchedLines} lines` : '' |
| }`, |
| collapsed: true, |
| metadata: { |
| toolName: 'Grep', |
| toolCategory: 'search' as ToolCategory, |
| summary: isCompleted ? `Found ${result?.matchedLines || 0} matches` : `Searching...`, |
| }, |
| }; |
| } |
|
|
| |
| if (toolCall.lsToolCall) { |
| const path = toolCall.lsToolCall.args?.path || '.'; |
| const result = toolCall.lsToolCall.result?.success; |
|
|
| return { |
| ...baseEntry, |
| id: `${baseEntry.id}-${event.call_id}`, |
| type: 'tool_call' as LogEntryType, |
| title: isStarted ? `Listing ${path}` : `Listed ${path}`, |
| content: `Path: ${path}${ |
| isCompleted && result |
| ? `\n${result.childrenFiles} files, ${result.childrenDirs} directories` |
| : '' |
| }`, |
| collapsed: true, |
| metadata: { |
| toolName: 'Ls', |
| toolCategory: 'read' as ToolCategory, |
| filePath: path, |
| summary: isCompleted |
| ? `${result?.childrenFiles || 0} files, ${result?.childrenDirs || 0} dirs` |
| : `Listing...`, |
| }, |
| }; |
| } |
|
|
| |
| if (toolCall.globToolCall) { |
| const pattern = toolCall.globToolCall.args?.globPattern || ''; |
| const targetDir = toolCall.globToolCall.args?.targetDirectory; |
| const result = toolCall.globToolCall.result?.success; |
|
|
| return { |
| ...baseEntry, |
| id: `${baseEntry.id}-${event.call_id}`, |
| type: 'tool_call' as LogEntryType, |
| title: isStarted ? `Finding: ${pattern}` : `Found: ${pattern}`, |
| content: `Pattern: ${pattern}${targetDir ? `\nDirectory: ${targetDir}` : ''}${ |
| isCompleted && result ? `\nFound ${result.totalFiles} files` : '' |
| }`, |
| collapsed: true, |
| metadata: { |
| toolName: 'Glob', |
| toolCategory: 'search' as ToolCategory, |
| summary: isCompleted ? `Found ${result?.totalFiles || 0} files` : `Finding...`, |
| }, |
| }; |
| } |
|
|
| |
| if (toolCall.semSearchToolCall) { |
| const query = toolCall.semSearchToolCall.args?.query || ''; |
| const targetDirs = toolCall.semSearchToolCall.args?.targetDirectories; |
| const result = toolCall.semSearchToolCall.result?.success; |
| const shortQuery = query.length > 40 ? query.slice(0, 40) + '...' : query; |
| const resultCount = result?.codeResults?.length || 0; |
|
|
| return { |
| ...baseEntry, |
| id: `${baseEntry.id}-${event.call_id}`, |
| type: 'tool_call' as LogEntryType, |
| title: isStarted ? `Semantic search: "${shortQuery}"` : `Searched: "${shortQuery}"`, |
| content: `Query: ${query}${targetDirs?.length ? `\nDirectories: ${targetDirs.join(', ')}` : ''}${ |
| isCompleted |
| ? `\n${resultCount > 0 ? `Found ${resultCount} result(s)` : result?.results || 'No results'}` |
| : '' |
| }`, |
| collapsed: true, |
| metadata: { |
| toolName: 'SemanticSearch', |
| toolCategory: 'search' as ToolCategory, |
| summary: isCompleted |
| ? resultCount > 0 |
| ? `Found ${resultCount} result(s)` |
| : 'No results' |
| : `Searching...`, |
| }, |
| }; |
| } |
|
|
| |
| if (toolCall.readLintsToolCall) { |
| const paths = toolCall.readLintsToolCall.args?.paths || []; |
| const result = toolCall.readLintsToolCall.result?.success; |
| const pathCount = paths.length; |
|
|
| return { |
| ...baseEntry, |
| id: `${baseEntry.id}-${event.call_id}`, |
| type: 'tool_call' as LogEntryType, |
| title: isStarted ? `Reading lints for ${pathCount} file(s)` : `Read lints`, |
| content: `Paths: ${paths.join(', ')}${ |
| isCompleted && result |
| ? `\nFound ${result.totalDiagnostics} diagnostic(s) in ${result.totalFiles} file(s)` |
| : '' |
| }`, |
| collapsed: true, |
| metadata: { |
| toolName: 'ReadLints', |
| toolCategory: 'read' as ToolCategory, |
| summary: isCompleted |
| ? `${result?.totalDiagnostics || 0} diagnostic(s)` |
| : `Reading lints...`, |
| }, |
| }; |
| } |
|
|
| |
| if (toolCall.function) { |
| const name = toolCall.function.name; |
| const args = toolCall.function.arguments; |
|
|
| |
| const category = categorizeToolName(name); |
|
|
| return { |
| ...baseEntry, |
| id: `${baseEntry.id}-${event.call_id}`, |
| type: 'tool_call' as LogEntryType, |
| title: `${name} ${isStarted ? 'started' : 'completed'}`, |
| content: args || '', |
| collapsed: true, |
| metadata: { |
| toolName: name, |
| toolCategory: category, |
| summary: `${name} ${event.subtype}`, |
| }, |
| }; |
| } |
|
|
| return null; |
| } |
|
|
| |
| |
| |
| export function normalizeCursorEvent(event: CursorStreamEvent): LogEntry | null { |
| const timestamp = new Date().toISOString(); |
| const baseEntry = { |
| id: `cursor-${event.session_id}-${Date.now()}`, |
| timestamp, |
| }; |
|
|
| switch (event.type) { |
| case 'system': { |
| const sysEvent = event as CursorSystemEvent; |
| return { |
| ...baseEntry, |
| type: 'info' as LogEntryType, |
| title: 'Session Started', |
| content: `Model: ${sysEvent.model}\nAuth: ${sysEvent.apiKeySource}\nCWD: ${sysEvent.cwd}`, |
| collapsed: true, |
| metadata: { |
| phase: 'init', |
| }, |
| }; |
| } |
|
|
| case 'assistant': { |
| const assistEvent = event as CursorAssistantEvent; |
| const text = assistEvent.message.content |
| .filter((c) => c.type === 'text') |
| .map((c) => c.text) |
| .join(''); |
|
|
| if (!text.trim()) return null; |
|
|
| return { |
| ...baseEntry, |
| type: 'info' as LogEntryType, |
| title: 'Assistant', |
| content: text, |
| collapsed: false, |
| }; |
| } |
|
|
| case 'tool_call': { |
| const toolEvent = event as CursorToolCallEvent; |
| return normalizeCursorToolCall(toolEvent, baseEntry); |
| } |
|
|
| case 'result': { |
| const resultEvent = event as CursorResultEvent; |
|
|
| if (resultEvent.is_error) { |
| return { |
| ...baseEntry, |
| type: 'error' as LogEntryType, |
| title: 'Error', |
| content: resultEvent.error || resultEvent.result || 'Unknown error', |
| collapsed: false, |
| }; |
| } |
|
|
| return { |
| ...baseEntry, |
| type: 'success' as LogEntryType, |
| title: 'Completed', |
| content: `Duration: ${resultEvent.duration_ms}ms`, |
| collapsed: true, |
| }; |
| } |
|
|
| default: |
| return null; |
| } |
| } |
|
|
| |
| |
| |
| |
| export function parseLogLine(line: string): LogEntry | null { |
| if (!line.trim()) return null; |
|
|
| try { |
| const parsed = JSON.parse(line); |
|
|
| |
| if (isCursorEvent(parsed)) { |
| return normalizeCursorEvent(parsed); |
| } |
|
|
| |
| return { |
| id: `json-${Date.now()}-${Math.random().toString(36).slice(2)}`, |
| type: 'debug', |
| title: 'Debug Info', |
| content: line, |
| timestamp: new Date().toISOString(), |
| collapsed: true, |
| }; |
| } catch { |
| |
| return { |
| id: `text-${Date.now()}-${Math.random().toString(36).slice(2)}`, |
| type: 'info', |
| title: 'Output', |
| content: line, |
| timestamp: new Date().toISOString(), |
| collapsed: false, |
| }; |
| } |
| } |
|
|
| |
| |
| |
| export function getProviderStyle(entry: LogEntry): { badge?: string; icon?: string } { |
| |
| if (entry.id.startsWith('cursor-')) { |
| return { |
| badge: 'Cursor', |
| icon: 'terminal', |
| }; |
| } |
|
|
| |
| return { |
| badge: 'Claude', |
| icon: 'bot', |
| }; |
| } |
|
|
| |
| |
| |
| export function shouldCollapseByDefault(entry: LogEntry): boolean { |
| |
| if (entry.content.length > 200) return true; |
|
|
| |
| const lineCount = entry.content.split('\n').length; |
| if (lineCount > 5 && (entry.content.includes('{') || entry.content.includes('['))) { |
| return true; |
| } |
|
|
| |
| if (entry.metadata?.toolName === 'TodoWrite') { |
| try { |
| const inputMatch = entry.content.match(/Input:\s*([\s\S]*)/); |
| if (inputMatch) { |
| const parsed = JSON.parse(inputMatch[1].trim()) as Record<string, unknown>; |
| const todos = parsed.todos as unknown[] | undefined; |
| if (todos && todos.length > 1) return true; |
| } |
| } catch { |
| |
| } |
| } |
|
|
| |
| if (entry.metadata?.toolName === 'Edit' && entry.content.includes('old_string')) { |
| return true; |
| } |
|
|
| return false; |
| } |
|
|
| |
| |
| |
| function generateTitle(type: LogEntryType, content: string): string { |
| |
| const cleaned = cleanFragmentedText(content); |
|
|
| switch (type) { |
| case 'tool_call': { |
| const toolName = extractToolName(content); |
| return toolName ? `Tool Call: ${toolName}` : 'Tool Call'; |
| } |
| case 'tool_result': |
| return 'Tool Input/Result'; |
| case 'phase': { |
| const phase = extractPhase(content); |
| if (phase) { |
| |
| const formatted = phase |
| .split(/\s+/) |
| .map((word) => word.charAt(0).toUpperCase() + word.slice(1)) |
| .join(' '); |
| return `Phase: ${formatted}`; |
| } |
| return 'Phase Change'; |
| } |
| case 'error': |
| return 'Error'; |
| case 'success': { |
| |
| if ( |
| content.startsWith('<summary>') || |
| content.includes('<summary>') || |
| cleaned.startsWith('<summary>') || |
| cleaned.includes('<summary>') |
| ) { |
| return 'Summary'; |
| } |
| if ( |
| content.match(/^##\s+(Summary|Feature|Changes|Implementation)/i) || |
| cleaned.match(/^##\s+(Summary|Feature|Changes|Implementation)/i) |
| ) { |
| return 'Summary'; |
| } |
| if ( |
| content.match(/^All tasks completed/i) || |
| content.match(/^(I've|I have) (successfully |now )?(completed|finished|implemented)/i) |
| ) { |
| return 'Summary'; |
| } |
| return 'Success'; |
| } |
| case 'warning': |
| return 'Warning'; |
| case 'thinking': |
| return 'Thinking Level'; |
| case 'debug': |
| return 'Debug Info'; |
| case 'prompt': |
| return 'Prompt'; |
| default: |
| return 'Info'; |
| } |
| } |
|
|
| |
| |
| |
| function calculateBracketDepth(line: string): { braceChange: number; bracketChange: number } { |
| let braceChange = 0; |
| let bracketChange = 0; |
| let inString = false; |
| let escapeNext = false; |
|
|
| for (const char of line) { |
| if (escapeNext) { |
| escapeNext = false; |
| continue; |
| } |
| if (char === '\\') { |
| escapeNext = true; |
| continue; |
| } |
| if (char === '"') { |
| inString = !inString; |
| continue; |
| } |
| if (inString) continue; |
|
|
| if (char === '{') braceChange++; |
| else if (char === '}') braceChange--; |
| else if (char === '[') bracketChange++; |
| else if (char === ']') bracketChange--; |
| } |
|
|
| return { braceChange, bracketChange }; |
| } |
|
|
| |
| |
| |
| export function parseLogOutput(rawOutput: string): LogEntry[] { |
| if (!rawOutput || !rawOutput.trim()) { |
| return []; |
| } |
|
|
| const entries: LogEntry[] = []; |
| const lines = rawOutput.split('\n'); |
|
|
| let currentEntry: (Omit<LogEntry, 'id'> & { id?: string }) | null = null; |
| let currentContent: string[] = []; |
| let entryStartLine = 0; |
|
|
| |
| let inJsonAccumulation = false; |
| let jsonBraceDepth = 0; |
| let jsonBracketDepth = 0; |
|
|
| |
| let inSummaryAccumulation = false; |
|
|
| const finalizeEntry = () => { |
| if (currentEntry && currentContent.length > 0) { |
| currentEntry.content = currentContent.join('\n').trim(); |
| if (currentEntry.content) { |
| |
| const toolName = currentEntry.metadata?.toolName; |
| if (toolName && currentEntry.type === 'tool_call') { |
| const toolCategory = categorizeToolName(toolName); |
| const filePath = extractFilePath(currentEntry.content); |
| const summary = generateToolSummary(toolName, currentEntry.content); |
|
|
| currentEntry.metadata = { |
| ...currentEntry.metadata, |
| toolCategory, |
| filePath, |
| summary, |
| }; |
| } |
|
|
| |
| const entryWithId: LogEntry = { |
| ...(currentEntry as Omit<LogEntry, 'id'>), |
| id: generateDeterministicId(currentEntry.content, entryStartLine), |
| }; |
| entries.push(entryWithId); |
| } |
| } |
| currentContent = []; |
| inJsonAccumulation = false; |
| jsonBraceDepth = 0; |
| jsonBracketDepth = 0; |
| inSummaryAccumulation = false; |
| }; |
|
|
| let lineIndex = 0; |
| for (const line of lines) { |
| const trimmedLine = line.trim(); |
|
|
| |
| if (!trimmedLine && !currentEntry) { |
| lineIndex++; |
| continue; |
| } |
|
|
| |
| |
| if (trimmedLine.startsWith('{') && trimmedLine.endsWith('}')) { |
| try { |
| const parsed = JSON.parse(trimmedLine); |
| if (isCursorEvent(parsed)) { |
| |
| finalizeEntry(); |
| const cursorEntry = normalizeCursorEvent(parsed); |
| if (cursorEntry) { |
| entries.push(cursorEntry); |
| } |
| lineIndex++; |
| continue; |
| } |
| } catch { |
| |
| } |
| } |
|
|
| |
| if (inJsonAccumulation) { |
| currentContent.push(line); |
| const { braceChange, bracketChange } = calculateBracketDepth(trimmedLine); |
| jsonBraceDepth += braceChange; |
| jsonBracketDepth += bracketChange; |
|
|
| |
| if (jsonBraceDepth <= 0 && jsonBracketDepth <= 0) { |
| inJsonAccumulation = false; |
| jsonBraceDepth = 0; |
| jsonBracketDepth = 0; |
| } |
| lineIndex++; |
| continue; |
| } |
|
|
| |
| if (inSummaryAccumulation) { |
| currentContent.push(line); |
| |
| if (trimmedLine.includes('</summary>')) { |
| inSummaryAccumulation = false; |
| |
| } |
| lineIndex++; |
| continue; |
| } |
|
|
| |
| const lineType = detectEntryType(trimmedLine); |
| const isNewEntry = |
| trimmedLine.startsWith('🔧') || |
| trimmedLine.startsWith('📋') || |
| trimmedLine.startsWith('⚡') || |
| trimmedLine.startsWith('✅') || |
| trimmedLine.startsWith('❌') || |
| trimmedLine.startsWith('⚠️') || |
| trimmedLine.startsWith('🧠') || |
| trimmedLine.match(/\[Phase:\s*([^\]]+)\]/) || |
| trimmedLine.match(/\[Feature Creation\]/i) || |
| trimmedLine.match(/\[Tool\]/i) || |
| trimmedLine.match(/\[Agent\]/i) || |
| trimmedLine.match(/\[Complete\]/i) || |
| trimmedLine.match(/\[ERROR\]/i) || |
| trimmedLine.match(/\[Status\]/i) || |
| trimmedLine.toLowerCase().includes('ultrathink preparation') || |
| trimmedLine.match(/thinking level[:\s]*(low|medium|high|none|\d)/i) || |
| |
| trimmedLine.startsWith('<summary>') || |
| cleanFragmentedText(trimmedLine).startsWith('<summary>') || |
| |
| trimmedLine.match(/^##\s+(Summary|Feature|Changes|Implementation)/i) || |
| cleanFragmentedText(trimmedLine).match(/^##\s+(Summary|Feature|Changes|Implementation)/i) || |
| |
| trimmedLine.match(/^All tasks completed/i) || |
| trimmedLine.match(/^(I've|I have) (successfully |now )?(completed|finished|implemented)/i); |
|
|
| |
| const isInputLine = trimmedLine.startsWith('Input:') && currentEntry?.type === 'tool_call'; |
|
|
| if (isNewEntry) { |
| |
| finalizeEntry(); |
|
|
| |
| entryStartLine = lineIndex; |
|
|
| |
| currentEntry = { |
| type: lineType, |
| title: generateTitle(lineType, trimmedLine), |
| content: '', |
| metadata: { |
| toolName: extractToolName(trimmedLine), |
| phase: extractPhase(trimmedLine), |
| }, |
| }; |
| currentContent.push(trimmedLine); |
|
|
| |
| |
| const cleanedTrimmed = cleanFragmentedText(trimmedLine); |
| if ( |
| (trimmedLine.startsWith('<summary>') || cleanedTrimmed.startsWith('<summary>')) && |
| !trimmedLine.includes('</summary>') && |
| !cleanedTrimmed.includes('</summary>') |
| ) { |
| inSummaryAccumulation = true; |
| } |
| } else if (isInputLine && currentEntry) { |
| |
| currentContent.push(trimmedLine); |
|
|
| |
| const inputContent = trimmedLine.replace(/^Input:\s*/, ''); |
| if (inputContent) { |
| const { braceChange, bracketChange } = calculateBracketDepth(inputContent); |
| jsonBraceDepth = braceChange; |
| jsonBracketDepth = bracketChange; |
|
|
| |
| if (jsonBraceDepth > 0 || jsonBracketDepth > 0) { |
| inJsonAccumulation = true; |
| } |
| } else { |
| |
| inJsonAccumulation = true; |
| } |
| } else if (currentEntry) { |
| |
| currentContent.push(line); |
|
|
| |
| if (trimmedLine.startsWith('{') || trimmedLine.startsWith('[')) { |
| const { braceChange, bracketChange } = calculateBracketDepth(trimmedLine); |
| if (braceChange > 0 || bracketChange > 0) { |
| jsonBraceDepth = braceChange; |
| jsonBracketDepth = bracketChange; |
| if (jsonBraceDepth > 0 || jsonBracketDepth > 0) { |
| inJsonAccumulation = true; |
| } |
| } |
| } |
| } else { |
| |
| entryStartLine = lineIndex; |
|
|
| |
| currentEntry = { |
| type: 'info', |
| title: 'Info', |
| content: '', |
| }; |
| currentContent.push(line); |
| } |
| lineIndex++; |
| } |
|
|
| |
| finalizeEntry(); |
|
|
| |
| const mergedEntries = mergeConsecutiveEntries(entries); |
|
|
| return mergedEntries; |
| } |
|
|
| |
| |
| |
| function mergeConsecutiveEntries(entries: LogEntry[]): LogEntry[] { |
| if (entries.length <= 1) return entries; |
|
|
| const merged: LogEntry[] = []; |
| let current: LogEntry | null = null; |
| let mergeIndex = 0; |
|
|
| for (const entry of entries) { |
| if ( |
| current && |
| (current.type === 'debug' || current.type === 'info') && |
| current.type === entry.type |
| ) { |
| |
| current.content += '\n\n' + entry.content; |
| current.id = generateDeterministicId(current.content, mergeIndex); |
| } else { |
| if (current) { |
| merged.push(current); |
| } |
| current = { ...entry }; |
| mergeIndex = merged.length; |
| } |
| } |
|
|
| if (current) { |
| merged.push(current); |
| } |
|
|
| return merged; |
| } |
|
|
| |
| |
| |
| |
| |
| export function extractSummary(rawOutput: string): string | null { |
| if (!rawOutput || !rawOutput.trim()) { |
| return null; |
| } |
|
|
| |
| |
| |
| const cleanedOutput = cleanFragmentedText(rawOutput); |
|
|
| |
| |
| const regexesToTry: Array<{ |
| regex: RegExp; |
| processor: (m: RegExpMatchArray) => string; |
| }> = [ |
| { regex: /<summary>([\s\S]*?)<\/summary>/gi, processor: (m) => m[1] }, |
| { regex: /^##\s+Summary[^\n]*\n([\s\S]*?)(?=\n##\s+[^#]|\n🔧|$)/gm, processor: (m) => m[1] }, |
| { |
| regex: /^##\s+(Feature|Changes|Implementation)[^\n]*\n([\s\S]*?)(?=\n##\s+[^#]|\n🔧|$)/gm, |
| processor: (m) => `## ${m[1]}\n${m[2]}`, |
| }, |
| { |
| regex: /(^|\n)(All tasks completed[\s\S]*?)(?=\n🔧|\n📋|\n⚡|\n❌|$)/g, |
| processor: (m) => m[2], |
| }, |
| { |
| regex: |
| /(^|\n)((I've|I have) (successfully |now )?(completed|finished|implemented)[\s\S]*?)(?=\n🔧|\n📋|\n⚡|\n❌|$)/g, |
| processor: (m) => m[2], |
| }, |
| ]; |
|
|
| for (const { regex, processor } of regexesToTry) { |
| const matches = [...cleanedOutput.matchAll(regex)]; |
| if (matches.length > 0) { |
| const lastMatch = matches[matches.length - 1]; |
| return cleanFragmentedText(processor(lastMatch)).trim(); |
| } |
| } |
|
|
| return null; |
| } |
|
|
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| const PHASE_SEPARATOR = '\n\n---\n\n'; |
| const PHASE_SEPARATOR_REGEX = /\n\n---\n\n/; |
| const PHASE_HEADER_REGEX = /^###\s+(.+?)(?:\n|$)/; |
| const PHASE_HEADER_WITH_PREFIX_REGEX = /^(###\s+)(.+?)(?:\n|$)/; |
|
|
| function getPhaseSections(summary: string): { |
| sections: string[]; |
| leadingImplementationSection: string | null; |
| } { |
| const sections = summary.split(PHASE_SEPARATOR_REGEX); |
| const hasSeparator = summary.includes(PHASE_SEPARATOR); |
| const hasAnyHeader = sections.some((section) => PHASE_HEADER_REGEX.test(section.trim())); |
| const firstSection = sections[0]?.trim() ?? ''; |
| const leadingImplementationSection = |
| hasSeparator && hasAnyHeader && firstSection && !PHASE_HEADER_REGEX.test(firstSection) |
| ? firstSection |
| : null; |
|
|
| return { sections, leadingImplementationSection }; |
| } |
|
|
| export function parsePhaseSummaries(summary: string | undefined): Map<string, string> { |
| const phaseSummaries = new Map<string, string>(); |
|
|
| if (!summary || !summary.trim()) { |
| return phaseSummaries; |
| } |
|
|
| const { sections, leadingImplementationSection } = getPhaseSections(summary); |
|
|
| |
| |
| |
| if (leadingImplementationSection) { |
| phaseSummaries.set('implementation', leadingImplementationSection); |
| } |
|
|
| for (const section of sections) { |
| |
| const headerMatch = section.match(PHASE_HEADER_REGEX); |
| if (headerMatch) { |
| const phaseName = headerMatch[1].trim().toLowerCase(); |
| |
| const content = section.substring(headerMatch[0].length).trim(); |
| phaseSummaries.set(phaseName, content); |
| } |
| } |
|
|
| return phaseSummaries; |
| } |
|
|
| |
| |
| |
| |
| |
| |
| |
| export function extractPhaseSummary(summary: string | undefined, phaseName: string): string | null { |
| const phaseSummaries = parsePhaseSummaries(summary); |
| const normalizedPhaseName = phaseName.toLowerCase(); |
| return phaseSummaries.get(normalizedPhaseName) || null; |
| } |
|
|
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| export function extractImplementationSummary(summary: string | undefined): string | null { |
| if (!summary || !summary.trim()) { |
| return null; |
| } |
|
|
| const phaseSummaries = parsePhaseSummaries(summary); |
|
|
| |
| const implementationContent = phaseSummaries.get('implementation'); |
| if (implementationContent) { |
| return implementationContent; |
| } |
|
|
| |
| for (const [phaseName, content] of phaseSummaries) { |
| if (phaseName.includes('implement')) { |
| return content; |
| } |
| } |
|
|
| |
| |
| |
| if (!summary.includes('### ') && !summary.includes(PHASE_SEPARATOR)) { |
| return summary; |
| } |
|
|
| return null; |
| } |
|
|
| |
| |
| |
| |
| |
| |
| export function isAccumulatedSummary(summary: string | undefined): boolean { |
| if (!summary || !summary.trim()) { |
| return false; |
| } |
|
|
| |
| const hasMultiplePhases = |
| summary.includes(PHASE_SEPARATOR) && (summary.match(/###\s+.+/g)?.length ?? 0) > 0; |
|
|
| return hasMultiplePhases; |
| } |
|
|
| |
| |
| |
| export interface PhaseSummaryEntry { |
| |
| phaseName: string; |
| |
| content: string; |
| |
| header: string; |
| } |
|
|
| |
| const DEFAULT_PHASE_NAME = 'Summary'; |
|
|
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| export function parseAllPhaseSummaries(summary: string | undefined): PhaseSummaryEntry[] { |
| const entries: PhaseSummaryEntry[] = []; |
|
|
| if (!summary || !summary.trim()) { |
| return entries; |
| } |
|
|
| |
| |
| const hasPhaseHeaders = /^###\s+/m.test(summary); |
| if (!hasPhaseHeaders) { |
| |
| return [ |
| { phaseName: DEFAULT_PHASE_NAME, content: summary, header: `### ${DEFAULT_PHASE_NAME}` }, |
| ]; |
| } |
|
|
| const { sections, leadingImplementationSection } = getPhaseSections(summary); |
|
|
| |
| |
| if (leadingImplementationSection) { |
| entries.push({ |
| phaseName: 'Implementation', |
| content: leadingImplementationSection, |
| header: '### Implementation', |
| }); |
| } |
|
|
| for (const section of sections) { |
| |
| const headerMatch = section.match(PHASE_HEADER_WITH_PREFIX_REGEX); |
| if (headerMatch) { |
| const header = headerMatch[0].trim(); |
| const phaseName = headerMatch[2].trim(); |
| |
| const content = section.substring(headerMatch[0].length).trim(); |
| entries.push({ phaseName, content, header }); |
| } |
| } |
|
|
| |
| |
| if (entries.length === 0) { |
| return [ |
| { phaseName: DEFAULT_PHASE_NAME, content: summary, header: `### ${DEFAULT_PHASE_NAME}` }, |
| ]; |
| } |
|
|
| return entries; |
| } |
|
|
| export function getLogTypeColors(type: LogEntryType): { |
| bg: string; |
| border: string; |
| text: string; |
| icon: string; |
| badge: string; |
| } { |
| switch (type) { |
| case 'prompt': |
| return { |
| bg: 'bg-blue-500/10', |
| border: 'border-blue-500/30', |
| text: 'text-blue-300', |
| icon: 'text-blue-400', |
| badge: 'bg-blue-500/20 text-blue-300', |
| }; |
| case 'tool_call': |
| return { |
| bg: 'bg-amber-500/10', |
| border: 'border-amber-500/30', |
| text: 'text-amber-300', |
| icon: 'text-amber-400', |
| badge: 'bg-amber-500/20 text-amber-300', |
| }; |
| case 'tool_result': |
| return { |
| bg: 'bg-slate-500/10', |
| border: 'border-slate-400/30', |
| text: 'text-slate-300', |
| icon: 'text-slate-400', |
| badge: 'bg-slate-500/20 text-slate-300', |
| }; |
| case 'phase': |
| return { |
| bg: 'bg-cyan-500/10', |
| border: 'border-cyan-500/30', |
| text: 'text-cyan-300', |
| icon: 'text-cyan-400', |
| badge: 'bg-cyan-500/20 text-cyan-300', |
| }; |
| case 'error': |
| return { |
| bg: 'bg-red-500/10', |
| border: 'border-red-500/30', |
| text: 'text-red-300', |
| icon: 'text-red-400', |
| badge: 'bg-red-500/20 text-red-300', |
| }; |
| case 'success': |
| return { |
| bg: 'bg-emerald-500/20', |
| border: 'border-emerald-500/40', |
| text: 'text-emerald-200', |
| icon: 'text-emerald-400', |
| badge: 'bg-emerald-500/30 text-emerald-200', |
| }; |
| case 'warning': |
| return { |
| bg: 'bg-orange-500/10', |
| border: 'border-orange-500/30', |
| text: 'text-orange-300', |
| icon: 'text-orange-400', |
| badge: 'bg-orange-500/20 text-orange-300', |
| }; |
| case 'thinking': |
| return { |
| bg: 'bg-indigo-500/10', |
| border: 'border-indigo-500/30', |
| text: 'text-indigo-300', |
| icon: 'text-indigo-400', |
| badge: 'bg-indigo-500/20 text-indigo-300', |
| }; |
| case 'debug': |
| return { |
| bg: 'bg-primary/10', |
| border: 'border-primary/30', |
| text: 'text-primary', |
| icon: 'text-primary', |
| badge: 'bg-primary/20 text-primary', |
| }; |
| case 'info': |
| return { |
| bg: 'bg-zinc-500/10', |
| border: 'border-zinc-500/30', |
| text: 'text-primary', |
| icon: 'text-zinc-400', |
| badge: 'bg-zinc-500/20 text-primary', |
| }; |
| default: |
| return { |
| bg: 'bg-zinc-500/10', |
| border: 'border-zinc-500/30', |
| text: 'text-black', |
| icon: 'text-zinc-400', |
| badge: 'bg-zinc-500/20 text-black', |
| }; |
| } |
| } |
|
|