- VERSION file as authoritative version source - export.mjs reads VERSION with package.json fallback - bw-ota.ps1 DryRun mode for safe testing - auto-setup.ps1 bumped to v3.2.0 (Phase 8 OTA)
361 lines
13 KiB
JavaScript
361 lines
13 KiB
JavaScript
#!/usr/bin/env node
|
|
/**
|
|
* token-saver-dispatcher.js - TSE 6-in-1 unified dispatcher
|
|
* Modes: model-advisor | read-guard | bash-limiter | post-output-guard | mcp-tracker | session-report
|
|
* Usage: node token-saver-dispatcher.js --mode=<handler>
|
|
* Behavior: fail-open (all paths)
|
|
*/
|
|
'use strict';
|
|
|
|
const fs = require('fs');
|
|
const path = require('path');
|
|
const CLAUDE_ROOT = require('./lib/root.js');
|
|
const readStdin = require('./lib/read-stdin.js');
|
|
|
|
const STATE_DIR = path.join(CLAUDE_ROOT, 'session-state');
|
|
const MODE = (process.argv.find(a => a.startsWith('--mode=')) || '').slice(7);
|
|
if (!MODE) process.exit(0);
|
|
|
|
/* ── Shared State IO ── */
|
|
|
|
function stateLoad(file) {
|
|
const p = path.join(STATE_DIR, file);
|
|
try { return fs.existsSync(p) ? JSON.parse(fs.readFileSync(p, 'utf8')) : null; } catch { return null; }
|
|
}
|
|
|
|
function stateSave(file, data) {
|
|
try {
|
|
if (!fs.existsSync(STATE_DIR)) fs.mkdirSync(STATE_DIR, { recursive: true });
|
|
const p = path.join(STATE_DIR, file);
|
|
const tmp = p + '.tmp.' + process.pid;
|
|
fs.writeFileSync(tmp, JSON.stringify(data, null, 2), 'utf8');
|
|
fs.renameSync(tmp, p);
|
|
} catch {}
|
|
}
|
|
|
|
function purgeOld(obj, ttlMs) {
|
|
const cutoff = Date.now() - ttlMs;
|
|
for (const k of Object.keys(obj)) {
|
|
if (typeof obj[k] === 'number' && obj[k] < cutoff) delete obj[k];
|
|
else if (obj[k] && typeof obj[k] === 'object' && obj[k].ts && obj[k].ts < cutoff) delete obj[k];
|
|
}
|
|
}
|
|
|
|
// [P2-2] CJK_TOKEN_FIX_v1
|
|
function estimateFileTokens(filePath, fileSize) {
|
|
try {
|
|
var fd = fs.openSync(filePath, 'r');
|
|
var buf = Buffer.alloc(4096);
|
|
var n = fs.readSync(fd, buf, 0, 4096, 0);
|
|
fs.closeSync(fd);
|
|
if (n < 50) return Math.round(fileSize / 3.5);
|
|
var cjkBytes = 0;
|
|
for (var i = 0; i < n; i++) {
|
|
if (buf[i] >= 0xE4 && buf[i] <= 0xED) cjkBytes += 3;
|
|
}
|
|
var ratio = cjkBytes / n;
|
|
var bpt = ratio >= 0.40 ? 2.2 : ratio >= 0.15 ? 2.8 : 3.5;
|
|
return Math.round(fileSize / bpt);
|
|
} catch { return Math.round(fileSize / 3.5); }
|
|
}
|
|
|
|
function estimateStringTokens(str) {
|
|
var len = str.length;
|
|
if (len < 50) return Math.round(len / 4);
|
|
var sampleLen = Math.min(len, 2000);
|
|
var cjk = 0;
|
|
for (var i = 0; i < sampleLen; i++) {
|
|
var c = str.charCodeAt(i);
|
|
if ((c >= 0x3400 && c <= 0x9FFF) || (c >= 0xAC00 && c <= 0xD7AF)) cjk++;
|
|
}
|
|
var ratio = cjk / sampleLen;
|
|
var tokPerChar = ratio * 1.5 + (1 - ratio) * 0.25;
|
|
return Math.round(len * tokPerChar);
|
|
}
|
|
|
|
|
|
function emit(eventName, opts) {
|
|
if (!opts) opts = {};
|
|
var out = { continue: true };
|
|
if (opts.suppress) out.suppressOutput = true;
|
|
var hso = { hookEventName: eventName };
|
|
if (opts.ctx) hso.additionalContext = opts.ctx;
|
|
if (opts.input) hso.updatedInput = opts.input;
|
|
out.hookSpecificOutput = hso;
|
|
process.stdout.write(JSON.stringify(out));
|
|
}
|
|
|
|
/* ── model-advisor (UserPromptSubmit) ── */
|
|
|
|
var SIMPLE_RE = [
|
|
/^(查找|搜索|找到?|在哪|哪个文件)/, /^(翻译|translate)\b/i,
|
|
/^(格式化|format)\b/i, /^(解释|explain|what is|what does)\b/i,
|
|
/^(列出|list|show)\b/i, /^(帮我看|看一下|看看)/,
|
|
/^(改个?名|rename)\b/i, /^(运行|run|execute)\s+(test|build|lint)\b/i,
|
|
];
|
|
var COMPLEX_RE = [
|
|
/(架构|architecture|设计方案|system design)/i,
|
|
/(全面审计|comprehensive audit|全栈审计)/i,
|
|
/(从零开始|from scratch|end.to.end)/i,
|
|
/(重构整个|refactor the entire|重新设计)/i,
|
|
/(安全审查|security review|红队|red.team)/i,
|
|
/(性能优化方案|performance optimization plan)/i,
|
|
/(对比分析|comparative analysis|trade.off)/i,
|
|
];
|
|
|
|
function handleModelAdvisor(hd) {
|
|
var prompt = hd.prompt || '';
|
|
var sid = hd.session_id || 'u';
|
|
var p = prompt.trim();
|
|
if (!p || p.length < 3) return;
|
|
|
|
var level = null;
|
|
for (var i = 0; i < SIMPLE_RE.length; i++) { if (SIMPLE_RE[i].test(p)) { level = 'simple'; break; } }
|
|
if (!level) for (var i = 0; i < COMPLEX_RE.length; i++) { if (COMPLEX_RE[i].test(p)) { level = 'complex'; break; } }
|
|
if (!level && p.length < 25) level = 'simple';
|
|
if (!level) return;
|
|
|
|
var state = stateLoad('tse-model-advisor.json') || {};
|
|
var ss = state[sid] || { a: {}, ts: Date.now() };
|
|
if (ss.a[level]) return;
|
|
ss.a[level] = true; ss.ts = Date.now();
|
|
state[sid] = ss;
|
|
purgeOld(state, 86400000);
|
|
stateSave('tse-model-advisor.json', state);
|
|
|
|
var msg = level === 'simple'
|
|
? '[TSE\xb7MODEL_ADVISOR] 简单任务检测。如当前是 Opus, 建议 /model sonnet 或 /model haiku 以节省 5 倍额度。子 Agent 请指定 model: "haiku"。'
|
|
: '[TSE\xb7MODEL_ADVISOR] 复杂任务检测。Opus 适合规划阶段。建议: 方案确定后切回 Sonnet 执行, 子 Agent 用 Sonnet/Haiku。';
|
|
|
|
emit('UserPromptSubmit', { suppress: true, ctx: msg });
|
|
}
|
|
|
|
/* ── read-guard (PreToolUse:Read) ── */
|
|
|
|
function handleReadGuard(hd) {
|
|
if (hd.tool_name !== 'Read') return;
|
|
var input = hd.tool_input || {};
|
|
if (!input.file_path) return;
|
|
if (input.offset !== undefined || input.limit !== undefined || input.pages !== undefined) return;
|
|
|
|
var fileSize = 0;
|
|
try { fileSize = fs.statSync(input.file_path).size; } catch { return; }
|
|
if (fileSize < 10000) return;
|
|
|
|
var state = stateLoad('tse-read-guard.json') || {};
|
|
var now = Date.now();
|
|
var key = input.file_path.replace(/[\\\/\:]/g, '_');
|
|
if (state[key] && (now - state[key]) < 180000) return;
|
|
state[key] = now;
|
|
purgeOld(state, 600000);
|
|
stateSave('tse-read-guard.json', state);
|
|
|
|
var estLines = Math.round(fileSize * 30 / 1000);
|
|
var estTokens = estimateFileTokens(input.file_path, fileSize);
|
|
var bn = path.basename(input.file_path);
|
|
|
|
var msg = fileSize >= 35000
|
|
? '[TSE\xb7READ_GUARD] ⚠️ 大文件: ' + bn + ' ≈' + estLines + '行 (' + estTokens + ' tokens)\n你必须使用 offset+limit 分段读取。如需全文分析, 委托 Agent 子进程。'
|
|
: '[TSE\xb7READ_GUARD] 提示: ' + bn + ' ≈' + estLines + '行 (' + estTokens + ' tokens). 建议用 offset+limit 分段。';
|
|
|
|
emit('PreToolUse', { ctx: msg });
|
|
}
|
|
|
|
/* ── bash-limiter (PreToolUse:Bash) ── */
|
|
|
|
var VIEW_RE = [
|
|
/\bcat\s+\S+/, /\bdocker\s+logs\b/, /\bjournalctl\b/, /\bdmesg\b/,
|
|
/\bps\s+aux/, /\bfind\s+\//, /\bls\s+-[^\s]*R/, /\btree\b/,
|
|
/\bsqlite3\b.*\.dump/, /\bnpm\s+ls\b/, /\bpip\s+(list|freeze)\b/,
|
|
/\bdpkg\s+-l/, /\bsystemctl\s+list/,
|
|
];
|
|
var SKIP_RE = [
|
|
/\|\s*head\b/, /\|\s*tail\b/, /\|\s*grep\b/, /\|\s*awk\b/,
|
|
/\|\s*sed\b/, /\|\s*wc\b/, /[>]/,
|
|
/\bgit\s+(push|pull|fetch|clone|rebase|merge|commit)/,
|
|
/\bnpm\s+(install|run|build|test)/, /\bpnpm\s/,
|
|
/\bdocker\s+(build|run|push|compose)/, /\bssh\b/, /\bscp\b/,
|
|
/\bcurl\b/, /\bwget\b/, /\bmake\b/, /\bcargo\b/, /\bgo\s+(build|run|test)/,
|
|
];
|
|
|
|
function handleBashLimiter(hd) {
|
|
if (hd.tool_name !== 'Bash') return;
|
|
var cmd = (hd.tool_input || {}).command || '';
|
|
if (!cmd || cmd.length < 5) return;
|
|
for (var i = 0; i < SKIP_RE.length; i++) { if (SKIP_RE[i].test(cmd)) return; }
|
|
var match = false;
|
|
for (var i = 0; i < VIEW_RE.length; i++) { if (VIEW_RE[i].test(cmd)) { match = true; break; } }
|
|
if (!match) return;
|
|
|
|
var limit = /\bfind\s+\/|journalctl|tree\s+\/|\.dump/.test(cmd) ? 80 : 150;
|
|
emit('PreToolUse', {
|
|
input: { command: cmd + ' 2>&1 | head -' + limit, description: (hd.tool_input || {}).description },
|
|
ctx: '[TSE\xb7BASH_LIMITER] 输出已截断至 ' + limit + ' 行。需完整输出请 > file 重定向。'
|
|
});
|
|
}
|
|
|
|
/* ── post-output-guard (PostToolUse:Read|Bash) ── */
|
|
|
|
function handlePostOutputGuard(hd) {
|
|
var tn = hd.tool_name;
|
|
if (tn !== 'Read' && tn !== 'Bash') return;
|
|
var out = hd.tool_output;
|
|
if (!out || typeof out !== 'string' || out.length < 5000) return;
|
|
|
|
var state = stateLoad('tse-post-output-guard.json') || {};
|
|
var now = Date.now();
|
|
if (state[tn] && (now - state[tn]) < 60000) return;
|
|
state[tn] = now;
|
|
purgeOld(state, 600000);
|
|
stateSave('tse-post-output-guard.json', state);
|
|
|
|
var len = out.length;
|
|
var tokens = estimateStringTokens(out);
|
|
var cr = len >= 15000;
|
|
|
|
var msg;
|
|
if (tn === 'Read') {
|
|
msg = cr
|
|
? '[TSE\xb7POST_GUARD] Read ' + len + ' chars (' + tokens + ' tokens). 仅提取与当前任务直接相关的信息。不要在回复中重复完整文件内容。如需多次引用,记下行号用 offset+limit 精确读取。'
|
|
: '[TSE\xb7POST_GUARD] Read ' + len + ' chars. 聚焦相关段落,避免引用大段原文。';
|
|
} else {
|
|
msg = cr
|
|
? '[TSE\xb7POST_GUARD] Bash ' + len + ' chars (' + tokens + ' tokens). 聚焦错误/警告行和最终状态,忽略冗余日志。如需完整分析,写入文件后分段读取。'
|
|
: '[TSE\xb7POST_GUARD] Bash ' + len + ' chars. 聚焦关键输出行,跳过冗余信息。';
|
|
}
|
|
|
|
emit('PostToolUse', { ctx: msg });
|
|
}
|
|
|
|
/* ── mcp-tracker (PostToolUse:mcp__) ── */
|
|
|
|
function handleMcpTracker(hd) {
|
|
var tn = hd.tool_name || '';
|
|
if (!tn.startsWith('mcp__')) return;
|
|
var parts = tn.split('__');
|
|
if (parts.length < 3) return;
|
|
var server = parts[1];
|
|
var method = parts.slice(2).join('__');
|
|
|
|
var state = stateLoad('tse-mcp-usage.json') || {
|
|
version: 1, servers: {}, totalCalls: 0, trackingSince: new Date().toISOString()
|
|
};
|
|
state.totalCalls = (state.totalCalls || 0) + 1;
|
|
state.lastCall = new Date().toISOString();
|
|
|
|
if (!state.servers[server]) {
|
|
state.servers[server] = { count: 0, tools: {}, firstSeen: new Date().toISOString() };
|
|
}
|
|
state.servers[server].count++;
|
|
state.servers[server].lastUsed = new Date().toISOString();
|
|
state.servers[server].tools[method] = (state.servers[server].tools[method] || 0) + 1;
|
|
stateSave('tse-mcp-usage.json', state);
|
|
|
|
if (state.totalCalls % 20 === 0) {
|
|
var active = Object.keys(state.servers);
|
|
var summary = active.map(function(k) { return k + '(' + state.servers[k].count + ')'; }).join(', ');
|
|
emit('PostToolUse', {
|
|
ctx: '[TSE\xb7MCP_TRACKER] MCP 调用统计 (累计 ' + state.totalCalls + ' 次, ' + active.length + ' 个活跃服务器)\n活跃: ' + summary + '\n建议: 运行 /mcp-prune 检查零调用 MCP 服务器并考虑禁用以减少启动延迟。'
|
|
});
|
|
}
|
|
}
|
|
|
|
/* ── session-report (Stop) ── */
|
|
|
|
function handleSessionReport(hd) {
|
|
var tp = hd.transcript_path;
|
|
if (!tp || !fs.existsSync(tp)) return;
|
|
var stat = fs.statSync(tp);
|
|
if (stat.size > 20 * 1024 * 1024 || stat.size < 100) return;
|
|
|
|
var lines = fs.readFileSync(tp, 'utf8').split('\n').filter(Boolean);
|
|
var m = {
|
|
rounds: 0, compacts: 0, toolCalls: 0, mcpCalls: 0, agentCalls: 0,
|
|
largeOutputs: 0, tseReadGuard: 0, tseBashLimiter: 0, tsePostGuard: 0,
|
|
reads: 0, edits: 0, bashes: 0, models: {}
|
|
};
|
|
|
|
for (var i = 0; i < lines.length; i++) {
|
|
var obj;
|
|
try { obj = JSON.parse(lines[i]); } catch { continue; }
|
|
var content = (obj && obj.message && obj.message.content) || (obj && obj.content);
|
|
var role = obj && obj.message && obj.message.role;
|
|
var model = obj && obj.model;
|
|
|
|
if (role === 'assistant') m.rounds++;
|
|
if (model) m.models[model] = (m.models[model] || 0) + 1;
|
|
|
|
if (!Array.isArray(content)) {
|
|
if (typeof content === 'string') {
|
|
countTse(content, m);
|
|
if (content.indexOf('PreCompact') !== -1) m.compacts++;
|
|
}
|
|
continue;
|
|
}
|
|
|
|
for (var j = 0; j < content.length; j++) {
|
|
var part = content[j];
|
|
if (!part) continue;
|
|
if (part.type === 'tool_use') {
|
|
m.toolCalls++;
|
|
var nm = part.name || '';
|
|
if (nm.startsWith('mcp__')) m.mcpCalls++;
|
|
if (nm === 'Agent') m.agentCalls++;
|
|
if (nm === 'Read') m.reads++;
|
|
if (nm === 'Edit' || nm === 'Write') m.edits++;
|
|
if (nm === 'Bash') m.bashes++;
|
|
}
|
|
if (part.type === 'tool_result') {
|
|
var txt = typeof part.content === 'string' ? part.content : '';
|
|
if (txt.length > 5000) m.largeOutputs++;
|
|
}
|
|
var t = part.text || (typeof part === 'string' ? part : '');
|
|
if (typeof t === 'string') {
|
|
countTse(t, m);
|
|
if (t.indexOf('PreCompact') !== -1) m.compacts++;
|
|
}
|
|
}
|
|
}
|
|
|
|
var score = 80;
|
|
score -= Math.min(m.compacts * 8, 24);
|
|
score -= Math.min(m.largeOutputs * 2, 20);
|
|
score -= Math.max(0, Math.floor((m.rounds - 40) * 0.5));
|
|
if (m.agentCalls > 0) score += 10;
|
|
if (m.tseReadGuard + m.tseBashLimiter > 0 && m.tseReadGuard + m.tseBashLimiter < 8) score += 5;
|
|
score = Math.max(0, Math.min(100, score));
|
|
|
|
var grade = score >= 90 ? 'A' : score >= 80 ? 'B' : score >= 70 ? 'C' : score >= 60 ? 'D' : 'F';
|
|
var report = { ts: new Date().toISOString(), sid: hd.session_id || 'unknown', score: score, grade: grade, metrics: m };
|
|
|
|
if (!fs.existsSync(STATE_DIR)) fs.mkdirSync(STATE_DIR, { recursive: true });
|
|
fs.appendFileSync(path.join(STATE_DIR, 'tse-efficiency-log.jsonl'), JSON.stringify(report) + '\n', 'utf8');
|
|
}
|
|
|
|
function countTse(text, m) {
|
|
if (text.indexOf('READ_GUARD') !== -1) m.tseReadGuard++;
|
|
if (text.indexOf('BASH_LIMITER') !== -1) m.tseBashLimiter++;
|
|
if (text.indexOf('POST_GUARD') !== -1) m.tsePostGuard++;
|
|
}
|
|
|
|
/* ── Main ── */
|
|
|
|
(async () => {
|
|
try {
|
|
var maxSize = MODE === 'session-report' ? 128 * 1024
|
|
: MODE === 'post-output-guard' ? 2 * 1024 * 1024
|
|
: 512 * 1024;
|
|
var hd = await readStdin({ maxSize: maxSize });
|
|
|
|
switch (MODE) {
|
|
case 'model-advisor': handleModelAdvisor(hd); break;
|
|
case 'read-guard': handleReadGuard(hd); break;
|
|
case 'bash-limiter': handleBashLimiter(hd); break;
|
|
case 'post-output-guard': handlePostOutputGuard(hd); break;
|
|
case 'mcp-tracker': handleMcpTracker(hd); break;
|
|
case 'session-report': handleSessionReport(hd); break;
|
|
}
|
|
process.exit(0);
|
|
} catch { process.exit(0); }
|
|
})();
|