OverviewFilesCommitsIssuesDocsReleasesPeers

knowledge-ratchet / atomspace / attention-router.js

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

/**
 * attention-router.js — Inject relevant meta-cognition BEFORE Claude acts
 *
 * Runs as UserPromptSubmit hook. On every user message:
 *   1. Extract context: current cwd, files mentioned, session-edited files
 *   2. Query AtomSpace for top-k wisdom-ranked rules matching this context
 *   3. Output a concise briefing (≤500 tokens) as additionalContext
 *
 * This is Linas's "little birdie" — the attentional steering mechanism.
 * Complements preflight-review.js (which fires on Edit/Write) by adding
 * proactive guidance on every prompt.
 *
 * Fails silently if CogServer not running or AtomSpace empty.
 */

const fs = require('fs')
const path = require('path')
const { isCogServerUp, evalScheme } = require('./cogserver')
const { relevantAtoms, liveSessionContext } = require('./query')
const { logFire } = require('./fire-log')
const { evaluateNow: evalStrategicRules } = require('./strategic-rules')

const SESSION_FILE = '/tmp/claude-atomspace-session'
const SESSION_STATE_FILE = '/tmp/claude-session-score.json'
const TOKEN_BUDGET = 500  // hard cap on injected context size

function readSessionState () {
  try { return JSON.parse(fs.readFileSync(SESSION_STATE_FILE, 'utf8')) }
  catch { return {} }
}

/**
 * Extract file paths mentioned in the user's prompt.
 * Simple heuristic: look for slash-separated paths with common extensions.
 */
function extractMentionedFiles (prompt) {
  if (!prompt) return []
  const fileRegex = /\b[\w./-]+\.(js|ts|py|cpp|cc|h|hpp|gd|glsl|scm|md|json|toml|yaml|yml|tscn|tres)\b/g
  const matches = prompt.match(fileRegex) || []
  return [...new Set(matches)]
}

/**
 * Extract module name from cwd.
 * /storage/projects/code/hypercore-kb/hyper-fleeky/MODULE/... → MODULE
 */
function extractModule (cwd) {
  if (!cwd) return null
  const m = cwd.match(/\/hyper-fleeky\/([^/]+)/)
  if (m) return m[1]
  const m2 = cwd.match(/\/godot\/([^/]+)/)
  if (m2) return m2[1]
  return null
}

/**
 * Extract language from module (best guess based on module type).
 */
function inferLanguage (module) {
  if (!module) return null
  // Godot projects
  if (/^(dc-terrain|proc-nodes|blender-bridge|nvidia-rtx|godot-)/.test(module)) return 'cpp'
  if (module === 'hyper-tps') return 'gdscript'
  // JS projects (hyper-fleeky)
  return 'javascript'
}

/**
 * Build the attention briefing — concise, budget-capped.
 */
function formatBriefing (relevant, session, module, hasMentionedFiles, strategicRules) {
  const hasTier1 = relevant && relevant.tier1.length > 0
  const hasTier2 = relevant && relevant.tier2.length > 0
  const hasStrategic = strategicRules && strategicRules.length > 0

  // Strategic rules fire regardless of prompt content — they're about session STATE
  // If nothing else AND no strategic rules, be quiet
  if (!hasTier1 && !hasTier2 && !hasStrategic) return null

  const sessionInteresting = session && session.totalSessionCorrections > 2
  // Silence only if nothing to say: no files mentioned, no session issues, no strategic rules
  if (!hasMentionedFiles && !sessionInteresting && !hasStrategic) return null

  const lines = []

  // STRATEGIC RULES FIRST — these are situational warnings that matter most
  if (hasStrategic) {
    for (const r of strategicRules.slice(0, 2)) {  // cap at 2 to respect budget
      const marker = r.severity === 'high' ? '‼' : '⚠'
      lines.push(`${marker} [${r.id}] ${r.message}`)
    }
  }

  // Session context (only if there are corrections AND this is work-related)
  if (sessionInteresting && hasMentionedFiles) {
    lines.push(`⚠ This session: ${session.totalSessionCorrections} corrections already — slow down`)
  }

  // Tier 1: top 3 high-wisdom rules (only show if we have mentioned files)
  if (hasTier1 && hasMentionedFiles) {
    lines.push('Top attention signals:')
    for (const t of relevant.tier1) {
      if (!t.summary) continue
      const prefix = t.wisdom >= 1.0 ? '★' : '·'
      lines.push(`  ${prefix} ${t.summary}`)
    }
  }

  // Tier 2: abbreviated next 2
  if (hasTier2 && hasMentionedFiles && lines.length > 0) {
    lines.push(`Also relevant: ${relevant.tier2.length} more rules available`)
  }

  if (lines.length === 0) return null

  const text = lines.join('\n')
  const tokens = Math.ceil(text.length / 4)

  // Hard budget cap
  if (tokens > TOKEN_BUDGET) {
    // Keep tier 1 only, drop tier 2
    const shortened = lines.slice(0, 4).join('\n')
    return shortened.length > TOKEN_BUDGET * 4
      ? shortened.slice(0, TOKEN_BUDGET * 4) + '...'
      : shortened
  }

  return text
}

