FABRIC/src/parser.ts
jeda 1a331eafaa feat(bd-122): Parse conversation events from NEEDLE logs
Add ConversationEvent types and parsing functions to extract conversation
events from NEEDLE log entries:

Types added to types.ts:
- ConversationRole: system, user, assistant, tool
- ConversationEventType: prompt, response, thinking, tool_call, tool_result
- PromptEvent: User input/prompt
- ResponseEvent: Assistant response text
- ThinkingEvent: Internal reasoning/thinking block
- ToolCallEvent: Tool being called with arguments
- ToolResultEvent: Result from a tool call
- ConversationSession: Complete conversation session
- ConversationParseOptions: Options for parsing

Functions added to parser.ts:
- isConversationEvent(): Check if log event contains conversation content
- parseConversationEvent(): Parse single log event to conversation event
- parseConversationEvents(): Parse multiple log events
- parseConversationLine(): Parse single log line
- parseConversationContent(): Parse multi-line log content
- formatConversationEvent(): Format for display

Features:
- Supports explicit conversation fields (conversation_role, conversation_type)
- Supports content fields (prompt, response, thinking, tool_result)
- Supports tool call parsing with arguments normalization
- Content truncation for large responses
- Human-readable tool summaries (e.g., "Read /src/main.ts")
- Filtering options for thinking blocks and tool results

Comprehensive unit tests added for all conversation parsing functions.

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude Worker <noreply@anthropic.com>
2026-03-04 04:23:09 +00:00

708 lines
18 KiB
TypeScript

/**
* FABRIC Log Parser
*
* Parses NEEDLE log lines into structured LogEvent objects.
* Also extracts conversation events from log entries.
*/
import {
LogEvent,
LogLevel,
ConversationEvent,
PromptEvent,
ResponseEvent,
ThinkingEvent,
ToolCallEvent,
ToolResultEvent,
ConversationParseOptions,
} from './types.js';
/**
* Parse a single log line
*
* @param line - Raw log line (JSON string)
* @returns Parsed LogEvent or null if invalid
*/
export function parseLogLine(line: string): LogEvent | null {
// Skip empty lines
if (!line || !line.trim()) {
return null;
}
try {
const parsed = JSON.parse(line);
// Validate required fields
if (typeof parsed.ts !== 'number') {
return null;
}
if (typeof parsed.worker !== 'string') {
return null;
}
if (!isValidLogLevel(parsed.level)) {
return null;
}
if (typeof parsed.msg !== 'string') {
return null;
}
// Construct LogEvent with validated fields
const event: LogEvent = {
ts: parsed.ts,
worker: parsed.worker,
level: parsed.level,
msg: parsed.msg,
};
// Copy optional fields if present
if (typeof parsed.tool === 'string') event.tool = parsed.tool;
if (typeof parsed.path === 'string') event.path = parsed.path;
if (typeof parsed.bead === 'string') event.bead = parsed.bead;
if (typeof parsed.duration_ms === 'number') event.duration_ms = parsed.duration_ms;
if (typeof parsed.error === 'string') event.error = parsed.error;
// Copy any additional fields
for (const key of Object.keys(parsed)) {
if (!isStandardField(key) && !(key in event)) {
event[key] = parsed[key];
}
}
return event;
} catch {
// Not valid JSON
return null;
}
}
/**
* 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;
}
/**
* Check if level is valid
*/
function isValidLogLevel(level: unknown): level is LogLevel {
return level === 'debug' || level === 'info' || level === 'warn' || level === 'error';
}
/**
* Check if field is a standard LogEvent field
*/
function isStandardField(key: string): boolean {
return ['ts', 'worker', 'level', 'msg', 'tool', 'path', 'bead', 'duration_ms', 'error'].includes(key);
}
/**
* 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<LogLevel, string> = {
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<T = Record<string, unknown>>(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<string, import('./types.js').ToolArgValue>,
toolCallId: getString(event, 'tool_call_id') ?? getString(event, 'call_id'),
summary: generateToolSummary(tool, args as Record<string, import('./types.js').ToolArgValue>),
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<string, unknown> {
// 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<string, unknown>;
}
/**
* 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, unknown>): 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} <thinking>\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;
}
}