/ commands / insights.ts
insights.ts
   1  import { execFileSync } from 'child_process'
   2  import { diffLines } from 'diff'
   3  import { constants as fsConstants } from 'fs'
   4  import {
   5    copyFile,
   6    mkdir,
   7    mkdtemp,
   8    readdir,
   9    readFile,
  10    rm,
  11    unlink,
  12    writeFile,
  13  } from 'fs/promises'
  14  import { tmpdir } from 'os'
  15  import { extname, join } from 'path'
  16  import type { Command } from '../commands.js'
  17  import { queryWithModel } from '../services/api/claude.js'
  18  import {
  19    AGENT_TOOL_NAME,
  20    LEGACY_AGENT_TOOL_NAME,
  21  } from '../tools/AgentTool/constants.js'
  22  import type { LogOption } from '../types/logs.js'
  23  import { getClaudeConfigHomeDir } from '../utils/envUtils.js'
  24  import { toError } from '../utils/errors.js'
  25  import { execFileNoThrow } from '../utils/execFileNoThrow.js'
  26  import { logError } from '../utils/log.js'
  27  import { extractTextContent } from '../utils/messages.js'
  28  import { getDefaultOpusModel } from '../utils/model/model.js'
  29  import {
  30    getProjectsDir,
  31    getSessionFilesWithMtime,
  32    getSessionIdFromLog,
  33    loadAllLogsFromSessionFile,
  34  } from '../utils/sessionStorage.js'
  35  import { jsonParse, jsonStringify } from '../utils/slowOperations.js'
  36  import { countCharInString } from '../utils/stringUtils.js'
  37  import { asSystemPrompt } from '../utils/systemPromptType.js'
  38  import { escapeXmlAttr as escapeHtml } from '../utils/xml.js'
  39  
  40  // Model for facet extraction and summarization (Opus - best quality)
  41  function getAnalysisModel(): string {
  42    return getDefaultOpusModel()
  43  }
  44  
  45  // Model for narrative insights (Opus - best quality)
  46  function getInsightsModel(): string {
  47    return getDefaultOpusModel()
  48  }
  49  
  50  // ============================================================================
  51  // Homespace Data Collection
  52  // ============================================================================
  53  
  54  type RemoteHostInfo = {
  55    name: string
  56    sessionCount: number
  57  }
  58  
  59  /* eslint-disable custom-rules/no-process-env-top-level */
  60  const getRunningRemoteHosts: () => Promise<string[]> =
  61    process.env.USER_TYPE === 'ant'
  62      ? async () => {
  63          const { stdout, code } = await execFileNoThrow(
  64            'coder',
  65            ['list', '-o', 'json'],
  66            { timeout: 30000 },
  67          )
  68          if (code !== 0) return []
  69          try {
  70            const workspaces = jsonParse(stdout) as Array<{
  71              name: string
  72              latest_build?: { status?: string }
  73            }>
  74            return workspaces
  75              .filter(w => w.latest_build?.status === 'running')
  76              .map(w => w.name)
  77          } catch {
  78            return []
  79          }
  80        }
  81      : async () => []
  82  
  83  const getRemoteHostSessionCount: (hs: string) => Promise<number> =
  84    process.env.USER_TYPE === 'ant'
  85      ? async (homespace: string) => {
  86          const { stdout, code } = await execFileNoThrow(
  87            'ssh',
  88            [
  89              `${homespace}.coder`,
  90              'find /root/.claude/projects -name "*.jsonl" 2>/dev/null | wc -l',
  91            ],
  92            { timeout: 30000 },
  93          )
  94          if (code !== 0) return 0
  95          return parseInt(stdout.trim(), 10) || 0
  96        }
  97      : async () => 0
  98  
  99  const collectFromRemoteHost: (
 100    hs: string,
 101    destDir: string,
 102  ) => Promise<{ copied: number; skipped: number }> =
 103    process.env.USER_TYPE === 'ant'
 104      ? async (homespace: string, destDir: string) => {
 105          const result = { copied: 0, skipped: 0 }
 106  
 107          // Create temp directory
 108          const tempDir = await mkdtemp(join(tmpdir(), 'claude-hs-'))
 109  
 110          try {
 111            // SCP the projects folder
 112            const scpResult = await execFileNoThrow(
 113              'scp',
 114              ['-rq', `${homespace}.coder:/root/.claude/projects/`, tempDir],
 115              { timeout: 300000 },
 116            )
 117            if (scpResult.code !== 0) {
 118              // SCP failed
 119              return result
 120            }
 121  
 122            const projectsDir = join(tempDir, 'projects')
 123            let projectDirents: Awaited<ReturnType<typeof readdir>>
 124            try {
 125              projectDirents = await readdir(projectsDir, { withFileTypes: true })
 126            } catch {
 127              return result
 128            }
 129  
 130            // Merge into destination (parallel per project directory)
 131            await Promise.all(
 132              projectDirents.map(async dirent => {
 133                const projectName = dirent.name
 134                const projectPath = join(projectsDir, projectName)
 135  
 136                // Skip if not a directory
 137                if (!dirent.isDirectory()) return
 138  
 139                const destProjectName = `${projectName}__${homespace}`
 140                const destProjectPath = join(destDir, destProjectName)
 141  
 142                try {
 143                  await mkdir(destProjectPath, { recursive: true })
 144                } catch {
 145                  // Directory may already exist
 146                }
 147  
 148                // Copy session files (skip existing)
 149                let files: Awaited<ReturnType<typeof readdir>>
 150                try {
 151                  files = await readdir(projectPath, { withFileTypes: true })
 152                } catch {
 153                  return
 154                }
 155                await Promise.all(
 156                  files.map(async fileDirent => {
 157                    const fileName = fileDirent.name
 158                    if (!fileName.endsWith('.jsonl')) return
 159  
 160                    const srcFile = join(projectPath, fileName)
 161                    const destFile = join(destProjectPath, fileName)
 162  
 163                    try {
 164                      await copyFile(srcFile, destFile, fsConstants.COPYFILE_EXCL)
 165                      result.copied++
 166                    } catch {
 167                      // EEXIST from COPYFILE_EXCL means dest already exists
 168                      result.skipped++
 169                    }
 170                  }),
 171                )
 172              }),
 173            )
 174          } finally {
 175            try {
 176              await rm(tempDir, { recursive: true, force: true })
 177            } catch {
 178              // Ignore cleanup errors
 179            }
 180          }
 181  
 182          return result
 183        }
 184      : async () => ({ copied: 0, skipped: 0 })
 185  
 186  const collectAllRemoteHostData: (destDir: string) => Promise<{
 187    hosts: RemoteHostInfo[]
 188    totalCopied: number
 189    totalSkipped: number
 190  }> =
 191    process.env.USER_TYPE === 'ant'
 192      ? async (destDir: string) => {
 193          const rHosts = await getRunningRemoteHosts()
 194          const result: RemoteHostInfo[] = []
 195          let totalCopied = 0
 196          let totalSkipped = 0
 197  
 198          // Collect from all hosts in parallel (SCP per host can take seconds)
 199          const hostResults = await Promise.all(
 200            rHosts.map(async hs => {
 201              const sessionCount = await getRemoteHostSessionCount(hs)
 202              if (sessionCount > 0) {
 203                const { copied, skipped } = await collectFromRemoteHost(
 204                  hs,
 205                  destDir,
 206                )
 207                return { name: hs, sessionCount, copied, skipped }
 208              }
 209              return { name: hs, sessionCount, copied: 0, skipped: 0 }
 210            }),
 211          )
 212  
 213          for (const hr of hostResults) {
 214            result.push({ name: hr.name, sessionCount: hr.sessionCount })
 215            totalCopied += hr.copied
 216            totalSkipped += hr.skipped
 217          }
 218  
 219          return { hosts: result, totalCopied, totalSkipped }
 220        }
 221      : async () => ({ hosts: [], totalCopied: 0, totalSkipped: 0 })
 222  /* eslint-enable custom-rules/no-process-env-top-level */
 223  
 224  // ============================================================================
 225  // Types
 226  // ============================================================================
 227  
 228  type SessionMeta = {
 229    session_id: string
 230    project_path: string
 231    start_time: string
 232    duration_minutes: number
 233    user_message_count: number
 234    assistant_message_count: number
 235    tool_counts: Record<string, number>
 236    languages: Record<string, number>
 237    git_commits: number
 238    git_pushes: number
 239    input_tokens: number
 240    output_tokens: number
 241    first_prompt: string
 242    summary?: string
 243    // New stats
 244    user_interruptions: number
 245    user_response_times: number[]
 246    tool_errors: number
 247    tool_error_categories: Record<string, number>
 248    uses_task_agent: boolean
 249    uses_mcp: boolean
 250    uses_web_search: boolean
 251    uses_web_fetch: boolean
 252    // Additional stats
 253    lines_added: number
 254    lines_removed: number
 255    files_modified: number
 256    message_hours: number[]
 257    user_message_timestamps: string[] // ISO timestamps for multi-clauding detection
 258  }
 259  
 260  type SessionFacets = {
 261    session_id: string
 262    underlying_goal: string
 263    goal_categories: Record<string, number>
 264    outcome: string
 265    user_satisfaction_counts: Record<string, number>
 266    claude_helpfulness: string
 267    session_type: string
 268    friction_counts: Record<string, number>
 269    friction_detail: string
 270    primary_success: string
 271    brief_summary: string
 272    user_instructions_to_claude?: string[]
 273  }
 274  
 275  type AggregatedData = {
 276    total_sessions: number
 277    total_sessions_scanned?: number
 278    sessions_with_facets: number
 279    date_range: { start: string; end: string }
 280    total_messages: number
 281    total_duration_hours: number
 282    total_input_tokens: number
 283    total_output_tokens: number
 284    tool_counts: Record<string, number>
 285    languages: Record<string, number>
 286    git_commits: number
 287    git_pushes: number
 288    projects: Record<string, number>
 289    goal_categories: Record<string, number>
 290    outcomes: Record<string, number>
 291    satisfaction: Record<string, number>
 292    helpfulness: Record<string, number>
 293    session_types: Record<string, number>
 294    friction: Record<string, number>
 295    success: Record<string, number>
 296    session_summaries: Array<{
 297      id: string
 298      date: string
 299      summary: string
 300      goal?: string
 301    }>
 302    // New aggregated stats
 303    total_interruptions: number
 304    total_tool_errors: number
 305    tool_error_categories: Record<string, number>
 306    user_response_times: number[]
 307    median_response_time: number
 308    avg_response_time: number
 309    sessions_using_task_agent: number
 310    sessions_using_mcp: number
 311    sessions_using_web_search: number
 312    sessions_using_web_fetch: number
 313    // Additional stats from Python reference
 314    total_lines_added: number
 315    total_lines_removed: number
 316    total_files_modified: number
 317    days_active: number
 318    messages_per_day: number
 319    message_hours: number[] // Hour of day for each user message (for time of day chart)
 320    // Multi-clauding stats (matching Python reference)
 321    multi_clauding: {
 322      overlap_events: number
 323      sessions_involved: number
 324      user_messages_during: number
 325    }
 326  }
 327  
 328  // ============================================================================
 329  // Constants
 330  // ============================================================================
 331  
 332  const EXTENSION_TO_LANGUAGE: Record<string, string> = {
 333    '.ts': 'TypeScript',
 334    '.tsx': 'TypeScript',
 335    '.js': 'JavaScript',
 336    '.jsx': 'JavaScript',
 337    '.py': 'Python',
 338    '.rb': 'Ruby',
 339    '.go': 'Go',
 340    '.rs': 'Rust',
 341    '.java': 'Java',
 342    '.md': 'Markdown',
 343    '.json': 'JSON',
 344    '.yaml': 'YAML',
 345    '.yml': 'YAML',
 346    '.sh': 'Shell',
 347    '.css': 'CSS',
 348    '.html': 'HTML',
 349  }
 350  
 351  // Label map for cleaning up category names (matching Python reference)
 352  const LABEL_MAP: Record<string, string> = {
 353    // Goal categories
 354    debug_investigate: 'Debug/Investigate',
 355    implement_feature: 'Implement Feature',
 356    fix_bug: 'Fix Bug',
 357    write_script_tool: 'Write Script/Tool',
 358    refactor_code: 'Refactor Code',
 359    configure_system: 'Configure System',
 360    create_pr_commit: 'Create PR/Commit',
 361    analyze_data: 'Analyze Data',
 362    understand_codebase: 'Understand Codebase',
 363    write_tests: 'Write Tests',
 364    write_docs: 'Write Docs',
 365    deploy_infra: 'Deploy/Infra',
 366    warmup_minimal: 'Cache Warmup',
 367    // Success factors
 368    fast_accurate_search: 'Fast/Accurate Search',
 369    correct_code_edits: 'Correct Code Edits',
 370    good_explanations: 'Good Explanations',
 371    proactive_help: 'Proactive Help',
 372    multi_file_changes: 'Multi-file Changes',
 373    handled_complexity: 'Multi-file Changes',
 374    good_debugging: 'Good Debugging',
 375    // Friction types
 376    misunderstood_request: 'Misunderstood Request',
 377    wrong_approach: 'Wrong Approach',
 378    buggy_code: 'Buggy Code',
 379    user_rejected_action: 'User Rejected Action',
 380    claude_got_blocked: 'Claude Got Blocked',
 381    user_stopped_early: 'User Stopped Early',
 382    wrong_file_or_location: 'Wrong File/Location',
 383    excessive_changes: 'Excessive Changes',
 384    slow_or_verbose: 'Slow/Verbose',
 385    tool_failed: 'Tool Failed',
 386    user_unclear: 'User Unclear',
 387    external_issue: 'External Issue',
 388    // Satisfaction labels
 389    frustrated: 'Frustrated',
 390    dissatisfied: 'Dissatisfied',
 391    likely_satisfied: 'Likely Satisfied',
 392    satisfied: 'Satisfied',
 393    happy: 'Happy',
 394    unsure: 'Unsure',
 395    neutral: 'Neutral',
 396    delighted: 'Delighted',
 397    // Session types
 398    single_task: 'Single Task',
 399    multi_task: 'Multi Task',
 400    iterative_refinement: 'Iterative Refinement',
 401    exploration: 'Exploration',
 402    quick_question: 'Quick Question',
 403    // Outcomes
 404    fully_achieved: 'Fully Achieved',
 405    mostly_achieved: 'Mostly Achieved',
 406    partially_achieved: 'Partially Achieved',
 407    not_achieved: 'Not Achieved',
 408    unclear_from_transcript: 'Unclear',
 409    // Helpfulness
 410    unhelpful: 'Unhelpful',
 411    slightly_helpful: 'Slightly Helpful',
 412    moderately_helpful: 'Moderately Helpful',
 413    very_helpful: 'Very Helpful',
 414    essential: 'Essential',
 415  }
 416  
 417  // Lazy getters: getClaudeConfigHomeDir() is memoized and reads process.env.
 418  // Calling it at module scope would populate the memoize cache before
 419  // entrypoints can set CLAUDE_CONFIG_DIR, breaking all 150+ other callers.
 420  function getDataDir(): string {
 421    return join(getClaudeConfigHomeDir(), 'usage-data')
 422  }
 423  function getFacetsDir(): string {
 424    return join(getDataDir(), 'facets')
 425  }
 426  function getSessionMetaDir(): string {
 427    return join(getDataDir(), 'session-meta')
 428  }
 429  
 430  const FACET_EXTRACTION_PROMPT = `Analyze this Claude Code session and extract structured facets.
 431  
 432  CRITICAL GUIDELINES:
 433  
 434  1. **goal_categories**: Count ONLY what the USER explicitly asked for.
 435     - DO NOT count Claude's autonomous codebase exploration
 436     - DO NOT count work Claude decided to do on its own
 437     - ONLY count when user says "can you...", "please...", "I need...", "let's..."
 438  
 439  2. **user_satisfaction_counts**: Base ONLY on explicit user signals.
 440     - "Yay!", "great!", "perfect!" → happy
 441     - "thanks", "looks good", "that works" → satisfied
 442     - "ok, now let's..." (continuing without complaint) → likely_satisfied
 443     - "that's not right", "try again" → dissatisfied
 444     - "this is broken", "I give up" → frustrated
 445  
 446  3. **friction_counts**: Be specific about what went wrong.
 447     - misunderstood_request: Claude interpreted incorrectly
 448     - wrong_approach: Right goal, wrong solution method
 449     - buggy_code: Code didn't work correctly
 450     - user_rejected_action: User said no/stop to a tool call
 451     - excessive_changes: Over-engineered or changed too much
 452  
 453  4. If very short or just warmup, use warmup_minimal for goal_category
 454  
 455  SESSION:
 456  `
 457  
 458  // ============================================================================
 459  // Helper Functions
 460  // ============================================================================
 461  
 462  function getLanguageFromPath(filePath: string): string | null {
 463    const ext = extname(filePath).toLowerCase()
 464    return EXTENSION_TO_LANGUAGE[ext] || null
 465  }
 466  
 467  function extractToolStats(log: LogOption): {
 468    toolCounts: Record<string, number>
 469    languages: Record<string, number>
 470    gitCommits: number
 471    gitPushes: number
 472    inputTokens: number
 473    outputTokens: number
 474    // New stats
 475    userInterruptions: number
 476    userResponseTimes: number[]
 477    toolErrors: number
 478    toolErrorCategories: Record<string, number>
 479    usesTaskAgent: boolean
 480    usesMcp: boolean
 481    usesWebSearch: boolean
 482    usesWebFetch: boolean
 483    // Additional stats
 484    linesAdded: number
 485    linesRemoved: number
 486    filesModified: Set<string>
 487    messageHours: number[]
 488    userMessageTimestamps: string[] // ISO timestamps for multi-clauding detection
 489  } {
 490    const toolCounts: Record<string, number> = {}
 491    const languages: Record<string, number> = {}
 492    let gitCommits = 0
 493    let gitPushes = 0
 494    let inputTokens = 0
 495    let outputTokens = 0
 496  
 497    // New stats
 498    let userInterruptions = 0
 499    const userResponseTimes: number[] = []
 500    let toolErrors = 0
 501    const toolErrorCategories: Record<string, number> = {}
 502    let usesTaskAgent = false
 503  
 504    // Additional stats
 505    let linesAdded = 0
 506    let linesRemoved = 0
 507    const filesModified = new Set<string>()
 508    const messageHours: number[] = []
 509    const userMessageTimestamps: string[] = [] // For multi-clauding detection
 510    let usesMcp = false
 511    let usesWebSearch = false
 512    let usesWebFetch = false
 513    let lastAssistantTimestamp: string | null = null
 514  
 515    for (const msg of log.messages) {
 516      // Get message timestamp for response time calculation
 517      const msgTimestamp = (msg as { timestamp?: string }).timestamp
 518  
 519      if (msg.type === 'assistant' && msg.message) {
 520        // Track timestamp for response time calculation
 521        if (msgTimestamp) {
 522          lastAssistantTimestamp = msgTimestamp
 523        }
 524  
 525        const usage = (
 526          msg.message as {
 527            usage?: { input_tokens?: number; output_tokens?: number }
 528          }
 529        ).usage
 530        if (usage) {
 531          inputTokens += usage.input_tokens || 0
 532          outputTokens += usage.output_tokens || 0
 533        }
 534  
 535        const content = msg.message.content
 536        if (Array.isArray(content)) {
 537          for (const block of content) {
 538            if (block.type === 'tool_use' && 'name' in block) {
 539              const toolName = block.name as string
 540              toolCounts[toolName] = (toolCounts[toolName] || 0) + 1
 541  
 542              // Check for special tool usage
 543              if (
 544                toolName === AGENT_TOOL_NAME ||
 545                toolName === LEGACY_AGENT_TOOL_NAME
 546              )
 547                usesTaskAgent = true
 548              if (toolName.startsWith('mcp__')) usesMcp = true
 549              if (toolName === 'WebSearch') usesWebSearch = true
 550              if (toolName === 'WebFetch') usesWebFetch = true
 551  
 552              const input = (block as { input?: Record<string, unknown> }).input
 553  
 554              if (input) {
 555                const filePath = (input.file_path as string) || ''
 556                if (filePath) {
 557                  const lang = getLanguageFromPath(filePath)
 558                  if (lang) {
 559                    languages[lang] = (languages[lang] || 0) + 1
 560                  }
 561                  // Track files modified by Edit/Write tools
 562                  if (toolName === 'Edit' || toolName === 'Write') {
 563                    filesModified.add(filePath)
 564                  }
 565                }
 566  
 567                if (toolName === 'Edit') {
 568                  const oldString = (input.old_string as string) || ''
 569                  const newString = (input.new_string as string) || ''
 570                  for (const change of diffLines(oldString, newString)) {
 571                    if (change.added) linesAdded += change.count || 0
 572                    if (change.removed) linesRemoved += change.count || 0
 573                  }
 574                }
 575  
 576                // Track lines from Write tool (all added)
 577                if (toolName === 'Write') {
 578                  const writeContent = (input.content as string) || ''
 579                  if (writeContent) {
 580                    linesAdded += countCharInString(writeContent, '\n') + 1
 581                  }
 582                }
 583  
 584                const command = (input.command as string) || ''
 585                if (command.includes('git commit')) gitCommits++
 586                if (command.includes('git push')) gitPushes++
 587              }
 588            }
 589          }
 590        }
 591      }
 592  
 593      // Check user messages
 594      if (msg.type === 'user' && msg.message) {
 595        const content = msg.message.content
 596  
 597        // Check if this is an actual human message (has text) vs just tool_result
 598        // matching Python reference logic
 599        let isHumanMessage = false
 600        if (typeof content === 'string' && content.trim()) {
 601          isHumanMessage = true
 602        } else if (Array.isArray(content)) {
 603          for (const block of content) {
 604            if (block.type === 'text' && 'text' in block) {
 605              isHumanMessage = true
 606              break
 607            }
 608          }
 609        }
 610  
 611        // Only track message hours and response times for actual human messages
 612        if (isHumanMessage) {
 613          // Track message hour for time-of-day analysis and timestamp for multi-clauding
 614          if (msgTimestamp) {
 615            try {
 616              const msgDate = new Date(msgTimestamp)
 617              const hour = msgDate.getHours() // Local hour 0-23
 618              messageHours.push(hour)
 619              // Collect timestamp for multi-clauding detection (matching Python)
 620              userMessageTimestamps.push(msgTimestamp)
 621            } catch {
 622              // Skip invalid timestamps
 623            }
 624          }
 625  
 626          // Calculate response time (time from last assistant message to this user message)
 627          // Only count gaps > 2 seconds (real user think time, not tool results)
 628          if (lastAssistantTimestamp && msgTimestamp) {
 629            const assistantTime = new Date(lastAssistantTimestamp).getTime()
 630            const userTime = new Date(msgTimestamp).getTime()
 631            const responseTimeSec = (userTime - assistantTime) / 1000
 632            // Only count reasonable response times (2s-1 hour) matching Python
 633            if (responseTimeSec > 2 && responseTimeSec < 3600) {
 634              userResponseTimes.push(responseTimeSec)
 635            }
 636          }
 637        }
 638  
 639        // Process tool results (for error tracking)
 640        if (Array.isArray(content)) {
 641          for (const block of content) {
 642            if (block.type === 'tool_result' && 'content' in block) {
 643              const isError = (block as { is_error?: boolean }).is_error
 644  
 645              // Count and categorize tool errors (matching Python reference logic)
 646              if (isError) {
 647                toolErrors++
 648                const resultContent = (block as { content?: string }).content
 649                let category = 'Other'
 650                if (typeof resultContent === 'string') {
 651                  const lowerContent = resultContent.toLowerCase()
 652                  if (lowerContent.includes('exit code')) {
 653                    category = 'Command Failed'
 654                  } else if (
 655                    lowerContent.includes('rejected') ||
 656                    lowerContent.includes("doesn't want")
 657                  ) {
 658                    category = 'User Rejected'
 659                  } else if (
 660                    lowerContent.includes('string to replace not found') ||
 661                    lowerContent.includes('no changes')
 662                  ) {
 663                    category = 'Edit Failed'
 664                  } else if (lowerContent.includes('modified since read')) {
 665                    category = 'File Changed'
 666                  } else if (
 667                    lowerContent.includes('exceeds maximum') ||
 668                    lowerContent.includes('too large')
 669                  ) {
 670                    category = 'File Too Large'
 671                  } else if (
 672                    lowerContent.includes('file not found') ||
 673                    lowerContent.includes('does not exist')
 674                  ) {
 675                    category = 'File Not Found'
 676                  }
 677                }
 678                toolErrorCategories[category] =
 679                  (toolErrorCategories[category] || 0) + 1
 680              }
 681            }
 682          }
 683        }
 684  
 685        // Check for interruptions (matching Python reference)
 686        if (typeof content === 'string') {
 687          if (content.includes('[Request interrupted by user')) {
 688            userInterruptions++
 689          }
 690        } else if (Array.isArray(content)) {
 691          for (const block of content) {
 692            if (
 693              block.type === 'text' &&
 694              'text' in block &&
 695              (block.text as string).includes('[Request interrupted by user')
 696            ) {
 697              userInterruptions++
 698              break
 699            }
 700          }
 701        }
 702      }
 703    }
 704  
 705    return {
 706      toolCounts,
 707      languages,
 708      gitCommits,
 709      gitPushes,
 710      inputTokens,
 711      outputTokens,
 712      // New stats
 713      userInterruptions,
 714      userResponseTimes,
 715      toolErrors,
 716      toolErrorCategories,
 717      usesTaskAgent,
 718      usesMcp,
 719      usesWebSearch,
 720      usesWebFetch,
 721      // Additional stats
 722      linesAdded,
 723      linesRemoved,
 724      filesModified,
 725      messageHours,
 726      userMessageTimestamps,
 727    }
 728  }
 729  
 730  function hasValidDates(log: LogOption): boolean {
 731    return (
 732      !Number.isNaN(log.created.getTime()) &&
 733      !Number.isNaN(log.modified.getTime())
 734    )
 735  }
 736  
 737  function logToSessionMeta(log: LogOption): SessionMeta {
 738    const stats = extractToolStats(log)
 739    const sessionId = getSessionIdFromLog(log) || 'unknown'
 740    const startTime = log.created.toISOString()
 741    const durationMinutes = Math.round(
 742      (log.modified.getTime() - log.created.getTime()) / 1000 / 60,
 743    )
 744  
 745    let userMessageCount = 0
 746    let assistantMessageCount = 0
 747    for (const msg of log.messages) {
 748      if (msg.type === 'assistant') assistantMessageCount++
 749      // Only count user messages that have actual text content (human messages)
 750      // not just tool_result messages (matching Python reference)
 751      if (msg.type === 'user' && msg.message) {
 752        const content = msg.message.content
 753        let isHumanMessage = false
 754        if (typeof content === 'string' && content.trim()) {
 755          isHumanMessage = true
 756        } else if (Array.isArray(content)) {
 757          for (const block of content) {
 758            if (block.type === 'text' && 'text' in block) {
 759              isHumanMessage = true
 760              break
 761            }
 762          }
 763        }
 764        if (isHumanMessage) {
 765          userMessageCount++
 766        }
 767      }
 768    }
 769  
 770    return {
 771      session_id: sessionId,
 772      project_path: log.projectPath || '',
 773      start_time: startTime,
 774      duration_minutes: durationMinutes,
 775      user_message_count: userMessageCount,
 776      assistant_message_count: assistantMessageCount,
 777      tool_counts: stats.toolCounts,
 778      languages: stats.languages,
 779      git_commits: stats.gitCommits,
 780      git_pushes: stats.gitPushes,
 781      input_tokens: stats.inputTokens,
 782      output_tokens: stats.outputTokens,
 783      first_prompt: log.firstPrompt || '',
 784      summary: log.summary,
 785      // New stats
 786      user_interruptions: stats.userInterruptions,
 787      user_response_times: stats.userResponseTimes,
 788      tool_errors: stats.toolErrors,
 789      tool_error_categories: stats.toolErrorCategories,
 790      uses_task_agent: stats.usesTaskAgent,
 791      uses_mcp: stats.usesMcp,
 792      uses_web_search: stats.usesWebSearch,
 793      uses_web_fetch: stats.usesWebFetch,
 794      // Additional stats
 795      lines_added: stats.linesAdded,
 796      lines_removed: stats.linesRemoved,
 797      files_modified: stats.filesModified.size,
 798      message_hours: stats.messageHours,
 799      user_message_timestamps: stats.userMessageTimestamps,
 800    }
 801  }
 802  
 803  /**
 804   * Deduplicate conversation branches within the same session.
 805   *
 806   * When a session file has multiple leaf messages (from retries or branching),
 807   * loadAllLogsFromSessionFile produces one LogOption per leaf. Each branch
 808   * shares the same root message, so its duration overlaps with sibling
 809   * branches. This keeps only the branch with the most user messages
 810   * (tie-break by longest duration) per session_id.
 811   */
 812  export function deduplicateSessionBranches(
 813    entries: Array<{ log: LogOption; meta: SessionMeta }>,
 814  ): Array<{ log: LogOption; meta: SessionMeta }> {
 815    const bestBySession = new Map<string, { log: LogOption; meta: SessionMeta }>()
 816    for (const entry of entries) {
 817      const id = entry.meta.session_id
 818      const existing = bestBySession.get(id)
 819      if (
 820        !existing ||
 821        entry.meta.user_message_count > existing.meta.user_message_count ||
 822        (entry.meta.user_message_count === existing.meta.user_message_count &&
 823          entry.meta.duration_minutes > existing.meta.duration_minutes)
 824      ) {
 825        bestBySession.set(id, entry)
 826      }
 827    }
 828    return [...bestBySession.values()]
 829  }
 830  
 831  function formatTranscriptForFacets(log: LogOption): string {
 832    const lines: string[] = []
 833    const meta = logToSessionMeta(log)
 834  
 835    lines.push(`Session: ${meta.session_id.slice(0, 8)}`)
 836    lines.push(`Date: ${meta.start_time}`)
 837    lines.push(`Project: ${meta.project_path}`)
 838    lines.push(`Duration: ${meta.duration_minutes} min`)
 839    lines.push('')
 840  
 841    for (const msg of log.messages) {
 842      if (msg.type === 'user' && msg.message) {
 843        const content = msg.message.content
 844        if (typeof content === 'string') {
 845          lines.push(`[User]: ${content.slice(0, 500)}`)
 846        } else if (Array.isArray(content)) {
 847          for (const block of content) {
 848            if (block.type === 'text' && 'text' in block) {
 849              lines.push(`[User]: ${(block.text as string).slice(0, 500)}`)
 850            }
 851          }
 852        }
 853      } else if (msg.type === 'assistant' && msg.message) {
 854        const content = msg.message.content
 855        if (Array.isArray(content)) {
 856          for (const block of content) {
 857            if (block.type === 'text' && 'text' in block) {
 858              lines.push(`[Assistant]: ${(block.text as string).slice(0, 300)}`)
 859            } else if (block.type === 'tool_use' && 'name' in block) {
 860              lines.push(`[Tool: ${block.name}]`)
 861            }
 862          }
 863        }
 864      }
 865    }
 866  
 867    return lines.join('\n')
 868  }
 869  
 870  const SUMMARIZE_CHUNK_PROMPT = `Summarize this portion of a Claude Code session transcript. Focus on:
 871  1. What the user asked for
 872  2. What Claude did (tools used, files modified)
 873  3. Any friction or issues
 874  4. The outcome
 875  
 876  Keep it concise - 3-5 sentences. Preserve specific details like file names, error messages, and user feedback.
 877  
 878  TRANSCRIPT CHUNK:
 879  `
 880  
 881  async function summarizeTranscriptChunk(chunk: string): Promise<string> {
 882    try {
 883      const result = await queryWithModel({
 884        systemPrompt: asSystemPrompt([]),
 885        userPrompt: SUMMARIZE_CHUNK_PROMPT + chunk,
 886        signal: new AbortController().signal,
 887        options: {
 888          model: getAnalysisModel(),
 889          querySource: 'insights',
 890          agents: [],
 891          isNonInteractiveSession: true,
 892          hasAppendSystemPrompt: false,
 893          mcpTools: [],
 894          maxOutputTokensOverride: 500,
 895        },
 896      })
 897  
 898      const text = extractTextContent(result.message.content)
 899      return text || chunk.slice(0, 2000)
 900    } catch {
 901      // On error, just return truncated chunk
 902      return chunk.slice(0, 2000)
 903    }
 904  }
 905  
 906  async function formatTranscriptWithSummarization(
 907    log: LogOption,
 908  ): Promise<string> {
 909    const fullTranscript = formatTranscriptForFacets(log)
 910  
 911    // If under 30k chars, use as-is
 912    if (fullTranscript.length <= 30000) {
 913      return fullTranscript
 914    }
 915  
 916    // For long transcripts, split into chunks and summarize in parallel
 917    const CHUNK_SIZE = 25000
 918    const chunks: string[] = []
 919  
 920    for (let i = 0; i < fullTranscript.length; i += CHUNK_SIZE) {
 921      chunks.push(fullTranscript.slice(i, i + CHUNK_SIZE))
 922    }
 923  
 924    // Summarize all chunks in parallel
 925    const summaries = await Promise.all(chunks.map(summarizeTranscriptChunk))
 926  
 927    // Combine summaries with session header
 928    const meta = logToSessionMeta(log)
 929    const header = [
 930      `Session: ${meta.session_id.slice(0, 8)}`,
 931      `Date: ${meta.start_time}`,
 932      `Project: ${meta.project_path}`,
 933      `Duration: ${meta.duration_minutes} min`,
 934      `[Long session - ${chunks.length} parts summarized]`,
 935      '',
 936    ].join('\n')
 937  
 938    return header + summaries.join('\n\n---\n\n')
 939  }
 940  
 941  async function loadCachedFacets(
 942    sessionId: string,
 943  ): Promise<SessionFacets | null> {
 944    const facetPath = join(getFacetsDir(), `${sessionId}.json`)
 945    try {
 946      const content = await readFile(facetPath, { encoding: 'utf-8' })
 947      const parsed: unknown = jsonParse(content)
 948      if (!isValidSessionFacets(parsed)) {
 949        // Delete corrupted cache file so it gets re-extracted next run
 950        try {
 951          await unlink(facetPath)
 952        } catch {
 953          // Ignore deletion errors
 954        }
 955        return null
 956      }
 957      return parsed
 958    } catch {
 959      return null
 960    }
 961  }
 962  
 963  async function saveFacets(facets: SessionFacets): Promise<void> {
 964    try {
 965      await mkdir(getFacetsDir(), { recursive: true })
 966    } catch {
 967      // Directory may already exist
 968    }
 969    const facetPath = join(getFacetsDir(), `${facets.session_id}.json`)
 970    await writeFile(facetPath, jsonStringify(facets, null, 2), {
 971      encoding: 'utf-8',
 972      mode: 0o600,
 973    })
 974  }
 975  
 976  async function loadCachedSessionMeta(
 977    sessionId: string,
 978  ): Promise<SessionMeta | null> {
 979    const metaPath = join(getSessionMetaDir(), `${sessionId}.json`)
 980    try {
 981      const content = await readFile(metaPath, { encoding: 'utf-8' })
 982      return jsonParse(content)
 983    } catch {
 984      return null
 985    }
 986  }
 987  
 988  async function saveSessionMeta(meta: SessionMeta): Promise<void> {
 989    try {
 990      await mkdir(getSessionMetaDir(), { recursive: true })
 991    } catch {
 992      // Directory may already exist
 993    }
 994    const metaPath = join(getSessionMetaDir(), `${meta.session_id}.json`)
 995    await writeFile(metaPath, jsonStringify(meta, null, 2), {
 996      encoding: 'utf-8',
 997      mode: 0o600,
 998    })
 999  }
