OverviewFilesCommitsIssuesDocsReleasesPeers

knowledge-ratchet / loop-detector.js

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

// Loop Detector — Knowledge Ratchet Phase 2
//
// Scans session turn data for correction loops:
//   A loop is: correction → fumble → correction → ... → resolution
//
// A loop STARTS when a correction appears.
// A loop CONTINUES while: more corrections within N turns, high tool calls, low scores.
// A loop BREAKS when: approval comes, or 3+ clean turns (no corrections, decent score).
//
// For each loop, captures:
//   - What went wrong (correction texts)
//   - What was tried (edit files, tool calls)
//   - What fixed it (the resolution — the turns that broke the loop)
//   - Context (project, files, branch)
//
// Usage:
//   node loop-detector.js              # extract loops, save to data/loops.json
//   node loop-detector.js --verbose    # show per-loop detail
//   node loop-detector.js --match "current context"  # find similar past loops

const fs = require('fs')
const path = require('path')
const { classifyProject } = require('./classify-project')

const DATA_DIR = path.join(__dirname, 'data')
const DETAIL_FILE = path.join(DATA_DIR, 'fused-turns-detail.json')
const LOOPS_FILE = path.join(DATA_DIR, 'loops.json')

// ─── Loop Detection Parameters ───
const MAX_GAP = 5          // max turns between corrections to still be "same loop"
const MIN_CORRECTIONS = 2  // need at least 2 corrections to be a loop (1 correction = normal)
const CLEAN_STREAK = 3     // 3 turns without correction = loop is over

function loadDetail () {
  if (!fs.existsSync(DETAIL_FILE)) {
    console.error('No fused-turns-detail.json. Run fuse-signals.js first.')
    process.exit(1)
  }
  return JSON.parse(fs.readFileSync(DETAIL_FILE, 'utf8'))
}

// ─── Loop Extraction ───

function extractLoops (session) {
  const turns = session.turns
  if (turns.length < 3) return []

  const loops = []
  let currentLoop = null
  let cleanCount = 0 // consecutive non-correction turns

  for (let i = 0; i < turns.length; i++) {
    const turn = turns[i]

    if (turn.isCorrection) {
      if (!currentLoop) {
        // Start a new loop
        currentLoop = {
          startTurn: i,
          endTurn: i,
          corrections: [],
          allTurns: [],
          editFiles: new Set(),
          totalToolCalls: 0,
        }
      }
      currentLoop.corrections.push({
        turn: i,
        text: turn.text || '',
        score: turn.score,
      })
      currentLoop.endTurn = i
      cleanCount = 0
    } else {
      cleanCount++
    }

    // Add turn to current loop if we're in one
    if (currentLoop) {
      currentLoop.allTurns.push({
        turn: i,
        score: turn.score,
        isCorrection: turn.isCorrection,
        isApproval: turn.isApproval,
        text: turn.text || '',
        toolCalls: turn.toolCalls || 0,
        editFiles: turn.editFiles || [],
      })
      currentLoop.totalToolCalls += turn.toolCalls || 0
      for (const f of (turn.editFiles || [])) currentLoop.editFiles.add(f)
    }

    // Check if loop is over
    if (currentLoop && cleanCount >= CLEAN_STREAK) {
      // Loop broke — finalize it
      if (currentLoop.corrections.length >= MIN_CORRECTIONS) {
        loops.push(finalizeLoop(currentLoop, session))
      }
      currentLoop = null
      cleanCount = 0
    }
  }

  // Handle loop at end of session (never resolved)
  if (currentLoop && currentLoop.corrections.length >= MIN_CORRECTIONS) {
    const loop = finalizeLoop(currentLoop, session)
    loop.resolved = false
    loops.push(loop)
  }

  return loops
}

