/** * 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 CostSectionexport { 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 patternsconst 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 signalsconst THINKING_SIGNALS: Record<string, RegExp> = { 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 detectionconst 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<string, ModelTokenStats>(); 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<string, number>(); // Tool usage counts const toolCounts = new Map<string, number>(); // Tool call index: toolUseId -> [messageIndex, toolCall] const toolCallIndex = new Map<string, [number, ToolCall]>(); // Tool errors const errors: ToolError[] = []; const errorsByTool = new Map<string, number>(); // Permission denials const permissionDenials: ToolError[] = []; // Key events const keyEvents: KeyEvent[] = []; // Thinking blocks let thinkingCount = 0; const thinkingAnalysis: ThinkingBlockAnalysis[] = []; // Git branches const branches = new Set<string>(); // Friction signals const corrections: FrictionCorrection[] = []; let userMessageCount = 0; // Thrashing detection const bashPrefixGroups = new Map<string, number>(); const fileEditIndices = new Map<string, number[]>(); // Startup overhead let firstWorkToolSeen = false; let startupMessages = 0; let startupTokens = 0; // Token density timeline const assistantMsgData: [Date, number][] = []; // Conversation tree const uuidToIdx = new Map<string, number>(); const parentMap = new Map<string, string | null>(); let sidechainCount = 0; const childrenMap = new Map<string, string[]>(); // 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<string>(); 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<string, number>(); // 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 !== '<synthetic>') { 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<string, unknown>; 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<string, boolean> = {}; 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]);Lines 1–500 of 1346. Load more
/** * 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<string, RegExp> = { 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<string, ModelTokenStats>(); 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<string, number>(); // Tool usage counts const toolCounts = new Map<string, number>(); // Tool call index: toolUseId -> [messageIndex, toolCall] const toolCallIndex = new Map<string, [number, ToolCall]>(); // Tool errors const errors: ToolError[] = []; const errorsByTool = new Map<string, number>(); // Permission denials const permissionDenials: ToolError[] = []; // Key events const keyEvents: KeyEvent[] = []; // Thinking blocks let thinkingCount = 0; const thinkingAnalysis: ThinkingBlockAnalysis[] = []; // Git branches const branches = new Set<string>(); // Friction signals const corrections: FrictionCorrection[] = []; let userMessageCount = 0; // Thrashing detection const bashPrefixGroups = new Map<string, number>(); const fileEditIndices = new Map<string, number[]>(); // Startup overhead let firstWorkToolSeen = false; let startupMessages = 0; let startupTokens = 0; // Token density timeline const assistantMsgData: [Date, number][] = []; // Conversation tree const uuidToIdx = new Map<string, number>(); const parentMap = new Map<string, string | null>(); let sidechainCount = 0; const childrenMap = new Map<string, string[]>(); // 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<string>(); 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<string, number>(); // 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 !== '<synthetic>') { 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<string, unknown>; 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<string, boolean> = {}; 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]);