bookworm-smart-assistant/hooks/route-interceptor-bundle.js

454 lines
19 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

#!/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('<task-notification>')) {
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();
}