OverviewFilesCommitsIssuesDocsReleasesPeers

knowledge-ratchet / analyze-transcripts.js

397 lines · javascript ref:
#!/usr/bin/env node

// Deep Transcript Analyzer
// Goes beyond regex — analyzes conversation flow, sentiment, and semantic patterns
// Detects subtle corrections, frustration, disengagement, and quality trends

const fs = require('fs')
const path = require('path')
const readline = require('readline')

const SESSIONS_DIRS = [
  path.join(process.env.HOME, '.claude/projects/-storage-projects-code-hypercore-kb'),
  path.join(process.env.HOME, '.claude/projects/-storage-projects-code-hypercore-kb-hyper-fleeky'),
  path.join(process.env.HOME, '.claude/projects/-storage-projects-code-hypercore-kb-hyper-fleeky-pear-git')
]

const OUTPUT_DIR = path.join(__dirname, 'data')
const ANALYSIS_FILE = path.join(OUTPUT_DIR, 'deep-analysis.json')

// Sentiment indicators — weighted, not binary
const FRUSTRATION_SIGNALS = {
  // Explicit frustration
  patterns: [
    { re: /\bwhy did you\b/i, weight: 0.8, label: 'questioning-action' },
    { re: /\byou just\b/i, weight: 0.6, label: 'pointing-out-mistake' },
    { re: /\bthat broke\b/i, weight: 0.9, label: 'breakage' },
    { re: /\byou broke\b/i, weight: 1.0, label: 'blame' },
    { re: /\bagain\b/i, weight: 0.5, label: 'repetition' },
    { re: /\balready\b/i, weight: 0.4, label: 'already-said' },
    { re: /\bi (just )?(said|told|asked|meant)\b/i, weight: 0.7, label: 'repeating-self' },
    { re: /\bthat's not (what|how|right)\b/i, weight: 0.8, label: 'disagreement' },
    { re: /\bwhat\?\b/i, weight: 0.5, label: 'confusion' },
    { re: /\bno\.?\s*$/i, weight: 0.7, label: 'flat-no' },
    { re: /\bstop\b/i, weight: 0.8, label: 'stop' },
    { re: /\bugh\b/i, weight: 0.9, label: 'exasperation' },
    { re: /\bhmm\b/i, weight: 0.3, label: 'hesitation' },
    { re: /\bsigh\b/i, weight: 0.9, label: 'sigh' },
    { re: /\.\.\.$/i, weight: 0.4, label: 'trailing-off' },
    { re: /\bworse\b/i, weight: 0.7, label: 'quality-decline' },
    { re: /\bnot (quite|really|exactly)\b/i, weight: 0.6, label: 'soft-disagreement' },
    { re: /\bi don't think\b/i, weight: 0.5, label: 'doubt' },
    { re: /\blet me (just |)do it\b/i, weight: 1.0, label: 'taking-over' },
    { re: /\bi'll (just |)do it\b/i, weight: 1.0, label: 'giving-up-on-claude' },
    { re: /\bforget it\b/i, weight: 0.9, label: 'abandonment' },
    { re: /\bnever\s*mind\b/i, weight: 0.8, label: 'nevermind' },
    { re: /\btry (again|a different)\b/i, weight: 0.5, label: 'retry-request' },
  ]
}

const SATISFACTION_SIGNALS = {
  patterns: [
    { re: /\bperfect\b/i, weight: 1.0, label: 'perfect' },
    { re: /\bexactly\b/i, weight: 0.9, label: 'exactly' },
    { re: /\bnice\b/i, weight: 0.7, label: 'nice' },
    { re: /\bgreat\b/i, weight: 0.8, label: 'great' },
    { re: /\bthat works\b/i, weight: 0.8, label: 'works' },
    { re: /\bgood\b/i, weight: 0.5, label: 'good' },
    { re: /\bthanks\b/i, weight: 0.6, label: 'thanks' },
    { re: /\byes\b/i, weight: 0.4, label: 'yes' },
    { re: /\bok\b/i, weight: 0.2, label: 'ok' },
    { re: /\blgtm\b/i, weight: 0.9, label: 'lgtm' },
    { re: /\bship it\b/i, weight: 1.0, label: 'ship' },
    { re: /\bcommit\b/i, weight: 0.5, label: 'commit-request' },
    { re: /\bpush\b/i, weight: 0.5, label: 'push-request' },
    { re: /\binteresting\b/i, weight: 0.6, label: 'engaged' },
    { re: /\bthat's (right|correct)\b/i, weight: 0.8, label: 'confirmation' },
  ]
}

// Conversation flow patterns
const FLOW_SIGNALS = {
  // User message getting shorter over time = disengagement
  shorteningResponses: (messages) => {
    if (messages.length < 4) return null
    const lengths = messages.map(m => m.length)
    const firstHalf = lengths.slice(0, Math.floor(lengths.length / 2))
    const secondHalf = lengths.slice(Math.floor(lengths.length / 2))
    const avgFirst = firstHalf.reduce((a, b) => a + b, 0) / firstHalf.length
    const avgSecond = secondHalf.reduce((a, b) => a + b, 0) / secondHalf.length
    if (avgSecond < avgFirst * 0.4 && avgFirst > 30) {
      return { signal: 'disengagement', ratio: avgSecond / avgFirst, weight: 0.7 }
    }
    return null
  },

  // Rapid corrections in sequence = escalating frustration
  correctionBurst: (classifications) => {
    let maxStreak = 0
    let streak = 0
    for (const c of classifications) {
      if (c.frustration > 0.5) { streak++; maxStreak = Math.max(maxStreak, streak) }
      else streak = 0
    }
    if (maxStreak >= 3) {
      return { signal: 'correction-burst', streak: maxStreak, weight: 0.9 }
    }
    return null
  },

  // User provides the answer instead of Claude = Claude failed
  userProvidingAnswer: (text) => {
    const patterns = [
      /\bthe (answer|solution|fix|issue|problem) is\b/i,
      /\byou need to\b/i,
      /\bhere'?s (what|how)\b/i,
      /\bjust (do|use|add|remove|change)\b/i,
      /```[\s\S]{20,}```/,  // user pasting code blocks = providing the solution
    ]
    const matches = patterns.filter(p => p.test(text))
    if (matches.length > 0) {
      return { signal: 'user-providing-answer', weight: 0.6 }
    }
    return null
  },

  // Tool call count proxy — if assistant has many tool results between user messages
  // it means Claude was fumbling (many reads/greps to find something)
  highToolCallCount: (assistantToolCalls) => {
    if (assistantToolCalls > 8) {
      return { signal: 'fumbling', toolCalls: assistantToolCalls, weight: 0.4 }
    }
    return null
  }
}

async function analyzeSession (filePath) {
  const stream = fs.createReadStream(filePath)
  const rl = readline.createInterface({ input: stream, crlfDelay: Infinity })

  const userMessages = []
  const classifications = []
  let assistantToolCalls = 0
  let sessionId = null
  let sessionDate = null

  for await (const line of rl) {
    try {
      const entry = JSON.parse(line)

      if (!sessionId && entry.sessionId) sessionId = entry.sessionId
      if (!sessionDate && entry.timestamp) sessionDate = entry.timestamp

      if (entry.type === 'user') {
        // Count tool calls since last user message
        if (assistantToolCalls > 0) {
          const flowSignal = FLOW_SIGNALS.highToolCallCount(assistantToolCalls)
          if (flowSignal) {
            classifications.push({
              type: 'flow',
              ...flowSignal,
              context: `${assistantToolCalls} tool calls before user response`
            })
          }
          assistantToolCalls = 0
        }

        const content = entry.message?.content
        let text = ''
        if (typeof content === 'string') text = content
        else if (Array.isArray(content)) {
          text = content.filter(c => c.type === 'text').map(c => c.text).join(' ')
        }

        if (!text || text.length < 2) continue
        if (text.startsWith('!') || text.startsWith('/') || text.startsWith('<')) continue

        // Filter system noise — these are NOT user messages
        if (text.startsWith('Base directory for this skill')) continue
        if (text.startsWith('Implement the following plan')) continue
        if (text.startsWith('This session is being continued')) continue
        if (text.startsWith('Service Unavailable')) continue
        if (/^Godot Engine v|^Vulkan \d/.test(text)) continue
        if (/^fleeky@\w+\s/.test(text)) continue
        if (/tool_use_id|tool_result/.test(text)) continue
        if (/^\[Request interrupted/.test(text)) continue

        userMessages.push(text)

        // Score frustration
        let frustration = 0
        let satisfaction = 0
        const labels = []

        for (const { re, weight, label } of FRUSTRATION_SIGNALS.patterns) {
          if (re.test(text)) {
            frustration += weight
            labels.push(`-${label}`)
          }
        }
        for (const { re, weight, label } of SATISFACTION_SIGNALS.patterns) {
          if (re.test(text)) {
            satisfaction += weight
            labels.push(`+${label}`)
          }
        }

        // User providing the answer
        const answerSignal = FLOW_SIGNALS.userProvidingAnswer(text)
        if (answerSignal) {
          frustration += answerSignal.weight
          labels.push('-user-providing-answer')
        }

        // Very short message after a presumably long assistant response = terse/disengaged
        if (text.length < 10 && userMessages.length > 2) {
          // Check if it's just "ok" or "yes" (neutral) vs "no" (correction)
          if (/^(no|nope|wrong|stop|ugh)\.?\s*$/i.test(text.trim())) {
            frustration += 0.5
            labels.push('-terse-rejection')
          }
        }

        classifications.push({
          type: 'message',
          text: text.slice(0, 300),
          frustration,
          satisfaction,
          net: satisfaction - frustration,
          labels,
          timestamp: entry.timestamp
        })
      }

      // Count assistant tool uses
      if (entry.type === 'assistant') {
        const content = entry.message?.content
        if (Array.isArray(content)) {
          assistantToolCalls += content.filter(c => c.type === 'tool_use').length
        }
      }
    } catch {}
  }

  if (userMessages.length < 3) return null

  // Flow analysis
  const flowSignals = []
  const shortening = FLOW_SIGNALS.shorteningResponses(userMessages)
  if (shortening) flowSignals.push(shortening)
  const burst = FLOW_SIGNALS.correctionBurst(classifications.filter(c => c.type === 'message'))
  if (burst) flowSignals.push(burst)

  // Compute session-level metrics
  const messageClassifications = classifications.filter(c => c.type === 'message')
  const totalFrustration = messageClassifications.reduce((sum, c) => sum + c.frustration, 0)
  const totalSatisfaction = messageClassifications.reduce((sum, c) => sum + c.satisfaction, 0)
  const netSentiment = totalSatisfaction - totalFrustration
  const avgSentiment = messageClassifications.length > 0
    ? netSentiment / messageClassifications.length
    : 0

  // Sentiment trajectory — is it getting better or worse within the session?
  let trajectory = 0
  if (messageClassifications.length >= 4) {
    const half = Math.floor(messageClassifications.length / 2)
    const firstHalfNet = messageClassifications.slice(0, half).reduce((s, c) => s + c.net, 0) / half
    const secondHalfNet = messageClassifications.slice(half).reduce((s, c) => s + c.net, 0) / (messageClassifications.length - half)
    trajectory = secondHalfNet - firstHalfNet
  }

  return {
    sessionId,
    date: sessionDate?.slice(0, 10),
    timestamp: sessionDate,
    messageCount: userMessages.length,
    totalFrustration: Math.round(totalFrustration * 100) / 100,
    totalSatisfaction: Math.round(totalSatisfaction * 100) / 100,
    netSentiment: Math.round(netSentiment * 100) / 100,
    avgSentiment: Math.round(avgSentiment * 100) / 100,
    trajectory: Math.round(trajectory * 100) / 100,
    flowSignals,
    // Top frustration moments
    topFrustrations: messageClassifications
      .filter(c => c.frustration > 0.5)
      .sort((a, b) => b.frustration - a.frustration)
      .slice(0, 5)
      .map(c => ({ text: c.text.slice(0, 200), score: c.frustration, labels: c.labels })),
    // Top satisfaction moments
    topSatisfactions: messageClassifications
      .filter(c => c.satisfaction > 0.5)
      .sort((a, b) => b.satisfaction - a.satisfaction)
      .slice(0, 3)
      .map(c => ({ text: c.text.slice(0, 200), score: c.satisfaction, labels: c.labels }))
  }
}

async function main () {
  if (!fs.existsSync(OUTPUT_DIR)) fs.mkdirSync(OUTPUT_DIR, { recursive: true })

  const sessions = []
  let totalFiles = 0

  for (const dir of SESSIONS_DIRS) {
    if (!fs.existsSync(dir)) continue
    const files = fs.readdirSync(dir)
      .filter(f => f.endsWith('.jsonl') && !f.includes('subagent'))
      .map(f => path.join(dir, f))

    for (const file of files) {
      totalFiles++
      const result = await analyzeSession(file)
      if (result) sessions.push(result)
    }
  }

  // Sort by date
  sessions.sort((a, b) => (a.timestamp || '').localeCompare(b.timestamp || ''))

  // Weekly aggregation
  const weeks = {}
  for (const s of sessions) {
    if (!s.date) continue
    const d = new Date(s.date)
    d.setDate(d.getDate() - d.getDay())
    const weekKey = d.toISOString().slice(0, 10)
    if (!weeks[weekKey]) weeks[weekKey] = { sessions: 0, avgSentiment: 0, totalFrustration: 0, totalSatisfaction: 0 }
    weeks[weekKey].sessions++
    weeks[weekKey].totalFrustration += s.totalFrustration
    weeks[weekKey].totalSatisfaction += s.totalSatisfaction
  }

  for (const w of Object.values(weeks)) {
    w.avgSentiment = Math.round(((w.totalSatisfaction - w.totalFrustration) / w.sessions) * 100) / 100
  }

  console.log(`Analyzed ${totalFiles} files, ${sessions.length} sessions with sufficient data`)

  // Print weekly sentiment trend
  console.log('\n═══ WEEKLY SENTIMENT TREND ═══')
  console.log('(positive = more satisfaction, negative = more frustration)')
  for (const [week, data] of Object.entries(weeks).sort()) {
    const bar = data.avgSentiment >= 0
      ? '░'.repeat(Math.min(Math.round(data.avgSentiment * 5), 40))
      : '█'.repeat(Math.min(Math.round(-data.avgSentiment * 5), 40))
    const sign = data.avgSentiment >= 0 ? '+' : ''
    console.log(`  ${week}  ${sign}${data.avgSentiment.toFixed(1)}  ${bar}  (${data.sessions} sessions)`)
  }

  // Find worst sessions
  const worstSessions = sessions
    .filter(s => s.netSentiment < -2)
    .sort((a, b) => a.netSentiment - b.netSentiment)
    .slice(0, 10)

  if (worstSessions.length > 0) {
    console.log('\n═══ WORST SESSIONS (highest frustration) ═══')
    for (const s of worstSessions) {
      console.log(`\n  ${s.date} [net: ${s.netSentiment}] ${s.messageCount} messages`)
      if (s.flowSignals.length > 0) {
        console.log(`    Flow: ${s.flowSignals.map(f => f.signal).join(', ')}`)
      }
      for (const f of s.topFrustrations.slice(0, 3)) {
        console.log(`    ⚠ [${f.score.toFixed(1)}] "${f.text.slice(0, 100)}..."`)
        console.log(`      labels: ${f.labels.join(', ')}`)
      }
    }
  }

  // Recent trend (last 2 weeks vs previous 2 weeks)
  const sortedWeeks = Object.entries(weeks).sort()
  if (sortedWeeks.length >= 4) {
    const recent2 = sortedWeeks.slice(-2)
    const prev2 = sortedWeeks.slice(-4, -2)
    const recentAvg = recent2.reduce((s, [, d]) => s + d.avgSentiment, 0) / 2
    const prevAvg = prev2.reduce((s, [, d]) => s + d.avgSentiment, 0) / 2
    const delta = recentAvg - prevAvg

    console.log('\n═══ RECENT TREND ═══')
    console.log(`  Previous 2 weeks avg: ${prevAvg.toFixed(2)}`)
    console.log(`  Recent 2 weeks avg:   ${recentAvg.toFixed(2)}`)
    console.log(`  Delta:                ${delta >= 0 ? '+' : ''}${delta.toFixed(2)}`)
    if (delta < -0.5) {
      console.log('  ⚠ QUALITY DECLINING — frustration is rising relative to satisfaction')
    } else if (delta > 0.5) {
      console.log('  ✓ QUALITY IMPROVING — satisfaction rising')
    } else {
      console.log('  ~ STABLE')
    }
  }

  // Save full analysis
  const report = {
    timestamp: new Date().toISOString(),
    totalSessions: sessions.length,
    weeklyTrend: weeks,
    worstSessions: worstSessions.slice(0, 10),
    recentSessions: sessions.slice(-20)
  }
  fs.writeFileSync(ANALYSIS_FILE, JSON.stringify(report, null, 2))
  console.log(`\nFull analysis: ${ANALYSIS_FILE}`)
}

main().catch(err => {
  console.error('Analysis failed:', err.message)
  process.exit(1)
})