OverviewFilesCommitsIssuesDocsReleasesPeers

knowledge-ratchet / realtime-score.js

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

// Realtime Session Scorer — Knowledge Ratchet Phase 2
//
// Maintains a live quality score during Claude Code sessions.
// Called from hooks (UserPromptSubmit, PostToolUse, Stop) and status line.
//
// Usage:
//   echo '{"prompt":"..."}' | node realtime-score.js prompt    # UserPromptSubmit hook
//   echo '{"tool_name":"..."}' | node realtime-score.js tool   # PostToolUse hook
//   node realtime-score.js stop                                 # Stop hook (session end)
//   node realtime-score.js status                               # Print current score (for statusline)
//   node realtime-score.js reset                                # Reset state (SessionStart)

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

const STATE_FILE = '/tmp/claude-session-score.json'
const DATA_DIR = path.join(__dirname, 'data')
const TUNED_PATTERNS_FILE = path.join(DATA_DIR, 'tuned-patterns.json')
const LOOPS_FILE = path.join(DATA_DIR, 'loops.json')

// ─── Correction/Approval Detection ───
// Loads tuned patterns from tune-regex.js if available

function loadTunedPatterns () {
  if (fs.existsSync(TUNED_PATTERNS_FILE)) {
    try {
      const tuned = JSON.parse(fs.readFileSync(TUNED_PATTERNS_FILE, 'utf8'))
      const corrPatterns = []
      for (const tier of ['high', 'medium']) {
        for (const p of (tuned.correction?.[tier] || [])) {
          corrPatterns.push(new RegExp(p.pattern, p.flags || 'i'))
        }
      }
      const apprPatterns = []
      for (const p of (tuned.approval?.high || [])) {
        apprPatterns.push(new RegExp(p.pattern, p.flags || 'i'))
      }
      if (corrPatterns.length > 0) return { correction: corrPatterns, approval: apprPatterns }
    } catch {}
  }
  return null
}

const _tuned = loadTunedPatterns()

