/** * FABRIC Log Parser * * Parses NEEDLE log lines into structured LogEvent objects. * Also extracts conversation events from log entries. */ import { LogEvent, LogLevel, NeedleEvent, ConversationEvent, PromptEvent, ResponseEvent, ThinkingEvent, ToolCallEvent, ToolResultEvent, ConversationParseOptions, } from './types.js'; import { normalizeToLogEvent, normalize, needleEventToLogEvent } from './normalizer.js'; // Re-export from normalizer for backward compatibility export { normalize, needleEventToLogEvent } from './normalizer.js'; /** * Parse a JSON log line into a canonical NeedleEvent. * * Handles all JSONL sub-formats: * 1. Canonical NeedleEvent: {timestamp, event_type, worker_id, session_id, sequence, data} * 2. Legacy NEEDLE format: {ts: ISO string, event: string, worker: {...}, session: string, data: {...}} * 3. Flat legacy format: {ts: Unix ms, worker: string, level: string, msg: string} * * @returns NeedleEvent with all fields preserved, or null if the line is unparseable */ export function parseNeedleEvent(line: string): NeedleEvent | null { return normalize(line, 'jsonl'); } /** * Parse a single log line into a legacy LogEvent. * * Thin adapter: calls parseNeedleEvent to get the canonical shape, * then projects to the flat LogEvent for backward-compatible UI consumers. */ export function parseLogLine(line: string): LogEvent | null { const ne = parseNeedleEvent(line); return ne ? needleEventToLogEvent(ne) : null; } /** * Parse a JSON object directly into a LogEvent. * Delegates to the normalizer for all format detection. */ export function parseEventObject(obj: unknown): LogEvent | null { return normalizeToLogEvent(obj, 'jsonl'); } /** * Parse multiple log lines * * @param content - Multi-line string of log entries * @returns Array of parsed LogEvents (skips invalid lines) */ export function parseLogLines(content: string): LogEvent[] { const events: LogEvent[] = []; for (const line of content.split('\n')) { const event = parseLogLine(line); if (event) { events.push(event); } } return events; } /** * Format a LogEvent for display */ export function formatEvent(event: LogEvent, options: FormatOptions = {}): string { const { showWorker = true, showLevel = true, colorize = false } = options; const timestamp = formatTimestamp(event.ts); const parts: string[] = []; if (showWorker) { parts.push(padWorker(event.worker)); } if (showLevel) { parts.push(formatLevel(event.level, colorize)); } parts.push(event.msg); // Add optional context if (event.tool) { parts.push(`[${event.tool}]`); } if (event.path) { parts.push(event.path); } if (event.bead) { parts.push(`bead:${event.bead}`); } if (event.duration_ms !== undefined) { parts.push(`(${formatDuration(event.duration_ms)})`); } if (event.error) { parts.push(`ERROR: ${event.error}`); } return `${timestamp} ${parts.join(' ')}`; } export interface FormatOptions { showWorker?: boolean; showLevel?: boolean; colorize?: boolean; } /** * Format timestamp for display */ function formatTimestamp(ts: number): string { const date = new Date(ts); const hours = date.getHours().toString().padStart(2, '0'); const minutes = date.getMinutes().toString().padStart(2, '0'); const seconds = date.getSeconds().toString().padStart(2, '0'); return `${hours}:${minutes}:${seconds}`; } /** * Pad worker ID for alignment */ function padWorker(worker: string): string { return worker.padEnd(12); } /** * Format log level with optional color */ function formatLevel(level: LogLevel, colorize: boolean): string { const padded = level.toUpperCase().padEnd(5); if (!colorize) { return padded; } // ANSI color codes const colors: Record = { debug: '\x1b[36m', // cyan info: '\x1b[32m', // green warn: '\x1b[33m', // yellow error: '\x1b[31m', // red }; const reset = '\x1b[0m'; return `${colors[level]}${padded}${reset}`; } /** * Format duration in human-readable form */ function formatDuration(ms: number): string { if (ms < 1000) { return `${ms}ms`; } else if (ms < 60000) { return `${(ms / 1000).toFixed(1)}s`; } else { const minutes = Math.floor(ms / 60000); const seconds = Math.round((ms % 60000) / 1000); return `${minutes}m ${seconds}s`; } } // ============================================ // Conversation Event Parsing // ============================================ /** * Event sequence counter for generating unique IDs */ let eventSequence = 0; /** * Generate a unique event ID */ function generateEventId(): string { return `ce-${Date.now()}-${++eventSequence}`; } /** * Helper: Get string value from LogEvent */ function getString(event: LogEvent, key: string): string | undefined { const value = event[key]; return typeof value === 'string' ? value : undefined; } /** * Helper: Get number value from LogEvent */ function getNumber(event: LogEvent, key: string): number | undefined { const value = event[key]; return typeof value === 'number' ? value : undefined; } /** * Helper: Get boolean value from LogEvent */ function getBoolean(event: LogEvent, key: string): boolean | undefined { const value = event[key]; return typeof value === 'boolean' ? value : undefined; } /** * Helper: Get object value from LogEvent */ function getObject>(event: LogEvent, key: string): T | undefined { const value = event[key]; return typeof value === 'object' && value !== null && !Array.isArray(value) ? (value as T) : undefined; } /** * Check if a log event contains conversation-related content */ export function isConversationEvent(event: LogEvent): boolean { // Check for explicit conversation fields if ( event.conversation_role || event.conversation_type || event.prompt || event.response || event.thinking || event.tool_call || event.tool_result ) { return true; } // Check message patterns that indicate conversation content const msg = event.msg.toLowerCase(); if ( msg.includes('user prompt') || msg.includes('assistant response') || msg.includes('thinking') || msg.includes('tool call') || msg.includes('tool result') ) { return true; } // Tool events with arguments/results are conversation events if (event.tool && (event.tool_args || event.tool_input || event.args)) { return true; } // Events with explicit content field if (event.content && typeof event.content === 'string') { return true; } return false; } /** * Parse a log event into a conversation event * * @param event - The log event to parse * @param sequence - Sequence number in the conversation * @param options - Parse options * @returns Parsed conversation event or null if not a conversation event */ export function parseConversationEvent( event: LogEvent, sequence: number, options: ConversationParseOptions = {} ): ConversationEvent | null { const { maxContentLength = 10000, maxToolResultLength = 5000 } = options; // Check for explicit conversation type if (event.conversation_type) { return parseByConversationType(event, sequence, options); } // Check for user prompt if (event.prompt || event.conversation_role === 'user' || event.role === 'user') { return parsePromptEvent(event, sequence, maxContentLength); } // Check for assistant response if (event.response || event.conversation_role === 'assistant' || event.role === 'assistant') { // Check if it's a thinking block if (event.thinking || event.msg.toLowerCase().includes('thinking')) { return parseThinkingEvent(event, sequence, maxContentLength); } return parseResponseEvent(event, sequence, maxContentLength); } // Check for thinking block if (event.thinking || event.msg.toLowerCase().includes('thinking')) { return parseThinkingEvent(event, sequence, maxContentLength); } // Check for tool call if (event.tool_call || (event.tool && (event.tool_args || event.tool_input || event.args))) { return parseToolCallEvent(event, sequence); } // Check for tool result if (event.tool_result || (event.tool && (event.result || event.tool_output))) { return parseToolResultEvent(event, sequence, maxToolResultLength); } // Check message patterns const msg = event.msg.toLowerCase(); if (msg.includes('prompt') && !msg.includes('tool')) { return parsePromptEvent(event, sequence, maxContentLength); } if (msg.includes('response') && !msg.includes('tool')) { return parseResponseEvent(event, sequence, maxContentLength); } if (msg.includes('tool call') || (event.tool && event.msg.includes('Tool call'))) { return parseToolCallEvent(event, sequence); } if (msg.includes('tool result') || msg.includes('tool response')) { return parseToolResultEvent(event, sequence, maxToolResultLength); } return null; } /** * Parse by explicit conversation_type field */ function parseByConversationType( event: LogEvent, sequence: number, options: ConversationParseOptions ): ConversationEvent | null { const type = event.conversation_type as string; const { maxContentLength = 10000, maxToolResultLength = 5000 } = options; switch (type) { case 'prompt': case 'user': return parsePromptEvent(event, sequence, maxContentLength); case 'response': case 'assistant': return parseResponseEvent(event, sequence, maxContentLength); case 'thinking': return parseThinkingEvent(event, sequence, maxContentLength); case 'tool_call': return parseToolCallEvent(event, sequence); case 'tool_result': return parseToolResultEvent(event, sequence, maxToolResultLength); default: return null; } } /** * Parse a user prompt event */ function parsePromptEvent( event: LogEvent, sequence: number, maxLength: number ): PromptEvent | null { const content = extractContent(event, 'prompt') || extractContent(event, 'content'); if (!content) return null; return { id: generateEventId(), type: 'prompt', role: 'user', ts: event.ts, worker: event.worker, bead: event.bead, sequence, content: truncate(content, maxLength), isContinuation: getBoolean(event, 'is_continuation') ?? getBoolean(event, 'continuation'), tokens: getNumber(event, 'tokens') ?? getNumber(event, 'input_tokens'), }; } /** * Parse an assistant response event */ function parseResponseEvent( event: LogEvent, sequence: number, maxLength: number ): ResponseEvent | null { const content = extractContent(event, 'response') || extractContent(event, 'content'); if (!content) return null; return { id: generateEventId(), type: 'response', role: 'assistant', ts: event.ts, worker: event.worker, bead: event.bead, sequence, content: truncate(content, maxLength), isTruncated: content.length > maxLength, model: getString(event, 'model') ?? getString(event, 'model_name'), stopReason: getString(event, 'stop_reason') as ResponseEvent['stopReason'], tokens: getNumber(event, 'tokens') ?? getNumber(event, 'output_tokens'), }; } /** * Parse a thinking block event */ function parseThinkingEvent( event: LogEvent, sequence: number, maxLength: number ): ThinkingEvent | null { const content = extractContent(event, 'thinking') || extractContent(event, 'content'); if (!content) return null; return { id: generateEventId(), type: 'thinking', role: 'assistant', ts: event.ts, worker: event.worker, bead: event.bead, sequence, content: truncate(content, maxLength), isTruncated: content.length > maxLength, durationMs: getNumber(event, 'thinking_duration_ms') ?? event.duration_ms, tokens: getNumber(event, 'tokens'), }; } /** * Parse a tool call event */ function parseToolCallEvent(event: LogEvent, sequence: number): ToolCallEvent | null { const tool = event.tool || getString(event, 'tool_name'); if (!tool) return null; const args = normalizeToolArgs(event); return { id: generateEventId(), type: 'tool_call', role: 'assistant', ts: event.ts, worker: event.worker, bead: event.bead, sequence, tool, args: args as Record, toolCallId: getString(event, 'tool_call_id') ?? getString(event, 'call_id'), summary: generateToolSummary(tool, args as Record), tokens: getNumber(event, 'tokens'), }; } /** * Parse a tool result event */ function parseToolResultEvent( event: LogEvent, sequence: number, maxLength: number ): ToolResultEvent | null { const tool = event.tool || getString(event, 'tool_name'); if (!tool) return null; const content = extractContent(event, 'tool_result') || extractContent(event, 'result') || extractContent(event, 'content') || ''; const hasError = event.error || getString(event, 'tool_error') || event.success === false; return { id: generateEventId(), type: 'tool_result', role: 'tool', ts: event.ts, worker: event.worker, bead: event.bead, sequence, tool, toolCallId: getString(event, 'tool_call_id') ?? getString(event, 'call_id'), content: truncate(content, maxLength), success: !hasError, error: event.error || getString(event, 'tool_error'), durationMs: event.duration_ms ?? getNumber(event, 'tool_duration_ms'), isTruncated: content.length > maxLength, resultSize: content.length, tokens: getNumber(event, 'tokens'), }; } /** * Extract content from various field names */ function extractContent(event: LogEvent, primaryField: string): string | null { // Try primary field if (typeof event[primaryField] === 'string') { return event[primaryField] as string; } // Try content field if (primaryField !== 'content' && typeof event.content === 'string') { return event.content; } // Try message as fallback for some cases if (primaryField === 'prompt' && event.msg && !event.msg.includes('Tool')) { return event.msg; } return null; } /** * Normalize tool arguments from various field names */ function normalizeToolArgs(event: LogEvent): Record { // Check various argument field names const args = event.tool_args || event.tool_input || event.args || event.arguments || event.input || {}; // Ensure it's an object if (typeof args !== 'object' || Array.isArray(args)) { return { value: args }; } return args as Record; } /** * Truncate content to max length */ function truncate(content: string, maxLength: number): string { if (content.length <= maxLength) { return content; } return content.slice(0, maxLength - 3) + '...'; } /** * Generate a human-readable summary of a tool call */ function generateToolSummary(tool: string, args: Record): string { switch (tool) { case 'Read': return `Read ${args.file_path || args.path || 'file'}`; case 'Edit': return `Edit ${args.file_path || args.path || 'file'}`; case 'Write': return `Write ${args.file_path || args.path || 'file'}`; case 'Bash': return `Run: ${(args.command as string)?.slice(0, 50) || 'command'}`; case 'Grep': return `Search: ${args.pattern || 'pattern'}`; case 'Glob': return `Find: ${args.pattern || 'files'}`; default: return `${tool}()`; } } /** * Parse all conversation events from a list of log events * * @param events - List of log events to parse * @param options - Parse options * @returns List of conversation events in chronological order */ export function parseConversationEvents( events: LogEvent[], options: ConversationParseOptions = {} ): ConversationEvent[] { const { includeThinking = true, includeToolResults = true } = options; const conversationEvents: ConversationEvent[] = []; let sequence = 0; for (const event of events) { const convEvent = parseConversationEvent(event, sequence, options); if (convEvent) { // Filter based on options if (convEvent.type === 'thinking' && !includeThinking) { continue; } if (convEvent.type === 'tool_result' && !includeToolResults) { continue; } conversationEvents.push(convEvent); sequence++; } } return conversationEvents; } /** * Extract conversation from a single log line * * @param line - Raw log line * @param options - Parse options * @returns Conversation event or null */ export function parseConversationLine( line: string, options: ConversationParseOptions = {} ): ConversationEvent | null { const logEvent = parseLogLine(line); if (!logEvent) return null; return parseConversationEvent(logEvent, 0, options); } /** * Extract conversation events from multi-line log content * * @param content - Multi-line log content * @param options - Parse options * @returns List of conversation events */ export function parseConversationContent( content: string, options: ConversationParseOptions = {} ): ConversationEvent[] { const logEvents = parseLogLines(content); return parseConversationEvents(logEvents, options); } /** * Format a conversation event for display */ export function formatConversationEvent(event: ConversationEvent): string { const timestamp = formatTimestamp(event.ts); const prefix = `${timestamp} [${event.role}]`; switch (event.type) { case 'prompt': return `${prefix}\n${event.content}`; case 'response': return `${prefix}\n${event.content}${event.isTruncated ? ' [truncated]' : ''}`; case 'thinking': return `${prefix} \n${event.content}${event.isTruncated ? ' [truncated]' : ''}`; case 'tool_call': return `${prefix} Tool: ${event.summary}`; case 'tool_result': const status = event.success ? '✓' : '✗'; const duration = event.durationMs ? ` (${formatDuration(event.durationMs)})` : ''; return `${prefix} Tool result: ${event.tool} ${status}${duration}`; default: return prefix; } }