OverviewFilesCommitsIssuesDocsReleasesPeers

knowledge-ratchet / scan-transcripts.js

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

// Transcript Scanner
// Reads Claude Code session JSONL files, extracts user messages,
// detects corrections and approvals, appends to corrections.log

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 LOG_FILE = path.join(__dirname, 'corrections.log')

const CORRECTION_PATTERNS = [
  /\b(no[,.]|wrong|not that|that's not|that is not|incorrect|stop doing)\b/i,
  /\b(i said|i meant|i asked for|that's wrong|that is wrong)\b/i,
  /\b(it's actually|you should use|the function is|look at|use .+ instead)\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
]

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

// Skip patterns — messages that look like corrections but aren't
const SKIP_PATTERNS = [
  /^!/, // shell commands
  /^\s*$/, // empty
  /^\//, // slash commands
  /^</, // XML/system tags
]

function isUserText (entry) {
  if (entry.type !== 'user') return false
  const content = entry.message?.content
  if (!content) return false
  if (typeof content === 'string') return content.length > 3
  if (Array.isArray(content)) {
    return content.some(c => c.type === 'text' && c.text?.length > 3)
  }
  return false
}

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

function classifyMessage (text) {
  if (SKIP_PATTERNS.some(p => p.test(text))) return null
  if (text.length < 4) return null

  const isCorrection = CORRECTION_PATTERNS.some(p => p.test(text))
  const isApproval = APPROVAL_PATTERNS.some(p => p.test(text))

  // If both match (e.g. "no that's not right, use X instead — thanks"), prefer correction
  if (isCorrection) return 'correction'
  if (isApproval) return 'approval'
  return null
}

async function scanFile (filePath) {
  const results = []

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

  for await (const line of rl) {
    try {
      const entry = JSON.parse(line)
      if (!isUserText(entry)) continue

      const text = extractText(entry)
      const classification = classifyMessage(text)
      if (!classification) continue

      results.push({
        timestamp: entry.timestamp || new Date().toISOString(),
        type: classification,
        text: text.slice(0, 500),
        session: entry.sessionId || path.basename(filePath, '.jsonl'),
        cwd: entry.cwd || ''
      })
    } catch {}
  }

  return results
}

async function scanAll () {
  let allResults = []
  let filesScanned = 0
  let sessionsFound = 0

  for (const dir of SESSIONS_DIRS) {
    if (!fs.existsSync(dir)) continue

    // Find all JSONL files (skip subagents — they're not user conversations)
    const files = fs.readdirSync(dir)
      .filter(f => f.endsWith('.jsonl'))
      .map(f => path.join(dir, f))

    for (const file of files) {
      filesScanned++
      const results = await scanFile(file)
      if (results.length > 0) {
        sessionsFound++
        allResults.push(...results)
      }
    }
  }

  // Sort by timestamp
  allResults.sort((a, b) => a.timestamp.localeCompare(b.timestamp))

  // Deduplicate against existing log
  let existing = new Set()
  if (fs.existsSync(LOG_FILE)) {
    const log = fs.readFileSync(LOG_FILE, 'utf8')
    // Extract timestamps + first few words as dedup key
    for (const entry of log.split('\n---\n')) {
      const match = entry.match(/^(\d{4}-\d{2}-\d{2}T[\d:]+Z?)/)
      if (match) existing.add(match[1])
    }
  }

  // Append new entries
  let newCount = 0
  const corrections = allResults.filter(r => r.type === 'correction')
  const approvals = allResults.filter(r => r.type === 'approval')

  for (const r of allResults) {
    // Skip if we already have an entry at this exact timestamp
    const tsKey = r.timestamp.slice(0, 20)
    if (existing.has(tsKey)) continue
    existing.add(tsKey)

    const logLine = `\n---\n${r.timestamp} [${r.type}]\n${r.text}\ncwd: ${r.cwd}\nsession: ${r.session}\n`
    fs.appendFileSync(LOG_FILE, logLine)
    newCount++
  }

  console.log(`Scanned ${filesScanned} transcript files`)
  console.log(`Found signals in ${sessionsFound} sessions`)
  console.log(`Total signals: ${allResults.length} (${corrections.length} corrections, ${approvals.length} approvals)`)
  console.log(`New entries added: ${newCount} (deduplicated against existing log)`)
  console.log(`Log file: ${LOG_FILE}`)
}

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