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

518 lines
22 KiB
JavaScript
Raw Normal View History

#!/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');
let safeAppendJsonl;
try { ({ safeAppendJsonl } = require('./lib/safe-append.js')); } catch { safeAppendJsonl = () => {}; }
let readStdin;
try { readStdin = require('./lib/read-stdin.js'); } catch { readStdin = () => Promise.resolve(''); }
// === P3-1 BUNDLE: preload routing deps (fail-open: 模块缺失时降级为空路由) ===
let runRouteEngine, loadSkillsIndex, _engineRequire;
let buildBWRDirective, _EXEMPT;
let _writeRouteState;
let _routingReady = true;
try {
({ runRouteEngine, loadSkillsIndex, safeRequire: _engineRequire } = require('../scripts/route-engine.js'));
({ buildBWRDirective, MUST_INVOKE_EXEMPT_INTENTS: _EXEMPT } = require('../scripts/bwr-builder.js'));
({ writeRouteState: _writeRouteState } = require('../scripts/route-state.js'));
} catch (e) {
_routingReady = false;
runRouteEngine = () => ({ skill: null, confidence: 0, candidates: [] });
loadSkillsIndex = () => [];
_engineRequire = () => null;
buildBWRDirective = () => '[BWR:skip] routing modules unavailable';
_EXEMPT = [];
_writeRouteState = () => {};
process.stderr.write('[route-interceptor] WARN: routing modules not found, degraded to skip mode: ' + (e.message || '') + '\n');
}
// 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 {}
// /* ROUTE-ACC-3D-V1 */ near-3-day route accuracy
let routeAccuracy3d = 'N/A';
try {
const cutoff = Date.now() - 3 * 86400 * 1000;
const files = fs.readdirSync(DEBUG_DIR).filter(function(f){return /^route-\d{4}-\d{2}-\d{2}\.jsonl$/.test(f);});
let total = 0, hit = 0;
for (const f of files) {
const lines = fs.readFileSync(path.join(DEBUG_DIR, f), 'utf8').split('\n');
for (const L of lines) {
if (!L) continue;
try {
const j = JSON.parse(L);
const ts = new Date(j.ts).getTime();
if (!Number.isFinite(ts) || ts < cutoff) continue;
/* ROUTE-ACC-3D-V2-FILTER */
const q = (j.query || '').trim();
if (q.length <= 3 || q.startsWith('[Image') || !q) continue;
if (j.topResult === 'none' && (!j.candidates || j.candidates.length === 0)) continue;
total++;
if (j.topConfidence && j.topConfidence > 0) hit++;
} catch {}
}
}
if (total > 0) routeAccuracy3d = (hit / total * 100).toFixed(1) + '%';
} 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}`,
`route_accuracy_3d: ${routeAccuracy3d}`,
`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);
// [P2-1] SHADOW_HAIKU_v1
try {
var _ffp = path.join(CLAUDE_ROOT, '.bookworm-features.json');
var _shOk = true;
try { if (fs.existsSync(_ffp)) { var _f = JSON.parse(fs.readFileSync(_ffp, 'utf8')); if (_f.shadow_haiku_route === false) _shOk = false; } } catch {}
if (_shOk) {
var _shLog = path.join(CLAUDE_ROOT, 'debug', 'shadow-route-log.jsonl');
try { fs.mkdirSync(path.dirname(_shLog), { recursive: true }); } catch {}
var _shEntry = {
ts: new Date().toISOString(), tid: traceId,
ph: prompt.slice(0, 200), pl: prompt.length,
it: intent ? { i: intent.intents, c: intent.complexity } : null,
p: routing.primary, cf: routing.confidence,
t5: (routing.candidates || []).slice(0, 5).map(function(c) { return { n: c.name, c: c.confidence }; }),
d: routing.domain || null,
fr: (routing._firedRules || []).map(function(r) { return r.id || r.rule || ''; }).filter(Boolean).slice(0, 5),
ih: inherited, cs: routing._coldStartApplied || false,
};
fs.appendFileSync(_shLog, JSON.stringify(_shEntry) + '\n');
}
} catch {}
// 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();
}