function finalizeLoop (raw, session) {
  const lastCorrIdx = raw.corrections[raw.corrections.length - 1].turn
  const turnsAfterLastCorr = raw.allTurns.filter(t => t.turn > lastCorrIdx)

  // Resolution: the turns after the last correction that have good scores
  // These contain the fix that broke the loop
  const resolutionTurns = turnsAfterLastCorr.filter(t => !t.isCorrection)
  const resolutionFiles = [...new Set(resolutionTurns.flatMap(t => t.editFiles))]
  const resolutionApproval = resolutionTurns.find(t => t.isApproval)

  // Classify the loop by what files were touched
  const allFiles = [...raw.editFiles]
  const project = classifyProject(session.cwd || '', allFiles)
  const loopType = classifyType(raw.corrections)

  // A loop is only truly resolved if:
  // 1. There's an explicit approval after the corrections, OR
  // 2. The clean turns after the loop have decent scores AND
  //    the loop doesn't end near the session end (user didn't just give up)
  const totalTurns = raw.allTurns[raw.allTurns.length - 1]?.turn || 0
  const sessionEnd = (session.turns?.length || totalTurns) - 1
  const loopEndsTurn = raw.allTurns[raw.allTurns.length - 1]?.turn || 0
  const nearSessionEnd = sessionEnd - loopEndsTurn < 5
  const avgScoreAfter = resolutionTurns.length > 0
    ? resolutionTurns.reduce((s, t) => s + t.score, 0) / resolutionTurns.length
    : 0

  const isResolved = resolutionApproval
    ? true
    : (!nearSessionEnd && avgScoreAfter > 0.6 && resolutionTurns.length >= CLEAN_STREAK)

  return {
    sessionId: session.sessionId,
    date: session.date,
    project,
    type: loopType,
    cwd: session.cwd,
    gitBranch: session.gitBranch,
    startTurn: raw.startTurn,
    endTurn: raw.allTurns[raw.allTurns.length - 1]?.turn || raw.endTurn,
    duration: (raw.allTurns[raw.allTurns.length - 1]?.turn || raw.endTurn) - raw.startTurn + 1,
    correctionCount: raw.corrections.length,
    totalToolCalls: raw.totalToolCalls,
    resolved: isResolved,

    // The problem: what corrections said
    corrections: raw.corrections.map(c => c.text.slice(0, 200)),

    // What was tried: files edited during the loop
    filesTried: allFiles.slice(0, 20),

    // The fix: what broke the loop
    resolution: {
      files: resolutionFiles.slice(0, 10),
      approval: resolutionApproval?.text?.slice(0, 200) || null,
      turnCount: resolutionTurns.length,
    },

    // Fingerprint for matching
    fingerprint: buildFingerprint(project, loopType, allFiles),
  }
}

// ─── Classification ───
// classifyProject is imported from classify-project.js (shared with
// loop-briefing.js). It uses hardcoded substring matching first for
// backwards compatibility, then walks parent dirs to find .git.

function classifyType (corrections) {
  const allText = corrections.map(c => c.text).join(' ').toLowerCase()

  if (/invisible|not (visible|showing|rendering)|can.t see|don.t see|flat|blank/.test(allText)) return 'not-rendering'
  if (/crash|error|fail|exception|panic/.test(allText)) return 'runtime-error'
  if (/same|still|worse|no change|didn.t (work|help|fix)/.test(allText)) return 'fix-failed'
  if (/wrong|reversed|flipped|incorrect|cube/.test(allText)) return 'wrong-output'
  if (/why (are|is|did)|approach|design|should we/.test(allText)) return 'wrong-approach'
  if (/audit|planning|think deeper|step back/.test(allText)) return 'needs-rethink'
  if (/hmm|hmmm/.test(allText)) return 'quality-doubt'
  return 'other'
}

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

// ─── Loop Matching ───

function findSimilarLoops (currentFingerprint, pastLoops) {
  const [proj, type, filesStr] = currentFingerprint.split(':')
  const currentFiles = new Set(filesStr.split(',').filter(Boolean))

  const scored = pastLoops
    .filter(l => l.resolved) // only match against resolved loops
    .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 // same project = strong signal
      if (lType === type) score += 2 // same error type
      // File overlap
      const overlap = [...currentFiles].filter(f => loopFiles.has(f)).length
      score += overlap * 2

      return { loop, score }
    })
    .filter(s => s.score >= 3) // minimum relevance
    .sort((a, b) => b.score - a.score)

  return scored.slice(0, 3).map(s => ({
    score: s.score,
    fingerprint: s.loop.fingerprint,
    corrections: s.loop.corrections.slice(0, 3),
    resolution: s.loop.resolution,
    date: s.loop.date,
    duration: s.loop.duration,
  }))
}

// ─── Main ───