const CORRECTION_PATTERNS = _tuned?.correction || [
  /\b(no[,.]|wrong|not that|that's not|incorrect|stop doing)\b/i,
  /\b(i said|i meant|i asked for|that's wrong)\b/i,
  /\b(again|already told you|how many times|we discussed this)\b/i,
  /\b(don't ever|never do that|stop|please don't|do not)\b/i,
  /\b(why did you|why are you|that broke|you broke|you just)\b/i,
  /\b(revert|undo|go back|roll back|put it back)\b/i,
  /\b(let me (just |)do it|i'll (just |)do it|forget it|never\s*mind)\b/i,
]

const APPROVAL_PATTERNS = _tuned?.approval || [
  /\b(perfect|exactly|great|lgtm|looks good|nice work|nailed it)\b/i,
  /\b(that works|that's right|correct|good job|well done)\b/i,
  /\b(ship it|merge it|push it|deploy it|commit it)\b/i,
  /\b(thanks|thank you)\b/i,
]

// Weak-positive patterns — not full approval but proceed signal.
const PROCEED_PATTERNS = [
  /\b(continue|proceed|go ahead|go on|keep going|next)\b/i,
  /^\s*(yes|yep|yeah|sure|ok|okay)\b/i,
  /\b(do it|do that|try it|let'?s try)\b/i
]

// Direction / new-problem signals — user giving a new task or pivoting.
// These reset the stuck-loop counter because we're starting fresh.
const NEW_PROBLEM_PATTERNS = [
  // Directives that start new work
  /^\s*(now|next|also|and|then)\b/i,
  /\b(let'?s|lets)\s+(add|build|make|create|try|do|check|write|fix|update|run|test|deploy|restart)\b/i,
  // Imperative commands (usually building on prior state)
  /^\s*(add|build|make|create|fix|update|run|test|deploy|restart|remove|delete|show|tell)\b/i,
  // Explicit pivots
  /\b(actually|instead|wait|hold on)\b.*\b(let'?s|try|do)\b/i,
  /^\s*i (want|need|would like) (you|to|us)\b/i
]

// Simple commands — one-word or short imperative that's not a correction
const COMMAND_PATTERNS = [
  /^\s*(restart|run|test|build|commit|push|deploy|check|show|list|help)\s*\??\s*$/i
]

/**
 * Classify a user prompt semantically.
 * Returns: 'correction' | 'approval' | 'proceed' | 'new-problem' | 'command' | 'question' | 'neutral'
 *
 * Categories affect state differently:
 *   correction   → corrections++, keeps problem-turns running
 *   approval     → full completion, resets problem, increments problemCount
 *   proceed      → weak reset on stuck-loop if stuck, else continues problem
 *   new-problem  → full reset (new work starting)
 *   command      → weak reset (using prior state for a new action)
 *   question     → neutral (info-seeking, doesn't advance or block)
 *   neutral      → no signal
 */
function classifyPrompt (text) {
  if (!text) return 'neutral'
  const t = text.trim()
  // Order matters — check corrections first (strongest negative)
  if (CORRECTION_PATTERNS.some(p => p.test(t))) return 'correction'
  if (APPROVAL_PATTERNS.some(p => p.test(t))) return 'approval'
  if (COMMAND_PATTERNS.some(p => p.test(t))) return 'command'
  if (NEW_PROBLEM_PATTERNS.some(p => p.test(t))) return 'new-problem'
  if (PROCEED_PATTERNS.some(p => p.test(t))) return 'proceed'
  // Question heuristic
  if (/\?/.test(t) && /\b(why|how|what|when|where|which|can you|do you|is there)\b/i.test(t)) return 'question'
  return 'neutral'
}

// Load skip patterns from tuned config, fall back to defaults
const _skipFromConfig = (() => {
  if (fs.existsSync(TUNED_PATTERNS_FILE)) {
    try {
      const tuned = JSON.parse(fs.readFileSync(TUNED_PATTERNS_FILE, 'utf8'))
      if (tuned.skip?.length > 0) return tuned.skip.map(p => new RegExp(p, 'i'))
    } catch {}
  }
  return null
})()

const SKIP_PATTERNS = _skipFromConfig || [
  /^!/,
  /^<command-name>/,
  /^<system-reminder>/,
  /^<task-notification>/,
  /^<local-command/,
  /^Base directory for this skill/,
  /^Implement the following plan/,
  /^This session is being continued/,
  /^Service Unavailable/,
  /^fleeky@\w+/,
  /tool_use_id/,
  /tool_result/,
  /^Godot Engine v/,
  /^Vulkan \d/,
]

const INTERRUPTION_PATTERN = /^\[Request interrupted/

// ─── State Management ───

function loadState () {
  if (!fs.existsSync(STATE_FILE)) return createFreshState()
  try {
    const state = JSON.parse(fs.readFileSync(STATE_FILE, 'utf8'))
    // Migrate old state: ensure new problem-tracking fields exist
    // Seed from existing counters so context-saturation etc work immediately
    if (state._problemStartTurn == null) state._problemStartTurn = 0
    if (state._problemTurns == null) state._problemTurns = state.turns || 0
    if (state._problemCount == null) state._problemCount = (state.approvals || 0) + 1
    if (state._lastApprovalTurn == null) state._lastApprovalTurn = 0
    if (state._lastProceedTurn == null) state._lastProceedTurn = 0
    return state
  } catch {
    return createFreshState()
  }
}

function saveState (state) {
  state.lastUpdate = new Date().toISOString()
  fs.writeFileSync(STATE_FILE, JSON.stringify(state, null, 2))
}

function createFreshState () {
  return {
    turns: 0,
    score: 0.75, // start neutral-positive
    corrections: 0,
    approvals: 0,
    toolCalls: 0,
    buildErrors: 0,
    interruptions: 0,
    editedFiles: [],
    revertedEdits: 0,
    turnScores: [],
    trajectory: 'stable',
    lastUpdate: new Date().toISOString(),
    // Track last turn's edits for survival check
    _lastTurnEdits: [],
    _lastTurnHadCorrection: false,
    // Loop detection
    _recentCorrections: [],  // last 5 correction texts
    _loopActive: false,
    _loopMatches: null,
    // Problem-boundary tracking (NEW)
    // "Problem turns" = turns since last approval/proceed (or session start).
    // Resets on approval, proceed signal, or topic shift.
    _problemStartTurn: 0,       // turn # when current problem started
    _problemTurns: 0,           // turns spent on current problem
    _problemCount: 1,           // # of problems tackled this session
    _lastApprovalTurn: 0,       // last turn with explicit approval
    _lastProceedTurn: 0         // last turn with weak-positive signal
  }
}

// ─── Loop Matching ───

function checkForLoop (state) {
  // A loop is forming when 2+ corrections in the last 5 turns
  const recentCorr = state._recentCorrections || []
  if (recentCorr.length < 2) return null

  // Build fingerprint from current context
  const cwd = state._cwd || process.cwd()
  const files = (state.editedFiles || []).slice(-10)

  // Classify project from cwd/files
  const all = (cwd + ' ' + files.join(' ')).toLowerCase()
  let project = 'unknown'
  if (all.includes('dc-terrain') || all.includes('dc_terrain')) project = 'dc-terrain'
  else if (all.includes('hyper-tps')) project = 'hyper-tps'
  else if (all.includes('hypernote')) project = 'hypernote'
  else if (all.includes('pear-git')) project = 'pear-git'
  else if (all.includes('blender-bridge') || all.includes('blender_bridge')) project = 'blender-bridge'
  else if (all.includes('proc-nodes')) project = 'proc-nodes'
  else if (all.includes('hyper-overlay')) project = 'hyper-overlay'
  else if (all.includes('godot')) project = 'godot'

  // Classify type from correction texts
  const corrText = recentCorr.join(' ').toLowerCase()
  let type = 'other'
  if (/invisible|not (visible|showing|rendering)|can.t see|flat/.test(corrText)) type = 'not-rendering'
  else if (/crash|error|fail|exception/.test(corrText)) type = 'runtime-error'
  else if (/same|still|worse|didn.t (work|help|fix)/.test(corrText)) type = 'fix-failed'
  else if (/wrong|reversed|flipped|incorrect/.test(corrText)) type = 'wrong-output'
  else if (/why (are|is)|approach|design/.test(corrText)) type = 'wrong-approach'
  else if (/audit|planning|think deeper/.test(corrText)) type = 'needs-rethink'

  // Build fingerprint
  const path_ = require('path')
  const stems = [...new Set(files.map(f => {
    const base = path_.basename(f)
    return base.replace(/\.(js|cpp|h|gd|glsl|gdshader|tscn|tres)$/, '')
  }))].slice(0, 5).sort()
  const fingerprint = `${project}:${type}:${stems.join(',')}`

  // Query past loops
  if (!fs.existsSync(LOOPS_FILE)) return { fingerprint, matches: [] }

  try {
    const loopsData = JSON.parse(fs.readFileSync(LOOPS_FILE, 'utf8'))
    const pastLoops = (loopsData.loops || []).filter(l => l.resolved)

    const [proj, typ, filesStr] = fingerprint.split(':')
    const currentFiles = new Set(filesStr.split(',').filter(Boolean))

    const matches = pastLoops
      .map(loop => {
        const [lProj, lType, lFilesStr] = (loop.fingerprint || '').split(':')
        const loopFiles = new Set((lFilesStr || '').split(',').filter(Boolean))
        let score = 0
        if (lProj === proj) score += 3
        if (lType === typ) score += 2
        const overlap = [...currentFiles].filter(f => loopFiles.has(f)).length
        score += overlap * 2
        return { loop, score }
      })
      .filter(s => s.score >= 3)
      .sort((a, b) => b.score - a.score)
      .slice(0, 2)
      .map(s => ({
        score: s.score,
        corrections: (s.loop.corrections || []).slice(0, 2),
        resolutionFiles: (s.loop.resolution?.files || []).map(f => path_.basename(f)),
        approval: s.loop.resolution?.approval?.slice(0, 150),
        date: s.loop.date,
      }))

    return { fingerprint, matches }
  } catch {
    return { fingerprint, matches: [] }
  }
}

// ─── Score Computation ───

function computeScore (state) {
  if (state.turns === 0) return 0.75

  // Session-level signals (same approach as fuse-signals.js)
  const signals = {}

  // Correction rate — 0% = 1.0, 5% = 0.75, 10% = 0.5, 20%+ = 0.1
  const correctionRate = state.corrections / Math.max(1, state.turns)
  signals.correctionRate = Math.max(0.1, 1.0 - correctionRate * 5)

  // Approval rate
  const approvalRate = state.approvals / Math.max(1, state.turns)
  signals.approvalRate = Math.min(1.0, 0.3 + approvalRate * 3)

  // Correction-to-approval ratio
  const totalSignals = state.corrections + state.approvals
  signals.signalRatio = totalSignals > 0
    ? state.approvals / totalSignals
    : 0.5

  // Tool call density — avg tool calls per turn
  const avgToolCalls = state.toolCalls / Math.max(1, state.turns)
  signals.toolEfficiency = avgToolCalls <= 3 ? 1.0
    : avgToolCalls <= 6 ? 0.8
    : avgToolCalls <= 10 ? 0.6
    : 0.4

  // Build error rate
  const errorRate = state.buildErrors / Math.max(1, state.toolCalls)
  signals.buildQuality = Math.max(0.2, 1.0 - errorRate * 5)

  // Edit survival
  const totalEdits = state.editedFiles.length
  if (totalEdits > 0) {
    signals.editSurvival = Math.max(0.2, 1.0 - (state.revertedEdits / totalEdits))
  } else {
    signals.editSurvival = 0.7
  }

  // Turn score trajectory — requires both improving turns AND decent overall score
  if (state.turnScores.length >= 4) {
    const recent = state.turnScores.slice(-3)
    const earlier = state.turnScores.slice(-6, -3)
    if (earlier.length > 0) {
      const recentAvg = recent.reduce((a, b) => a + b, 0) / recent.length
      const earlierAvg = earlier.reduce((a, b) => a + b, 0) / earlier.length
      const delta = recentAvg - earlierAvg
      if (delta > 0.05 && state.score > 0.5 && state.approvals > 0) state.trajectory = 'improving'
      else if (delta < -0.05 || (state.corrections > 2 && state.approvals === 0)) state.trajectory = 'declining'
      else state.trajectory = 'stable'
    }
  }

  // Weighted average of signals
  const weights = {
    correctionRate: 1.0,
    approvalRate: 0.6,
    signalRatio: 0.8,
    toolEfficiency: 0.3,
    buildQuality: 0.5,
    editSurvival: 0.4,
  }

  let weightedSum = 0
  let totalWeight = 0
  for (const [key, value] of Object.entries(signals)) {
    const w = weights[key] || 0.3
    weightedSum += value * w
    totalWeight += w
  }

  return totalWeight > 0 ? Math.round((weightedSum / totalWeight) * 1000) / 1000 : 0.5
}

// ─── Hook Handlers ───

async function handlePrompt () {
  let input = ''
  for await (const chunk of process.stdin) {
    input += chunk
  }

  let userText = ''
  try {
    const data = JSON.parse(input)
    userText = data.prompt || data.user_prompt || data.message || ''
  } catch {
    return // can't parse, skip
  }

  if (!userText || userText.length < 3) return
  if (SKIP_PATTERNS.some(p => p.test(userText))) return

  const state = loadState()

  // Semantic prompt classification
  const category = classifyPrompt(userText)
  const isCorrection = category === 'correction'
  const isApproval = category === 'approval'
  const isInterruption = INTERRUPTION_PATTERN.test(userText)

  if (isCorrection) state.corrections++
  if (isApproval) state.approvals++
  if (isInterruption) state.interruptions++

  // Problem-boundary tracking based on semantic category
  // Each category has a distinct effect on stuck-loop counter:
  switch (category) {
    case 'approval':
      // Full completion — new problem begins
      state._lastApprovalTurn = state.turns
      state._problemStartTurn = state.turns
      state._problemTurns = 0
      state._problemCount = (state._problemCount || 1) + 1
      break
    case 'new-problem':
    case 'command':
      // Fresh work starting — reset problem-turns, count as weak completion
      state._problemStartTurn = state.turns
      state._problemTurns = 0
      state._problemCount = (state._problemCount || 1) + 1
      state._lastProceedTurn = state.turns
      break
    case 'proceed':
      // Weak reset — resets stuck-loop if currently stuck, else continues problem
      state._lastProceedTurn = state.turns
      if (state._problemTurns > 4) {
        state._problemStartTurn = state.turns
        state._problemTurns = 0
      }
      break
    case 'correction':
      // Stays in current problem; counter advances as normal
      break
    case 'question':
    case 'neutral':
      // No categorical effect — fall through to counter update
      break
  }

  // Always update problem-turns (resets above take effect)
  state._problemTurns = state.turns - (state._problemStartTurn || 0)
  state._lastPromptCategory = category

  // Check edit survival — if this turn is a correction and last turn had edits,
  // those edits likely didn't survive
  if (isCorrection && state._lastTurnEdits.length > 0) {
    state.revertedEdits += state._lastTurnEdits.length
  }

  // ─── Loop Detection ───
  if (!state._recentCorrections) state._recentCorrections = []

  if (isCorrection) {
    state._recentCorrections.push(userText.slice(0, 200))
    if (state._recentCorrections.length > 5) state._recentCorrections.shift()

    // Check if a loop is forming (2+ corrections in recent history)
    if (state._recentCorrections.length >= 2) {
      const loopInfo = checkForLoop(state)
      if (loopInfo) {
        state._loopActive = true
        state._loopMatches = loopInfo.matches.length > 0 ? loopInfo : null

        // Output loop warning to stderr (Claude sees this)
        if (loopInfo.matches.length > 0) {
          const match = loopInfo.matches[0]
          const msg = [
            '',
            '⚠ LOOP DETECTED — you\'ve been here before',
            `  Pattern: ${loopInfo.fingerprint}`,
            `  Past match (${match.date}): "${match.corrections[0]?.slice(0, 80)}"`,
          ]
          if (match.resolutionFiles.length > 0) {
            msg.push(`  What fixed it: ${match.resolutionFiles.join(', ')}`)
          }
          if (match.approval) {
            msg.push(`  Resolution: "${match.approval.slice(0, 100)}"`)
          }
          msg.push('')
          process.stderr.write(msg.join('\n'))
        }
      }
    }
  } else if (isApproval) {
    // Approval breaks the loop
    state._recentCorrections = []
    state._loopActive = false
    state._loopMatches = null
  }

  // Compute per-turn score
  let turnScore = 0.7 // neutral
  if (isCorrection) turnScore = 0.2
  else if (isApproval) turnScore = 1.0
  else if (isInterruption) turnScore = 0.1

  state.turns++
  state.turnScores.push(turnScore)
  // Keep only last 50 turn scores to bound memory
  if (state.turnScores.length > 50) state.turnScores = state.turnScores.slice(-50)

  // Reset per-turn tracking
  state._lastTurnEdits = []
  state._lastTurnHadCorrection = isCorrection
  state._cwd = process.cwd()

  // Recompute session score
  state.score = computeScore(state)
  saveState(state)
}

async function handleTool () {
  let input = ''
  for await (const chunk of process.stdin) {
    input += chunk
  }

  let data
  try {
    data = JSON.parse(input)
  } catch {
    return
  }

  const state = loadState()
  state.toolCalls++

  const toolName = data.tool_name || ''
  const toolInput = data.tool_input || {}
  const toolResult = data.tool_result || ''

  // Track edited files for survival analysis
  if (toolName === 'Edit' || toolName === 'Write') {
    const fp = toolInput.file_path || ''
    if (fp) {
      state.editedFiles.push(fp)
      state._lastTurnEdits.push(fp)
    }
  }

  // Detect build/test errors in tool results
  const resultText = typeof toolResult === 'string' ? toolResult
    : typeof toolResult === 'object' ? JSON.stringify(toolResult)
    : ''

  if (toolName === 'Bash' && resultText.length > 0) {
    if (/error|Error|ERROR|FAIL|fatal|panic|exception|TypeError|ReferenceError|SyntaxError/i.test(resultText)) {
      // Check it's actually an error, not just the word "error" in normal output
      if (!/no errors?|error.*(free|safe|handling|message|log)|fix.*error/i.test(resultText)) {
        state.buildErrors++
      }
    }
  }

  // Recompute score periodically (every 5 tool calls to stay fast)
  if (state.toolCalls % 5 === 0) {
    state.score = computeScore(state)
  }

  saveState(state)
}

function handleStop () {
  const state = loadState()
  state.score = computeScore(state)
  state.endTime = new Date().toISOString()
  saveState(state)

  // Write session summary to data dir for historical tracking
  const summaryDir = path.join(__dirname, 'data', 'session-summaries')
  if (!fs.existsSync(summaryDir)) fs.mkdirSync(summaryDir, { recursive: true })

  const summary = {
    score: state.score,
    turns: state.turns,
    corrections: state.corrections,
    approvals: state.approvals,
    toolCalls: state.toolCalls,
    buildErrors: state.buildErrors,
    interruptions: state.interruptions,
    revertedEdits: state.revertedEdits,
    trajectory: state.trajectory,
    endTime: state.endTime,
  }

  const summaryFile = path.join(summaryDir, `${new Date().toISOString().slice(0, 10)}-${Date.now()}.json`)
  fs.writeFileSync(summaryFile, JSON.stringify(summary, null, 2))
}

function handleStatus () {
  const state = loadState()

  // Trajectory arrow
  const arrow = state.trajectory === 'improving' ? '▲'
    : state.trajectory === 'declining' ? '▼'
    : '▬'

  // Compute learning rate: what % of correction BURSTS led to clean turns?
  // A burst = consecutive corrections collapsed into one event.
  // Learned = burst followed by 3+ non-correction turns.
  // Failed = burst followed by another burst within 3 turns.
  const scores = state.turnScores || []
  let corrLearned = 0
  let corrFailed = 0
  let i = 0
  while (i < scores.length) {
    if (scores[i] === 0.2) {
      // Found a correction burst — skip consecutive corrections
      while (i < scores.length && scores[i] === 0.2) i++
      // Now check what follows
      const after = scores.slice(i, i + 3)
      if (after.includes(0.2)) corrFailed++
      else corrLearned++
    } else {
      i++
    }
  }
  const totalCorr = corrLearned + corrFailed
  const learningRate = totalCorr > 0 ? Math.round(corrLearned / totalCorr * 100) : null

  // Format: ▬ 0.72 │ 5ok 2fix │ 34T │ 75% learning │ ⟳LOOP
  const parts = [
    `${arrow} ${state.score.toFixed(2)}`,
    `${state.approvals}ok ${state.corrections}fix`,
    `${state.toolCalls}T`,
  ]

  if (learningRate !== null) {
    parts.push(`${learningRate}% learning`)
  }

  if (state._loopActive) {
    parts.push('⟳LOOP')
  }

  process.stdout.write(parts.join(' │ '))
}

function handleReset () {
  saveState(createFreshState())
}

// ─── CLI ───

const command = process.argv[2] || 'status'

switch (command) {
  case 'prompt':
    handlePrompt().catch(() => process.exit(0))
    break
  case 'tool':
    handleTool().catch(() => process.exit(0))
    break
  case 'stop':
    handleStop()
    break
  case 'status':
    handleStatus()
    break
  case 'reset':
    handleReset()
    break
  default:
    console.error(`Usage: realtime-score.js [prompt|tool|stop|status|reset]`)
    process.exit(1)
}