/** * Session analyzer — TypeScript port of scripts/analyze-session.py. * * Takes a SessionDetail (already parsed by the main process) and produces * a SessionReport with structured metrics, cost analysis, friction signals, * conversation tree analysis, idle gap detection, and more. * * Runs entirely in the renderer process — no IPC needed. */ import { computeCacheEfficiencyAssessment, computeCacheRatioAssessment, computeCostPerCommitAssessment, computeCostPerLineAssessment, computeIdleAssessment, computeOverheadAssessment, computeRedundancyAssessment, computeSubagentCostShareAssessment, computeThrashingAssessment, computeToolHealthAssessment, detectModelMismatch, detectSwitchPattern, } from '@renderer/utils/reportAssessments'; import { calculateMessageCost } from '@shared/utils/pricing'; import type { AgentTreeNode, FrictionCorrection, GitCommit, IdleGap, KeyEvent, ModelSwitch, ModelTokenStats, OutOfScopeFindings, SessionReport, SkillInvocation, SubagentBasicEntry, SubagentEntry, TestSnapshot, ThinkingBlockAnalysis, ToolError, ToolSuccessRate, UserQuestion, } from '@renderer/types/sessionReport'; import type { ContentBlock, ParsedMessage, Process, SessionDetail, TextContent, ThinkingContent, ToolCall, } from '@shared/types'; // Re-export getDisplayPricing as getPricing for backward compat with CostSection export { getDisplayPricing as getPricing } from '@shared/utils/pricing'; // ============================================================================= // Helpers // ============================================================================= function isTextBlock(block: ContentBlock): block is TextContent { return block.type === 'text'; } function isThinkingBlock(block: ContentBlock): block is ThinkingContent { return block.type === 'thinking'; } function extractTextContent(msg: ParsedMessage): string { const { content } = msg; if (typeof content === 'string') return content; if (Array.isArray(content)) { return content .filter(isTextBlock) .map((block) => block.text) .join(' '); } return ''; } function formatDuration(totalSeconds: number): string { const h = Math.floor(totalSeconds / 3600); const m = Math.floor((totalSeconds % 3600) / 60); const s = Math.floor(totalSeconds % 60); if (h > 0) return `${h}:${String(m).padStart(2, '0')}:${String(s).padStart(2, '0')}`; return `${m}:${String(s).padStart(2, '0')}`; } // Friction keyword patterns const FRICTION_PATTERNS: [RegExp, string][] = [ [/\bno,/i, 'no,'], [/\bwrong\b/i, 'wrong'], [/\bactually\b/i, 'actually'], [/\bundo\b/i, 'undo'], [/\brevert\b/i, 'revert'], [/that's not\b/i, "that's not"], [/\binstead,/i, 'instead,'], [/\bwait,/i, 'wait,'], [/\bnevermind\b/i, 'nevermind'], [/I don't want\b/i, "I don't want"], ]; // Permission denial keywords (case-insensitive substring match) const PERMISSION_KEYWORDS = [ 'permission denied', 'not allowed', 'requires approval', 'cannot execute', 'access denied', 'operation not permitted', 'eacces', 'eperm', 'user rejected', 'user denied', 'needs_user_approval', ]; function isPermissionDenial(text: string): boolean { const lower = text.toLowerCase(); return PERMISSION_KEYWORDS.some((kw) => lower.includes(kw)); } /** * Extract a number immediately before a keyword in text. * E.g., extractNumberBefore("42 passed", "passed") => 42 */ function extractNumberBefore(text: string, keyword: string): number | null { const idx = text.toLowerCase().indexOf(keyword.toLowerCase()); if (idx <= 0) return null; const before = text.slice(Math.max(0, idx - 15), idx).trim(); const parts = before.split(/\s+/); const numStr = parts[parts.length - 1]; if (numStr && /^\d+$/.test(numStr)) return parseInt(numStr, 10); return null; } /** * Parse test summary from command output. * Returns [passed, failed] or null if no match. */ function parseTestSummary(text: string): [number, number] | null { // Try "passed"/"failed" keywords — treat missing count as 0 // (runners often omit "0 failed" when all tests pass) const passed = extractNumberBefore(text, ' passed'); const failed = extractNumberBefore(text, ' failed'); if (passed != null || failed != null) return [passed ?? 0, failed ?? 0]; // Try "passing"/"failing" keywords (mocha-style) const passing = extractNumberBefore(text, ' passing'); const failing = extractNumberBefore(text, ' failing'); if (passing != null || failing != null) return [passing ?? 0, failing ?? 0]; return null; } // Thinking block analysis signals const THINKING_SIGNALS: Record = { alternatives: /\balternative(?:ly|s)?\b|\binstead\b|\bother approach\b|\bcould also\b/i, uncertainty: /\bnot sure\b|\buncertain\b|\bmight be\b|\bpossibly\b|\bI think\b.*\bbut\b/i, errors_noticed: /\bbug\b|\berror\b|\bwrong\b|\bincorrect\b|\bfail\b|\bbroken\b/i, planning: /\bfirst.*then\b|\bstep \d\b|\bplan\b|\bapproach\b|\bstrategy\b/i, direction_change: /\bwait\b|\bactually\b|\bon second thought\b|\blet me reconsider\b|\bhmm\b/i, }; // "Work" tools (non-Skill) for startup overhead detection const NON_SKILL_TOOLS = new Set([ 'Read', 'Write', 'Edit', 'Bash', 'Grep', 'Glob', 'Task', 'WebFetch', 'WebSearch', 'NotebookEdit', ]); // ============================================================================= // Main Analyzer // ============================================================================= export function analyzeSession(detail: SessionDetail): SessionReport { const { session, messages } = detail; // --- Session Overview --- const timestamps = messages.filter((m) => m.timestamp).map((m) => m.timestamp); const firstTs = timestamps.length > 0 ? timestamps[0] : null; const lastTs = timestamps.length > 0 ? timestamps[timestamps.length - 1] : null; const durationMs = firstTs && lastTs ? lastTs.getTime() - firstTs.getTime() : 0; const durationSeconds = durationMs / 1000; // Context consumption interpretation const ctxConsumption = session.contextConsumption ?? 0; let ctxConsumptionPct: number | null = null; let ctxAssessment: 'critical' | 'high' | 'moderate' | 'healthy' | null = null; if (ctxConsumption <= 1) { ctxConsumptionPct = ctxConsumption ? Math.round(ctxConsumption * 1000) / 10 : 0; if (ctxConsumption > 0.8) ctxAssessment = 'critical'; else if (ctxConsumption > 0.6) ctxAssessment = 'high'; else if (ctxConsumption > 0.4) ctxAssessment = 'moderate'; else ctxAssessment = 'healthy'; } // =================================================================== // SINGLE-PASS ACCUMULATORS // =================================================================== // Token usage by model const modelStats = new Map(); const getModelStats = (model: string): ModelTokenStats => { let stats = modelStats.get(model); if (!stats) { stats = { apiCalls: 0, inputTokens: 0, outputTokens: 0, cacheCreation: 0, cacheRead: 0, costUsd: 0, }; modelStats.set(model, stats); } return stats; }; // Cache economics let totalCacheCreation = 0; let totalCacheRead = 0; let coldStartDetected = false; let firstAssistantWithUsageSeen = false; // Message type counts const typeCounts = new Map(); // Tool usage counts const toolCounts = new Map(); // Tool call index: toolUseId -> [messageIndex, toolCall] const toolCallIndex = new Map(); // Tool errors const errors: ToolError[] = []; const errorsByTool = new Map(); // Permission denials const permissionDenials: ToolError[] = []; // Key events const keyEvents: KeyEvent[] = []; // Thinking blocks let thinkingCount = 0; const thinkingAnalysis: ThinkingBlockAnalysis[] = []; // Git branches const branches = new Set(); // Friction signals const corrections: FrictionCorrection[] = []; let userMessageCount = 0; // Thrashing detection const bashPrefixGroups = new Map(); const fileEditIndices = new Map(); // Startup overhead let firstWorkToolSeen = false; let startupMessages = 0; let startupTokens = 0; // Token density timeline const assistantMsgData: [Date, number][] = []; // Conversation tree const uuidToIdx = new Map(); const parentMap = new Map(); let sidechainCount = 0; const childrenMap = new Map(); // Idle gap detection let lastAssistantTs: Date | null = null; const idleGaps: IdleGap[] = []; const IDLE_THRESHOLD_SEC = 60; // Model switch detection let lastModel: string | null = null; const modelSwitches: ModelSwitch[] = []; // Working directory tracking const cwdSet = new Set(); const cwdChanges: { from: string; to: string; messageIndex: number }[] = []; let lastCwd: string | null = null; // Test progression const testSnapshots: TestSnapshot[] = []; // Cost tracking let parentCost = 0; // Git activity const gitCommits: GitCommit[] = []; let gitPushCount = 0; const gitBranchCreations: string[] = []; let linesAddedTotal = 0; let linesRemovedTotal = 0; // File read redundancy const fileReadCounts = new Map(); // First user message length let firstUserMessageLength = 0; let firstUserSeen = false; // Skills invoked const skillsInvoked: SkillInvocation[] = []; // Bash commands const bashCmds: string[] = []; // Subagents list (backward compat) const subagentsList: SubagentBasicEntry[] = []; // Lifecycle tasks const lifecycleTasks: string[] = []; // User questions const userQuestions: UserQuestion[] = []; // Out-of-scope findings const OUT_OF_SCOPE_KEYWORDS = [ 'pre-existing', 'out of scope', 'tech debt', 'anti-pattern', 'existed before', ]; const outOfScopeFindings: OutOfScopeFindings[] = []; // Agent tree metadata const agentTreeNodes: AgentTreeNode[] = []; // Compact summary count let compactSummaryCount = 0; // =================================================================== // SINGLE PASS // =================================================================== for (let i = 0; i < messages.length; i++) { const m = messages[i]; const msgType = m.type ?? 'unknown'; typeCounts.set(msgType, (typeCounts.get(msgType) ?? 0) + 1); const msgUuid = m.uuid ?? ''; const msgParent = m.parentUuid ?? ''; const msgTs = m.timestamp; // --- Conversation tree --- if (msgUuid) { uuidToIdx.set(msgUuid, i); parentMap.set(msgUuid, msgParent || null); if (msgParent) { const children = childrenMap.get(msgParent); if (children) children.push(msgUuid); else childrenMap.set(msgParent, [msgUuid]); } } if (m.isSidechain) sidechainCount++; // --- Working directory tracking --- const msgCwd = m.cwd ?? ''; if (msgCwd) { cwdSet.add(msgCwd); if (lastCwd && msgCwd !== lastCwd) { cwdChanges.push({ from: lastCwd, to: msgCwd, messageIndex: i }); } lastCwd = msgCwd; } // --- Token usage, cache economics, and cost --- // Skip sidechain messages to avoid double-counting (subagent costs are // accounted for separately via processSubagentCost). if (m.usage && m.model && !m.isSidechain && m.model !== '') { const model = m.model; const u = m.usage; const inpTok = u.input_tokens ?? 0; const outTok = u.output_tokens ?? 0; const cc = u.cache_creation_input_tokens ?? 0; const cr = u.cache_read_input_tokens ?? 0; const stats = getModelStats(model); stats.apiCalls += 1; stats.inputTokens += inpTok; stats.outputTokens += outTok; stats.cacheCreation += cc; stats.cacheRead += cr; const callCost = calculateMessageCost(model, inpTok, outTok, cr, cc); stats.costUsd += callCost; parentCost += callCost; totalCacheCreation += cc; totalCacheRead += cr; // Cold start detection if (msgType === 'assistant' && !firstAssistantWithUsageSeen) { firstAssistantWithUsageSeen = true; if (cc > 0 && cr === 0) coldStartDetected = true; } } // --- Git branches --- if (m.gitBranch) branches.add(m.gitBranch); // --- Compact summaries --- if (m.isCompactSummary) compactSummaryCount++; // --- Agent tree metadata --- if (m.agentId) { // agentType/teamName/parentToolUseId may exist on raw data but not typed in ParsedMessage const raw = m as unknown as Record; agentTreeNodes.push({ agentId: m.agentId, agentType: (raw.agentType as string) ?? 'unknown', teamName: (raw.teamName as string) ?? '', parentToolUseId: (raw.parentToolUseId as string) ?? '', messageIndex: i, }); } // --- Thinking blocks (with content analysis) --- if (Array.isArray(m.content)) { for (const block of m.content) { if (isThinkingBlock(block)) { thinkingCount++; const thinkText = block.thinking ?? ''; const signalsFound: Record = {}; for (const [signalName, pattern] of Object.entries(THINKING_SIGNALS)) { if (pattern.test(thinkText)) signalsFound[signalName] = true; } if (Object.keys(signalsFound).length > 0 || thinkingCount <= 5) { thinkingAnalysis.push({ messageIndex: i, preview: thinkText.slice(0, 200).replace(/\n/g, ' ').trim(), charLength: thinkText.length, signals: signalsFound, }); } } } } // --- Model switch detection --- if (msgType === 'assistant' && m.model) { const currentModel = m.model; if (lastModel && currentModel !== lastModel) { modelSwitches.push({ from: lastModel, to: currentModel, messageIndex: i, timestamp: msgTs ?? null, }); } lastModel = currentModel; } // --- Idle gap detection --- if (msgType === 'assistant' && msgTs) { lastAssistantTs = msgTs; } if (msgType === 'user' && msgTs && lastAssistantTs) { const gap = (msgTs.getTime() - lastAssistantTs.getTime()) / 1000; if (gap > IDLE_THRESHOLD_SEC) { idleGaps.push({ gapSeconds: Math.round(gap * 10) / 10, gapHuman: formatDuration(Math.floor(gap)), afterMessageIndex: i, }); } } // --- First user message length (prompt quality) --- if (msgType === 'user' && !firstUserSeen && !m.isMeta) { const contentText = extractTextContent(m); if (contentText.trim()) { firstUserMessageLength = contentText.length; firstUserSeen = true; } } // --- Tool calls (assistant messages) --- for (const tc of m.toolCalls) { const toolName = tc.name; toolCounts.set(toolName, (toolCounts.get(toolName) ?? 0) + 1); if (tc.id) toolCallIndex.set(tc.id, [i, tc]); const inp = tc.input ?? {}; // Bash commands if (toolName === 'Bash') { const cmd = typeof inp.command === 'string' ? inp.command : ''; const cmdTrunc = cmd.slice(0, 200); bashCmds.push(cmdTrunc); // Thrashing: bash prefix groups const prefix = cmd.slice(0, 40); bashPrefixGroups.set(prefix, (bashPrefixGroups.get(prefix) ?? 0) + 1); // Git activity if (cmd.includes('git commit')) { const heredocMatch = /cat\s+<<['"]?EOF['"]?\n(.+?)(?:\n|$)/.exec(cmd); let preview: string; if (heredocMatch) { preview = heredocMatch[1].trim().slice(0, 80); } else { const msgMatch = /-m\s+["'](.+?)["']/.exec(cmd); preview = msgMatch ? msgMatch[1].slice(0, 80) : cmd.slice(0, 80); } gitCommits.push({ messagePreview: preview, messageIndex: i }); } if (cmd.includes('git push')) gitPushCount++; if (cmd.includes('git checkout -b') || cmd.includes('git switch -c')) { const branchMatch = /git (?:checkout -b|switch -c)\s+(\S+)/.exec(cmd); if (branchMatch) gitBranchCreations.push(branchMatch[1]); } } // Skills if (toolName === 'Skill') { skillsInvoked.push({ skill: (inp.skill as string) ?? 'unknown', argsPreview: (typeof inp.args === 'string' ? inp.args : JSON.stringify(inp.args ?? '') ).slice(0, 120), }); } // Task (subagent list) if (toolName === 'Task') { subagentsList.push({ description: (inp.description as string) ?? 'unknown', subagentType: (inp.subagent_type as string) ?? 'unknown', model: (inp.model as string) ?? 'default (inherits parent)', runInBackground: (inp.run_in_background as boolean) ?? false, }); } // TaskCreate if (toolName === 'TaskCreate') { lifecycleTasks.push((inp.subject as string) ?? 'unknown'); } // AskUserQuestion if (toolName === 'AskUserQuestion') { const questions = inp.questions as { question?: string; options?: { label?: string }[] }[]; if (Array.isArray(questions)) { for (const q of questions) { userQuestions.push({ question: q.question ?? '', options: Array.isArray(q.options) ? q.options.map((o) => o.label ?? '') : [], }); } } } // File reads if (toolName === 'Read') { const filePath = (inp.file_path as string) ?? ''; if (filePath) { fileReadCounts.set(filePath, (fileReadCounts.get(filePath) ?? 0) + 1); } } // Write/Edit for thrashing if (toolName === 'Write' || toolName === 'Edit') { const fp = (inp.file_path as string) ?? ''; if (fp) { const indices = fileEditIndices.get(fp); if (indices) indices.push(i); else fileEditIndices.set(fp, [i]); } } // Startup overhead: track first non-Skill tool call if (!firstWorkToolSeen && NON_SKILL_TOOLS.has(toolName)) { firstWorkToolSeen = true; } } // --- Startup overhead: count assistant messages before first work tool --- if (msgType === 'assistant' && !firstWorkToolSeen) { startupMessages++; if (m.usage) { startupTokens += m.usage.output_tokens ?? 0; startupTokens += m.usage.input_tokens ?? 0; startupTokens += m.usage.cache_creation_input_tokens ?? 0; startupTokens += m.usage.cache_read_input_tokens ?? 0; } } // --- Token density timeline --- if (msgType === 'assistant' && msgTs && m.usage) { const totalMsgTokens = (m.usage.input_tokens ?? 0) + (m.usage.output_tokens ?? 0) + (m.usage.cache_creation_input_tokens ?? 0) + (m.usage.cache_read_input_tokens ?? 0); assistantMsgData.push([msgTs, totalMsgTokens]); } // --- Timing / key events --- if (msgTs) { let label: string | null = null; if (msgType === 'user' && typeof m.content === 'string') { const content = m.content; if (content.includes('start feature')) { label = `User: ${content.slice(0, 60)}`; } else if (content.includes('being continued')) { label = 'Context compaction/continuation'; } } for (const tc of m.toolCalls) { if (tc.name === 'Skill') { label = `Skill: ${(tc.input.skill as string) ?? ''}`; } else if (tc.name === 'Task') { const inpTc = tc.input ?? {}; label = `Task: ${(inpTc.description as string) ?? ''} (${(inpTc.subagent_type as string) ?? ''})`; } } if (label) { keyEvents.push({ timestamp: msgTs, label }); } } // --- Friction signals (user messages) --- if (msgType === 'user' && !m.isMeta) { const contentText = extractTextContent(m); if (contentText.trim()) { userMessageCount++; for (const [regex, keyword] of FRICTION_PATTERNS) { if (regex.test(contentText)) { corrections.push({ messageIndex: i, keyword, preview: contentText.slice(0, 120).replace(/\n/g, ' '), }); break; } } } } // --- Out-of-scope findings (assistant messages) --- if (msgType === 'assistant') { const contentText = extractTextContent(m); const contentLower = contentText.toLowerCase(); for (const kw of OUT_OF_SCOPE_KEYWORDS) { const kwIdx = contentLower.indexOf(kw.toLowerCase()); if (kwIdx >= 0) { const start = Math.max(0, kwIdx - 80); const end = Math.min(contentText.length, kwIdx + 300); outOfScopeFindings.push({ keyword: kw, messageIndex: i, snippet: contentText.slice(start, end).replace(/\n/g, ' ').trim(), }); break; } } } // --- Tool results --- for (const tr of m.toolResults) { const toolUseId = tr.toolUseId; if (!toolUseId) continue; const contentStr = typeof tr.content === 'string' ? tr.content : JSON.stringify(tr.content); // Tool errors if (tr.isError) { let toolName = 'unknown'; let toolInput = ''; const indexed = toolCallIndex.get(toolUseId); if (indexed) { const [, tc] = indexed; toolName = tc.name ?? 'unknown'; toolInput = JSON.stringify(tc.input ?? {}).slice(0, 300); } const errorEntry: ToolError = { tool: toolName, inputPreview: toolInput, error: contentStr.slice(0, 500), messageIndex: i, isPermissionDenial: false, }; if (isPermissionDenial(contentStr)) { errorEntry.isPermissionDenial = true; permissionDenials.push(errorEntry); } errors.push(errorEntry); errorsByTool.set(toolName, (errorsByTool.get(toolName) ?? 0) + 1); } // Bash exit code errors if ( !tr.isError && (contentStr.includes('Exit code 1') || contentStr.includes('Exit code 127')) ) { const indexed = toolCallIndex.get(toolUseId); if (indexed) { const [, tc] = indexed; if (tc.name === 'Bash') { const bashError: ToolError = { tool: 'Bash (non-zero exit)', inputPreview: JSON.stringify(tc.input ?? {}).slice(0, 300), error: contentStr.slice(0, 500), messageIndex: i, isPermissionDenial: false, }; if (isPermissionDenial(contentStr)) { bashError.isPermissionDenial = true; permissionDenials.push(bashError); } errors.push(bashError); errorsByTool.set( 'Bash (non-zero exit)', (errorsByTool.get('Bash (non-zero exit)') ?? 0) + 1 ); } } } // --- Test progression: parse test output from bash results --- const indexedForTest = toolCallIndex.get(toolUseId); if (indexedForTest) { const [, tcOrig] = indexedForTest; if (tcOrig.name === 'Bash') { const testResult = parseTestSummary(contentStr); if (testResult) { const [passed, failed] = testResult; testSnapshots.push({ messageIndex: i, passed, failed, total: passed + failed, raw: contentStr.slice(0, 200).replace(/\n/g, ' '), }); } } } // --- Lines changed: parse git diff --stat output --- const indexedForDiff = toolCallIndex.get(toolUseId); if (indexedForDiff) { const [, tcOrig] = indexedForDiff; if (tcOrig.name === 'Bash') { const rawCmd = tcOrig.input?.command; const cmdText = typeof rawCmd === 'string' ? rawCmd : ''; if (cmdText.includes('git diff') || cmdText.includes('git show')) { const insertionIdx = contentStr.indexOf(' insertion'); const deletionIdx = contentStr.indexOf(' deletion'); if (insertionIdx > 0) { const numStr = contentStr .slice(Math.max(0, insertionIdx - 10), insertionIdx) .trim() .split(/\s+/) .pop(); if (numStr && /^\d+$/.test(numStr)) linesAddedTotal += parseInt(numStr, 10); } if (deletionIdx > 0) { const numStr = contentStr .slice(Math.max(0, deletionIdx - 10), deletionIdx) .trim() .split(/\s+/) .pop(); if (numStr && /^\d+$/.test(numStr)) linesRemovedTotal += parseInt(numStr, 10); } } } } } } // =================================================================== // POST-PASS AGGREGATION // =================================================================== // --- Token usage --- let totalInputTokens = 0; let totalOutputTokens = 0; let totalCacheCreationTokens = 0; let totalCacheReadTokens = 0; const byModel: Record = {}; for (const [model, stats] of modelStats) { stats.costUsd = Math.round(stats.costUsd * 10000) / 10000; byModel[model] = stats; totalInputTokens += stats.inputTokens; totalOutputTokens += stats.outputTokens; totalCacheCreationTokens += stats.cacheCreation; totalCacheReadTokens += stats.cacheRead; } let grandTotal = totalInputTokens + totalOutputTokens + totalCacheCreationTokens + totalCacheReadTokens; // --- Cost analysis --- const commitCount = gitCommits.length; const linesChanged = linesAddedTotal + linesRemovedTotal; // --- Subagent metrics from detail.processes --- const subagentEntries: SubagentEntry[] = detail.processes.map((proc: Process) => { const desc = proc.description ?? 'unknown'; // Extract actual model from subagent messages (first assistant message with a model field) const subagentModel = proc.messages.find((m: ParsedMessage) => m.type === 'assistant' && m.model)?.model ?? 'default (inherits parent)'; // Compute cost from subagent token breakdown (proc.metrics.costUsd is not populated upstream) const computedCost = calculateMessageCost( subagentModel, proc.metrics.inputTokens, proc.metrics.outputTokens, proc.metrics.cacheReadTokens, proc.metrics.cacheCreationTokens ); return { description: desc, subagentType: proc.subagentType ?? 'unknown', model: subagentModel, totalTokens: proc.metrics.totalTokens, totalDurationMs: proc.durationMs, totalToolUseCount: proc.messages.reduce( (sum: number, pm: ParsedMessage) => sum + pm.toolCalls.length, 0 ), costUsd: computedCost, modelMismatch: detectModelMismatch(desc, subagentModel), }; }); const saFromProcesses = { count: subagentEntries.length, totalTokens: subagentEntries.reduce((sum, a) => sum + a.totalTokens, 0), totalDurationMs: subagentEntries.reduce((sum, a) => sum + a.totalDurationMs, 0), totalToolUseCount: subagentEntries.reduce((sum, a) => sum + a.totalToolUseCount, 0), totalCostUsd: Math.round(subagentEntries.reduce((sum, a) => sum + a.costUsd, 0) * 10000) / 10000, byAgent: subagentEntries, }; // --- Tool usage with success rates --- const toolSuccessRates: Record = {}; const sortedToolCounts = [...toolCounts.entries()].sort((a, b) => b[1] - a[1]); const countsRecord: Record = {}; for (const [tool, count] of sortedToolCounts) { countsRecord[tool] = count; const errCount = errorsByTool.get(tool) ?? 0; const successPct = count ? Math.round(((count - errCount) / count) * 1000) / 10 : 0; toolSuccessRates[tool] = { totalCalls: count, errors: errCount, successRatePct: successPct, assessment: computeToolHealthAssessment(successPct), }; } // Overall tool health: worst assessment among tools with >5 calls const significantTools = Object.values(toolSuccessRates).filter((t) => t.totalCalls > 5); type THAssessment = 'healthy' | 'degraded' | 'unreliable'; const overallToolHealth: THAssessment = significantTools.length > 0 ? significantTools.reduce((worst, t) => { const order = { healthy: 0, degraded: 1, unreliable: 2 } as const; return order[t.assessment] > order[worst] ? t.assessment : worst; }, 'healthy') : computeToolHealthAssessment(100); // --- Key events timing --- for (let j = 1; j < keyEvents.length; j++) { const prevDt = keyEvents[j - 1].timestamp; const currDt = keyEvents[j].timestamp; const delta = (currDt.getTime() - prevDt.getTime()) / 1000; keyEvents[j].deltaSeconds = Math.round(delta * 10) / 10; keyEvents[j].deltaHuman = formatDuration(Math.floor(delta)); } // --- Thinking blocks signal aggregation --- const signalTotals: Record = {}; for (const ta of thinkingAnalysis) { for (const sig of Object.keys(ta.signals)) { signalTotals[sig] = (signalTotals[sig] ?? 0) + 1; } } // --- Cache economics --- const cacheTotalCreationAndRead = totalCacheCreation + totalCacheRead; const cacheEfficiency = cacheTotalCreationAndRead ? Math.round((totalCacheRead / cacheTotalCreationAndRead) * 10000) / 100 : 0; const cacheRwRatio = totalCacheCreation ? Math.round((totalCacheRead / totalCacheCreation) * 10) / 10 : 0; // --- File read redundancy --- let totalReads = 0; const redundantFiles: Record = {}; for (const [path, count] of fileReadCounts) { totalReads += count; if (count > 2) redundantFiles[path] = count; } const uniqueFiles = fileReadCounts.size; // --- Token density timeline --- const quartiles: { q: number; avgTokens: number; messageCount: number }[] = []; if (assistantMsgData.length > 0) { const n = assistantMsgData.length; const qSize = Math.max(1, Math.floor(n / 4)); for (let q = 0; q < 4; q++) { const startIdx = q * qSize; const endIdx = q === 3 ? n : (q + 1) * qSize; const chunk = assistantMsgData.slice(startIdx, endIdx); if (chunk.length > 0) { const avgTokens = Math.round(chunk.reduce((sum, [, t]) => sum + t, 0) / chunk.length); quartiles.push({ q: q + 1, avgTokens, messageCount: chunk.length }); } else { quartiles.push({ q: q + 1, avgTokens: 0, messageCount: 0 }); } } } else { for (let q = 0; q < 4; q++) { quartiles.push({ q: q + 1, avgTokens: 0, messageCount: 0 }); } } // --- Conversation tree analysis --- const depthMemo = new Map(); function getDepth(uuid: string, visited = new Set()): number { if (depthMemo.has(uuid)) return depthMemo.get(uuid)!; if (visited.has(uuid)) { depthMemo.set(uuid, 0); return 0; } visited.add(uuid); const parent = parentMap.get(uuid); if (!parent) { depthMemo.set(uuid, 0); return 0; } const depth = 1 + getDepth(parent, visited); depthMemo.set(uuid, depth); return depth; } let maxDepth = 0; for (const uuid of parentMap.keys()) { const d = getDepth(uuid); if (d > maxDepth) maxDepth = d; } // Branch points: parents with multiple children const branchPoints = new Map(); for (const [parent, children] of childrenMap) { if (children.length > 1) branchPoints.set(parent, children); } const branchDetails = [...branchPoints.entries()] .sort((a, b) => b[1].length - a[1].length) .slice(0, 10) .map(([p, c]) => ({ parentUuid: p.slice(0, 12) + '...', childCount: c.length, parentMessageIndex: uuidToIdx.get(p), })); // --- Idle gap analysis --- const totalIdle = idleGaps.reduce((sum, g) => sum + g.gapSeconds, 0); const wallClock = durationSeconds; const activeTime = wallClock > 0 ? wallClock - totalIdle : 0; // --- Thrashing signals --- const bashNearDuplicates = [...bashPrefixGroups.entries()] .filter(([, count]) => count > 2) .sort((a, b) => b[1] - a[1]) .map(([prefix, count]) => ({ prefix, count })); const editReworkFiles = [...fileEditIndices.entries()] .filter(([, indices]) => indices.length >= 3) .map(([filePath, editIndices]) => ({ filePath, editIndices })); // --- Model switches --- const modelsUsed = modelSwitches.length > 0 ? [...new Set([...modelSwitches.map((s) => s.from), ...modelSwitches.map((s) => s.to)])] : [...modelStats.keys()]; // --- Test progression trajectory --- let trajectory: 'improving' | 'regressing' | 'stable' | 'insufficient_data' = 'insufficient_data'; if (testSnapshots.length >= 2) { const first = testSnapshots[0]; const last = testSnapshots[testSnapshots.length - 1]; if (last.passed > first.passed) trajectory = 'improving'; else if (last.passed < first.passed) trajectory = 'regressing'; else trajectory = 'stable'; } // --- Prompt quality assessment --- const correctionCount = corrections.length; const frictionRate = userMessageCount ? Math.round((correctionCount / userMessageCount) * 10000) / 10000 : 0; type PromptAssessment = | 'underspecified' | 'verbose_but_unclear' | 'well_specified' | 'moderate_friction'; let assessment: PromptAssessment; let promptNote: string; if (firstUserMessageLength < 100 && correctionCount >= 2) { assessment = 'underspecified'; promptNote = 'Short initial prompt with multiple corrections suggests the task needed more upfront specification.'; } else if (firstUserMessageLength > 500 && correctionCount >= 3) { assessment = 'verbose_but_unclear'; promptNote = 'Initial prompt was detailed but still required corrections — consider restructuring for clarity.'; } else if (correctionCount <= 1) { assessment = 'well_specified'; promptNote = 'Low friction — initial prompt effectively communicated intent.'; } else { assessment = 'moderate_friction'; promptNote = 'Moderate friction detected — review correction patterns for improvement opportunities.'; } // --- Message types --- const messageTypes: Record = {}; for (const [type, count] of typeCounts) { messageTypes[type] = count; } // --- Subagent cost from processes --- const processSubagentCost = subagentEntries.reduce((sum, a) => sum + a.costUsd, 0); const totalCost = parentCost + processSubagentCost; // Add aggregated subagent row to costByModel and byModel for the cost table if (subagentEntries.length > 0 && processSubagentCost > 0) { const subagentTokenStats: ModelTokenStats = { apiCalls: 0, inputTokens: 0, outputTokens: 0, cacheCreation: 0, cacheRead: 0, costUsd: 0, }; for (const proc of detail.processes) { subagentTokenStats.inputTokens += proc.metrics.inputTokens; subagentTokenStats.outputTokens += proc.metrics.outputTokens; subagentTokenStats.cacheCreation += proc.metrics.cacheCreationTokens; subagentTokenStats.cacheRead += proc.metrics.cacheReadTokens; // Count assistant messages with usage as API calls subagentTokenStats.apiCalls += proc.messages.filter( (m: ParsedMessage) => m.type === 'assistant' && m.usage ).length; } subagentTokenStats.costUsd = Math.round(processSubagentCost * 10000) / 10000; const subagentLabel = 'Subagents (combined)'; byModel[subagentLabel] = subagentTokenStats; modelStats.set(subagentLabel, subagentTokenStats); // Update totals to include subagent tokens so the footer row stays consistent totalInputTokens += subagentTokenStats.inputTokens; totalOutputTokens += subagentTokenStats.outputTokens; totalCacheCreationTokens += subagentTokenStats.cacheCreation; totalCacheReadTokens += subagentTokenStats.cacheRead; grandTotal = totalInputTokens + totalOutputTokens + totalCacheCreationTokens + totalCacheReadTokens; } // --- Assessment computations --- const costPerCommitVal = commitCount > 0 ? Math.round((totalCost / commitCount) * 10000) / 10000 : null; const costPerLineVal = linesChanged > 0 ? Math.round((totalCost / linesChanged) * 1000000) / 1000000 : null; const subagentCostSharePct = totalCost > 0 ? Math.round((processSubagentCost / totalCost) * 10000) / 100 : null; const readsPerUniqueFile = uniqueFiles ? Math.round((totalReads / uniqueFiles) * 100) / 100 : 0; const startupPctOfTotal = grandTotal ? Math.round((startupTokens / grandTotal) * 10000) / 100 : 0; const idlePct = wallClock > 0 ? Math.round((totalIdle / wallClock) * 1000) / 10 : 0; const thrashingSignalCount = bashNearDuplicates.length + editReworkFiles.length; // =================================================================== // BUILD REPORT // =================================================================== const report: SessionReport = { overview: { sessionId: session.id, projectId: session.projectId ?? 'unknown', projectPath: session.projectPath ?? 'unknown', firstMessage: session.firstMessage ?? 'unknown', messageCount: session.messageCount ?? 0, hasSubagents: session.hasSubagents ?? false, contextConsumption: ctxConsumption, contextConsumptionPct: ctxConsumptionPct, contextAssessment: ctxAssessment, compactionCount: session.compactionCount ?? 0, gitBranch: session.gitBranch ?? 'unknown', startTime: firstTs, endTime: lastTs, durationSeconds, durationHuman: durationSeconds > 0 ? formatDuration(Math.floor(durationSeconds)) : 'unknown', totalMessages: messages.length, }, tokenUsage: { byModel, totals: { inputTokens: totalInputTokens, outputTokens: totalOutputTokens, cacheCreation: totalCacheCreationTokens, cacheRead: totalCacheReadTokens, grandTotal, cacheReadPct: grandTotal ? Math.round((totalCacheReadTokens / grandTotal) * 10000) / 100 : 0, }, }, costAnalysis: { parentCostUsd: Math.round(parentCost * 10000) / 10000, subagentCostUsd: Math.round(processSubagentCost * 10000) / 10000, totalSessionCostUsd: Math.round(totalCost * 10000) / 10000, costByModel: Object.fromEntries( [...modelStats.entries()].map(([model, stats]) => [ model, Math.round(stats.costUsd * 10000) / 10000, ]) ), costPerCommit: costPerCommitVal, costPerLineChanged: costPerLineVal, costPerCommitAssessment: costPerCommitVal != null ? computeCostPerCommitAssessment(costPerCommitVal) : null, costPerLineAssessment: costPerLineVal != null ? computeCostPerLineAssessment(costPerLineVal) : null, subagentCostSharePct, subagentCostShareAssessment: subagentCostSharePct != null ? computeSubagentCostShareAssessment(subagentCostSharePct) : null, }, cacheEconomics: { cacheRead: totalCacheRead, cacheEfficiencyPct: cacheEfficiency, coldStartDetected, cacheReadToWriteRatio: cacheRwRatio, cacheEfficiencyAssessment: cacheTotalCreationAndRead > 0 ? computeCacheEfficiencyAssessment(cacheEfficiency) : null, cacheRatioAssessment: totalCacheCreation > 0 ? computeCacheRatioAssessment(cacheRwRatio) : null, }, toolUsage: { counts: countsRecord, totalCalls: [...toolCounts.values()].reduce((sum, c) => sum + c, 0), successRates: toolSuccessRates, overallToolHealth, }, subagentMetrics: saFromProcesses, subagentsList, errors: { errors, permissionDenials: { count: permissionDenials.length, denials: permissionDenials, affectedTools: [...new Set(permissionDenials.map((d) => d.tool))], }, }, gitActivity: { commitCount: gitCommits.length, commits: gitCommits, pushCount: gitPushCount, branchCreations: gitBranchCreations, linesAdded: linesAddedTotal, linesRemoved: linesRemovedTotal, linesChanged, }, frictionSignals: { correctionCount, corrections, frictionRate, }, thrashingSignals: { bashNearDuplicates, editReworkFiles, thrashingAssessment: computeThrashingAssessment(thrashingSignalCount), }, conversationTree: { totalNodes: uuidToIdx.size, maxDepth, sidechainCount, branchPoints: branchPoints.size, branchDetails, }, idleAnalysis: { idleThresholdSeconds: IDLE_THRESHOLD_SEC, idleGapCount: idleGaps.length, totalIdleSeconds: Math.round(totalIdle * 10) / 10, totalIdleHuman: formatDuration(Math.floor(totalIdle)), wallClockSeconds: Math.round(wallClock * 10) / 10, activeWorkingSeconds: Math.round(Math.max(activeTime, 0) * 10) / 10, activeWorkingHuman: formatDuration(Math.floor(Math.max(activeTime, 0))), idlePct, longestGaps: [...idleGaps].sort((a, b) => b.gapSeconds - a.gapSeconds).slice(0, 5), idleAssessment: computeIdleAssessment(idlePct), }, modelSwitches: { count: modelSwitches.length, switches: modelSwitches, modelsUsed, switchPattern: detectSwitchPattern(modelSwitches), }, workingDirectories: { uniqueDirectories: [...cwdSet], directoryCount: cwdSet.size, changes: cwdChanges, changeCount: cwdChanges.length, isMultiDirectory: cwdSet.size > 1, }, testProgression: { snapshotCount: testSnapshots.length, snapshots: testSnapshots, trajectory, firstSnapshot: testSnapshots.length > 0 ? testSnapshots[0] : null, lastSnapshot: testSnapshots.length > 0 ? testSnapshots[testSnapshots.length - 1] : null, }, startupOverhead: { messagesBeforeFirstWork: startupMessages, tokensBeforeFirstWork: startupTokens, pctOfTotal: startupPctOfTotal, overheadAssessment: computeOverheadAssessment(startupPctOfTotal), }, tokenDensityTimeline: { quartiles }, promptQuality: { firstMessageLengthChars: firstUserMessageLength, userMessageCount, correctionCount, frictionRate, assessment, note: promptNote, }, thinkingBlocks: { count: thinkingCount, analyzedCount: thinkingAnalysis.length, signalSummary: signalTotals, notableBlocks: thinkingAnalysis.slice(0, 20), }, keyEvents, messageTypes, fileReadRedundancy: { totalReads, uniqueFiles, readsPerUniqueFile, redundantFiles, redundancyAssessment: computeRedundancyAssessment(readsPerUniqueFile), }, compaction: { count: session.compactionCount ?? 0, compactSummaryCount, note: (session.compactionCount ?? 0) > 0 ? 'Session underwent compaction, which may have caused loss of earlier context. Check for repeated work after compaction events.' : 'No compaction occurred — session stayed within context limits.', }, gitBranches: [...branches], skillsInvoked, bashCommands: { total: bashCmds.length, unique: new Set(bashCmds).size, repeated: Object.fromEntries( [ ...bashCmds .reduce((acc, cmd) => acc.set(cmd, (acc.get(cmd) ?? 0) + 1), new Map()) .entries(), ] .filter(([, count]) => count > 1) .sort((a, b) => b[1] - a[1]) ), }, lifecycleTasks, userQuestions, outOfScopeFindings, agentTree: (() => { const uniqueAgents = new Map(); for (const node of agentTreeNodes) { if (!uniqueAgents.has(node.agentId)) uniqueAgents.set(node.agentId, node); } return { agentCount: uniqueAgents.size, agents: [...uniqueAgents.values()], hasTeamMode: agentTreeNodes.some((n) => n.teamName), teamNames: [...new Set(agentTreeNodes.filter((n) => n.teamName).map((n) => n.teamName))], }; })(), }; return report; }