function main () {
  const verbose = process.argv.includes('--verbose')
  const matchArg = process.argv.find(a => a.startsWith('--match='))

  const data = loadDetail()

  // Extract loops from all sessions
  const allLoops = []
  for (const session of data.sessions) {
    const loops = extractLoops(session)
    allLoops.push(...loops)
  }

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

  // ─── Report ───
  console.log('═'.repeat(60))
  console.log('  LOOP DETECTOR')
  console.log('═'.repeat(60))
  console.log(`  Sessions analyzed: ${data.sessions.length}`)
  console.log(`  Loops found: ${allLoops.length}`)
  console.log(`  Resolved: ${allLoops.filter(l => l.resolved).length}`)
  console.log(`  Unresolved: ${allLoops.filter(l => !l.resolved).length}`)

  // Stats
  if (allLoops.length > 0) {
    const avgDuration = allLoops.reduce((s, l) => s + l.duration, 0) / allLoops.length
    const avgCorr = allLoops.reduce((s, l) => s + l.correctionCount, 0) / allLoops.length
    const avgTools = allLoops.reduce((s, l) => s + l.totalToolCalls, 0) / allLoops.length
    console.log(`  Avg loop duration: ${avgDuration.toFixed(1)} turns`)
    console.log(`  Avg corrections per loop: ${avgCorr.toFixed(1)}`)
    console.log(`  Avg tool calls per loop: ${avgTools.toFixed(0)}`)
  }

  // By project
  const byProject = {}
  for (const loop of allLoops) {
    if (!byProject[loop.project]) byProject[loop.project] = { count: 0, resolved: 0, avgDuration: 0 }
    byProject[loop.project].count++
    if (loop.resolved) byProject[loop.project].resolved++
    byProject[loop.project].avgDuration += loop.duration
  }

  console.log('\n  BY PROJECT')
  console.log('  ' + '-'.repeat(56))
  for (const [proj, stats] of Object.entries(byProject).sort((a, b) => b[1].count - a[1].count)) {
    stats.avgDuration = (stats.avgDuration / stats.count).toFixed(1)
    console.log(`  ${proj.padEnd(20)} ${String(stats.count).padStart(3)} loops  ${stats.resolved} resolved  avg ${stats.avgDuration} turns`)
  }

  // By type
  const byType = {}
  for (const loop of allLoops) {
    byType[loop.type] = (byType[loop.type] || 0) + 1
  }

  console.log('\n  BY TYPE')
  console.log('  ' + '-'.repeat(56))
  for (const [type, count] of Object.entries(byType).sort((a, b) => b - a)) {
    const bar = '█'.repeat(Math.round(count / allLoops.length * 20))
    console.log(`  ${bar.padEnd(10)} ${type.padEnd(20)} ${count}`)
  }

  // Show individual loops
  if (verbose) {
    console.log('\n  ALL LOOPS')
    console.log('  ' + '-'.repeat(56))
    for (const loop of allLoops) {
      const status = loop.resolved ? '✓' : '✗'
      console.log(`\n  ${status} ${loop.date} [${loop.project}/${loop.type}] ${loop.duration} turns, ${loop.correctionCount} corrections`)
      console.log(`    fingerprint: ${loop.fingerprint}`)
      for (const c of loop.corrections.slice(0, 3)) {
        console.log(`    ✗ "${c.slice(0, 100)}"`)
      }
      if (loop.resolved && loop.resolution.files.length > 0) {
        console.log(`    fix: ${loop.resolution.files.slice(0, 3).map(f => path.basename(f)).join(', ')}`)
      }
      if (loop.resolved && loop.resolution.approval) {
        console.log(`    ✓ "${loop.resolution.approval.slice(0, 100)}"`)
      }
    }
  }

  // Match mode
  if (matchArg) {
    const query = matchArg.split('=')[1]
    console.log(`\n  MATCHING: "${query}"`)
    console.log('  ' + '-'.repeat(56))
    // Build a fingerprint from the query (project:type:files)
    const matches = findSimilarLoops(query, allLoops)
    if (matches.length === 0) {
      console.log('  No similar past loops found.')
    } else {
      for (const m of matches) {
        console.log(`\n  Score: ${m.score}  Date: ${m.date}  Duration: ${m.duration} turns`)
        console.log(`  Fingerprint: ${m.fingerprint}`)
        console.log(`  Corrections: ${m.corrections.slice(0, 2).map(c => `"${c.slice(0, 80)}"`).join(', ')}`)
        if (m.resolution.files.length > 0) {
          console.log(`  Fix files: ${m.resolution.files.map(f => path.basename(f)).join(', ')}`)
        }
        if (m.resolution.approval) {
          console.log(`  Approval: "${m.resolution.approval.slice(0, 100)}"`)
        }
      }
    }
  }

  // Save
  const report = {
    generated: new Date().toISOString(),
    totalLoops: allLoops.length,
    resolved: allLoops.filter(l => l.resolved).length,
    byProject,
    byType,
    loops: allLoops,
  }
  fs.writeFileSync(LOOPS_FILE, JSON.stringify(report, null, 2))
  console.log(`\nSaved: ${LOOPS_FILE}`)
  console.log('═'.repeat(60))
}

module.exports = { extractLoops, findSimilarLoops, buildFingerprint }

if (require.main === module) main()