feat(bf-52d6): add conversation transcript parser module

Add standalone conversationParser module to extract full conversation
transcripts from NEEDLE logs. This completes Phase 1 Core Infrastructure
item for conversation parsing.

Implementation:
- isConversationSpanEvent(): Identify conversation-related span events
- buildConversationSessions(): Build sessions from log events
- getWorkerConversationSessions(): Filter sessions by worker
- getBeadConversationSession(): Get session for a specific bead
- extractConversationEvents(): Extract all conversation events

Types supported:
- PromptEvent: User input/prompt
- ResponseEvent: Assistant response text
- ThinkingEvent: Internal reasoning/thinking blocks
- ToolCallEvent: Tool invocations with arguments
- ToolResultEvent: Tool call results

Also updates docs/plan.md to mark Phase 1 Core Infrastructure items complete.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
jedarden 2026-05-08 14:24:56 -04:00
parent 1484adb7a1
commit 3da90f1be0
3 changed files with 1045 additions and 5 deletions

View file

@ -1341,11 +1341,11 @@ fabric logs --worker w-abc123 # Filter by worker
## Implementation Phases
### Phase 1: Core Infrastructure
- [ ] Log tailer that watches `~/.needle/logs/`
- [ ] JSON line parser
- [ ] Event emitter for parsed events
- [ ] In-memory event index (by worker, bead, file, timestamp)
- [ ] Conversation transcript parser (extract full conversation from logs)
- [x] Log tailer that watches `~/.needle/logs/`
- [x] JSON line parser
- [x] Event emitter for parsed events
- [x] In-memory event index (by worker, bead, file, timestamp)
- [x] Conversation transcript parser (extract full conversation from logs)
### Phase 2: TUI Display
- [ ] Worker list panel

View file

