#!/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)
})