1346 lines · 43.8 KB · TypeScript
RawDownload
/** * 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.