diff --git a/src/store.ts b/src/store.ts index 8e5176e..b5510e3 100644 --- a/src/store.ts +++ b/src/store.ts @@ -44,6 +44,8 @@ import { TimelapseOptions, HeatmapTimelapse, HeatmapSnapshot, + ConversationSession, + ConversationEvent, compareEventsBySequence, } from './types.js'; import { isWorkerStuck } from './tui/utils/stuckDetection.js'; @@ -55,6 +57,12 @@ import { WorkerAnalytics, getWorkerAnalytics } from './workerAnalytics.js'; import { CostTracker } from './tui/utils/costTracking.js'; import { SemanticNarrativeGenerator, getSemanticNarrativeManager } from './semanticNarrative.js'; import { HistoricalStore, getHistoricalStore } from './historicalStore.js'; +import { + buildConversationSessions, + getWorkerConversationSessions, + getBeadConversationSession, + extractConversationEvents, +} from './conversationParser.js'; /** Time window (in ms) to consider events as concurrent */ const COLLISION_WINDOW_MS = 5000; @@ -157,6 +165,20 @@ export class InMemoryEventStore implements EventStore { add(event: LogEvent): void { this.events.push(event); + // Invalidate conversation cache when conversation events are added + const spanName = String(event.span_name || ''); + if ( + spanName.includes('llm.') || + spanName.includes('tool.') || + event.prompt || + event.response || + event.tool || + event.msg.includes('llm.request') || + event.msg.includes('tool.call') + ) { + this.invalidateConversationCache(); + } + // Populate secondary index keyed on (worker, sequence) if (event.sequence != null && event.sequence >= 0) { this.sequenceIndex.set(`${event.worker}:${event.sequence}`, event); @@ -312,6 +334,9 @@ export class InMemoryEventStore implements EventStore { clearTimeout(this.batchTimeout); this.batchTimeout = null; } + + // Clear conversation cache + this.invalidateConversationCache(); } /** @@ -1938,6 +1963,97 @@ export class InMemoryEventStore implements EventStore { getSemanticNarrativeManager(): SemanticNarrativeGenerator { return this.semanticNarrativeManager; } + + // ============================================ + // Conversation Transcript Methods + // ============================================ + + /** + * Cached conversation sessions built from events + */ + private conversationSessions: ConversationSession[] | null = null; + private conversationSessionsCacheTime: number = 0; + private readonly CONVERSATION_CACHE_TTL_MS = 5000; // 5 seconds + + /** + * Invalidate the conversation sessions cache + */ + private invalidateConversationCache(): void { + this.conversationSessions = null; + this.conversationSessionsCacheTime = 0; + } + + /** + * Build or retrieve cached conversation sessions + */ + private buildConversationSessions(): ConversationSession[] { + const now = Date.now(); + + // Return cached sessions if still valid + if ( + this.conversationSessions && + (now - this.conversationSessionsCacheTime) < this.CONVERSATION_CACHE_TTL_MS + ) { + return this.conversationSessions; + } + + // Build new sessions from events + this.conversationSessions = buildConversationSessions(this.events); + this.conversationSessionsCacheTime = now; + return this.conversationSessions; + } + + /** + * Get all conversation sessions + */ + getConversationSessions(): ConversationSession[] { + return this.buildConversationSessions(); + } + + /** + * Get conversation sessions for a specific worker + */ + getWorkerConversationSessions(workerId: string): ConversationSession[] { + return getWorkerConversationSessions(this.events, workerId); + } + + /** + * Get conversation session for a specific bead + */ + getBeadConversationSession(beadId: string): ConversationSession | null { + return getBeadConversationSession(this.events, beadId); + } + + /** + * Get a specific conversation session by ID + */ + getConversationSession(sessionId: string): ConversationSession | undefined { + const sessions = this.buildConversationSessions(); + return sessions.find(s => s.id === sessionId); + } + + /** + * Get all conversation events across all sessions + */ + getConversationEvents(): ConversationEvent[] { + return extractConversationEvents(this.events); + } + + /** + * Get conversation events for a specific worker + */ + getWorkerConversationEvents(workerId: string): ConversationEvent[] { + const workerEvents = this.events.filter(e => e.worker === workerId); + return extractConversationEvents(workerEvents); + } + + /** + * Get conversation events for a specific bead + */ + getBeadConversationEvents(beadId: string): ConversationEvent[] { + const beadEvents = this.events.filter(e => e.bead === beadId); + return extractConversationEvents(beadEvents); + } } /** diff --git a/src/web/server.ts b/src/web/server.ts index e934b91..cda00c2 100644 --- a/src/web/server.ts +++ b/src/web/server.ts @@ -1166,6 +1166,100 @@ export function createWebServer(options: WebServerOptions): WebServer { } }); + // ============================================ + // Conversation Transcript API + // ============================================ + + // Get all conversation sessions + app.get('/api/conversations/sessions', (_req: Request, res: Response) => { + try { + const sessions = store.getConversationSessions(); + res.json(sessions); + } catch (err) { + console.error('Error getting conversation sessions:', err); + res.status(500).json({ error: 'Failed to get conversation sessions' }); + } + }); + + // Get conversation sessions for a specific worker + app.get('/api/conversations/workers/:workerId', (req: Request, res: Response) => { + try { + const workerId = req.params.workerId as string; + const sessions = store.getWorkerConversationSessions(workerId); + res.json(sessions); + } catch (err) { + console.error('Error getting worker conversation sessions:', err); + res.status(500).json({ error: 'Failed to get worker conversation sessions' }); + } + }); + + // Get conversation session for a specific bead + app.get('/api/conversations/beads/:beadId', (req: Request, res: Response) => { + try { + const beadId = req.params.beadId as string; + const session = store.getBeadConversationSession(beadId); + if (!session) { + res.status(404).json({ error: 'Conversation session not found' }); + return; + } + res.json(session); + } catch (err) { + console.error('Error getting bead conversation session:', err); + res.status(500).json({ error: 'Failed to get bead conversation session' }); + } + }); + + // Get a specific conversation session by ID + app.get('/api/conversations/:sessionId', (req: Request, res: Response) => { + try { + const sessionId = req.params.sessionId as string; + const session = store.getConversationSession(sessionId); + if (!session) { + res.status(404).json({ error: 'Conversation session not found' }); + return; + } + res.json(session); + } catch (err) { + console.error('Error getting conversation session:', err); + res.status(500).json({ error: 'Failed to get conversation session' }); + } + }); + + // Get all conversation events across all sessions + app.get('/api/conversations/events', (_req: Request, res: Response) => { + try { + const events = store.getConversationEvents(); + res.json(events); + } catch (err) { + console.error('Error getting conversation events:', err); + res.status(500).json({ error: 'Failed to get conversation events' }); + } + }); + + // Get conversation events for a specific worker + app.get('/api/conversations/workers/:workerId/events', (req: Request, res: Response) => { + try { + const workerId = req.params.workerId as string; + const events = store.getWorkerConversationEvents(workerId); + res.json(events); + } catch (err) { + console.error('Error getting worker conversation events:', err); + res.status(500).json({ error: 'Failed to get worker conversation events' }); + } + }); + + // Get conversation events for a specific bead + app.get('/api/conversations/beads/:beadId/events', (req: Request, res: Response) => { + try { + const beadId = req.params.beadId as string; + const events = store.getBeadConversationEvents(beadId); + res.json(events); + } catch (err) { + console.error('Error getting bead conversation events:', err); + res.status(500).json({ error: 'Failed to get bead conversation events' }); + } + }); + // Serve static frontend files const staticPath = join(__dirname, 'public'); app.use(express.static(staticPath));