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