1000  
1001  async function extractFacetsFromAPI(
1002    log: LogOption,
1003    sessionId: string,
1004  ): Promise<SessionFacets | null> {
1005    try {
1006      // Use summarization for long transcripts
1007      const transcript = await formatTranscriptWithSummarization(log)
1008  
1009      // Build prompt asking for JSON directly (no tool use)
1010      const jsonPrompt = `${FACET_EXTRACTION_PROMPT}${transcript}
1011  
1012  RESPOND WITH ONLY A VALID JSON OBJECT matching this schema:
1013  {
1014    "underlying_goal": "What the user fundamentally wanted to achieve",
1015    "goal_categories": {"category_name": count, ...},
1016    "outcome": "fully_achieved|mostly_achieved|partially_achieved|not_achieved|unclear_from_transcript",
1017    "user_satisfaction_counts": {"level": count, ...},
1018    "claude_helpfulness": "unhelpful|slightly_helpful|moderately_helpful|very_helpful|essential",
1019    "session_type": "single_task|multi_task|iterative_refinement|exploration|quick_question",
1020    "friction_counts": {"friction_type": count, ...},
1021    "friction_detail": "One sentence describing friction or empty",
1022    "primary_success": "none|fast_accurate_search|correct_code_edits|good_explanations|proactive_help|multi_file_changes|good_debugging",
1023    "brief_summary": "One sentence: what user wanted and whether they got it"
1024  }`
1025  
1026      const result = await queryWithModel({
1027        systemPrompt: asSystemPrompt([]),
1028        userPrompt: jsonPrompt,
1029        signal: new AbortController().signal,
1030        options: {
1031          model: getAnalysisModel(),
1032          querySource: 'insights',
1033          agents: [],
1034          isNonInteractiveSession: true,
1035          hasAppendSystemPrompt: false,
1036          mcpTools: [],
1037          maxOutputTokensOverride: 4096,
1038        },
1039      })
1040  
1041      const text = extractTextContent(result.message.content)
1042  
1043      // Parse JSON from response
1044      const jsonMatch = text.match(/\{[\s\S]*\}/)
1045      if (!jsonMatch) return null
1046  
1047      const parsed: unknown = jsonParse(jsonMatch[0])
1048      if (!isValidSessionFacets(parsed)) return null
1049      const facets: SessionFacets = { ...parsed, session_id: sessionId }
1050      return facets
1051    } catch (err) {
1052      logError(new Error(`Facet extraction failed: ${toError(err).message}`))
1053      return null
1054    }
1055  }
1056  
1057  /**
1058   * Detects multi-clauding (using multiple Claude sessions concurrently).
1059   * Uses a sliding window to find the pattern: session1 -> session2 -> session1
1060   * within a 30-minute window.
1061   */
1062  export function detectMultiClauding(
1063    sessions: Array<{
1064      session_id: string
1065      user_message_timestamps: string[]
1066    }>,
1067  ): {
1068    overlap_events: number
1069    sessions_involved: number
1070    user_messages_during: number
1071  } {
1072    const OVERLAP_WINDOW_MS = 30 * 60000
1073    const allSessionMessages: Array<{ ts: number; sessionId: string }> = []
1074  
1075    for (const session of sessions) {
1076      for (const timestamp of session.user_message_timestamps) {
1077        try {
1078          const ts = new Date(timestamp).getTime()
1079          allSessionMessages.push({ ts, sessionId: session.session_id })
1080        } catch {
1081          // Skip invalid timestamps
1082        }
1083      }
1084    }
1085  
1086    allSessionMessages.sort((a, b) => a.ts - b.ts)
1087  
1088    const multiClaudeSessionPairs = new Set<string>()
1089    const messagesDuringMulticlaude = new Set<string>()
1090  
1091    // Sliding window: sessionLastIndex tracks the most recent index for each session
1092    let windowStart = 0
1093    const sessionLastIndex = new Map<string, number>()
1094  
1095    for (let i = 0; i < allSessionMessages.length; i++) {
1096      const msg = allSessionMessages[i]!
1097  
1098      // Shrink window from the left
1099      while (
1100        windowStart < i &&
1101        msg.ts - allSessionMessages[windowStart]!.ts > OVERLAP_WINDOW_MS
1102      ) {
1103        const expiring = allSessionMessages[windowStart]!
1104        if (sessionLastIndex.get(expiring.sessionId) === windowStart) {
1105          sessionLastIndex.delete(expiring.sessionId)
1106        }
1107        windowStart++
1108      }
1109  
1110      // Check if this session appeared earlier in the window (pattern: s1 -> s2 -> s1)
1111      const prevIndex = sessionLastIndex.get(msg.sessionId)
1112      if (prevIndex !== undefined) {
1113        for (let j = prevIndex + 1; j < i; j++) {
1114          const between = allSessionMessages[j]!
1115          if (between.sessionId !== msg.sessionId) {
1116            const pair = [msg.sessionId, between.sessionId].sort().join(':')
1117            multiClaudeSessionPairs.add(pair)
1118            messagesDuringMulticlaude.add(
1119              `${allSessionMessages[prevIndex]!.ts}:${msg.sessionId}`,
1120            )
1121            messagesDuringMulticlaude.add(`${between.ts}:${between.sessionId}`)
1122            messagesDuringMulticlaude.add(`${msg.ts}:${msg.sessionId}`)
1123            break
1124          }
1125        }
1126      }
1127  
1128      sessionLastIndex.set(msg.sessionId, i)
1129    }
1130  
1131    const sessionsWithOverlaps = new Set<string>()
1132    for (const pair of multiClaudeSessionPairs) {
1133      const [s1, s2] = pair.split(':')
1134      if (s1) sessionsWithOverlaps.add(s1)
1135      if (s2) sessionsWithOverlaps.add(s2)
1136    }
1137  
1138    return {
1139      overlap_events: multiClaudeSessionPairs.size,
1140      sessions_involved: sessionsWithOverlaps.size,
1141      user_messages_during: messagesDuringMulticlaude.size,
1142    }
1143  }
1144  
1145  function aggregateData(
1146    sessions: SessionMeta[],
1147    facets: Map<string, SessionFacets>,
1148  ): AggregatedData {
1149    const result: AggregatedData = {
1150      total_sessions: sessions.length,
1151      sessions_with_facets: facets.size,
1152      date_range: { start: '', end: '' },
1153      total_messages: 0,
1154      total_duration_hours: 0,
1155      total_input_tokens: 0,
1156      total_output_tokens: 0,
1157      tool_counts: {},
1158      languages: {},
1159      git_commits: 0,
1160      git_pushes: 0,
1161      projects: {},
1162      goal_categories: {},
1163      outcomes: {},
1164      satisfaction: {},
1165      helpfulness: {},
1166      session_types: {},
1167      friction: {},
1168      success: {},
1169      session_summaries: [],
1170      // New stats
1171      total_interruptions: 0,
1172      total_tool_errors: 0,
1173      tool_error_categories: {},
1174      user_response_times: [],
1175      median_response_time: 0,
1176      avg_response_time: 0,
1177      sessions_using_task_agent: 0,
1178      sessions_using_mcp: 0,
1179      sessions_using_web_search: 0,
1180      sessions_using_web_fetch: 0,
1181      // Additional stats
1182      total_lines_added: 0,
1183      total_lines_removed: 0,
1184      total_files_modified: 0,
1185      days_active: 0,
1186      messages_per_day: 0,
1187      message_hours: [],
1188      // Multi-clauding stats (matching Python reference)
1189      multi_clauding: {
1190        overlap_events: 0,
1191        sessions_involved: 0,
1192        user_messages_during: 0,
1193      },
1194    }
1195  
1196    const dates: string[] = []
1197    const allResponseTimes: number[] = []
1198    const allMessageHours: number[] = []
1199  
1200    for (const session of sessions) {
1201      dates.push(session.start_time)
1202      result.total_messages += session.user_message_count
1203      result.total_duration_hours += session.duration_minutes / 60
1204      result.total_input_tokens += session.input_tokens
1205      result.total_output_tokens += session.output_tokens
1206      result.git_commits += session.git_commits
1207      result.git_pushes += session.git_pushes
1208  
1209      // New stats aggregation
1210      result.total_interruptions += session.user_interruptions
1211      result.total_tool_errors += session.tool_errors
1212      for (const [cat, count] of Object.entries(session.tool_error_categories)) {
1213        result.tool_error_categories[cat] =
1214          (result.tool_error_categories[cat] || 0) + count
1215      }
1216      allResponseTimes.push(...session.user_response_times)
1217      if (session.uses_task_agent) result.sessions_using_task_agent++
1218      if (session.uses_mcp) result.sessions_using_mcp++
1219      if (session.uses_web_search) result.sessions_using_web_search++
1220      if (session.uses_web_fetch) result.sessions_using_web_fetch++
1221  
1222      // Additional stats aggregation
1223      result.total_lines_added += session.lines_added
1224      result.total_lines_removed += session.lines_removed
1225      result.total_files_modified += session.files_modified
1226      allMessageHours.push(...session.message_hours)
1227  
1228      for (const [tool, count] of Object.entries(session.tool_counts)) {
1229        result.tool_counts[tool] = (result.tool_counts[tool] || 0) + count
1230      }
1231  
1232      for (const [lang, count] of Object.entries(session.languages)) {
1233        result.languages[lang] = (result.languages[lang] || 0) + count
1234      }
1235  
1236      if (session.project_path) {
1237        result.projects[session.project_path] =
1238          (result.projects[session.project_path] || 0) + 1
1239      }
1240  
1241      const sessionFacets = facets.get(session.session_id)
1242      if (sessionFacets) {
1243        // Goal categories
1244        for (const [cat, count] of safeEntries(sessionFacets.goal_categories)) {
1245          if (count > 0) {
1246            result.goal_categories[cat] =
1247              (result.goal_categories[cat] || 0) + count
1248          }
1249        }
1250  
1251        // Outcomes
1252        result.outcomes[sessionFacets.outcome] =
1253          (result.outcomes[sessionFacets.outcome] || 0) + 1
1254  
1255        // Satisfaction counts
1256        for (const [level, count] of safeEntries(
1257          sessionFacets.user_satisfaction_counts,
1258        )) {
1259          if (count > 0) {
1260            result.satisfaction[level] = (result.satisfaction[level] || 0) + count
1261          }
1262        }
1263  
1264        // Helpfulness
1265        result.helpfulness[sessionFacets.claude_helpfulness] =
1266          (result.helpfulness[sessionFacets.claude_helpfulness] || 0) + 1
1267  
1268        // Session types
1269        result.session_types[sessionFacets.session_type] =
1270          (result.session_types[sessionFacets.session_type] || 0) + 1
1271  
1272        // Friction counts
1273        for (const [type, count] of safeEntries(sessionFacets.friction_counts)) {
1274          if (count > 0) {
1275            result.friction[type] = (result.friction[type] || 0) + count
1276          }
1277        }
1278  
1279        // Success factors
1280        if (sessionFacets.primary_success !== 'none') {
1281          result.success[sessionFacets.primary_success] =
1282            (result.success[sessionFacets.primary_success] || 0) + 1
1283        }
1284      }
1285  
1286      if (result.session_summaries.length < 50) {
1287        result.session_summaries.push({
1288          id: session.session_id.slice(0, 8),
1289          date: session.start_time.split('T')[0] || '',
1290          summary: session.summary || session.first_prompt.slice(0, 100),
1291          goal: sessionFacets?.underlying_goal,
1292        })
1293      }
1294    }
1295  
1296    dates.sort()
1297    result.date_range.start = dates[0]?.split('T')[0] || ''
1298    result.date_range.end = dates[dates.length - 1]?.split('T')[0] || ''
1299  
1300    // Calculate response time stats
1301    result.user_response_times = allResponseTimes
1302    if (allResponseTimes.length > 0) {
1303      const sorted = [...allResponseTimes].sort((a, b) => a - b)
1304      result.median_response_time = sorted[Math.floor(sorted.length / 2)] || 0
1305      result.avg_response_time =
1306        allResponseTimes.reduce((a, b) => a + b, 0) / allResponseTimes.length
1307    }
1308  
1309    // Calculate days active and messages per day
1310    const uniqueDays = new Set(dates.map(d => d.split('T')[0]))
1311    result.days_active = uniqueDays.size
1312    result.messages_per_day =
1313      result.days_active > 0
1314        ? Math.round((result.total_messages / result.days_active) * 10) / 10
1315        : 0
1316  
1317    // Store message hours for time-of-day chart
1318    result.message_hours = allMessageHours
1319  
1320    result.multi_clauding = detectMultiClauding(sessions)
1321  
1322    return result
1323  }
1324  
1325  // ============================================================================
1326  // Parallel Insights Generation (6 sections)
1327  // ============================================================================
1328  
1329  type InsightSection = {
1330    name: string
1331    prompt: string
1332    maxTokens: number
1333  }
1334  
1335  // Sections that run in parallel first
1336  const INSIGHT_SECTIONS: InsightSection[] = [
1337    {
1338      name: 'project_areas',
1339      prompt: `Analyze this Claude Code usage data and identify project areas.
1340  
1341  RESPOND WITH ONLY A VALID JSON OBJECT:
1342  {
1343    "areas": [
1344      {"name": "Area name", "session_count": N, "description": "2-3 sentences about what was worked on and how Claude Code was used."}
1345    ]
1346  }
1347  
1348  Include 4-5 areas. Skip internal CC operations.`,
1349      maxTokens: 8192,
1350    },
1351    {
1352      name: 'interaction_style',
1353      prompt: `Analyze this Claude Code usage data and describe the user's interaction style.
1354  
1355  RESPOND WITH ONLY A VALID JSON OBJECT:
1356  {
1357    "narrative": "2-3 paragraphs analyzing HOW the user interacts with Claude Code. Use second person 'you'. Describe patterns: iterate quickly vs detailed upfront specs? Interrupt often or let Claude run? Include specific examples. Use **bold** for key insights.",
1358    "key_pattern": "One sentence summary of most distinctive interaction style"
1359  }`,
1360      maxTokens: 8192,
1361    },
1362    {
1363      name: 'what_works',
1364      prompt: `Analyze this Claude Code usage data and identify what's working well for this user. Use second person ("you").
1365  
1366  RESPOND WITH ONLY A VALID JSON OBJECT:
1367  {
1368    "intro": "1 sentence of context",
1369    "impressive_workflows": [
1370      {"title": "Short title (3-6 words)", "description": "2-3 sentences describing the impressive workflow or approach. Use 'you' not 'the user'."}
1371    ]
1372  }
1373  
1374  Include 3 impressive workflows.`,
1375      maxTokens: 8192,
1376    },
1377    {
1378      name: 'friction_analysis',
1379      prompt: `Analyze this Claude Code usage data and identify friction points for this user. Use second person ("you").
1380  
1381  RESPOND WITH ONLY A VALID JSON OBJECT:
1382  {
1383    "intro": "1 sentence summarizing friction patterns",
1384    "categories": [
1385      {"category": "Concrete category name", "description": "1-2 sentences explaining this category and what could be done differently. Use 'you' not 'the user'.", "examples": ["Specific example with consequence", "Another example"]}
1386    ]
1387  }
1388  
1389  Include 3 friction categories with 2 examples each.`,
1390      maxTokens: 8192,
1391    },
1392    {
1393      name: 'suggestions',
1394      prompt: `Analyze this Claude Code usage data and suggest improvements.
1395  
1396  ## CC FEATURES REFERENCE (pick from these for features_to_try):
1397  1. **MCP Servers**: Connect Claude to external tools, databases, and APIs via Model Context Protocol.
1398     - How to use: Run \`claude mcp add <server-name> -- <command>\`
1399     - Good for: database queries, Slack integration, GitHub issue lookup, connecting to internal APIs
1400  
1401  2. **Custom Skills**: Reusable prompts you define as markdown files that run with a single /command.
1402     - How to use: Create \`.claude/skills/commit/SKILL.md\` with instructions. Then type \`/commit\` to run it.
1403     - Good for: repetitive workflows - /commit, /review, /test, /deploy, /pr, or complex multi-step workflows
1404  
1405  3. **Hooks**: Shell commands that auto-run at specific lifecycle events.
1406     - How to use: Add to \`.claude/settings.json\` under "hooks" key.
1407     - Good for: auto-formatting code, running type checks, enforcing conventions
1408  
1409  4. **Headless Mode**: Run Claude non-interactively from scripts and CI/CD.
1410     - How to use: \`claude -p "fix lint errors" --allowedTools "Edit,Read,Bash"\`
1411     - Good for: CI/CD integration, batch code fixes, automated reviews
1412  
1413  5. **Task Agents**: Claude spawns focused sub-agents for complex exploration or parallel work.
1414     - How to use: Claude auto-invokes when helpful, or ask "use an agent to explore X"
1415     - Good for: codebase exploration, understanding complex systems
1416  
1417  RESPOND WITH ONLY A VALID JSON OBJECT:
1418  {
1419    "claude_md_additions": [
1420      {"addition": "A specific line or block to add to CLAUDE.md based on workflow patterns. E.g., 'Always run tests after modifying auth-related files'", "why": "1 sentence explaining why this would help based on actual sessions", "prompt_scaffold": "Instructions for where to add this in CLAUDE.md. E.g., 'Add under ## Testing section'"}
1421    ],
1422    "features_to_try": [
1423      {"feature": "Feature name from CC FEATURES REFERENCE above", "one_liner": "What it does", "why_for_you": "Why this would help YOU based on your sessions", "example_code": "Actual command or config to copy"}
1424    ],
1425    "usage_patterns": [
1426      {"title": "Short title", "suggestion": "1-2 sentence summary", "detail": "3-4 sentences explaining how this applies to YOUR work", "copyable_prompt": "A specific prompt to copy and try"}
1427    ]
1428  }
1429  
1430  IMPORTANT for claude_md_additions: PRIORITIZE instructions that appear MULTIPLE TIMES in the user data. If user told Claude the same thing in 2+ sessions (e.g., 'always run tests', 'use TypeScript'), that's a PRIME candidate - they shouldn't have to repeat themselves.
1431  
1432  IMPORTANT for features_to_try: Pick 2-3 from the CC FEATURES REFERENCE above. Include 2-3 items for each category.`,
1433      maxTokens: 8192,
1434    },
1435    {
1436      name: 'on_the_horizon',
1437      prompt: `Analyze this Claude Code usage data and identify future opportunities.
1438  
1439  RESPOND WITH ONLY A VALID JSON OBJECT:
1440  {
1441    "intro": "1 sentence about evolving AI-assisted development",
1442    "opportunities": [
1443      {"title": "Short title (4-8 words)", "whats_possible": "2-3 ambitious sentences about autonomous workflows", "how_to_try": "1-2 sentences mentioning relevant tooling", "copyable_prompt": "Detailed prompt to try"}
1444    ]
1445  }
1446  
1447  Include 3 opportunities. Think BIG - autonomous workflows, parallel agents, iterating against tests.`,
1448      maxTokens: 8192,
1449    },
1450    ...(process.env.USER_TYPE === 'ant'
1451      ? [
1452          {
1453            name: 'cc_team_improvements',
1454            prompt: `Analyze this Claude Code usage data and suggest product improvements for the CC team.
1455  
1456  RESPOND WITH ONLY A VALID JSON OBJECT:
1457  {
1458    "improvements": [
1459      {"title": "Product/tooling improvement", "detail": "3-4 sentences describing the improvement", "evidence": "3-4 sentences with specific session examples"}
1460    ]
1461  }
1462  
1463  Include 2-3 improvements based on friction patterns observed.`,
1464            maxTokens: 8192,
1465          },
1466          {
1467            name: 'model_behavior_improvements',
1468            prompt: `Analyze this Claude Code usage data and suggest model behavior improvements.
1469  
1470  RESPOND WITH ONLY A VALID JSON OBJECT:
1471  {
1472    "improvements": [
1473      {"title": "Model behavior change", "detail": "3-4 sentences describing what the model should do differently", "evidence": "3-4 sentences with specific examples"}
1474    ]
1475  }
1476  
1477  Include 2-3 improvements based on friction patterns observed.`,
1478            maxTokens: 8192,
1479          },
1480        ]
1481      : []),
1482    {
1483      name: 'fun_ending',
1484      prompt: `Analyze this Claude Code usage data and find a memorable moment.
1485  
1486  RESPOND WITH ONLY A VALID JSON OBJECT:
1487  {
1488    "headline": "A memorable QUALITATIVE moment from the transcripts - not a statistic. Something human, funny, or surprising.",
1489    "detail": "Brief context about when/where this happened"
1490  }
1491  
1492  Find something genuinely interesting or amusing from the session summaries.`,
1493      maxTokens: 8192,
1494    },
1495  ]
1496  
1497  type InsightResults = {
1498    at_a_glance?: {
1499      whats_working?: string
1500      whats_hindering?: string
1501      quick_wins?: string
1502      ambitious_workflows?: string
1503    }
1504    project_areas?: {
1505      areas?: Array<{ name: string; session_count: number; description: string }>
1506    }
1507    interaction_style?: {
1508      narrative?: string
1509      key_pattern?: string
1510    }
1511    what_works?: {
1512      intro?: string
1513      impressive_workflows?: Array<{ title: string; description: string }>
1514    }
1515    friction_analysis?: {
1516      intro?: string
1517      categories?: Array<{
1518        category: string
1519        description: string
1520        examples?: string[]
1521      }>
1522    }
1523    suggestions?: {
1524      claude_md_additions?: Array<{
1525        addition: string
1526        why: string
1527        where?: string
1528        prompt_scaffold?: string
1529      }>
1530      features_to_try?: Array<{
1531        feature: string
1532        one_liner: string
1533        why_for_you: string
1534        example_code?: string
1535      }>
1536      usage_patterns?: Array<{
1537        title: string
1538        suggestion: string
1539        detail?: string
1540        copyable_prompt?: string
1541      }>
1542    }
1543    on_the_horizon?: {
1544      intro?: string
1545      opportunities?: Array<{
1546        title: string
1547        whats_possible: string
1548        how_to_try?: string
1549        copyable_prompt?: string
1550      }>
1551    }
1552    cc_team_improvements?: {
1553      improvements?: Array<{
1554        title: string
1555        detail: string
1556        evidence?: string
1557      }>
1558    }
1559    model_behavior_improvements?: {
1560      improvements?: Array<{
1561        title: string
1562        detail: string
1563        evidence?: string
1564      }>
1565    }
1566    fun_ending?: {
1567      headline?: string
1568      detail?: string
1569    }
1570  }
1571  
1572  async function generateSectionInsight(
1573    section: InsightSection,
1574    dataContext: string,
1575  ): Promise<{ name: string; result: unknown }> {
1576    try {
1577      const result = await queryWithModel({
1578        systemPrompt: asSystemPrompt([]),
1579        userPrompt: section.prompt + '\n\nDATA:\n' + dataContext,
1580        signal: new AbortController().signal,
1581        options: {
1582          model: getInsightsModel(),
1583          querySource: 'insights',
1584          agents: [],
1585          isNonInteractiveSession: true,
1586          hasAppendSystemPrompt: false,
1587          mcpTools: [],
1588          maxOutputTokensOverride: section.maxTokens,
1589        },
1590      })
1591  
1592      const text = extractTextContent(result.message.content)
1593  
1594      if (text) {
1595        // Parse JSON from response
1596        const jsonMatch = text.match(/\{[\s\S]*\}/)
1597        if (jsonMatch) {
1598          try {
1599            return { name: section.name, result: jsonParse(jsonMatch[0]) }
1600          } catch {
1601            return { name: section.name, result: null }
1602          }
1603        }
1604      }
1605      return { name: section.name, result: null }
1606    } catch (err) {
1607      logError(new Error(`${section.name} failed: ${toError(err).message}`))
1608      return { name: section.name, result: null }
1609    }
1610  }
1611  
1612  async function generateParallelInsights(
1613    data: AggregatedData,
1614    facets: Map<string, SessionFacets>,
1615  ): Promise<InsightResults> {
1616    // Build data context string
1617    const facetSummaries = Array.from(facets.values())
1618      .slice(0, 50)
1619      .map(f => `- ${f.brief_summary} (${f.outcome}, ${f.claude_helpfulness})`)
1620      .join('\n')
1621  
1622    const frictionDetails = Array.from(facets.values())
1623      .filter(f => f.friction_detail)
1624      .slice(0, 20)
1625      .map(f => `- ${f.friction_detail}`)
1626      .join('\n')
1627  
1628    const userInstructions = Array.from(facets.values())
1629      .flatMap(f => f.user_instructions_to_claude || [])
1630      .slice(0, 15)
1631      .map(i => `- ${i}`)
1632      .join('\n')
1633  
1634    const dataContext = jsonStringify(
1635      {
1636        sessions: data.total_sessions,
1637        analyzed: data.sessions_with_facets,
1638        date_range: data.date_range,
1639        messages: data.total_messages,
1640        hours: Math.round(data.total_duration_hours),
1641        commits: data.git_commits,
1642        top_tools: Object.entries(data.tool_counts)
1643          .sort((a, b) => b[1] - a[1])
1644          .slice(0, 8),
1645        top_goals: Object.entries(data.goal_categories)
1646          .sort((a, b) => b[1] - a[1])
1647          .slice(0, 8),
1648        outcomes: data.outcomes,
1649        satisfaction: data.satisfaction,
1650        friction: data.friction,
1651        success: data.success,
1652        languages: data.languages,
1653      },
1654      null,
1655      2,
1656    )
1657  
1658    const fullContext =
1659      dataContext +
1660      '\n\nSESSION SUMMARIES:\n' +
1661      facetSummaries +
1662      '\n\nFRICTION DETAILS:\n' +
1663      frictionDetails +
1664      '\n\nUSER INSTRUCTIONS TO CLAUDE:\n' +
1665      (userInstructions || 'None captured')
1666  
1667    // Run sections in parallel first (excluding at_a_glance)
1668    const results = await Promise.all(
1669      INSIGHT_SECTIONS.map(section =>
1670        generateSectionInsight(section, fullContext),
1671      ),
1672    )
1673  
1674    // Combine results
1675    const insights: InsightResults = {}
1676    for (const { name, result } of results) {
1677      if (result) {
1678        ;(insights as Record<string, unknown>)[name] = result
1679      }
1680    }
1681  
1682    // Build rich context from generated sections for At a Glance
1683    const projectAreasText =
1684      (
1685        insights.project_areas as {
1686          areas?: Array<{ name: string; description: string }>
1687        }
1688      )?.areas
1689        ?.map(a => `- ${a.name}: ${a.description}`)
1690        .join('\n') || ''
1691  
1692    const bigWinsText =
1693      (
1694        insights.what_works as {
1695          impressive_workflows?: Array<{ title: string; description: string }>
1696        }
1697      )?.impressive_workflows
1698        ?.map(w => `- ${w.title}: ${w.description}`)
1699        .join('\n') || ''
1700  
1701    const frictionText =
1702      (
1703        insights.friction_analysis as {
1704          categories?: Array<{ category: string; description: string }>
1705        }
1706      )?.categories
1707        ?.map(c => `- ${c.category}: ${c.description}`)
1708        .join('\n') || ''
1709  
1710    const featuresText =
1711      (
1712        insights.suggestions as {
1713          features_to_try?: Array<{ feature: string; one_liner: string }>
1714        }
1715      )?.features_to_try
1716        ?.map(f => `- ${f.feature}: ${f.one_liner}`)
1717        .join('\n') || ''
1718  
1719    const patternsText =
1720      (
1721        insights.suggestions as {
1722          usage_patterns?: Array<{ title: string; suggestion: string }>
1723        }
1724      )?.usage_patterns
1725        ?.map(p => `- ${p.title}: ${p.suggestion}`)
1726        .join('\n') || ''
1727  
1728    const horizonText =
1729      (
1730        insights.on_the_horizon as {
1731          opportunities?: Array<{ title: string; whats_possible: string }>
1732        }
1733      )?.opportunities
1734        ?.map(o => `- ${o.title}: ${o.whats_possible}`)
1735        .join('\n') || ''
1736  
1737    // Now generate "At a Glance" with access to other sections' outputs
1738    const atAGlancePrompt = `You're writing an "At a Glance" summary for a Claude Code usage insights report for Claude Code users. The goal is to help them understand their usage and improve how they can use Claude better, especially as models improve.
1739  
1740  Use this 4-part structure:
1741  
1742  1. **What's working** - What is the user's unique style of interacting with Claude and what are some impactful things they've done? You can include one or two details, but keep it high level since things might not be fresh in the user's memory. Don't be fluffy or overly complimentary. Also, don't focus on the tool calls they use.
1743  
1744  2. **What's hindering you** - Split into (a) Claude's fault (misunderstandings, wrong approaches, bugs) and (b) user-side friction (not providing enough context, environment issues -- ideally more general than just one project). Be honest but constructive.
1745  
1746  3. **Quick wins to try** - Specific Claude Code features they could try from the examples below, or a workflow technique if you think it's really compelling. (Avoid stuff like "Ask Claude to confirm before taking actions" or "Type out more context up front" which are less compelling.)
1747  
1748  4. **Ambitious workflows for better models** - As we move to much more capable models over the next 3-6 months, what should they prepare for? What workflows that seem impossible now will become possible? Draw from the appropriate section below.
1749  
1750  Keep each section to 2-3 not-too-long sentences. Don't overwhelm the user. Don't mention specific numerical stats or underlined_categories from the session data below. Use a coaching tone.
1751  
1752  RESPOND WITH ONLY A VALID JSON OBJECT:
1753  {
1754    "whats_working": "(refer to instructions above)",
1755    "whats_hindering": "(refer to instructions above)",
1756    "quick_wins": "(refer to instructions above)",
1757    "ambitious_workflows": "(refer to instructions above)"
1758  }
1759  
1760  SESSION DATA:
1761  ${fullContext}
1762  
1763  ## Project Areas (what user works on)
1764  ${projectAreasText}
1765  
1766  ## Big Wins (impressive accomplishments)
1767  ${bigWinsText}
1768  
1769  ## Friction Categories (where things go wrong)
1770  ${frictionText}
1771  
1772  ## Features to Try
1773  ${featuresText}
1774  
1775  ## Usage Patterns to Adopt
1776  ${patternsText}
1777  
1778  ## On the Horizon (ambitious workflows for better models)
1779  ${horizonText}`
1780  
1781    const atAGlanceSection: InsightSection = {
1782      name: 'at_a_glance',
1783      prompt: atAGlancePrompt,
1784      maxTokens: 8192,
1785    }
1786  
1787    const atAGlanceResult = await generateSectionInsight(atAGlanceSection, '')
1788    if (atAGlanceResult.result) {
1789      insights.at_a_glance = atAGlanceResult.result as {
1790        whats_working?: string
1791        whats_hindering?: string
1792        quick_wins?: string
1793        ambitious_workflows?: string
1794      }
1795    }
1796  
1797    return insights
1798  }
1799  
1800  // Escape HTML but render **bold** as <strong>
1801  function escapeHtmlWithBold(text: string): string {
1802    const escaped = escapeHtml(text)
1803    return escaped.replace(/\*\*(.+?)\*\*/g, '<strong>$1</strong>')
1804  }
1805  
1806  // Fixed orderings for specific charts (matching Python reference)
1807  const SATISFACTION_ORDER = [
1808    'frustrated',
1809    'dissatisfied',
1810    'likely_satisfied',
1811    'satisfied',
1812    'happy',
1813    'unsure',
1814  ]
1815  
1816  const OUTCOME_ORDER = [
1817    'not_achieved',
1818    'partially_achieved',
1819    'mostly_achieved',
1820    'fully_achieved',
1821    'unclear_from_transcript',
1822  ]
1823  
1824  function generateBarChart(
1825    data: Record<string, number>,
1826    color: string,
1827    maxItems = 6,
1828    fixedOrder?: string[],
1829  ): string {
1830    let entries: [string, number][]
1831  
1832    if (fixedOrder) {
1833      // Use fixed order, only including items that exist in data
1834      entries = fixedOrder
1835        .filter(key => key in data && (data[key] ?? 0) > 0)
1836        .map(key => [key, data[key] ?? 0] as [string, number])
1837    } else {
1838      // Sort by count descending
1839      entries = Object.entries(data)
1840        .sort((a, b) => b[1] - a[1])
1841        .slice(0, maxItems)
1842    }
1843  
1844    if (entries.length === 0) return '<p class="empty">No data</p>'
1845  
1846    const maxVal = Math.max(...entries.map(e => e[1]))
1847    return entries
1848      .map(([label, count]) => {
1849        const pct = (count / maxVal) * 100
1850        // Use LABEL_MAP if available, otherwise clean up underscores and title case
1851        const cleanLabel =
1852          LABEL_MAP[label] ||
1853          label.replace(/_/g, ' ').replace(/\b\w/g, c => c.toUpperCase())
1854        return `<div class="bar-row">
1855          <div class="bar-label">${escapeHtml(cleanLabel)}</div>
1856          <div class="bar-track"><div class="bar-fill" style="width:${pct}%;background:${color}"></div></div>
1857          <div class="bar-value">${count}</div>
1858        </div>`
1859      })
1860      .join('\n')
1861  }
1862  
1863  function generateResponseTimeHistogram(times: number[]): string {
1864    if (times.length === 0) return '<p class="empty">No response time data</p>'
1865  
1866    // Create buckets (matching Python reference)
1867    const buckets: Record<string, number> = {
1868      '2-10s': 0,
1869      '10-30s': 0,
1870      '30s-1m': 0,
1871      '1-2m': 0,
1872      '2-5m': 0,
1873      '5-15m': 0,
1874      '>15m': 0,
1875    }
1876  
1877    for (const t of times) {
1878      if (t < 10) buckets['2-10s'] = (buckets['2-10s'] ?? 0) + 1
1879      else if (t < 30) buckets['10-30s'] = (buckets['10-30s'] ?? 0) + 1
1880      else if (t < 60) buckets['30s-1m'] = (buckets['30s-1m'] ?? 0) + 1
1881      else if (t < 120) buckets['1-2m'] = (buckets['1-2m'] ?? 0) + 1
1882      else if (t < 300) buckets['2-5m'] = (buckets['2-5m'] ?? 0) + 1
1883      else if (t < 900) buckets['5-15m'] = (buckets['5-15m'] ?? 0) + 1
1884      else buckets['>15m'] = (buckets['>15m'] ?? 0) + 1
1885    }
1886  
1887    const maxVal = Math.max(...Object.values(buckets))
1888    if (maxVal === 0) return '<p class="empty">No response time data</p>'
1889  
1890    return Object.entries(buckets)
1891      .map(([label, count]) => {
1892        const pct = (count / maxVal) * 100
1893        return `<div class="bar-row">
1894          <div class="bar-label">${label}</div>
1895          <div class="bar-track"><div class="bar-fill" style="width:${pct}%;background:#6366f1"></div></div>
1896          <div class="bar-value">${count}</div>
1897        </div>`
1898      })
1899      .join('\n')
1900  }
1901  
1902  function generateTimeOfDayChart(messageHours: number[]): string {
1903    if (messageHours.length === 0) return '<p class="empty">No time data</p>'
1904  
1905    // Group into time periods
1906    const periods = [
1907      { label: 'Morning (6-12)', range: [6, 7, 8, 9, 10, 11] },
1908      { label: 'Afternoon (12-18)', range: [12, 13, 14, 15, 16, 17] },
1909      { label: 'Evening (18-24)', range: [18, 19, 20, 21, 22, 23] },
1910      { label: 'Night (0-6)', range: [0, 1, 2, 3, 4, 5] },
1911    ]
1912  
1913    const hourCounts: Record<number, number> = {}
1914    for (const h of messageHours) {
1915      hourCounts[h] = (hourCounts[h] || 0) + 1
1916    }
1917  
1918    const periodCounts = periods.map(p => ({
1919      label: p.label,
1920      count: p.range.reduce((sum, h) => sum + (hourCounts[h] || 0), 0),
1921    }))
1922  
1923    const maxVal = Math.max(...periodCounts.map(p => p.count)) || 1
1924  
1925    const barsHtml = periodCounts
1926      .map(
1927        p => `
1928        <div class="bar-row">
1929          <div class="bar-label">${p.label}</div>
1930          <div class="bar-track"><div class="bar-fill" style="width:${(p.count / maxVal) * 100}%;background:#8b5cf6"></div></div>
1931          <div class="bar-value">${p.count}</div>
1932        </div>`,
1933      )
1934      .join('\n')
1935  
1936    return `<div id="hour-histogram">${barsHtml}</div>`
1937  }
1938  
1939  function getHourCountsJson(messageHours: number[]): string {
1940    const hourCounts: Record<number, number> = {}
1941    for (const h of messageHours) {
1942      hourCounts[h] = (hourCounts[h] || 0) + 1
1943    }
1944    return jsonStringify(hourCounts)
1945  }
1946  
1947  function generateHtmlReport(
1948    data: AggregatedData,
1949    insights: InsightResults,
1950  ): string {
1951    const markdownToHtml = (md: string): string => {
1952      if (!md) return ''
1953      return md
1954        .split('\n\n')
1955        .map(p => {
1956          let html = escapeHtml(p)
1957          html = html.replace(/\*\*(.+?)\*\*/g, '<strong>$1</strong>')
1958          html = html.replace(/^- /gm, '• ')
1959          html = html.replace(/\n/g, '<br>')
1960          return `<p>${html}</p>`
1961        })
1962        .join('\n')
1963    }
1964  
1965    // Build At a Glance section (new 4-part format with links to sections)
1966    const atAGlance = insights.at_a_glance
1967    const atAGlanceHtml = atAGlance
1968      ? `
1969      <div class="at-a-glance">
1970        <div class="glance-title">At a Glance</div>
1971        <div class="glance-sections">
1972          ${atAGlance.whats_working ? `<div class="glance-section"><strong>What's working:</strong> ${escapeHtmlWithBold(atAGlance.whats_working)} <a href="#section-wins" class="see-more">Impressive Things You Did →</a></div>` : ''}
1973          ${atAGlance.whats_hindering ? `<div class="glance-section"><strong>What's hindering you:</strong> ${escapeHtmlWithBold(atAGlance.whats_hindering)} <a href="#section-friction" class="see-more">Where Things Go Wrong →</a></div>` : ''}
1974          ${atAGlance.quick_wins ? `<div class="glance-section"><strong>Quick wins to try:</strong> ${escapeHtmlWithBold(atAGlance.quick_wins)} <a href="#section-features" class="see-more">Features to Try →</a></div>` : ''}
1975          ${atAGlance.ambitious_workflows ? `<div class="glance-section"><strong>Ambitious workflows:</strong> ${escapeHtmlWithBold(atAGlance.ambitious_workflows)} <a href="#section-horizon" class="see-more">On the Horizon →</a></div>` : ''}
1976        </div>
1977      </div>
1978      `
1979      : ''
1980  
1981    // Build project areas section
1982    const projectAreas = insights.project_areas?.areas || []
1983    const projectAreasHtml =
1984      projectAreas.length > 0
1985        ? `
1986      <h2 id="section-work">What You Work On</h2>
1987      <div class="project-areas">
1988        ${projectAreas
1989          .map(
1990            area => `
1991          <div class="project-area">
1992            <div class="area-header">
1993              <span class="area-name">${escapeHtml(area.name)}</span>
1994              <span class="area-count">~${area.session_count} sessions</span>
1995            </div>
1996            <div class="area-desc">${escapeHtml(area.description)}</div>
1997          </div>
1998        `,
1999          )
2000          .join('')}
2001      </div>
2002      `
2003        : ''
2004  
2005    // Build interaction style section
2006    const interactionStyle = insights.interaction_style
2007    const interactionHtml = interactionStyle?.narrative
2008      ? `
2009      <h2 id="section-usage">How You Use Claude Code</h2>
2010      <div class="narrative">
2011        ${markdownToHtml(interactionStyle.narrative)}
2012        ${interactionStyle.key_pattern ? `<div class="key-insight"><strong>Key pattern:</strong> ${escapeHtml(interactionStyle.key_pattern)}</div>` : ''}
2013      </div>
2014      `
2015      : ''
2016  
2017    // Build what works section
2018    const whatWorks = insights.what_works
2019    const whatWorksHtml =
2020      whatWorks?.impressive_workflows && whatWorks.impressive_workflows.length > 0
2021        ? `
2022      <h2 id="section-wins">Impressive Things You Did</h2>
2023      ${whatWorks.intro ? `<p class="section-intro">${escapeHtml(whatWorks.intro)}</p>` : ''}
2024      <div class="big-wins">
2025        ${whatWorks.impressive_workflows
2026          .map(
2027            wf => `
2028          <div class="big-win">
2029            <div class="big-win-title">${escapeHtml(wf.title || '')}</div>
2030            <div class="big-win-desc">${escapeHtml(wf.description || '')}</div>
2031          </div>
2032        `,
2033          )
2034          .join('')}
2035      </div>
2036      `
2037        : ''
2038  
2039    // Build friction section
2040    const frictionAnalysis = insights.friction_analysis
2041    const frictionHtml =
2042      frictionAnalysis?.categories && frictionAnalysis.categories.length > 0
2043        ? `
2044      <h2 id="section-friction">Where Things Go Wrong</h2>
2045      ${frictionAnalysis.intro ? `<p class="section-intro">${escapeHtml(frictionAnalysis.intro)}</p>` : ''}
2046      <div class="friction-categories">
2047        ${frictionAnalysis.categories
2048          .map(
2049            cat => `
2050          <div class="friction-category">
2051            <div class="friction-title">${escapeHtml(cat.category || '')}</div>
2052            <div class="friction-desc">${escapeHtml(cat.description || '')}</div>
2053            ${cat.examples ? `<ul class="friction-examples">${cat.examples.map(ex => `<li>${escapeHtml(ex)}</li>`).join('')}</ul>` : ''}
2054          </div>
2055        `,
2056          )
2057          .join('')}
2058      </div>
2059      `
2060        : ''
2061  
2062    // Build suggestions section
2063    const suggestions = insights.suggestions
2064    const suggestionsHtml = suggestions
2065      ? `
2066      ${
2067        suggestions.claude_md_additions &&
2068        suggestions.claude_md_additions.length > 0
2069          ? `
2070      <h2 id="section-features">Existing CC Features to Try</h2>
2071      <div class="claude-md-section">
2072        <h3>Suggested CLAUDE.md Additions</h3>
2073        <p style="font-size: 12px; color: #64748b; margin-bottom: 12px;">Just copy this into Claude Code to add it to your CLAUDE.md.</p>
2074        <div class="claude-md-actions">
2075          <button class="copy-all-btn" onclick="copyAllCheckedClaudeMd()">Copy All Checked</button>
2076        </div>
2077        ${suggestions.claude_md_additions
2078          .map(
2079            (add, i) => `
2080          <div class="claude-md-item">
2081            <input type="checkbox" id="cmd-${i}" class="cmd-checkbox" checked data-text="${escapeHtml(add.prompt_scaffold || add.where || 'Add to CLAUDE.md')}\\n\\n${escapeHtml(add.addition)}">
2082            <label for="cmd-${i}">
2083              <code class="cmd-code">${escapeHtml(add.addition)}</code>
2084              <button class="copy-btn" onclick="copyCmdItem(${i})">Copy</button>
2085            </label>
2086            <div class="cmd-why">${escapeHtml(add.why)}</div>
2087          </div>
2088        `,
2089          )
2090          .join('')}
2091      </div>
2092      `
2093          : ''
2094      }
2095      ${
2096        suggestions.features_to_try && suggestions.features_to_try.length > 0
2097          ? `
2098      <p style="font-size: 13px; color: #64748b; margin-bottom: 12px;">Just copy this into Claude Code and it'll set it up for you.</p>
2099      <div class="features-section">
2100        ${suggestions.features_to_try
2101          .map(
2102            feat => `
2103          <div class="feature-card">
2104            <div class="feature-title">${escapeHtml(feat.feature || '')}</div>
2105            <div class="feature-oneliner">${escapeHtml(feat.one_liner || '')}</div>
2106            <div class="feature-why"><strong>Why for you:</strong> ${escapeHtml(feat.why_for_you || '')}</div>
2107            ${
2108              feat.example_code
2109                ? `
2110            <div class="feature-examples">
2111              <div class="feature-example">
2112                <div class="example-code-row">
2113                  <code class="example-code">${escapeHtml(feat.example_code)}</code>
2114                  <button class="copy-btn" onclick="copyText(this)">Copy</button>
2115                </div>
2116              </div>
2117            </div>
2118            `
2119                : ''
2120            }
2121          </div>
2122        `,
2123          )
2124          .join('')}
2125      </div>
2126      `
2127          : ''
2128      }
2129      ${
2130        suggestions.usage_patterns && suggestions.usage_patterns.length > 0
2131          ? `
2132      <h2 id="section-patterns">New Ways to Use Claude Code</h2>
2133      <p style="font-size: 13px; color: #64748b; margin-bottom: 12px;">Just copy this into Claude Code and it'll walk you through it.</p>
2134      <div class="patterns-section">
2135        ${suggestions.usage_patterns
2136          .map(
2137            pat => `
2138          <div class="pattern-card">
2139            <div class="pattern-title">${escapeHtml(pat.title || '')}</div>
2140            <div class="pattern-summary">${escapeHtml(pat.suggestion || '')}</div>
2141            ${pat.detail ? `<div class="pattern-detail">${escapeHtml(pat.detail)}</div>` : ''}
2142            ${
2143              pat.copyable_prompt
2144                ? `
2145            <div class="copyable-prompt-section">
2146              <div class="prompt-label">Paste into Claude Code:</div>
2147              <div class="copyable-prompt-row">
2148                <code class="copyable-prompt">${escapeHtml(pat.copyable_prompt)}</code>
2149                <button class="copy-btn" onclick="copyText(this)">Copy</button>
2150              </div>
2151            </div>
2152            `
2153                : ''
2154            }
2155          </div>
2156        `,
2157          )
2158          .join('')}
2159      </div>
2160      `
2161          : ''
2162      }
2163      `
2164      : ''
2165  
2166    // Build On the Horizon section
2167    const horizonData = insights.on_the_horizon
2168    const horizonHtml =
2169      horizonData?.opportunities && horizonData.opportunities.length > 0
2170        ? `
2171      <h2 id="section-horizon">On the Horizon</h2>
2172      ${horizonData.intro ? `<p class="section-intro">${escapeHtml(horizonData.intro)}</p>` : ''}
2173      <div class="horizon-section">
2174        ${horizonData.opportunities
2175          .map(
2176            opp => `
2177          <div class="horizon-card">
2178            <div class="horizon-title">${escapeHtml(opp.title || '')}</div>
2179            <div class="horizon-possible">${escapeHtml(opp.whats_possible || '')}</div>
2180            ${opp.how_to_try ? `<div class="horizon-tip"><strong>Getting started:</strong> ${escapeHtml(opp.how_to_try)}</div>` : ''}
2181            ${opp.copyable_prompt ? `<div class="pattern-prompt"><div class="prompt-label">Paste into Claude Code:</div><code>${escapeHtml(opp.copyable_prompt)}</code><button class="copy-btn" onclick="copyText(this)">Copy</button></div>` : ''}
2182          </div>
2183        `,
2184          )
2185          .join('')}
2186      </div>
2187      `
2188        : ''
2189  
2190    // Build Team Feedback section (collapsible, ant-only)
2191    const ccImprovements =
2192      process.env.USER_TYPE === 'ant'
2193        ? insights.cc_team_improvements?.improvements || []
2194        : []
2195    const modelImprovements =
2196      process.env.USER_TYPE === 'ant'
2197        ? insights.model_behavior_improvements?.improvements || []
2198        : []
2199    const teamFeedbackHtml =
2200      ccImprovements.length > 0 || modelImprovements.length > 0
2201        ? `
2202      <h2 id="section-feedback" class="feedback-header">Closing the Loop: Feedback for Other Teams</h2>
2203      <p class="feedback-intro">Suggestions for the CC product and model teams based on your usage patterns. Click to expand.</p>
2204      ${
2205        ccImprovements.length > 0
2206          ? `
2207      <div class="collapsible-section">
2208        <div class="collapsible-header" onclick="toggleCollapsible(this)">
2209          <span class="collapsible-arrow">▶</span>
2210          <h3>Product Improvements for CC Team</h3>
2211        </div>
2212        <div class="collapsible-content">
2213          <div class="suggestions-section">
2214            ${ccImprovements
2215              .map(
2216                imp => `
2217              <div class="feedback-card team-card">
2218                <div class="feedback-title">${escapeHtml(imp.title || '')}</div>
2219                <div class="feedback-detail">${escapeHtml(imp.detail || '')}</div>
2220                ${imp.evidence ? `<div class="feedback-evidence"><em>Evidence:</em> ${escapeHtml(imp.evidence)}</div>` : ''}
2221              </div>
2222            `,
2223              )
2224              .join('')}
2225          </div>
2226        </div>
2227      </div>
2228      `
2229          : ''
2230      }
2231      ${
2232        modelImprovements.length > 0
2233          ? `
2234      <div class="collapsible-section">
2235        <div class="collapsible-header" onclick="toggleCollapsible(this)">
2236          <span class="collapsible-arrow">▶</span>
2237          <h3>Model Behavior Improvements</h3>
2238        </div>
2239        <div class="collapsible-content">
2240          <div class="suggestions-section">
2241            ${modelImprovements
2242              .map(
2243                imp => `
2244              <div class="feedback-card model-card">
2245                <div class="feedback-title">${escapeHtml(imp.title || '')}</div>
2246                <div class="feedback-detail">${escapeHtml(imp.detail || '')}</div>
2247                ${imp.evidence ? `<div class="feedback-evidence"><em>Evidence:</em> ${escapeHtml(imp.evidence)}</div>` : ''}
2248              </div>
2249            `,
2250              )
2251              .join('')}
2252          </div>
2253        </div>
2254      </div>
2255      `
2256          : ''
2257      }
2258      `
2259        : ''
2260  
2261    // Build Fun Ending section
2262    const funEnding = insights.fun_ending
2263    const funEndingHtml = funEnding?.headline
2264      ? `
2265      <div class="fun-ending">
2266        <div class="fun-headline">"${escapeHtml(funEnding.headline)}"</div>
2267        ${funEnding.detail ? `<div class="fun-detail">${escapeHtml(funEnding.detail)}</div>` : ''}
2268      </div>
2269      `
2270      : ''
2271  
2272    const css = `
2273      * { box-sizing: border-box; margin: 0; padding: 0; }
2274      body { font-family: 'Inter', -apple-system, BlinkMacSystemFont, sans-serif; background: #f8fafc; color: #334155; line-height: 1.65; padding: 48px 24px; }
2275      .container { max-width: 800px; margin: 0 auto; }
2276      h1 { font-size: 32px; font-weight: 700; color: #0f172a; margin-bottom: 8px; }
2277      h2 { font-size: 20px; font-weight: 600; color: #0f172a; margin-top: 48px; margin-bottom: 16px; }
2278      .subtitle { color: #64748b; font-size: 15px; margin-bottom: 32px; }
2279      .nav-toc { display: flex; flex-wrap: wrap; gap: 8px; margin: 24px 0 32px 0; padding: 16px; background: white; border-radius: 8px; border: 1px solid #e2e8f0; }
2280      .nav-toc a { font-size: 12px; color: #64748b; text-decoration: none; padding: 6px 12px; border-radius: 6px; background: #f1f5f9; transition: all 0.15s; }
2281      .nav-toc a:hover { background: #e2e8f0; color: #334155; }
2282      .stats-row { display: flex; gap: 24px; margin-bottom: 40px; padding: 20px 0; border-top: 1px solid #e2e8f0; border-bottom: 1px solid #e2e8f0; flex-wrap: wrap; }
2283      .stat { text-align: center; }
2284      .stat-value { font-size: 24px; font-weight: 700; color: #0f172a; }
2285      .stat-label { font-size: 11px; color: #64748b; text-transform: uppercase; }
2286      .at-a-glance { background: linear-gradient(135deg, #fef3c7 0%, #fde68a 100%); border: 1px solid #f59e0b; border-radius: 12px; padding: 20px 24px; margin-bottom: 32px; }
2287      .glance-title { font-size: 16px; font-weight: 700; color: #92400e; margin-bottom: 16px; }
2288      .glance-sections { display: flex; flex-direction: column; gap: 12px; }
2289      .glance-section { font-size: 14px; color: #78350f; line-height: 1.6; }
2290      .glance-section strong { color: #92400e; }
2291      .see-more { color: #b45309; text-decoration: none; font-size: 13px; white-space: nowrap; }
2292      .see-more:hover { text-decoration: underline; }
2293      .project-areas { display: flex; flex-direction: column; gap: 12px; margin-bottom: 32px; }
2294      .project-area { background: white; border: 1px solid #e2e8f0; border-radius: 8px; padding: 16px; }
2295      .area-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 8px; }
2296      .area-name { font-weight: 600; font-size: 15px; color: #0f172a; }
2297      .area-count { font-size: 12px; color: #64748b; background: #f1f5f9; padding: 2px 8px; border-radius: 4px; }
2298      .area-desc { font-size: 14px; color: #475569; line-height: 1.5; }
2299      .narrative { background: white; border: 1px solid #e2e8f0; border-radius: 8px; padding: 20px; margin-bottom: 24px; }
2300      .narrative p { margin-bottom: 12px; font-size: 14px; color: #475569; line-height: 1.7; }
2301      .key-insight { background: #f0fdf4; border: 1px solid #bbf7d0; border-radius: 8px; padding: 12px 16px; margin-top: 12px; font-size: 14px; color: #166534; }
2302      .section-intro { font-size: 14px; color: #64748b; margin-bottom: 16px; }
2303      .big-wins { display: flex; flex-direction: column; gap: 12px; margin-bottom: 24px; }
2304      .big-win { background: #f0fdf4; border: 1px solid #bbf7d0; border-radius: 8px; padding: 16px; }
2305      .big-win-title { font-weight: 600; font-size: 15px; color: #166534; margin-bottom: 8px; }
2306      .big-win-desc { font-size: 14px; color: #15803d; line-height: 1.5; }
2307      .friction-categories { display: flex; flex-direction: column; gap: 16px; margin-bottom: 24px; }
2308      .friction-category { background: #fef2f2; border: 1px solid #fca5a5; border-radius: 8px; padding: 16px; }
2309      .friction-title { font-weight: 600; font-size: 15px; color: #991b1b; margin-bottom: 6px; }
2310      .friction-desc { font-size: 13px; color: #7f1d1d; margin-bottom: 10px; }
2311      .friction-examples { margin: 0 0 0 20px; font-size: 13px; color: #334155; }
2312      .friction-examples li { margin-bottom: 4px; }
2313      .claude-md-section { background: #eff6ff; border: 1px solid #bfdbfe; border-radius: 8px; padding: 16px; margin-bottom: 20px; }
2314      .claude-md-section h3 { font-size: 14px; font-weight: 600; color: #1e40af; margin: 0 0 12px 0; }
2315      .claude-md-actions { margin-bottom: 12px; padding-bottom: 12px; border-bottom: 1px solid #dbeafe; }
2316      .copy-all-btn { background: #2563eb; color: white; border: none; border-radius: 4px; padding: 6px 12px; font-size: 12px; cursor: pointer; font-weight: 500; transition: all 0.2s; }
2317      .copy-all-btn:hover { background: #1d4ed8; }
2318      .copy-all-btn.copied { background: #16a34a; }
2319      .claude-md-item { display: flex; flex-wrap: wrap; align-items: flex-start; gap: 8px; padding: 10px 0; border-bottom: 1px solid #dbeafe; }
2320      .claude-md-item:last-child { border-bottom: none; }
2321      .cmd-checkbox { margin-top: 2px; }
2322      .cmd-code { background: white; padding: 8px 12px; border-radius: 4px; font-size: 12px; color: #1e40af; border: 1px solid #bfdbfe; font-family: monospace; display: block; white-space: pre-wrap; word-break: break-word; flex: 1; }
2323      .cmd-why { font-size: 12px; color: #64748b; width: 100%; padding-left: 24px; margin-top: 4px; }
2324      .features-section, .patterns-section { display: flex; flex-direction: column; gap: 12px; margin: 16px 0; }
2325      .feature-card { background: #f0fdf4; border: 1px solid #86efac; border-radius: 8px; padding: 16px; }
2326      .pattern-card { background: #f0f9ff; border: 1px solid #7dd3fc; border-radius: 8px; padding: 16px; }
2327      .feature-title, .pattern-title { font-weight: 600; font-size: 15px; color: #0f172a; margin-bottom: 6px; }
2328      .feature-oneliner { font-size: 14px; color: #475569; margin-bottom: 8px; }
2329      .pattern-summary { font-size: 14px; color: #475569; margin-bottom: 8px; }
2330      .feature-why, .pattern-detail { font-size: 13px; color: #334155; line-height: 1.5; }
2331      .feature-examples { margin-top: 12px; }
2332      .feature-example { padding: 8px 0; border-top: 1px solid #d1fae5; }
2333      .feature-example:first-child { border-top: none; }
2334      .example-desc { font-size: 13px; color: #334155; margin-bottom: 6px; }
2335      .example-code-row { display: flex; align-items: flex-start; gap: 8px; }
2336      .example-code { flex: 1; background: #f1f5f9; padding: 8px 12px; border-radius: 4px; font-family: monospace; font-size: 12px; color: #334155; overflow-x: auto; white-space: pre-wrap; }
2337      .copyable-prompt-section { margin-top: 12px; padding-top: 12px; border-top: 1px solid #e2e8f0; }
2338      .copyable-prompt-row { display: flex; align-items: flex-start; gap: 8px; }
2339      .copyable-prompt { flex: 1; background: #f8fafc; padding: 10px 12px; border-radius: 4px; font-family: monospace; font-size: 12px; color: #334155; border: 1px solid #e2e8f0; white-space: pre-wrap; line-height: 1.5; }
2340      .feature-code { background: #f8fafc; padding: 12px; border-radius: 6px; margin-top: 12px; border: 1px solid #e2e8f0; display: flex; align-items: flex-start; gap: 8px; }
2341      .feature-code code { flex: 1; font-family: monospace; font-size: 12px; color: #334155; white-space: pre-wrap; }
2342      .pattern-prompt { background: #f8fafc; padding: 12px; border-radius: 6px; margin-top: 12px; border: 1px solid #e2e8f0; }
2343      .pattern-prompt code { font-family: monospace; font-size: 12px; color: #334155; display: block; white-space: pre-wrap; margin-bottom: 8px; }
2344      .prompt-label { font-size: 11px; font-weight: 600; text-transform: uppercase; color: #64748b; margin-bottom: 6px; }
2345      .copy-btn { background: #e2e8f0; border: none; border-radius: 4px; padding: 4px 8px; font-size: 11px; cursor: pointer; color: #475569; flex-shrink: 0; }
2346      .copy-btn:hover { background: #cbd5e1; }
2347      .charts-row { display: grid; grid-template-columns: 1fr 1fr; gap: 24px; margin: 24px 0; }
2348      .chart-card { background: white; border: 1px solid #e2e8f0; border-radius: 8px; padding: 16px; }
2349      .chart-title { font-size: 12px; font-weight: 600; color: #64748b; text-transform: uppercase; margin-bottom: 12px; }
2350      .bar-row { display: flex; align-items: center; margin-bottom: 6px; }
2351      .bar-label { width: 100px; font-size: 11px; color: #475569; flex-shrink: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
2352      .bar-track { flex: 1; height: 6px; background: #f1f5f9; border-radius: 3px; margin: 0 8px; }
2353      .bar-fill { height: 100%; border-radius: 3px; }
2354      .bar-value { width: 28px; font-size: 11px; font-weight: 500; color: #64748b; text-align: right; }
2355      .empty { color: #94a3b8; font-size: 13px; }
2356      .horizon-section { display: flex; flex-direction: column; gap: 16px; }
2357      .horizon-card { background: linear-gradient(135deg, #faf5ff 0%, #f5f3ff 100%); border: 1px solid #c4b5fd; border-radius: 8px; padding: 16px; }
2358      .horizon-title { font-weight: 600; font-size: 15px; color: #5b21b6; margin-bottom: 8px; }
2359      .horizon-possible { font-size: 14px; color: #334155; margin-bottom: 10px; line-height: 1.5; }
2360      .horizon-tip { font-size: 13px; color: #6b21a8; background: rgba(255,255,255,0.6); padding: 8px 12px; border-radius: 4px; }
2361      .feedback-header { margin-top: 48px; color: #64748b; font-size: 16px; }
2362      .feedback-intro { font-size: 13px; color: #94a3b8; margin-bottom: 16px; }
2363      .feedback-section { margin-top: 16px; }
2364      .feedback-section h3 { font-size: 14px; font-weight: 600; color: #475569; margin-bottom: 12px; }
2365      .feedback-card { background: white; border: 1px solid #e2e8f0; border-radius: 8px; padding: 16px; margin-bottom: 12px; }
2366      .feedback-card.team-card { background: #eff6ff; border-color: #bfdbfe; }
2367      .feedback-card.model-card { background: #faf5ff; border-color: #e9d5ff; }
2368      .feedback-title { font-weight: 600; font-size: 14px; color: #0f172a; margin-bottom: 6px; }
2369      .feedback-detail { font-size: 13px; color: #475569; line-height: 1.5; }
2370      .feedback-evidence { font-size: 12px; color: #64748b; margin-top: 8px; }
2371      .fun-ending { background: linear-gradient(135deg, #fef3c7 0%, #fde68a 100%); border: 1px solid #fbbf24; border-radius: 12px; padding: 24px; margin-top: 40px; text-align: center; }
2372      .fun-headline { font-size: 18px; font-weight: 600; color: #78350f; margin-bottom: 8px; }
2373      .fun-detail { font-size: 14px; color: #92400e; }
2374      .collapsible-section { margin-top: 16px; }
2375      .collapsible-header { display: flex; align-items: center; gap: 8px; cursor: pointer; padding: 12px 0; border-bottom: 1px solid #e2e8f0; }
2376      .collapsible-header h3 { margin: 0; font-size: 14px; font-weight: 600; color: #475569; }
2377      .collapsible-arrow { font-size: 12px; color: #94a3b8; transition: transform 0.2s; }
2378      .collapsible-content { display: none; padding-top: 16px; }
2379      .collapsible-content.open { display: block; }
2380      .collapsible-header.open .collapsible-arrow { transform: rotate(90deg); }
2381      @media (max-width: 640px) { .charts-row { grid-template-columns: 1fr; } .stats-row { justify-content: center; } }
2382    `
2383  
2384    const hourCountsJson = getHourCountsJson(data.message_hours)
2385  
2386    const js = `
2387      function toggleCollapsible(header) {
2388        header.classList.toggle('open');
2389        const content = header.nextElementSibling;
2390        content.classList.toggle('open');
2391      }
2392      function copyText(btn) {
2393        const code = btn.previousElementSibling;
2394        navigator.clipboard.writeText(code.textContent).then(() => {
2395          btn.textContent = 'Copied!';
2396          setTimeout(() => { btn.textContent = 'Copy'; }, 2000);
2397        });
2398      }
2399      function copyCmdItem(idx) {
2400        const checkbox = document.getElementById('cmd-' + idx);
2401        if (checkbox) {
2402          const text = checkbox.dataset.text;
2403          navigator.clipboard.writeText(text).then(() => {
2404            const btn = checkbox.nextElementSibling.querySelector('.copy-btn');
2405            if (btn) { btn.textContent = 'Copied!'; setTimeout(() => { btn.textContent = 'Copy'; }, 2000); }
2406          });
2407        }
2408      }
2409      function copyAllCheckedClaudeMd() {
2410        const checkboxes = document.querySelectorAll('.cmd-checkbox:checked');
2411        const texts = [];
2412        checkboxes.forEach(cb => {
2413          if (cb.dataset.text) { texts.push(cb.dataset.text); }
2414        });
2415        const combined = texts.join('\\n');
2416        const btn = document.querySelector('.copy-all-btn');
2417        if (btn) {
2418          navigator.clipboard.writeText(combined).then(() => {
2419            btn.textContent = 'Copied ' + texts.length + ' items!';
2420            btn.classList.add('copied');
2421            setTimeout(() => { btn.textContent = 'Copy All Checked'; btn.classList.remove('copied'); }, 2000);
2422          });
2423        }
2424      }
2425      // Timezone selector for time of day chart (data is from our own analytics, not user input)
2426      const rawHourCounts = ${hourCountsJson};
2427      function updateHourHistogram(offsetFromPT) {
2428        const periods = [
2429          { label: "Morning (6-12)", range: [6,7,8,9,10,11] },
2430          { label: "Afternoon (12-18)", range: [12,13,14,15,16,17] },
2431          { label: "Evening (18-24)", range: [18,19,20,21,22,23] },
2432          { label: "Night (0-6)", range: [0,1,2,3,4,5] }
2433        ];
2434        const adjustedCounts = {};
2435        for (const [hour, count] of Object.entries(rawHourCounts)) {
2436          const newHour = (parseInt(hour) + offsetFromPT + 24) % 24;
2437          adjustedCounts[newHour] = (adjustedCounts[newHour] || 0) + count;
2438        }
2439        const periodCounts = periods.map(p => ({
2440          label: p.label,
2441          count: p.range.reduce((sum, h) => sum + (adjustedCounts[h] || 0), 0)
2442        }));
2443        const maxCount = Math.max(...periodCounts.map(p => p.count)) || 1;
2444        const container = document.getElementById('hour-histogram');
2445        container.textContent = '';
2446        periodCounts.forEach(p => {
2447          const row = document.createElement('div');
2448          row.className = 'bar-row';
2449          const label = document.createElement('div');
2450          label.className = 'bar-label';
2451          label.textContent = p.label;
2452          const track = document.createElement('div');
2453          track.className = 'bar-track';
2454          const fill = document.createElement('div');
2455          fill.className = 'bar-fill';
2456          fill.style.width = (p.count / maxCount) * 100 + '%';
2457          fill.style.background = '#8b5cf6';
2458          track.appendChild(fill);
2459          const value = document.createElement('div');
2460          value.className = 'bar-value';
2461          value.textContent = p.count;
2462          row.appendChild(label);
2463          row.appendChild(track);
2464          row.appendChild(value);
2465          container.appendChild(row);
2466        });
2467      }
2468      document.getElementById('timezone-select').addEventListener('change', function() {
2469        const customInput = document.getElementById('custom-offset');
2470        if (this.value === 'custom') {
2471          customInput.style.display = 'inline-block';
2472          customInput.focus();
2473        } else {
2474          customInput.style.display = 'none';
2475          updateHourHistogram(parseInt(this.value));
2476        }
2477      });
2478      document.getElementById('custom-offset').addEventListener('change', function() {
2479        const offset = parseInt(this.value) + 8;
2480        updateHourHistogram(offset);
2481      });
2482    `
2483  
2484    return `<!DOCTYPE html>
2485  <html>
2486  <head>
2487    <meta charset="utf-8">
2488    <title>Claude Code Insights</title>
2489    <link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap" rel="stylesheet">
2490    <style>${css}</style>
2491  </head>
2492  <body>
2493    <div class="container">
2494      <h1>Claude Code Insights</h1>
2495      <p class="subtitle">${data.total_messages.toLocaleString()} messages across ${data.total_sessions} sessions${data.total_sessions_scanned && data.total_sessions_scanned > data.total_sessions ? ` (${data.total_sessions_scanned.toLocaleString()} total)` : ''} | ${data.date_range.start} to ${data.date_range.end}</p>
2496  
2497      ${atAGlanceHtml}
2498  
2499      <nav class="nav-toc">
2500        <a href="#section-work">What You Work On</a>
2501        <a href="#section-usage">How You Use CC</a>
2502        <a href="#section-wins">Impressive Things</a>
2503        <a href="#section-friction">Where Things Go Wrong</a>
2504        <a href="#section-features">Features to Try</a>
2505        <a href="#section-patterns">New Usage Patterns</a>
2506        <a href="#section-horizon">On the Horizon</a>
2507        <a href="#section-feedback">Team Feedback</a>
2508      </nav>
2509  
2510      <div class="stats-row">
2511        <div class="stat"><div class="stat-value">${data.total_messages.toLocaleString()}</div><div class="stat-label">Messages</div></div>
2512        <div class="stat"><div class="stat-value">+${data.total_lines_added.toLocaleString()}/-${data.total_lines_removed.toLocaleString()}</div><div class="stat-label">Lines</div></div>
2513        <div class="stat"><div class="stat-value">${data.total_files_modified}</div><div class="stat-label">Files</div></div>
2514        <div class="stat"><div class="stat-value">${data.days_active}</div><div class="stat-label">Days</div></div>
2515        <div class="stat"><div class="stat-value">${data.messages_per_day}</div><div class="stat-label">Msgs/Day</div></div>
2516      </div>
2517  
2518      ${projectAreasHtml}
2519  
2520      <div class="charts-row">
2521        <div class="chart-card">
2522          <div class="chart-title">What You Wanted</div>
2523          ${generateBarChart(data.goal_categories, '#2563eb')}
2524        </div>
2525        <div class="chart-card">
2526          <div class="chart-title">Top Tools Used</div>
2527          ${generateBarChart(data.tool_counts, '#0891b2')}
2528        </div>
2529      </div>
2530  
2531      <div class="charts-row">
2532        <div class="chart-card">
2533          <div class="chart-title">Languages</div>
2534          ${generateBarChart(data.languages, '#10b981')}
2535        </div>
2536        <div class="chart-card">
2537          <div class="chart-title">Session Types</div>
2538          ${generateBarChart(data.session_types || {}, '#8b5cf6')}
2539        </div>
2540      </div>
2541  
2542      ${interactionHtml}
2543  
2544      <!-- Response Time Distribution -->
2545      <div class="chart-card" style="margin: 24px 0;">
2546        <div class="chart-title">User Response Time Distribution</div>
2547        ${generateResponseTimeHistogram(data.user_response_times)}
2548        <div style="font-size: 12px; color: #64748b; margin-top: 8px;">
2549          Median: ${data.median_response_time.toFixed(1)}s &bull; Average: ${data.avg_response_time.toFixed(1)}s
2550        </div>
2551      </div>
2552  
2553      <!-- Multi-clauding Section (matching Python reference) -->
2554      <div class="chart-card" style="margin: 24px 0;">
2555        <div class="chart-title">Multi-Clauding (Parallel Sessions)</div>
2556        ${
2557          data.multi_clauding.overlap_events === 0
2558            ? `
2559          <p style="font-size: 14px; color: #64748b; padding: 8px 0;">
2560            No parallel session usage detected. You typically work with one Claude Code session at a time.
2561          </p>
2562        `
2563            : `
2564          <div style="display: flex; gap: 24px; margin: 12px 0;">
2565            <div style="text-align: center;">
2566              <div style="font-size: 24px; font-weight: 700; color: #7c3aed;">${data.multi_clauding.overlap_events}</div>
2567              <div style="font-size: 11px; color: #64748b; text-transform: uppercase;">Overlap Events</div>
2568            </div>
2569            <div style="text-align: center;">
2570              <div style="font-size: 24px; font-weight: 700; color: #7c3aed;">${data.multi_clauding.sessions_involved}</div>
2571              <div style="font-size: 11px; color: #64748b; text-transform: uppercase;">Sessions Involved</div>
2572            </div>
2573            <div style="text-align: center;">
2574              <div style="font-size: 24px; font-weight: 700; color: #7c3aed;">${data.total_messages > 0 ? Math.round((100 * data.multi_clauding.user_messages_during) / data.total_messages) : 0}%</div>
2575              <div style="font-size: 11px; color: #64748b; text-transform: uppercase;">Of Messages</div>
2576            </div>
2577          </div>
2578          <p style="font-size: 13px; color: #475569; margin-top: 12px;">
2579            You run multiple Claude Code sessions simultaneously. Multi-clauding is detected when sessions
2580            overlap in time, suggesting parallel workflows.
2581          </p>
2582        `
2583        }
2584      </div>
2585  
2586      <!-- Time of Day & Tool Errors -->
2587      <div class="charts-row">
2588        <div class="chart-card">
2589          <div class="chart-title" style="display: flex; align-items: center; gap: 12px;">
2590            User Messages by Time of Day
2591            <select id="timezone-select" style="font-size: 12px; padding: 4px 8px; border-radius: 4px; border: 1px solid #e2e8f0;">
2592              <option value="0">PT (UTC-8)</option>
2593              <option value="3">ET (UTC-5)</option>
2594              <option value="8">London (UTC)</option>
2595              <option value="9">CET (UTC+1)</option>
2596              <option value="17">Tokyo (UTC+9)</option>
2597              <option value="custom">Custom offset...</option>
2598            </select>
2599            <input type="number" id="custom-offset" placeholder="UTC offset" style="display: none; width: 80px; font-size: 12px; padding: 4px; border-radius: 4px; border: 1px solid #e2e8f0;">
2600          </div>
2601          ${generateTimeOfDayChart(data.message_hours)}
2602        </div>
2603        <div class="chart-card">
2604          <div class="chart-title">Tool Errors Encountered</div>
2605          ${Object.keys(data.tool_error_categories).length > 0 ? generateBarChart(data.tool_error_categories, '#dc2626') : '<p class="empty">No tool errors</p>'}
2606        </div>
2607      </div>
2608  
2609      ${whatWorksHtml}
2610  
2611      <div class="charts-row">
2612        <div class="chart-card">
2613          <div class="chart-title">What Helped Most (Claude's Capabilities)</div>
2614          ${generateBarChart(data.success, '#16a34a')}
2615        </div>
2616        <div class="chart-card">
2617          <div class="chart-title">Outcomes</div>
2618          ${generateBarChart(data.outcomes, '#8b5cf6', 6, OUTCOME_ORDER)}
2619        </div>
2620      </div>
2621  
2622      ${frictionHtml}
2623  
2624      <div class="charts-row">
2625        <div class="chart-card">
2626          <div class="chart-title">Primary Friction Types</div>
2627          ${generateBarChart(data.friction, '#dc2626')}
2628        </div>
2629        <div class="chart-card">
2630          <div class="chart-title">Inferred Satisfaction (model-estimated)</div>
2631          ${generateBarChart(data.satisfaction, '#eab308', 6, SATISFACTION_ORDER)}
2632        </div>
2633      </div>
2634  
2635      ${suggestionsHtml}
2636  
2637      ${horizonHtml}
2638  
2639      ${funEndingHtml}
2640  
2641      ${teamFeedbackHtml}
2642    </div>
2643    <script>${js}</script>
2644  </body>
2645  </html>`
2646  }
2647  
2648  // ============================================================================
2649  // Export Types & Functions
2650  // ============================================================================
2651  
2652  /**
2653   * Structured export format for claudescope consumption
2654   */
2655  export type InsightsExport = {
2656    metadata: {
2657      username: string
2658      generated_at: string
2659      claude_code_version: string
2660      date_range: { start: string; end: string }
2661      session_count: number
2662      remote_hosts_collected?: string[]
2663    }
2664    aggregated_data: AggregatedData
2665    insights: InsightResults
2666    facets_summary?: {
2667      total: number
2668      goal_categories: Record<string, number>
2669      outcomes: Record<string, number>
2670      satisfaction: Record<string, number>
2671      friction: Record<string, number>
2672    }
2673  }
2674  
2675  /**
2676   * Build export data from already-computed values.
2677   * Used by background upload to S3.
2678   */
2679  export function buildExportData(
2680    data: AggregatedData,
2681    insights: InsightResults,
2682    facets: Map<string, SessionFacets>,
2683    remoteStats?: { hosts: RemoteHostInfo[]; totalCopied: number },
2684  ): InsightsExport {
2685    const version = typeof MACRO !== 'undefined' ? MACRO.VERSION : 'unknown'
2686  
2687    const remote_hosts_collected = remoteStats?.hosts
2688      .filter(h => h.sessionCount > 0)
2689      .map(h => h.name)
2690  
2691    const facets_summary = {
2692      total: facets.size,
2693      goal_categories: {} as Record<string, number>,
2694      outcomes: {} as Record<string, number>,
2695      satisfaction: {} as Record<string, number>,
2696      friction: {} as Record<string, number>,
2697    }
2698    for (const f of facets.values()) {
2699      for (const [cat, count] of safeEntries(f.goal_categories)) {
2700        if (count > 0) {
2701          facets_summary.goal_categories[cat] =
2702            (facets_summary.goal_categories[cat] || 0) + count
2703        }
2704      }
2705      facets_summary.outcomes[f.outcome] =
2706        (facets_summary.outcomes[f.outcome] || 0) + 1
2707      for (const [level, count] of safeEntries(f.user_satisfaction_counts)) {
2708        if (count > 0) {
2709          facets_summary.satisfaction[level] =
2710            (facets_summary.satisfaction[level] || 0) + count
2711        }
2712      }
2713      for (const [type, count] of safeEntries(f.friction_counts)) {
2714        if (count > 0) {
2715          facets_summary.friction[type] =
2716            (facets_summary.friction[type] || 0) + count
2717        }
2718      }
2719    }
2720  
2721    return {
2722      metadata: {
2723        username: process.env.SAFEUSER || process.env.USER || 'unknown',
2724        generated_at: new Date().toISOString(),
2725        claude_code_version: version,
2726        date_range: data.date_range,
2727        session_count: data.total_sessions,
2728        ...(remote_hosts_collected &&
2729          remote_hosts_collected.length > 0 && {
2730            remote_hosts_collected,
2731          }),
2732      },
2733      aggregated_data: data,
2734      insights,
2735      facets_summary,
2736    }
2737  }
2738  
2739  // ============================================================================
2740  // Lite Session Scanning
2741  // ============================================================================
2742  
2743  type LiteSessionInfo = {
2744    sessionId: string
2745    path: string
2746    mtime: number
2747    size: number
2748  }
2749  
2750  /**
2751   * Scans all project directories using filesystem metadata only (no JSONL parsing).
2752   * Returns a list of session file info sorted by mtime descending.
2753   * Yields to the event loop between project directories to keep the UI responsive.
2754   */
2755  async function scanAllSessions(): Promise<LiteSessionInfo[]> {
2756    const projectsDir = getProjectsDir()
2757  
2758    let dirents: Awaited<ReturnType<typeof readdir>>
2759    try {
2760      dirents = await readdir(projectsDir, { withFileTypes: true })
2761    } catch {
2762      return []
2763    }
2764  
2765    const projectDirs = dirents
2766      .filter(dirent => dirent.isDirectory())
2767      .map(dirent => join(projectsDir, dirent.name))
2768  
2769    const allSessions: LiteSessionInfo[] = []
2770  
2771    for (let i = 0; i < projectDirs.length; i++) {
2772      const sessionFiles = await getSessionFilesWithMtime(projectDirs[i]!)
2773      for (const [sessionId, fileInfo] of sessionFiles) {
2774        allSessions.push({
2775          sessionId,
2776          path: fileInfo.path,
2777          mtime: fileInfo.mtime,
2778          size: fileInfo.size,
2779        })
2780      }
2781      // Yield to event loop every 10 project directories
2782      if (i % 10 === 9) {
2783        await new Promise<void>(resolve => setImmediate(resolve))
2784      }
2785    }
2786  
2787    // Sort by mtime descending (most recent first)
2788    allSessions.sort((a, b) => b.mtime - a.mtime)
2789    return allSessions
2790  }
2791  
2792  // ============================================================================
2793  // Main Function
2794  // ============================================================================
2795  
2796  export async function generateUsageReport(options?: {
2797    collectRemote?: boolean
2798  }): Promise<{
2799    insights: InsightResults
2800    htmlPath: string
2801    data: AggregatedData
2802    remoteStats?: { hosts: RemoteHostInfo[]; totalCopied: number }
2803    facets: Map<string, SessionFacets>
2804  }> {
2805    let remoteStats: { hosts: RemoteHostInfo[]; totalCopied: number } | undefined
2806  
2807    // Optionally collect data from remote hosts first (ant-only)
2808    if (process.env.USER_TYPE === 'ant' && options?.collectRemote) {
2809      const destDir = join(getClaudeConfigHomeDir(), 'projects')
2810      const { hosts, totalCopied } = await collectAllRemoteHostData(destDir)
2811      remoteStats = { hosts, totalCopied }
2812    }
2813  
2814    // Phase 1: Lite scan — filesystem metadata only (no JSONL parsing)
2815    const allScannedSessions = await scanAllSessions()
2816    const totalSessionsScanned = allScannedSessions.length
2817  
2818    // Phase 2: Load SessionMeta — use cache where available, parse only uncached
2819    // Read cached metas in parallel batches to avoid blocking the event loop
2820    const META_BATCH_SIZE = 50
2821    const MAX_SESSIONS_TO_LOAD = 200
2822    let allMetas: SessionMeta[] = []
2823    const uncachedSessions: LiteSessionInfo[] = []
2824  
2825    for (let i = 0; i < allScannedSessions.length; i += META_BATCH_SIZE) {
2826      const batch = allScannedSessions.slice(i, i + META_BATCH_SIZE)
2827      const results = await Promise.all(
2828        batch.map(async sessionInfo => ({
2829          sessionInfo,
2830          cached: await loadCachedSessionMeta(sessionInfo.sessionId),
2831        })),
2832      )
2833      for (const { sessionInfo, cached } of results) {
2834        if (cached) {
2835          allMetas.push(cached)
2836        } else if (uncachedSessions.length < MAX_SESSIONS_TO_LOAD) {
2837          uncachedSessions.push(sessionInfo)
2838        }
2839      }
2840    }
2841  
2842    // Load full message data only for uncached sessions and compute SessionMeta
2843    const logsForFacets = new Map<string, LogOption>()
2844  
2845    // Filter out /insights meta-sessions (facet extraction API calls get logged as sessions)
2846    const isMetaSession = (log: LogOption): boolean => {
2847      for (const msg of log.messages.slice(0, 5)) {
2848        if (msg.type === 'user' && msg.message) {
2849          const content = msg.message.content
2850          if (typeof content === 'string') {
2851            if (
2852              content.includes('RESPOND WITH ONLY A VALID JSON OBJECT') ||
2853              content.includes('record_facets')
2854            ) {
2855              return true
2856            }
2857          }
2858        }
2859      }
2860      return false
2861    }
2862  
2863    // Load uncached sessions in batches to yield to event loop between batches
2864    const LOAD_BATCH_SIZE = 10
2865    for (let i = 0; i < uncachedSessions.length; i += LOAD_BATCH_SIZE) {
2866      const batch = uncachedSessions.slice(i, i + LOAD_BATCH_SIZE)
2867      const batchResults = await Promise.all(
2868        batch.map(async sessionInfo => {
2869          try {
2870            return await loadAllLogsFromSessionFile(sessionInfo.path)
2871          } catch {
2872            return []
2873          }
2874        }),
2875      )
2876      // Collect metas synchronously, then save them in parallel (independent writes)
2877      const metasToSave: SessionMeta[] = []
2878      for (const logs of batchResults) {
2879        for (const log of logs) {
2880          if (isMetaSession(log) || !hasValidDates(log)) continue
2881          const meta = logToSessionMeta(log)
2882          allMetas.push(meta)
2883          metasToSave.push(meta)
2884          // Keep the log around for potential facet extraction
2885          logsForFacets.set(meta.session_id, log)
2886        }
2887      }
2888      await Promise.all(metasToSave.map(meta => saveSessionMeta(meta)))
2889    }
2890  
2891    // Deduplicate session branches (keep the one with most user messages per session_id)
2892    // This prevents inflated totals when a session has multiple conversation branches
2893    const bestBySession = new Map<string, SessionMeta>()
2894    for (const meta of allMetas) {
2895      const existing = bestBySession.get(meta.session_id)
2896      if (
2897        !existing ||
2898        meta.user_message_count > existing.user_message_count ||
2899        (meta.user_message_count === existing.user_message_count &&
2900          meta.duration_minutes > existing.duration_minutes)
2901      ) {
2902        bestBySession.set(meta.session_id, meta)
2903      }
2904    }
2905    // Replace allMetas with deduplicated list and remove unused logs from logsForFacets
2906    const keptSessionIds = new Set(bestBySession.keys())
2907    allMetas = [...bestBySession.values()]
2908    for (const sessionId of logsForFacets.keys()) {
2909      if (!keptSessionIds.has(sessionId)) {
2910        logsForFacets.delete(sessionId)
2911      }
2912    }
2913  
2914    // Sort all metas by start_time descending (most recent first)
2915    allMetas.sort((a, b) => b.start_time.localeCompare(a.start_time))
2916  
2917    // Pre-filter obviously minimal sessions to save API calls
2918    // (matching Python's substantive filtering concept)
2919    const isSubstantiveSession = (meta: SessionMeta): boolean => {
2920      // Skip sessions with very few user messages
2921      if (meta.user_message_count < 2) return false
2922      // Skip very short sessions (< 1 minute)
2923      if (meta.duration_minutes < 1) return false
2924      return true
2925    }
2926  
2927    const substantiveMetas = allMetas.filter(isSubstantiveSession)
2928  
2929    // Phase 3: Facet extraction — only for sessions without cached facets
2930    const facets = new Map<string, SessionFacets>()
2931    const toExtract: Array<{ log: LogOption; sessionId: string }> = []
2932    const MAX_FACET_EXTRACTIONS = 50
2933  
2934    // Load cached facets for all substantive sessions in parallel
2935    const cachedFacetResults = await Promise.all(
2936      substantiveMetas.map(async meta => ({
2937        sessionId: meta.session_id,
2938        cached: await loadCachedFacets(meta.session_id),
2939      })),
2940    )
2941    for (const { sessionId, cached } of cachedFacetResults) {
2942      if (cached) {
2943        facets.set(sessionId, cached)
2944      } else {
2945        const log = logsForFacets.get(sessionId)
2946        if (log && toExtract.length < MAX_FACET_EXTRACTIONS) {
2947          toExtract.push({ log, sessionId })
2948        }
2949      }
2950    }
2951  
2952    // Extract facets for sessions that need them (50 concurrent)
2953    const CONCURRENCY = 50
2954    for (let i = 0; i < toExtract.length; i += CONCURRENCY) {
2955      const batch = toExtract.slice(i, i + CONCURRENCY)
2956      const results = await Promise.all(
2957        batch.map(async ({ log, sessionId }) => {
2958          const newFacets = await extractFacetsFromAPI(log, sessionId)
2959          return { sessionId, newFacets }
2960        }),
2961      )
2962      // Collect facets synchronously, save in parallel (independent writes)
2963      const facetsToSave: SessionFacets[] = []
2964      for (const { sessionId, newFacets } of results) {
2965        if (newFacets) {
2966          facets.set(sessionId, newFacets)
2967          facetsToSave.push(newFacets)
2968        }
2969      }
2970      await Promise.all(facetsToSave.map(f => saveFacets(f)))
2971    }
2972  
2973    // Filter out warmup/minimal sessions (matching Python's is_minimal)
2974    // A session is minimal if warmup_minimal is the ONLY goal category
2975    const isMinimalSession = (sessionId: string): boolean => {
2976      const sessionFacets = facets.get(sessionId)
2977      if (!sessionFacets) return false
2978      const cats = sessionFacets.goal_categories
2979      const catKeys = safeKeys(cats).filter(k => (cats[k] ?? 0) > 0)
2980      return catKeys.length === 1 && catKeys[0] === 'warmup_minimal'
2981    }
2982  
2983    const substantiveSessions = substantiveMetas.filter(
2984      s => !isMinimalSession(s.session_id),
2985    )
2986  
2987    const substantiveFacets = new Map<string, SessionFacets>()
2988    for (const [sessionId, f] of facets) {
2989      if (!isMinimalSession(sessionId)) {
2990        substantiveFacets.set(sessionId, f)
2991      }
2992    }
2993  
2994    const aggregated = aggregateData(substantiveSessions, substantiveFacets)
2995    aggregated.total_sessions_scanned = totalSessionsScanned
2996  
2997    // Generate parallel insights from Claude (6 sections)
2998    const insights = await generateParallelInsights(aggregated, facets)
2999  
3000    // Generate HTML report
3001    const htmlReport = generateHtmlReport(aggregated, insights)
3002  
3003    // Save reports
3004    try {
3005      await mkdir(getDataDir(), { recursive: true })
3006    } catch {
3007      // Directory may already exist
3008    }
3009  
3010    const htmlPath = join(getDataDir(), 'report.html')
3011    await writeFile(htmlPath, htmlReport, {
3012      encoding: 'utf-8',
3013      mode: 0o600,
3014    })
3015  
3016    return {
3017      insights,
3018      htmlPath,
3019      data: aggregated,
3020      remoteStats,
3021      facets: substantiveFacets,
3022    }
3023  }
3024  
3025  function safeEntries<V>(
3026    obj: Record<string, V> | undefined | null,
3027  ): [string, V][] {
3028    return obj ? Object.entries(obj) : []
3029  }
3030  
3031  function safeKeys(obj: Record<string, unknown> | undefined | null): string[] {
3032    return obj ? Object.keys(obj) : []
3033  }
3034  
3035  // ============================================================================
3036  // Command Definition
3037  // ============================================================================
3038  
3039  const usageReport: Command = {
3040    type: 'prompt',
3041    name: 'insights',
3042    description: 'Generate a report analyzing your Claude Code sessions',
3043    contentLength: 0, // Dynamic content
3044    progressMessage: 'analyzing your sessions',
3045    source: 'builtin',
3046    async getPromptForCommand(args) {
3047      let collectRemote = false
3048      let remoteHosts: string[] = []
3049      let hasRemoteHosts = false
3050  
3051      if (process.env.USER_TYPE === 'ant') {
3052        // Parse --homespaces flag
3053        collectRemote = args?.includes('--homespaces') ?? false
3054  
3055        // Check for available remote hosts
3056        remoteHosts = await getRunningRemoteHosts()
3057        hasRemoteHosts = remoteHosts.length > 0
3058  
3059        // Show collection message if collecting
3060        if (collectRemote && hasRemoteHosts) {
3061          // biome-ignore lint/suspicious/noConsole: intentional
3062          console.error(
3063            `Collecting sessions from ${remoteHosts.length} homespace(s): ${remoteHosts.join(', ')}...`,
3064          )
3065        }
3066      }
3067  
3068      const { insights, htmlPath, data, remoteStats } = await generateUsageReport(
3069        { collectRemote },
3070      )
3071  
3072      let reportUrl = `file://${htmlPath}`
3073      let uploadHint = ''
3074  
3075      if (process.env.USER_TYPE === 'ant') {
3076        // Try to upload to S3
3077        const timestamp = new Date()
3078          .toISOString()
3079          .replace(/[-:]/g, '')
3080          .replace('T', '_')
3081          .slice(0, 15)
3082        const username = process.env.SAFEUSER || process.env.USER || 'unknown'
3083        const filename = `${username}_insights_${timestamp}.html`
3084        const s3Path = `s3://anthropic-serve/atamkin/cc-user-reports/${filename}`
3085        const s3Url = `https://s3-frontend.infra.ant.dev/anthropic-serve/atamkin/cc-user-reports/${filename}`
3086  
3087        reportUrl = s3Url
3088        try {
3089          execFileSync('ff', ['cp', htmlPath, s3Path], {
3090            timeout: 60000,
3091            stdio: 'pipe', // Suppress output
3092          })
3093        } catch {
3094          // Upload failed - fall back to local file and show upload command
3095          reportUrl = `file://${htmlPath}`
3096          uploadHint = `\nAutomatic upload failed. Are you on the boron namespace? Try \`use-bo\` and ensure you've run \`sso\`.
3097  To share, run: ff cp ${htmlPath} ${s3Path}
3098  Then access at: ${s3Url}`
3099        }
3100      }
3101  
3102      // Build header with stats
3103      const sessionLabel =
3104        data.total_sessions_scanned &&
3105        data.total_sessions_scanned > data.total_sessions
3106          ? `${data.total_sessions_scanned.toLocaleString()} sessions total · ${data.total_sessions} analyzed`
3107          : `${data.total_sessions} sessions`
3108      const stats = [
3109        sessionLabel,
3110        `${data.total_messages.toLocaleString()} messages`,
3111        `${Math.round(data.total_duration_hours)}h`,
3112        `${data.git_commits} commits`,
3113      ].join(' · ')
3114  
3115      // Build remote host info (ant-only)
3116      let remoteInfo = ''
3117      if (process.env.USER_TYPE === 'ant') {
3118        if (remoteStats && remoteStats.totalCopied > 0) {
3119          const hsNames = remoteStats.hosts
3120            .filter(h => h.sessionCount > 0)
3121            .map(h => h.name)
3122            .join(', ')
3123          remoteInfo = `\n_Collected ${remoteStats.totalCopied} new sessions from: ${hsNames}_\n`
3124        } else if (!collectRemote && hasRemoteHosts) {
3125          // Suggest using --homespaces if they have remote hosts but didn't use the flag
3126          remoteInfo = `\n_Tip: Run \`/insights --homespaces\` to include sessions from your ${remoteHosts.length} running homespace(s)_\n`
3127        }
3128      }
3129  
3130      // Build markdown summary from insights
3131      const atAGlance = insights.at_a_glance
3132      const summaryText = atAGlance
3133        ? `## At a Glance
3134  
3135  ${atAGlance.whats_working ? `**What's working:** ${atAGlance.whats_working} See _Impressive Things You Did_.` : ''}
3136  
3137  ${atAGlance.whats_hindering ? `**What's hindering you:** ${atAGlance.whats_hindering} See _Where Things Go Wrong_.` : ''}
3138  
3139  ${atAGlance.quick_wins ? `**Quick wins to try:** ${atAGlance.quick_wins} See _Features to Try_.` : ''}
3140  
3141  ${atAGlance.ambitious_workflows ? `**Ambitious workflows:** ${atAGlance.ambitious_workflows} See _On the Horizon_.` : ''}`
3142        : '_No insights generated_'
3143  
3144      const header = `# Claude Code Insights
3145  
3146  ${stats}
3147  ${data.date_range.start} to ${data.date_range.end}
3148  ${remoteInfo}
3149  `
3150  
3151      const userSummary = `${header}${summaryText}
3152  
3153  Your full shareable insights report is ready: ${reportUrl}${uploadHint}`
3154  
3155      // Return prompt for Claude to respond to
3156      return [
3157        {
3158          type: 'text',
3159          text: `The user just ran /insights to generate a usage report analyzing their Claude Code sessions.
3160  
3161  Here is the full insights data:
3162  ${jsonStringify(insights, null, 2)}
3163  
3164  Report URL: ${reportUrl}
3165  HTML file: ${htmlPath}
3166  Facets directory: ${getFacetsDir()}
3167  
3168  Here is what the user sees:
3169  ${userSummary}
3170  
3171  Now output the following message exactly:
3172  
3173  <message>
3174  Your shareable insights report is ready:
3175  ${reportUrl}${uploadHint}
3176  
3177  Want to dig into any section or try one of the suggestions?
3178  </message>`,
3179        },
3180      ]
3181    },
3182  }
3183  
3184  function isValidSessionFacets(obj: unknown): obj is SessionFacets {
3185    if (!obj || typeof obj !== 'object') return false
3186    const o = obj as Record<string, unknown>
3187    return (
3188      typeof o.underlying_goal === 'string' &&
3189      typeof o.outcome === 'string' &&
3190      typeof o.brief_summary === 'string' &&
3191      o.goal_categories !== null &&
3192      typeof o.goal_categories === 'object' &&
3193      o.user_satisfaction_counts !== null &&
3194      typeof o.user_satisfaction_counts === 'object' &&
3195      o.friction_counts !== null &&
3196      typeof o.friction_counts === 'object'
3197    )
3198  }
3199  
3200  export default usageReport