#!/usr/bin/env node /** * UserPromptSubmit Hook: 路由注入主管道 (v5.2 Neural Gateway) * * 处理流程: * 1. 解析用户 prompt * 2. 意图分类 → 三级分流 (simple/medium/complex) * 3. 运行路由引擎 (BM25 + 上下文融合) * 4. 生成 [BWR] 指令注入 additionalContext * 5. 写入 route-state-current.json (供下游 hook 消费) * * stdin: { session_id, transcript_path, cwd, prompt, hook_event_name } * stdout: JSON { hookSpecificOutput: { additionalContext } } * 退出码: 0 (始终放行) * * [P2-7] skills-index-lite.json mtime 缓存: 避免每次路由调用都重复 JSON.parse * * 性能预算: < 2000ms 总计 */ const fs = require('fs'); const path = require('path'); const crypto = require('crypto'); const { safeAppendJsonl } = require('./lib/safe-append.js'); const readStdin = require('./lib/read-stdin.js'); // === P3-1 BUNDLE: preload routing deps === // Phase 0 宪法合规拆分: 核心逻辑提取到独立模块 const { runRouteEngine, loadSkillsIndex, safeRequire: _engineRequire } = require('../scripts/route-engine.js'); const { buildBWRDirective, MUST_INVOKE_EXEMPT_INTENTS: _EXEMPT } = require('../scripts/bwr-builder.js'); const { writeRouteState: _writeRouteState } = require('../scripts/route-state.js'); // H13: 意图分类器立即加载 (每次必用) const _preloaded = {}; try { _preloaded['intent-classifier.js'] = require('../scripts/intent-classifier.js'); } catch {} // 次要模块 — 首次访问时延迟加载 function _getLazy(name) { if (!_preloaded[name]) { try { _preloaded[name] = require('../scripts/' + name); } catch { _preloaded[name] = null; } } return _preloaded[name]; } // 动态检测 Claude 配置根目录 const CLAUDE_ROOT = require('./lib/root.js'); const DEBUG_DIR = path.join(CLAUDE_ROOT, 'debug'); const SCRIPTS_DIR = path.join(CLAUDE_ROOT, 'scripts'); const STATE_FILE = path.join(DEBUG_DIR, 'route-state-current.json'); const SESSION_LOCK = path.join(DEBUG_DIR, 'session-active.lock'); let _currentSessionId = null; // MUST_INVOKE 豁免白名单 (来源: bwr-builder.js) const MUST_INVOKE_EXEMPT_INTENTS = _EXEMPT; /** * 日志脱敏 */ const sanitizePrompt = (() => { try { return require('../scripts/sanitize.js').sanitize; } catch { return (text) => text || ''; } })(); // === 会话首次激活横幅 (v5.3) === // 返回 null (非首条消息) 或横幅文本 (首条消息, 注入 additionalContext) function showActivationBanner(sessionId) { // 检查是否已有同 session 的锁文件 try { if (fs.existsSync(SESSION_LOCK)) { const lock = JSON.parse(fs.readFileSync(SESSION_LOCK, 'utf8')); if (lock.sessionId === sessionId) return null; // 同会话,不重复显示 } } catch {} // 写入新会话锁 try { if (!fs.existsSync(DEBUG_DIR)) fs.mkdirSync(DEBUG_DIR, { recursive: true }); fs.writeFileSync(SESSION_LOCK, JSON.stringify({ sessionId, ts: new Date().toISOString() })); } catch {} // 从 stats-compiled.json 读取系统指标 (唯一真相源) let skillCount = 0, hookCount = 0, mcpCount = 0, agentCount = 0, sysVersion = 'v5.9'; try { const stats = JSON.parse(fs.readFileSync(path.join(CLAUDE_ROOT, 'stats-compiled.json'), 'utf8')); const s = stats.summary || {}; skillCount = s.skills || 0; hookCount = s.hooks || 0; mcpCount = s.mcp || 0; agentCount = s.agents || 0; sysVersion = stats.version || 'v5.9'; } catch { // stats-compiled.json 不存在时回退扫描 (P2-7: 使用 mtime 缓存加载) try { const idx = loadSkillsIndex(path.join(CLAUDE_ROOT, 'skills-index-lite.json')); if (idx) skillCount = idx.skills ? idx.skills.length : 0; } catch {} try { const sJson = JSON.parse(fs.readFileSync(path.join(CLAUDE_ROOT, 'settings.json'), 'utf8')); mcpCount = Object.keys(sJson.mcpServers || {}).length; } catch {} try { hookCount = fs.readdirSync(path.join(CLAUDE_ROOT, 'hooks')).filter(f => f.endsWith('.js')).length; } catch {} try { agentCount = fs.readdirSync(path.join(CLAUDE_ROOT, 'agents')).filter(f => f.endsWith('.md')).length; } catch {} } // 从 route-stats.json 读取活跃技能数 (真实用户查询命中过的技能) let activeSkillCount = 0; try { const statsFile = path.join(DEBUG_DIR, 'route-stats.json'); if (fs.existsSync(statsFile)) { const routeStats = JSON.parse(fs.readFileSync(statsFile, 'utf8')); activeSkillCount = Object.keys(routeStats.stats || {}).length; } } catch {} const now = new Date(); const ts = `${now.getFullYear()}-${String(now.getMonth()+1).padStart(2,'0')}-${String(now.getDate()).padStart(2,'0')} ${String(now.getHours()).padStart(2,'0')}:${String(now.getMinutes()).padStart(2,'0')}`; // 纯数据上下文 (不含任何指令, 显示规则在 CLAUDE.md 中定义) const banner = [ `[BOOKWORM_SESSION_START]`, `version: ${sysVersion}`, `skills: ${skillCount}`, `agents: ${agentCount}`, `hooks: ${hookCount}`, `mcp: ${mcpCount}`, `active_skills: ${activeSkillCount}/${skillCount}`, `timestamp: ${ts}`, ].join('\n'); return banner; } // === Phase 0 宪法拆分: 核心函数委托到独立模块 === // runRouteEngine → scripts/route-engine.js // buildBWRDirective → scripts/bwr-builder.js // writeRouteState + appendRouteLog → scripts/route-state.js function safeRequire(modulePath) { const basename = path.basename(modulePath); if (_preloaded[basename] !== undefined) return _preloaded[basename] || null; try { return require(modulePath); } catch { return null; } } // runRouteEngine: 薄代理 → scripts/route-engine.js (宪法 2.2 拆分) // buildBWRDirective: 薄代理 → scripts/bwr-builder.js // writeRouteState: 薄代理 → scripts/route-state.js (注入 sessionId) function writeRouteState(traceId, prompt, intent, routing) { return _writeRouteState(traceId, prompt, intent, routing, _currentSessionId); } // === 主流程 === function main() { // v5.9: 硬 timeout 保护 — 超过 2000ms 强制退出并返回无路由建议 const HARD_TIMEOUT_MS = 2000; // [PERF v6.1] 从 2500→2000ms const timeoutTimer = setTimeout(() => { // 超时时静默退出,等同于无路由建议 (fallback 到 developer-expert) process.exit(0); }, HARD_TIMEOUT_MS); // 允许 Node.js 在 timer 未触发时正常退出 if (timeoutTimer.unref) timeoutTimer.unref(); readStdin({ maxSize: 256 * 1024 }).then(input => { try { const prompt = input.prompt; const cwd = input.cwd || process.cwd(); if (!prompt || typeof prompt !== 'string' || prompt.trim().length === 0) { process.exit(0); return; } // XC14 修复: task-notification 系统消息提前退出,不写 route-state-current.json // appendRouteLog 已有同类过滤,但 writeRouteState 调用早于它,state 文件会被污染 if (prompt.includes('')) { process.exit(0); return; } // v5.3: 会话首次激活横幅 (返回横幅文本或 null) const sessionId = (typeof input.session_id === 'string' && input.session_id.length >= 8) ? input.session_id : 'transient-' + process.pid; // H4 修复: session_id 无效时用进程级标识 _currentSessionId = sessionId; // RL-V01: 同步到模块级变量供 writeRouteState 使用 const bannerText = showActivationBanner(sessionId); // === Phase 0: 逃生舱命令检测 (在 /skill-name 检测之前) === const escapeMatch = prompt.trim().match(/^\/(force|checks|reset)(?:\s+(.*))?$/i); if (escapeMatch) { try { const { isEnabled } = require('../scripts/feature-flags.js'); const userOverrides = require('../scripts/user-overrides.js'); const cmd = escapeMatch[1].toLowerCase(); const arg = (escapeMatch[2] || '').trim(); let message = ''; if (cmd === 'force' && isEnabled('escape-hatch-force')) { userOverrides.setForce(arg || undefined); message = `[BWR:override] Force mode ON${arg ? ` (skill: ${arg})` : ''} — next routing will be bypassed`; } else if (cmd === 'checks' && isEnabled('escape-hatch-checks')) { const enabled = arg.toLowerCase() !== 'off'; userOverrides.setChecks(enabled); message = enabled ? '[BWR:override] Quality checks ON' : '[BWR:override] Quality checks OFF (1h expiry)'; } else if (cmd === 'reset' && isEnabled('escape-hatch-reset')) { userOverrides.resetAll(); message = '[BWR:override] All overrides cleared'; } if (message) { const ctx = bannerText ? bannerText + '\n\n' + message : message; const output = { hookSpecificOutput: { hookEventName: 'UserPromptSubmit', additionalContext: ctx, }, }; process.stdout.write(JSON.stringify(output)); process.exit(0); return; } } catch {} } // Warning#3 优化: 一次性读取 STATE_FILE,供隐式反馈检测 + simple 继承共享 let _cachedPrevState = null; try { if (fs.existsSync(STATE_FILE)) { _cachedPrevState = JSON.parse(fs.readFileSync(STATE_FILE, 'utf8')); } } catch {} // P1-4: Skill 隐式反馈检测 — 上一轮 Skill 调用后用户是否表示不满 try { if (_cachedPrevState) { const prevTs = _cachedPrevState.ts ? new Date(_cachedPrevState.ts).getTime() : 0; const elapsed = Date.now() - prevTs; if (elapsed < 3 * 60 * 1000 && _cachedPrevState.routing && _cachedPrevState.routing.primary) { // R2#1 修复: 仅匹配 prompt 前 30 字符,避免技术描述中的词误触发 // LV-06: 使用更精确的短语匹配,减少 "不对称"/"是不是" 等误判 const head = prompt.slice(0, 30).toLowerCase(); const negativeSignals = /^(不对|不是|换个|错了|重来|不要|别用|不行|太差)|^(no|not what|wrong|try again)/; const positiveSignals = /^(很好|不错|对的|好的|可以|继续)|^(exactly|perfect|great|yes)/; const isNegative = negativeSignals.test(head); const isPositive = positiveSignals.test(head) && !isNegative; if (isNegative || isPositive) { const feedbackFile = path.join(DEBUG_DIR, 'skill-implicit-feedback.jsonl'); safeAppendJsonl(feedbackFile, { ts: new Date().toISOString(), prevTraceId: _cachedPrevState.traceId, prevSkill: _cachedPrevState.routing.primary, prevConfidence: _cachedPrevState.routing.confidence, signal: isNegative ? 'negative' : 'positive', promptSnippet: sanitizePrompt(prompt.slice(0, 100)), }); } } } } catch {} // 用户显式调用 /skill-name → 直接放行不干预 if (/^\/[\w-]+/.test(prompt.trim())) { // 即使是显式调用,首条消息也要输出横幅 if (bannerText) { const output = { hookSpecificOutput: { hookEventName: 'UserPromptSubmit', additionalContext: bannerText, }, }; process.stdout.write(JSON.stringify(output)); } process.exit(0); return; } // 生成 traceId const traceId = crypto.randomUUID().slice(0, 8); // 意图分类 const intentClassifier = safeRequire(path.join(SCRIPTS_DIR, 'intent-classifier.js')); let intent; if (intentClassifier) { intent = intentClassifier.classifyIntent(prompt); } else { intent = { intents: ['general'], modifiers: [], entities: [], complexity: 'medium' }; } // 三级分流 + 斧二(短查询继承) + 斧四(图片继承) let routing; let inherited = false; // 斧四: 图片/附件查询检测 — [Image #N] 模式自动继承上轮 (占 none 的 24.3%) // v6.5.2: 移除 ^ 锚定,支持 "检查[Image #1]..." 等非行首位置 const isImageQuery = /\[Image\s*#?\d+\]/.test(prompt); // 继承尝试函数 (simple + 斧二 + 斧四 共用) const INHERIT_WINDOW_MS = 5 * 60 * 1000; function tryInherit() { if (!_cachedPrevState) return null; const prevTs = _cachedPrevState.ts ? new Date(_cachedPrevState.ts).getTime() : 0; const elapsed = Date.now() - prevTs; if ( elapsed > INHERIT_WINDOW_MS || !_cachedPrevState.routing?.primary || _cachedPrevState.routing.primary === 'none' ) return null; const prevRouting = _cachedPrevState.routing; return { primary: prevRouting.primary, candidates: (prevRouting.candidates || []).map(c => ({ ...c, confidence: Math.round(c.confidence * 0.7 * 100) / 100, })), confidence: Math.round((prevRouting.confidence || 0) * 0.7 * 100) / 100, chain: prevRouting.chain || [], // 宪法 13.1: 继承路由保留 mustInvoke 标记 _inheritedMustInvoke: _cachedPrevState.mustInvoke || false, }; } if (isImageQuery) { // 斧四: 图片查询 → 强制继承上轮,不走 TF-IDF routing = tryInherit() || { primary: 'none', candidates: [], confidence: 0, chain: [] }; inherited = routing.primary !== 'none'; } else if (intent.complexity === 'simple') { // simple: 继承上一次路由 (continue/select/confirm + general/explain) const inheritResult = tryInherit(); if (inheritResult && inheritResult.primary !== 'none') { routing = inheritResult; inherited = true; } else { // v6.5.2 追问兜底: CJK 3-14 字的 general 查询继承失败时走 TF-IDF // 避免 "再美化一下"/"系统自检" 等追问直接 none const cjkCount = (prompt.match(/[\u4e00-\u9fff\u3400-\u4dbf]/g) || []).length; const isPureSimple = intent.intents.some(i => i === 'confirm' || i === 'select' || i === 'continue'); if (!isPureSimple && cjkCount >= 3 && cjkCount < 15) { routing = null; // 落到 TF-IDF 路由引擎 } else { routing = { primary: 'none', candidates: [], confidence: 0, chain: [] }; } } } else if (intent.complexity === 'medium') { // 斧二: medium 短查询尝试继承 (V-03: CJK 独立阈值) // CJK 字符数 < 6 且前轮有效 → 继承; 否则走 TF-IDF const cjkCount = (prompt.match(/[\u4e00-\u9fff\u3400-\u4dbf]/g) || []).length; const isShortCJK = cjkCount > 0 && cjkCount < 6 && prompt.length < 20; if (isShortCJK) { const inheritResult = tryInherit(); // 继承质量门控: 衰减后置信度 >= 0.5 才继承 if (inheritResult && inheritResult.confidence >= 0.5) { routing = inheritResult; inherited = true; } } } if (!routing) { // medium / complex: 运行完整路由引擎 routing = runRouteEngine(prompt, cwd, intent); // v5.3: 会话级路由记忆 — 注入会话偏好加成 const sessionMemory = safeRequire(path.join(SCRIPTS_DIR, 'session-memory.js')); if (sessionMemory && routing.candidates.length > 0) { try { const sessionId = sessionMemory.getSessionId(); for (const c of routing.candidates) { const boost = sessionMemory.getSessionBoost(sessionId, c.name); if (boost > 0) c.confidence = Math.min(1.0, c.confidence + boost); } // 重新排序并更新 primary routing.candidates.sort((a, b) => b.confidence - a.confidence); if (routing.candidates[0]) { routing.primary = routing.candidates[0].name; routing.confidence = routing.candidates[0].confidence; } } catch {} } // v5.3: A/B 实验 — 低置信差时随机探索 const abTest = safeRequire(path.join(SCRIPTS_DIR, 'route-ab-test.js')); if (abTest && routing.candidates.length >= 2) { try { const top2 = routing.candidates.slice(0, 2); if (abTest.shouldExperiment(top2)) { const { selected, experiment } = abTest.selectVariant(top2[0].name, top2[1].name); routing.primary = selected; routing.experiment = experiment; // 记录实验信息供审计 } } catch {} } // v5.3: 记录技能使用到会话记忆 if (sessionMemory && routing.primary && routing.primary !== 'none') { try { sessionMemory.recordSessionSkill(sessionMemory.getSessionId(), routing.primary); } catch {} } } // 写入 route-state writeRouteState(traceId, prompt, intent, routing); // GH-6: 路由决策 trace event (提升 trace 覆盖率) try { const sessionTrace = require('../scripts/session-trace.js'); sessionTrace.appendTraceEvent('route-interceptor-bundle', 'route-decision', { traceId, primary: routing.primary, confidence: routing.confidence }); } catch {} // 构建 [BWR] 指令 const directive = buildBWRDirective(traceId, intent, routing, inherited); // 输出 additionalContext (横幅 + 路由指令) const fullContext = bannerText ? bannerText + '\n\n' + directive : directive; const output = { hookSpecificOutput: { hookEventName: 'UserPromptSubmit', additionalContext: fullContext, }, }; // 写入 stdout (JSON 格式供 Claude Code 消费) process.stdout.write(JSON.stringify(output)); } catch (e) { try { process.stderr.write('[route-err] ' + (e.message || '') + '\n'); } catch {} // 异常时静默放行 } process.exit(0); }).catch(() => { clearTimeout(timeoutTimer); process.exit(0); }); } // 模块导出 (供测试) if (typeof module !== 'undefined') { module.exports = { detectClaudeRoot: CLAUDE_ROOT, runRouteEngine, buildBWRDirective, writeRouteState, showActivationBanner, safeRequire, loadSkillsIndex, }; } if (require.main === module) { main(); }