// Transcript Engine - Parses Claude Code JSONL transcripts // Module-level state pattern (like terminal.ts, torch-handler.ts) import { existsSync, readFileSync, statSync, readdirSync } from 'fs' import { join } from 'path' import { homedir } from 'os' import { WORKING_DIR } from '../config' // ── Types ── export interface TranscriptAnalysis { sessionId: string model: string version: string gitBranch: string cwd: string startTime: string endTime: string duration: number messages: TranscriptMessage[] tokens: { totalInput: number totalOutput: number totalCacheRead: number totalCacheCreation: number byTurn: TurnTokens[] } tools: { summary: Record calls: ToolCall[] } filesModified: string[] subagents: SubagentInfo[] summaries: string[] stats: { messageCount: number userMessageCount: number assistantMessageCount: number toolCallCount: number thinkingBlocks: number errors: number } lastStopReason: string } export interface TranscriptMessage { uuid: string role: 'user' | 'assistant' content: string timestamp: string isMeta: boolean tokens?: { input: number; output: number } toolCalls?: string[] hasThinking: boolean } export interface ToolCall { name: string input: unknown output?: string timestamp: string isError: boolean } export interface TurnTokens { turnIndex: number input: number output: number cacheRead: number cacheCreation: number model: string } export interface SubagentInfo { agentId: string prompt: string timestamp: string } export interface SessionInfo { id: string startTime: string messageCount: number model: string } export interface ClaudeStats { today: { date: string messageCount: number sessionCount: number toolCallCount: number tokensByModel: Record } | null modelUsage: Record totalSessions: number totalMessages: number firstSessionDate: string } export interface ClaudeUsage { subscription: { type: string; tier: string; label: string; multiplier: number } today: { messages: number; outputTokens: number; sessions: number } daily: { used: number; limit: number; percent: number } weekly: { used: number; limit: number; percent: number } status: 'normal' | 'elevated' | 'extended' | 'limit_approaching' } const TIER_LIMITS: Record = { pro: { windowMessages: 45, dailyEstimate: 500, weeklyEstimate: 3500 }, max_5x: { windowMessages: 225, dailyEstimate: 2500, weeklyEstimate: 17500 }, max_20x: { windowMessages: 900, dailyEstimate: 10000, weeklyEstimate: 70000 }, } function parseTier(rateLimitTier: string): { key: string; label: string; multiplier: number } { const match = rateLimitTier.match(/max_(\d+)x/) if (match) { const n = parseInt(match[1]) return { key: `max_${n}x`, label: `Max ${n}x`, multiplier: n } } return { key: 'pro', label: 'Pro', multiplier: 1 } } // ── Module-level cache ── const cache = new Map() // ── Active session tracking (for real-time chat) ── const activeAgentSessions = new Map() // Byte offset per session (how far we've read) const readOffsets = new Map() // ── Project hash ── function getProjectHash(): string { // C:\Users\jodar\agent-ui → C--Users-jodar-agent-ui return WORKING_DIR.replace(/[\\/]/g, '-').replace(/:/g, '-') } function getProjectDir(): string { return join(homedir(), '.claude', 'projects', getProjectHash()) } // ── Path resolution ── function resolveTranscriptPath(sessionId?: string): string | null { const projectDir = getProjectDir() if (!existsSync(projectDir)) return null if (sessionId && sessionId !== 'latest') { const filePath = join(projectDir, `${sessionId}.jsonl`) return existsSync(filePath) ? filePath : null } // Find most recent by mtime try { const files = readdirSync(projectDir) .filter(f => f.endsWith('.jsonl')) .map(f => { const fullPath = join(projectDir, f) return { name: f, path: fullPath, mtime: statSync(fullPath).mtimeMs } }) .sort((a, b) => b.mtime - a.mtime) return files.length > 0 ? files[0].path : null } catch { return null } } function sessionIdFromPath(filePath: string): string { const basename = filePath.split(/[\\/]/).pop() || '' return basename.replace('.jsonl', '') } // ── Helpers ── function truncate(str: string, maxLen: number): string { if (!str || str.length <= maxLen) return str || '' return str.slice(0, maxLen) + '...' } function extractText(content: any): string { if (typeof content === 'string') { return content.replace(/<[^>]+>/g, '').trim() } if (Array.isArray(content)) { return content .filter((c: any) => c.type === 'text') .map((c: any) => c.text || '') .join('\n') .replace(/<[^>]+>/g, '') .trim() } return '' } // ── JSONL parsing ── interface ParsedLine { type: string data: any } function parseTranscriptFile(filePath: string): ParsedLine[] { const content = readFileSync(filePath, 'utf8') const rawLines = content.trim().split('\n') const lines: ParsedLine[] = [] for (const line of rawLines) { if (!line.trim()) continue try { const obj = JSON.parse(line) lines.push({ type: obj.type, data: obj }) } catch { // Skip unparseable lines } } return lines } // ── Build analysis from parsed lines ── function buildAnalysis(lines: ParsedLine[], fileSessionId: string): TranscriptAnalysis { let sessionId = fileSessionId let model = '' let version = '' let gitBranch = '' let cwd = '' let startTime = '' let endTime = '' const messages: TranscriptMessage[] = [] const toolCalls: ToolCall[] = [] const filesModified = new Set() const subagents: SubagentInfo[] = [] const summaries: string[] = [] const turnTokens: TurnTokens[] = [] let totalInput = 0 let totalOutput = 0 let totalCacheRead = 0 let totalCacheCreation = 0 let thinkingBlocks = 0 let errors = 0 // Track assistant message chunks by message.id (streaming chunks share the same id) const assistantChunks = new Map() // Track tool results by tool_use_id const toolResults = new Map() // ── First pass: collect all data ── for (const { type, data } of lines) { // Extract metadata from first message that has it if (data.sessionId && !sessionId) sessionId = data.sessionId if (data.version && !version) version = data.version if (data.gitBranch && !gitBranch) gitBranch = data.gitBranch if (data.cwd && !cwd) cwd = data.cwd // Track time bounds if (data.timestamp) { if (!startTime || data.timestamp < startTime) startTime = data.timestamp if (!endTime || data.timestamp > endTime) endTime = data.timestamp } switch (type) { case 'user': { const msg = data.message if (!msg) break // Collect tool results (user messages contain tool_result blocks) if (Array.isArray(msg.content)) { for (const block of msg.content) { if (block.type === 'tool_result') { const resultText = typeof block.content === 'string' ? block.content : Array.isArray(block.content) ? block.content.map((c: any) => c.text || '').join('\n') : '' toolResults.set(block.tool_use_id, { content: truncate(resultText, 300), isError: !!block.is_error }) if (block.is_error) errors++ } } } // Add as user message (skip meta, skip tool-result-only messages) const isMeta = !!data.isMeta const text = extractText(msg.content) const hasToolResult = Array.isArray(msg.content) && msg.content.some((c: any) => c.type === 'tool_result') if (text && !hasToolResult) { messages.push({ uuid: data.uuid || '', role: 'user', content: text, timestamp: data.timestamp || '', isMeta, hasThinking: false }) } break } case 'assistant': { const msg = data.message if (!msg || msg.role !== 'assistant') break const msgId = msg.id || data.uuid if (!model && msg.model) model = msg.model let chunk = assistantChunks.get(msgId) if (!chunk) { chunk = { uuid: data.uuid || '', timestamp: data.timestamp || '', model: msg.model || '', textParts: [], toolNames: [], hasThinking: false, usage: null, stopReason: '', pendingToolCalls: [] } assistantChunks.set(msgId, chunk) } // Take latest usage (streaming chunks repeat usage, last is most accurate) if (msg.usage) chunk.usage = msg.usage if (msg.stop_reason) chunk.stopReason = msg.stop_reason // Process content blocks (each JSONL line typically has one block) if (Array.isArray(msg.content)) { for (const block of msg.content) { if (block.type === 'text' && block.text?.trim()) { chunk.textParts.push(block.text) } else if (block.type === 'thinking') { chunk.hasThinking = true thinkingBlocks++ } else if (block.type === 'tool_use') { chunk.toolNames.push(block.name) chunk.pendingToolCalls.push({ name: block.name, input: block.input, id: block.id, timestamp: data.timestamp || '' }) } } } break } case 'progress': { if (data.data?.type === 'agent_progress' && data.data.agentId) { const existing = subagents.find(s => s.agentId === data.data.agentId) if (!existing) { subagents.push({ agentId: data.data.agentId, prompt: truncate(data.data.prompt || '', 200), timestamp: data.timestamp || '' }) } } break } case 'file-history-snapshot': { const backups = data.snapshot?.trackedFileBackups if (backups && typeof backups === 'object') { for (const filePath of Object.keys(backups)) { filesModified.add(filePath) } } break } case 'summary': { const summaryText = data.summary || data.message?.content if (summaryText) { summaries.push(truncate( typeof summaryText === 'string' ? summaryText : JSON.stringify(summaryText), 1000 )) } break } } } // ── Second pass: assemble assistant messages and finalize tool calls ── let turnIndex = 0 let lastStopReason = '' for (const [, chunk] of assistantChunks) { if (chunk.stopReason) lastStopReason = chunk.stopReason const text = chunk.textParts.join('\n').trim() if (text || chunk.toolNames.length > 0) { const msgTokens = chunk.usage ? { input: chunk.usage.input_tokens || 0, output: chunk.usage.output_tokens || 0 } : undefined messages.push({ uuid: chunk.uuid, role: 'assistant', content: text || `[Tool calls: ${chunk.toolNames.join(', ')}]`, timestamp: chunk.timestamp, isMeta: false, tokens: msgTokens, toolCalls: chunk.toolNames.length > 0 ? chunk.toolNames : undefined, hasThinking: chunk.hasThinking }) } // Finalize tool calls with results for (const tc of chunk.pendingToolCalls) { const result = toolResults.get(tc.id) const inputStr = typeof tc.input === 'string' ? tc.input : JSON.stringify(tc.input) toolCalls.push({ name: tc.name, input: truncate(inputStr, 500), output: result?.content, timestamp: tc.timestamp, isError: result?.isError || false }) } // Token tracking per turn if (chunk.usage) { const u = chunk.usage const input = u.input_tokens || 0 const output = u.output_tokens || 0 const cacheRead = u.cache_read_input_tokens || 0 const cacheCreation = u.cache_creation_input_tokens || 0 totalInput += input totalOutput += output totalCacheRead += cacheRead totalCacheCreation += cacheCreation turnTokens.push({ turnIndex: turnIndex++, input, output, cacheRead, cacheCreation, model: chunk.model }) } } // Sort messages chronologically messages.sort((a, b) => a.timestamp.localeCompare(b.timestamp)) // Build tool summary const toolSummary: Record = {} for (const tc of toolCalls) { toolSummary[tc.name] = (toolSummary[tc.name] || 0) + 1 } // Extract files from Edit/Write tool calls for (const tc of toolCalls) { if (['Edit', 'Write', 'NotebookEdit'].includes(tc.name) && tc.input) { try { const input = typeof tc.input === 'string' ? JSON.parse(tc.input) : tc.input if (input.file_path) filesModified.add(input.file_path) if (input.notebook_path) filesModified.add(input.notebook_path) } catch { /* skip */ } } } const duration = startTime && endTime ? new Date(endTime).getTime() - new Date(startTime).getTime() : 0 const userMsgCount = messages.filter(m => m.role === 'user').length const assistantMsgCount = messages.filter(m => m.role === 'assistant').length return { sessionId, model, version, gitBranch, cwd, startTime, endTime, duration, messages, tokens: { totalInput, totalOutput, totalCacheRead, totalCacheCreation, byTurn: turnTokens }, tools: { summary: toolSummary, calls: toolCalls }, filesModified: [...filesModified], subagents, summaries, stats: { messageCount: messages.length, userMessageCount: userMsgCount, assistantMessageCount: assistantMsgCount, toolCallCount: toolCalls.length, thinkingBlocks, errors }, lastStopReason } } // ── Exported API ── export function getTranscriptAnalysis(sessionId?: string, transcriptPath?: string): TranscriptAnalysis | null { const filePath = transcriptPath && existsSync(transcriptPath) ? transcriptPath : resolveTranscriptPath(sessionId) if (!filePath) return null const sid = sessionIdFromPath(filePath) try { const stat = statSync(filePath) const mtime = stat.mtimeMs // Return cached if file hasn't changed const cached = cache.get(sid) if (cached && cached.lastModified === mtime) { return cached.analysis } // Full parse const lines = parseTranscriptFile(filePath) const analysis = buildAnalysis(lines, sid) cache.set(sid, { analysis, lastModified: mtime }) return analysis } catch (e) { console.error('[transcript-engine] Error parsing transcript:', e) return null } } // ── Path normalization (Windows compat) ── function normalizeTranscriptPath(tp: string): string { tp = tp.replace(/\\/g, '/') if (/^\/[a-zA-Z]\//.test(tp)) { tp = tp[1].toUpperCase() + ':' + tp.slice(2) } return tp } // ── Active session API (for real-time chat) ── export function setActiveSession(agent: string, sessionId: string, transcriptPath: string): void { const normalizedPath = normalizeTranscriptPath(transcriptPath) activeAgentSessions.set(agent, { sessionId, transcriptPath: normalizedPath }) // Initialize offset to current file size (don't replay old messages as "new") if (!readOffsets.has(sessionId)) { try { const stat = statSync(normalizedPath) readOffsets.set(sessionId, stat.size) } catch { readOffsets.set(sessionId, 0) } } } export function getActiveSession(agent: string): { sessionId: string; transcriptPath: string } | null { return activeAgentSessions.get(agent) || null } export function resetSessionOffset(sessionId: string): void { readOffsets.set(sessionId, 0) } export function getIncrementalMessages(sessionId: string, transcriptPath?: string): TranscriptMessage[] { // Resolve path let filePath = transcriptPath ? normalizeTranscriptPath(transcriptPath) : null if (!filePath) { const session = [...activeAgentSessions.values()].find(s => s.sessionId === sessionId) filePath = session?.transcriptPath || null } if (!filePath) { filePath = resolveTranscriptPath(sessionId) } if (!filePath || !existsSync(filePath)) return [] try { const stat = statSync(filePath) const fileSize = stat.size const offset = readOffsets.get(sessionId) || 0 if (offset >= fileSize) return [] // Read only new bytes const buffer = readFileSync(filePath) const newContent = buffer.slice(offset, fileSize).toString('utf8') // Update offset readOffsets.set(sessionId, fileSize) // Parse new lines const rawLines = newContent.split('\n').filter(l => l.trim()) const messages: TranscriptMessage[] = [] // Track assistant chunks by message.id for consolidation const assistantChunks = new Map() for (const line of rawLines) { try { const obj = JSON.parse(line) if (obj.type === 'user') { const msg = obj.message if (!msg) continue const isMeta = !!obj.isMeta const text = extractText(msg.content) const hasToolResult = Array.isArray(msg.content) && msg.content.some((c: any) => c.type === 'tool_result') if (text && !hasToolResult && !isMeta) { messages.push({ uuid: obj.uuid || crypto.randomUUID(), role: 'user', content: text, timestamp: obj.timestamp || new Date().toISOString(), isMeta: false, hasThinking: false }) } } else if (obj.type === 'assistant') { const msg = obj.message if (!msg || msg.role !== 'assistant') continue const msgId = msg.id || obj.uuid || 'unknown' let chunk = assistantChunks.get(msgId) if (!chunk) { chunk = { uuid: obj.uuid || crypto.randomUUID(), timestamp: obj.timestamp || new Date().toISOString(), textParts: [], toolNames: [], hasThinking: false } assistantChunks.set(msgId, chunk) } if (Array.isArray(msg.content)) { for (const block of msg.content) { if (block.type === 'text' && block.text?.trim()) { chunk.textParts.push(block.text) } else if (block.type === 'thinking') { chunk.hasThinking = true } else if (block.type === 'tool_use') { chunk.toolNames.push(block.name) } } } } // Ignore progress, file-history-snapshot, summary } catch { // Skip unparseable lines } } // Assemble assistant messages from consolidated chunks for (const [, chunk] of assistantChunks) { const text = chunk.textParts.join('\n').trim() if (text || chunk.toolNames.length > 0) { messages.push({ uuid: chunk.uuid, role: 'assistant', content: text || `[Tool calls: ${chunk.toolNames.join(', ')}]`, timestamp: chunk.timestamp, isMeta: false, toolCalls: chunk.toolNames.length > 0 ? chunk.toolNames : undefined, hasThinking: chunk.hasThinking }) } } // Sort chronologically messages.sort((a, b) => a.timestamp.localeCompare(b.timestamp)) return messages } catch (e) { console.error('[transcript-engine] Incremental read error:', e) return [] } } export function getClaudeStats(): ClaudeStats | null { const statsPath = join(homedir(), '.claude', 'stats-cache.json') if (!existsSync(statsPath)) return null try { const raw = JSON.parse(readFileSync(statsPath, 'utf8')) const todayStr = new Date().toISOString().slice(0, 10) // Find today's daily activity const todayActivity = raw.dailyActivity?.find((d: any) => d.date === todayStr) || null const todayTokens = raw.dailyModelTokens?.find((d: any) => d.date === todayStr) || null const today = todayActivity ? { date: todayStr, messageCount: todayActivity.messageCount || 0, sessionCount: todayActivity.sessionCount || 0, toolCallCount: todayActivity.toolCallCount || 0, tokensByModel: todayTokens?.tokensByModel || {} } : null // Model usage const modelUsage: ClaudeStats['modelUsage'] = {} if (raw.modelUsage) { for (const [model, usage] of Object.entries(raw.modelUsage as Record)) { modelUsage[model] = { inputTokens: usage.inputTokens || 0, outputTokens: usage.outputTokens || 0, cacheReadInputTokens: usage.cacheReadInputTokens || 0, cacheCreationInputTokens: usage.cacheCreationInputTokens || 0, costUSD: usage.costUSD || 0 } } } return { today, modelUsage, totalSessions: raw.totalSessions || 0, totalMessages: raw.totalMessages || 0, firstSessionDate: raw.firstSessionDate || '' } } catch (e) { console.error('[transcript-engine] Error reading stats-cache.json:', e) return null } } export function getClaudeUsage(): ClaudeUsage | null { const credentialsPath = join(homedir(), '.claude', '.credentials.json') const statsPath = join(homedir(), '.claude', 'stats-cache.json') if (!existsSync(credentialsPath)) return null try { // 1. Read credentials (never expose tokens) const creds = JSON.parse(readFileSync(credentialsPath, 'utf8')) const oauth = creds.claudeAiOauth || {} const subType = oauth.subscriptionType || 'pro' const rawTier = oauth.rateLimitTier || '' const tier = parseTier(rawTier) const limits = TIER_LIMITS[tier.key] || TIER_LIMITS.pro // 2. Read stats-cache for daily + weekly data let todayMessages = 0 let todayOutputTokens = 0 let todaySessions = 0 let weeklyMessages = 0 if (existsSync(statsPath)) { const raw = JSON.parse(readFileSync(statsPath, 'utf8')) const todayStr = new Date().toISOString().slice(0, 10) const sevenDaysAgo = new Date(Date.now() - 7 * 24 * 60 * 60 * 1000).toISOString().slice(0, 10) for (const day of raw.dailyActivity || []) { if (day.date === todayStr) { todayMessages = day.messageCount || 0 todaySessions = day.sessionCount || 0 } if (day.date >= sevenDaysAgo) { weeklyMessages += day.messageCount || 0 } } // Output tokens for today const todayTokens = (raw.dailyModelTokens || []).find((d: any) => d.date === todayStr) if (todayTokens?.tokensByModel) { todayOutputTokens = Object.values(todayTokens.tokensByModel as Record) .reduce((sum: number, v: number) => sum + v, 0) } } // 3. Calculate percentages const dailyPercent = limits.dailyEstimate > 0 ? Math.round(todayMessages / limits.dailyEstimate * 100) : 0 const weeklyPercent = limits.weeklyEstimate > 0 ? Math.round(weeklyMessages / limits.weeklyEstimate * 100) : 0 // 4. Determine status based on highest usage (daily or weekly) const maxPercent = Math.max(dailyPercent, weeklyPercent) let status: ClaudeUsage['status'] = 'normal' if (maxPercent >= 100) status = 'extended' else if (maxPercent >= 80) status = 'limit_approaching' else if (maxPercent >= 50) status = 'elevated' return { subscription: { type: subType, tier: tier.key, label: tier.label, multiplier: tier.multiplier }, today: { messages: todayMessages, outputTokens: todayOutputTokens, sessions: todaySessions }, daily: { used: todayMessages, limit: limits.dailyEstimate, percent: dailyPercent }, weekly: { used: weeklyMessages, limit: limits.weeklyEstimate, percent: weeklyPercent }, status } } catch (e) { console.error('[transcript-engine] Error reading claude usage:', e) return null } } export function listSessions(): SessionInfo[] { const projectDir = getProjectDir() if (!existsSync(projectDir)) return [] try { const files = readdirSync(projectDir) .filter(f => f.endsWith('.jsonl')) .map(f => { const fullPath = join(projectDir, f) const stat = statSync(fullPath) return { name: f, path: fullPath, mtime: stat.mtimeMs } }) .sort((a, b) => b.mtime - a.mtime) return files.map(f => { const sid = f.name.replace('.jsonl', '') // Try cache first for quick metadata const cached = cache.get(sid) if (cached && cached.lastModified === f.mtime) { return { id: sid, startTime: cached.analysis.startTime, messageCount: cached.analysis.stats.messageCount, model: cached.analysis.model } } // Quick scan: read first few lines for metadata without full parse try { const content = readFileSync(f.path, 'utf8') const firstLines = content.split('\n').slice(0, 20) let startTime = '' let model = '' let lineCount = content.split('\n').filter(l => l.trim()).length for (const line of firstLines) { if (!line.trim()) continue try { const obj = JSON.parse(line) if (obj.timestamp && !startTime) startTime = obj.timestamp if (obj.type === 'assistant' && obj.message?.model && !model) { model = obj.message.model } } catch { /* skip */ } } return { id: sid, startTime, messageCount: lineCount, model } } catch { return { id: sid, startTime: '', messageCount: 0, model: '' } } }) } catch { return [] } }