@ -0,0 +1,474 @@
/**
* Tests for conversationParser module
*/
import { describe, it, expect } from 'vitest';
import {
isConversationSpanEvent,
buildConversationSessions,
getWorkerConversationSessions,
getBeadConversationSession,
extractConversationEvents,
} from './conversationParser.js';
import { LogEvent } from './types.js';
describe('conversationParser', () => {
describe('isConversationSpanEvent', () => {
it('should identify llm.request span events', () => {
const event: LogEvent = {
ts: Date.now(),
worker: 'tcb-alpha',
sequence: 1,
level: 'info',
msg: 'llm.request.started',
span_name: 'llm.request',
};
expect(isConversationSpanEvent(event)).toBe(true);
});
it('should identify tool.call span events', () => {
const event: LogEvent = {
ts: Date.now(),
worker: 'tcb-alpha',
sequence: 1,
level: 'info',
msg: 'tool.call.started',
span_name: 'tool.call',
tool: 'Read',
};
expect(isConversationSpanEvent(event)).toBe(true);
});
it('should identify events with prompt field', () => {
const event: LogEvent = {
ts: Date.now(),
worker: 'tcb-alpha',
sequence: 1,
level: 'info',
msg: 'bead.prompt_built',
prompt: 'Help me write code',
};
expect(isConversationSpanEvent(event)).toBe(true);
});
it('should identify events with response field', () => {
const event: LogEvent = {
ts: Date.now(),
worker: 'tcb-alpha',
sequence: 1,
level: 'info',
msg: 'agent.response',
response: 'I can help with that',
};
expect(isConversationSpanEvent(event)).toBe(true);
});
it('should return false for non-conversation events', () => {
const event: LogEvent = {
ts: Date.now(),
worker: 'tcb-alpha',
sequence: 1,
level: 'info',
msg: 'worker.state_transition',
};
expect(isConversationSpanEvent(event)).toBe(false);
});
it('should identify bead.agent_started events', () => {
const event: LogEvent = {
ts: Date.now(),
worker: 'tcb-alpha',
sequence: 1,
level: 'info',
msg: 'bead.agent_started',
bead: 'bd-test',
};
expect(isConversationSpanEvent(event)).toBe(true);
});
it('should identify bead.agent_completed events', () => {
const event: LogEvent = {
ts: Date.now(),
worker: 'tcb-alpha',
sequence: 1,
level: 'info',
msg: 'bead.agent_completed',
bead: 'bd-test',
};
expect(isConversationSpanEvent(event)).toBe(true);
});
});
describe('buildConversationSessions', () => {
it('should build a session from llm.request events', () => {
const events: LogEvent[] = [
{
ts: Date.now() - 1000,
worker: 'tcb-alpha',
sequence: 1,
level: 'info',
msg: 'llm.request.started',
span_name: 'llm.request',
bead: 'bd-test',
session: 'session-1',
prompt: 'Write a function',
},
{
ts: Date.now(),
worker: 'tcb-alpha',
sequence: 2,
level: 'info',
msg: 'llm.request.finished',
span_name: 'llm.request',
bead: 'bd-test',
session: 'session-1',
response: 'Here is a function',
model: 'sonnet',
tokens: 100,
},
];
const sessions = buildConversationSessions(events);
expect(sessions).toHaveLength(1);
const session = sessions[0];
expect(session.workerId).toBe('tcb-alpha');
expect(session.beadId).toBe('bd-test');
expect(session.events).toHaveLength(2);
expect(session.events[0].type).toBe('prompt');
expect(session.events[1].type).toBe('response');
expect(session.totalTokens).toBe(100);
});
it('should build a session with tool calls', () => {
const events: LogEvent[] = [
{
ts: Date.now() - 2000,
worker: 'tcb-alpha',
sequence: 1,
level: 'info',
msg: 'tool.call.started',
span_name: 'tool.call',
bead: 'bd-test',
tool: 'Read',
tool_args: { file_path: '/test/file.ts' },
},
{
ts: Date.now() - 1000,
worker: 'tcb-alpha',
sequence: 2,
level: 'info',
msg: 'tool.call.finished',
span_name: 'tool.call',
bead: 'bd-test',
tool: 'Read',
result: 'file contents',
},
];
const sessions = buildConversationSessions(events);
expect(sessions).toHaveLength(1);
const session = sessions[0];
expect(session.events).toHaveLength(2);
expect(session.events[0].type).toBe('tool_call');
expect(session.events[1].type).toBe('tool_result');
expect(session.toolsUsed).toEqual(['Read']);
});
it('should handle mixed conversation events', () => {
const events: LogEvent[] = [
{
ts: Date.now() - 4000,
worker: 'tcb-alpha',
sequence: 1,
level: 'info',
msg: 'llm.request.started',
bead: 'bd-test',
prompt: 'Read the file',
},
{
ts: Date.now() - 3000,
worker: 'tcb-alpha',
sequence: 2,
level: 'info',
msg: 'tool.call.started',
bead: 'bd-test',
tool: 'Read',
args: { file_path: '/test.ts' },
},
{
ts: Date.now() - 2000,
worker: 'tcb-alpha',
sequence: 3,
level: 'info',
msg: 'tool.call.finished',
bead: 'bd-test',
tool: 'Read',
result: 'content here',
},
{
ts: Date.now() - 1000,
worker: 'tcb-alpha',
sequence: 4,
level: 'info',
msg: 'llm.request.finished',
bead: 'bd-test',
response: 'The file contains: content here',
},
];
const sessions = buildConversationSessions(events);
expect(sessions).toHaveLength(1);
const session = sessions[0];
expect(session.events).toHaveLength(4);
expect(session.events[0].type).toBe('prompt');
expect(session.events[1].type).toBe('tool_call');
expect(session.events[2].type).toBe('tool_result');
expect(session.events[3].type).toBe('response');
expect(session.turnCount).toBe(1);
});
it('should separate sessions by worker and bead', () => {
const events: LogEvent[] = [
{
ts: Date.now(),
worker: 'tcb-alpha',
sequence: 1,
level: 'info',
msg: 'llm.request',
bead: 'bd-1',
prompt: 'Task 1',
},
{
ts: Date.now(),
worker: 'tcb-bravo',
sequence: 1,
level: 'info',
msg: 'llm.request',
bead: 'bd-2',
prompt: 'Task 2',
},
];
const sessions = buildConversationSessions(events);
expect(sessions).toHaveLength(2);
expect(sessions[0].workerId).toBe('tcb-alpha');
expect(sessions[1].workerId).toBe('tcb-bravo');
});
it('should return empty array for events with no conversation data', () => {
const events: LogEvent[] = [
{
ts: Date.now(),
worker: 'tcb-alpha',
sequence: 1,
level: 'info',
msg: 'worker.state_transition',
},
{
ts: Date.now(),
worker: 'tcb-alpha',
sequence: 2,
level: 'info',
msg: 'heartbeat.emitted',
},
];
const sessions = buildConversationSessions(events);
expect(sessions).toHaveLength(0);
});
it('should extract thinking blocks from assistant events', () => {
const events: LogEvent[] = [
{
ts: Date.now(),
worker: 'tcb-alpha',
sequence: 1,
level: 'info',
msg: 'llm.thinking',
response: 'Let me think about this...',
thinking: true,
},
];
const sessions = buildConversationSessions(events);
expect(sessions).toHaveLength(1);
expect(sessions[0].events[0].type).toBe('thinking');
});
it('should handle tool errors', () => {
const events: LogEvent[] = [
{
ts: Date.now(),
worker: 'tcb-alpha',
sequence: 1,
level: 'info',
msg: 'tool.call.finished',
tool: 'Read',
result: '',
error: 'File not found',
},
];
const sessions = buildConversationSessions(events);
expect(sessions).toHaveLength(1);
const session = sessions[0];
expect(session.events[0].type).toBe('tool_result');
const toolResult = session.events[0];
if (toolResult.type === 'tool_result') {
expect(toolResult.success).toBe(false);
expect(toolResult.error).toBe('File not found');
}
});
});
describe('getWorkerConversationSessions', () => {
it('should filter sessions by worker ID', () => {
const events: LogEvent[] = [
{
ts: Date.now(),
worker: 'tcb-alpha',
sequence: 1,
level: 'info',
msg: 'llm.request',
prompt: 'Alpha task',
},
{
ts: Date.now(),
worker: 'tcb-bravo',
sequence: 1,
level: 'info',
msg: 'llm.request',
prompt: 'Bravo task',
},
];
const alphaSessions = getWorkerConversationSessions(events, 'tcb-alpha');
expect(alphaSessions).toHaveLength(1);
expect(alphaSessions[0].workerId).toBe('tcb-alpha');
});
});
describe('getBeadConversationSession', () => {
it('should get session for a specific bead', () => {
const events: LogEvent[] = [
{
ts: Date.now(),
worker: 'tcb-alpha',
sequence: 1,
level: 'info',
msg: 'llm.request',
bead: 'bd-target',
prompt: 'Do this task',
},
];
const session = getBeadConversationSession(events, 'bd-target');
expect(session).not.toBeNull();
expect(session?.beadId).toBe('bd-target');
});
it('should return null for non-existent bead', () => {
const events: LogEvent[] = [
{
ts: Date.now(),
worker: 'tcb-alpha',
sequence: 1,
level: 'info',
msg: 'llm.request',
bead: 'bd-actual',
prompt: 'Task',
},
];
const session = getBeadConversationSession(events, 'bd-nonexistent');
expect(session).toBeNull();
});
});
describe('extractConversationEvents', () => {
it('should extract all conversation events from mixed log', () => {
const events: LogEvent[] = [
{
ts: Date.now() - 3000,
worker: 'tcb-alpha',
sequence: 1,
level: 'info',
msg: 'worker.state_transition',
},
{
ts: Date.now() - 2000,
worker: 'tcb-alpha',
sequence: 2,
level: 'info',
msg: 'llm.request',
prompt: 'Hello',
},
{
ts: Date.now() - 1000,
worker: 'tcb-alpha',
sequence: 3,
level: 'info',
msg: 'llm.response',
response: 'Hi there',
},
{
ts: Date.now(),
worker: 'tcb-alpha',
sequence: 4,
level: 'info',
msg: 'heartbeat.emitted',
},
];
const convEvents = extractConversationEvents(events);
expect(convEvents).toHaveLength(2);
expect(convEvents[0].type).toBe('prompt');
expect(convEvents[1].type).toBe('response');
});
it('should maintain chronological order across sessions', () => {
const baseTime = Date.now();
const events: LogEvent[] = [
{
ts: baseTime - 3000,
worker: 'tcb-alpha',
sequence: 1,
level: 'info',
msg: 'llm.request',
bead: 'bd-1',
prompt: 'First',
},
{
ts: baseTime - 2000,
worker: 'tcb-bravo',
sequence: 1,
level: 'info',
msg: 'llm.request',
bead: 'bd-2',
prompt: 'Second',
},
{
ts: baseTime - 1000,
worker: 'tcb-alpha',
sequence: 2,
level: 'info',
msg: 'llm.response',
bead: 'bd-1',
response: 'First response',
},
];
const convEvents = extractConversationEvents(events);
expect(convEvents).toHaveLength(3);
expect(convEvents[0].type).toBe('prompt');
expect((convEvents[0] as any).content).toBe('First');
expect(convEvents[1].type).toBe('prompt');
expect((convEvents[1] as any).content).toBe('Second');
expect(convEvents[2].type).toBe('response');
});
});
});