async function main () {
  // Read prompt input
  let input = ''
  for await (const chunk of process.stdin) input += chunk

  let data
  try { data = JSON.parse(input) } catch { process.exit(0) }

  const prompt = data.prompt || data.user_prompt || data.message || ''
  if (!prompt || prompt.length < 3) process.exit(0)

  // Skip analyzer prompts (prevent feedback loop)
  if (prompt.includes('You are analyzing a user') ||
      prompt.includes('REGEX CLASSIFIED AS') ||
      prompt.includes('respond with EXACTLY one line')) {
    process.exit(0)
  }

  // CogServer / session must be ready
  if (!await isCogServerUp()) process.exit(0)
  let sessionId = null
  try { sessionId = fs.readFileSync(SESSION_FILE, 'utf8').trim() } catch {}
  if (!sessionId) process.exit(0)

  // Build context
  const state = readSessionState()
  const cwd = state._cwd || process.cwd()
  const module = extractModule(cwd)
  const language = inferLanguage(module)

  // Files: mentioned in prompt + recently edited this session
  const mentioned = extractMentionedFiles(prompt)
  const edited = state.editedFiles || state._lastTurnEdits || []
  const files = [...new Set([...mentioned, ...edited])].slice(0, 10)

  // Resolve bare filenames to known file atoms (so dc_terrain.cpp finds dc-terrain/dc_terrain.cpp)
  const resolvedFiles = []
  for (const f of files) {
    if (f.includes('/')) {
      resolvedFiles.push(f)
      continue
    }
    // Bare filename — query AtomSpace for file: atoms ending with this filename
    try {
      const matchStr = await evalScheme(
        `(string-join (filter (lambda (n) (string-suffix? "/${f.replace(/"/g, '\\"')}" n)) (map cog-name (cog-get-atoms (quote ConceptNode)))) "|")`)
      const hits = (matchStr || '').replace(/^"|"$/g, '').split('|').filter(n => n.startsWith('file:'))
      for (const h of hits.slice(0, 3)) {
        resolvedFiles.push(h.replace('file:', ''))
      }
      if (hits.length === 0) resolvedFiles.push(f)  // keep original as fallback
    } catch {
      resolvedFiles.push(f)
    }
  }

  // Query AtomSpace for relevant atoms
  let relevant = null
  try {
    relevant = await relevantAtoms({ cwd, files: resolvedFiles, module, language })
  } catch {}

  // Also grab live session context for a sample file (if any)
  let session = null
  if (files.length > 0) {
    try { session = await liveSessionContext(files[0]) } catch {}
  }

  // Evaluate strategic situational rules (based on live session state)
  let strategicRules = []
  try { strategicRules = evalStrategicRules() } catch {}

  const briefing = formatBriefing(relevant, session, module, mentioned.length > 0, strategicRules)
  if (!briefing) process.exit(0)

  // Log each fired rule/meta atom for utility tracking + live dashboard
  if (relevant) {
    const promptExcerpt = prompt.slice(0, 200)
    const triggerFiles = mentioned.slice(0, 3).join(', ')
    for (const tier of [relevant.tier1, relevant.tier2]) {
      for (const t of (tier || [])) {
        logFire({
          session: sessionId,
          hook: 'attention-router',
          atom: t.atom,
          wisdom: t.wisdom,
          context: promptExcerpt,
          trigger: triggerFiles || module
        })
      }
    }
  }

  // Output as additionalContext (Claude Code hook convention)
  const output = {
    hookSpecificOutput: {
      hookEventName: 'UserPromptSubmit',
      additionalContext: '[AtomSpace attention]\n' + briefing
    }
  }

  process.stdout.write(JSON.stringify(output))
  process.exit(0)
}

main().catch(() => process.exit(0))