566
src/conversationParser.ts Normal file
View file

@ -0,0 +1,566 @@
/**
* FABRIC Conversation Transcript Parser
*
* Extracts full conversation transcripts from NEEDLE logs.
* Parses OTLP span events (llm.request, tool.call, etc.) into structured conversation events.
*
* Data sources:
* - llm.request.started/finished spans contain prompts, responses, thinking blocks
* - tool.call.started/finished spans contain tool invocations and results
* - bead.lifecycle spans provide session context (bead_id, worker_id)
*/
import {
LogEvent,
ConversationEvent,
ConversationSession,
PromptEvent,
ResponseEvent,
ThinkingEvent,
ToolCallEvent,
ToolResultEvent,
ConversationEventType,
ConversationRole,
ToolArgValue,
} from './types.js';
// ============================================
// Types
// ============================================
/**
* Span event name patterns that indicate conversation content
*/
const CONVERSATION_SPAN_NAMES = [
'llm.request',
'llm.prompt',
'llm.response',
'llm.completion',
'llm.chat',
'tool.call',
'tool.invocation',
'tool.result',
'tool.response',
'bead.prompt_built',
'bead.agent_started',
'bead.agent_completed',
] as const;
/**
* Message event types from spans
*/
type SpanMessageType =
| 'user'
| 'system'
| 'assistant'
| 'tool';
/**
* Parsed span message with content and metadata
*/
interface ParsedSpanMessage {
type: SpanMessageType;
content: string;
timestamp: number;
sequence?: number;
toolName?: string;
toolArgs?: Record<string, unknown>;
toolResult?: string;
toolError?: string;
isThinking?: boolean;
tokens?: number;
model?: string;
}
// ============================================
// Span Message Extraction
// ============================================
/**
* Check if a LogEvent is a conversation-related span event
*/
export function isConversationSpanEvent(event: LogEvent): boolean {
const msg = event.msg.toLowerCase();
const spanName = String(event.span_name || '').toLowerCase();
// Check span_name first (most reliable)
for (const pattern of CONVERSATION_SPAN_NAMES) {
if (spanName.includes(pattern.toLowerCase())) {
return true;
}
}
// Check message patterns
if (
msg.includes('llm.request') ||
msg.includes('llm.prompt') ||
msg.includes('llm.response') ||
msg.includes('llm.completion') ||
msg.includes('tool.call') ||
msg.includes('tool.invocation') ||
msg.includes('tool.result') ||
msg.includes('bead.prompt_built') ||
msg.includes('bead.agent_started') ||
msg.includes('bead.agent_completed')
) {
return true;
}
// Check for conversation attributes
if (
event.prompt ||
event.response ||
event.user_message ||
event.assistant_message ||
event.tool_name ||
event.conversation_role
) {
return true;
}
return false;
}
/**
* Extract conversation data from span event attributes
*/
function extractSpanMessage(event: LogEvent): ParsedSpanMessage | null {
// Try direct fields first
if (event.prompt || event.user_message) {
return {
type: 'user',
content: (event.prompt || event.user_message) as string,
timestamp: event.ts,
sequence: event.sequence,
};
}
if (event.response || event.assistant_message) {
// Check if it's a thinking block
const content = (event.response || event.assistant_message) as string;
const isThinking = event.thinking === true || event.is_thinking === true;
return {
type: 'assistant',
content,
timestamp: event.ts,
sequence: event.sequence,
isThinking,
tokens: event.tokens as number | undefined,
model: event.model as string | undefined,
};
}
// Check for tool call
if (event.tool_name || event.tool) {
const toolName = (event.tool_name || event.tool) as string;
// Check if this is a tool call invocation or result
if (event.msg.includes('started') || event.tool_args || event.args) {
return {
type: 'tool',
content: `Calling ${toolName}`,
timestamp: event.ts,
sequence: event.sequence,
toolName,
toolArgs: (event.tool_args || event.args || event.arguments) as Record<string, unknown> | undefined,
};
}
// Tool result
if (event.msg.includes('finished') || event.result || event.tool_result) {
const content = (event.result || event.tool_result || '') as string;
return {
type: 'tool',
content: content || 'Tool completed',
timestamp: event.ts,
sequence: event.sequence,
toolName,
toolResult: content,
toolError: event.error as string | undefined,
};
}
}
// Try to extract from data field (for OTLP span attributes)
const data = event.data as Record<string, unknown> | undefined;
if (data) {
// Check for LLM request/response in attributes
if (data.prompt || data.user_message || data['llm.prompt']) {
return {
type: 'user',
content: (data.prompt || data.user_message || data['llm.prompt']) as string,
timestamp: event.ts,
sequence: event.sequence,
};
}
if (data.response || data.assistant_message || data.completion || data['llm.response']) {
const content = (data.response || data.assistant_message || data.completion || data['llm.response']) as string;
return {
type: 'assistant',
content,
timestamp: event.ts,
sequence: event.sequence,
isThinking: data.thinking === true || data.is_thinking === true,
tokens: data.tokens as number | undefined,
model: data.model as string | undefined,
};
}
// Check for tool call in attributes
if (data.tool_name || data.tool || data['tool.name']) {
const toolName = (data.tool_name || data.tool || data['tool.name']) as string;
if (event.msg.includes('started') || data.tool_args || data.args || data['tool.arguments']) {
return {
type: 'tool',
content: `Calling ${toolName}`,
timestamp: event.ts,
sequence: event.sequence,
toolName,
toolArgs: (data.tool_args || data.args || data['tool.arguments']) as Record<string, unknown> | undefined,
};
}
if (event.msg.includes('finished') || data.result || data.tool_result || data['tool.result']) {
const content = (data.result || data.tool_result || data['tool.result'] || '') as string;
return {
type: 'tool',
content: content || 'Tool completed',
timestamp: event.ts,
sequence: event.sequence,
toolName,
toolResult: content,
toolError: data.error as string | undefined,
};
}
}
}
return null;
}
// ============================================
// Conversation Event Construction
// ============================================
let eventIdCounter = 0;
function generateEventId(): string {
return `conv-${Date.now()}-${++eventIdCounter}`;
}
/**
* Convert a parsed span message to a ConversationEvent
*/
function parsedMessageToConversationEvent(
message: ParsedSpanMessage,
worker: string,
bead?: string
): ConversationEvent | null {
const baseEvent = {
id: generateEventId(),
ts: message.timestamp,
worker,
bead,
sequence: message.sequence ?? 0,
};
switch (message.type) {
case 'user': {
const promptEvent: PromptEvent = {
...baseEvent,
type: 'prompt',
role: 'user',
content: message.content,
tokens: message.tokens,
};
return promptEvent;
}
case 'assistant': {
if (message.isThinking) {
const thinkingEvent: ThinkingEvent = {
...baseEvent,
type: 'thinking',
role: 'assistant',
content: message.content,
isTruncated: false,
tokens: message.tokens,
durationMs: undefined,
};
return thinkingEvent;
}
const responseEvent: ResponseEvent = {
...baseEvent,
type: 'response',
role: 'assistant',
content: message.content,
isTruncated: false,
model: message.model,
tokens: message.tokens,
};
return responseEvent;
}
case 'tool': {
if (message.toolArgs && message.toolName) {
const toolCallEvent: ToolCallEvent = {
...baseEvent,
type: 'tool_call',
role: 'assistant',
tool: message.toolName,
args: message.toolArgs as Record<string, ToolArgValue>,
summary: generateToolSummary(message.toolName, message.toolArgs),
};
return toolCallEvent;
}
if (message.toolName && message.toolResult !== undefined) {
const toolResultEvent: ToolResultEvent = {
...baseEvent,
type: 'tool_result',
role: 'tool',
tool: message.toolName,
content: message.toolResult,
success: !message.toolError,
error: message.toolError,
isTruncated: false,
resultSize: message.toolResult.length,
};
return toolResultEvent;
}
return null;
}
default:
return null;
}
}
/**
* Generate a human-readable summary for 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}()`;
}
}
// ============================================
// Session Building
// ============================================
/**
* Build a ConversationSession from a list of log events
*
* Groups events by (worker, bead, session) and extracts conversation
* data from span events within each session.
*/
export function buildConversationSessions(
events: LogEvent[]
): ConversationSession[] {
// Group events by potential session keys
const sessionGroups = groupEventsBySession(events);
const sessions: ConversationSession[] = [];
for (const [sessionKey, sessionEvents] of sessionGroups) {
const session = buildSessionFromEvents(sessionKey, sessionEvents);
if (session && session.events.length > 0) {
sessions.push(session);
}
}
return sessions;
}
/**
* Session key for grouping events
*/
interface SessionKey {
worker: string;
bead?: string;
session?: string;
}
function sessionKeyToString(key: SessionKey): string {
return `${key.worker}:${key.bead || 'none'}:${key.session || 'none'}`;
}
/**
* Group events by (worker, bead, session)
*/
function groupEventsBySession(events: LogEvent[]): Map<string, LogEvent[]> {
const groups = new Map<string, LogEvent[]>();
for (const event of events) {
// Skip events that aren't conversation-related
if (!isConversationSpanEvent(event)) {
continue;
}
const key: SessionKey = {
worker: event.worker,
bead: event.bead,
session: event.session,
};
const keyStr = sessionKeyToString(key);
const group = groups.get(keyStr) || [];
group.push(event);
groups.set(keyStr, group);
}
return groups;
}
/**
* Build a single ConversationSession from grouped events
*/
function buildSessionFromEvents(
sessionKey: string,
events: LogEvent[]
): ConversationSession | null {
if (events.length === 0) return null;
// Sort by sequence or timestamp
const sorted = [...events].sort((a, b) => {
const seqA = a.sequence ?? a.ts;
const seqB = b.sequence ?? b.ts;
return seqA - seqB;
});
// Extract conversation events
const conversationEvents: ConversationEvent[] = [];
let sequence = 0;
for (const event of sorted) {
const message = extractSpanMessage(event);
if (!message) continue;
message.sequence = sequence++;
const convEvent = parsedMessageToConversationEvent(
message,
event.worker,
event.bead
);
if (convEvent) {
conversationEvents.push(convEvent);
}
}
if (conversationEvents.length === 0) {
return null;
}
// Build session metadata
const firstEvent = sorted[0];
const lastEvent = sorted[sorted.length - 1];
// Extract worker, bead, session from first event
const workerId = firstEvent.worker;
const beadId = firstEvent.bead;
const sessionId = firstEvent.session || `${workerId}-${firstEvent.ts}`;
// Calculate tokens
const totalTokens = conversationEvents.reduce((sum, e) => sum + (e.tokens || 0), 0);
// Extract tools used
const toolsUsed = [
...new Set(
conversationEvents
.filter((e): e is ToolCallEvent => e.type === 'tool_call')
.map((e) => e.tool)
),
];
// Count turns (prompt + response pairs)
let turnCount = 0;
let inTurn = false;
for (const event of conversationEvents) {
if (event.type === 'prompt') {
inTurn = true;
} else if (event.type === 'response' && inTurn) {
turnCount++;
inTurn = false;
}
}
return {
id: sessionId,
workerId,
beadId,
startTime: firstEvent.ts,
endTime: lastEvent.ts,
events: conversationEvents,
totalTokens,
turnCount,
toolsUsed,
isActive: false, // Sessions from historical logs are complete
};
}
/**
* Get conversation sessions for a specific worker
*/
export function getWorkerConversationSessions(
events: LogEvent[],
workerId: string
): ConversationSession[] {
const workerEvents = events.filter(e => e.worker === workerId);
return buildConversationSessions(workerEvents);
}
/**
* Get conversation session for a specific bead
*/
export function getBeadConversationSession(
events: LogEvent[],
beadId: string
): ConversationSession | null {
const beadEvents = events.filter(e => e.bead === beadId);
const sessions = buildConversationSessions(beadEvents);
return sessions[0] || null;
}
/**
* Extract conversation events from a list of log events
*
* This is the main entry point for getting conversation data
* from raw log events.
*/
export function extractConversationEvents(
events: LogEvent[]
): ConversationEvent[] {
const sessions = buildConversationSessions(events);
const allEvents: ConversationEvent[] = [];
for (const session of sessions) {
allEvents.push(...session.events);
}
// Sort all events by timestamp/sequence
return allEvents.sort((a, b) => {
const seqA = a.sequence ?? a.ts;
const seqB = b.sequence ?? b.ts;
return seqA - seqB;
});
}