584 lines
23 KiB
JavaScript
584 lines
23 KiB
JavaScript
|
|
#!/usr/bin/env node
|
||
|
|
/**
|
||
|
|
* patch-token-saver-engine-v2.js · TSE v2.0 · 2026-04-27
|
||
|
|
*
|
||
|
|
* P0. token-saver-post-output-guard.js — PostToolUse Read|Bash 超长输出提示
|
||
|
|
* P1. token-saver-mcp-tracker.js — PostToolUse mcp__ MCP 使用率追踪
|
||
|
|
* P2. token-saver-session-report.js — Stop 会话效率报告
|
||
|
|
* P3. pre-compact-handoff.js 增强 — PreCompact 智能保留指令
|
||
|
|
*
|
||
|
|
* 幂等: sentinel 检查, 重复运行安全
|
||
|
|
* 用法: node scripts/patches/patch-token-saver-engine-v2.js
|
||
|
|
*/
|
||
|
|
'use strict';
|
||
|
|
|
||
|
|
const fs = require('fs');
|
||
|
|
const path = require('path');
|
||
|
|
|
||
|
|
const CLAUDE_ROOT = path.join(process.env.HOME || process.env.USERPROFILE, '.claude');
|
||
|
|
const HOOKS_DIR = path.join(CLAUDE_ROOT, 'hooks');
|
||
|
|
const LIB_DIR = path.join(HOOKS_DIR, 'lib');
|
||
|
|
const SETTINGS_PATH = path.join(CLAUDE_ROOT, 'settings.json');
|
||
|
|
|
||
|
|
// ═══════════════════════════════════════════════════════════════════════
|
||
|
|
// P0: PostToolUse (Read|Bash) — 超长输出注入聚焦提示
|
||
|
|
// ═══════════════════════════════════════════════════════════════════════
|
||
|
|
const P0_CODE = `#!/usr/bin/env node
|
||
|
|
/**
|
||
|
|
* token-saver-post-output-guard.js · TSE Layer 4 · 2026-04-27
|
||
|
|
* PostToolUse (Read|Bash) · 超长输出检测, 注入聚焦提示
|
||
|
|
* 行为: fail-open, 不修改输出, 仅 additionalContext
|
||
|
|
*/
|
||
|
|
'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 STATE_PATH = path.join(STATE_DIR, 'tse-post-output-guard.json');
|
||
|
|
const THRESHOLD = 5000;
|
||
|
|
const CRIT = 15000;
|
||
|
|
const THROTTLE_MS = 60 * 1000;
|
||
|
|
|
||
|
|
function loadState() {
|
||
|
|
try { return fs.existsSync(STATE_PATH) ? JSON.parse(fs.readFileSync(STATE_PATH, 'utf8')) : {}; } catch { return {}; }
|
||
|
|
}
|
||
|
|
function saveState(s) {
|
||
|
|
try {
|
||
|
|
if (!fs.existsSync(STATE_DIR)) fs.mkdirSync(STATE_DIR, { recursive: true });
|
||
|
|
const tmp = STATE_PATH + '.tmp.' + process.pid;
|
||
|
|
fs.writeFileSync(tmp, JSON.stringify(s), 'utf8');
|
||
|
|
fs.renameSync(tmp, STATE_PATH);
|
||
|
|
} catch {}
|
||
|
|
}
|
||
|
|
|
||
|
|
(async () => {
|
||
|
|
try {
|
||
|
|
const hd = await readStdin({ maxSize: 2 * 1024 * 1024 });
|
||
|
|
const tn = hd.tool_name;
|
||
|
|
if (tn !== 'Read' && tn !== 'Bash') process.exit(0);
|
||
|
|
|
||
|
|
const out = hd.tool_output;
|
||
|
|
if (!out || typeof out !== 'string' || out.length < THRESHOLD) process.exit(0);
|
||
|
|
|
||
|
|
const state = loadState();
|
||
|
|
const now = Date.now();
|
||
|
|
if (state[tn] && (now - state[tn]) < THROTTLE_MS) process.exit(0);
|
||
|
|
state[tn] = now;
|
||
|
|
for (const k of Object.keys(state)) { if (state[k] < now - 600000) delete state[k]; }
|
||
|
|
saveState(state);
|
||
|
|
|
||
|
|
const len = out.length;
|
||
|
|
const tokens = Math.round(len / 3.5);
|
||
|
|
const cr = len >= CRIT;
|
||
|
|
|
||
|
|
var msg;
|
||
|
|
if (tn === 'Read') {
|
||
|
|
msg = cr
|
||
|
|
? '[TSE\\xb7POST_GUARD] Read ' + len + ' chars (' + tokens + ' tokens). \\u4ec5\\u63d0\\u53d6\\u4e0e\\u5f53\\u524d\\u4efb\\u52a1\\u76f4\\u63a5\\u76f8\\u5173\\u7684\\u4fe1\\u606f\\u3002\\u4e0d\\u8981\\u5728\\u56de\\u590d\\u4e2d\\u91cd\\u590d\\u5b8c\\u6574\\u6587\\u4ef6\\u5185\\u5bb9\\u3002\\u5982\\u9700\\u591a\\u6b21\\u5f15\\u7528\\uff0c\\u8bb0\\u4e0b\\u884c\\u53f7\\u7528 offset+limit \\u7cbe\\u786e\\u8bfb\\u53d6\\u3002'
|
||
|
|
: '[TSE\\xb7POST_GUARD] Read ' + len + ' chars. \\u805a\\u7126\\u76f8\\u5173\\u6bb5\\u843d\\uff0c\\u907f\\u514d\\u5f15\\u7528\\u5927\\u6bb5\\u539f\\u6587\\u3002';
|
||
|
|
} else {
|
||
|
|
msg = cr
|
||
|
|
? '[TSE\\xb7POST_GUARD] Bash ' + len + ' chars (' + tokens + ' tokens). \\u805a\\u7126\\u9519\\u8bef/\\u8b66\\u544a\\u884c\\u548c\\u6700\\u7ec8\\u72b6\\u6001\\uff0c\\u5ffd\\u7565\\u5197\\u4f59\\u65e5\\u5fd7\\u3002\\u5982\\u9700\\u5b8c\\u6574\\u5206\\u6790\\uff0c\\u5199\\u5165\\u6587\\u4ef6\\u540e\\u5206\\u6bb5\\u8bfb\\u53d6\\u3002'
|
||
|
|
: '[TSE\\xb7POST_GUARD] Bash ' + len + ' chars. \\u805a\\u7126\\u5173\\u952e\\u8f93\\u51fa\\u884c\\uff0c\\u8df3\\u8fc7\\u5197\\u4f59\\u4fe1\\u606f\\u3002';
|
||
|
|
}
|
||
|
|
|
||
|
|
process.stdout.write(JSON.stringify({
|
||
|
|
continue: true,
|
||
|
|
hookSpecificOutput: { hookEventName: 'PostToolUse', additionalContext: msg }
|
||
|
|
}));
|
||
|
|
process.exit(0);
|
||
|
|
} catch { process.exit(0); }
|
||
|
|
})();
|
||
|
|
`;
|
||
|
|
|
||
|
|
// ═══════════════════════════════════════════════════════════════════════
|
||
|
|
// P1: PostToolUse (mcp__) — MCP 使用率追踪
|
||
|
|
// ═══════════════════════════════════════════════════════════════════════
|
||
|
|
const P1_CODE = `#!/usr/bin/env node
|
||
|
|
/**
|
||
|
|
* token-saver-mcp-tracker.js · TSE Layer 4 · 2026-04-27
|
||
|
|
* PostToolUse (mcp__) · MCP 工具使用率追踪
|
||
|
|
* 每 20 次 MCP 调用检查一次, 建议 /mcp-prune
|
||
|
|
* 状态: session-state/tse-mcp-usage.json (跨会话累积)
|
||
|
|
* 行为: fail-open
|
||
|
|
*/
|
||
|
|
'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 STATE_PATH = path.join(STATE_DIR, 'tse-mcp-usage.json');
|
||
|
|
const CHECK_INTERVAL = 20;
|
||
|
|
|
||
|
|
function loadState() {
|
||
|
|
try {
|
||
|
|
if (fs.existsSync(STATE_PATH)) return JSON.parse(fs.readFileSync(STATE_PATH, 'utf8'));
|
||
|
|
} catch {}
|
||
|
|
return { version: 1, servers: {}, totalCalls: 0, trackingSince: new Date().toISOString() };
|
||
|
|
}
|
||
|
|
function saveState(s) {
|
||
|
|
try {
|
||
|
|
if (!fs.existsSync(STATE_DIR)) fs.mkdirSync(STATE_DIR, { recursive: true });
|
||
|
|
const tmp = STATE_PATH + '.tmp.' + process.pid;
|
||
|
|
fs.writeFileSync(tmp, JSON.stringify(s, null, 2), 'utf8');
|
||
|
|
fs.renameSync(tmp, STATE_PATH);
|
||
|
|
} catch {}
|
||
|
|
}
|
||
|
|
|
||
|
|
(async () => {
|
||
|
|
try {
|
||
|
|
const hd = await readStdin();
|
||
|
|
const tn = hd.tool_name || '';
|
||
|
|
if (!tn.startsWith('mcp__')) process.exit(0);
|
||
|
|
|
||
|
|
var parts = tn.split('__');
|
||
|
|
if (parts.length < 3) process.exit(0);
|
||
|
|
var server = parts[1];
|
||
|
|
var method = parts.slice(2).join('__');
|
||
|
|
|
||
|
|
var state = loadState();
|
||
|
|
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;
|
||
|
|
|
||
|
|
saveState(state);
|
||
|
|
|
||
|
|
if (state.totalCalls % CHECK_INTERVAL === 0) {
|
||
|
|
var active = Object.keys(state.servers);
|
||
|
|
var summary = active.map(function(k) { return k + '(' + state.servers[k].count + ')'; }).join(', ');
|
||
|
|
var msg = '[TSE\\xb7MCP_TRACKER] MCP \\u8c03\\u7528\\u7edf\\u8ba1 (\\u7d2f\\u8ba1 ' + state.totalCalls + ' \\u6b21, ' + active.length + ' \\u4e2a\\u6d3b\\u8dc3\\u670d\\u52a1\\u5668)' +
|
||
|
|
'\\n\\u6d3b\\u8dc3: ' + summary +
|
||
|
|
'\\n\\u5efa\\u8bae: \\u8fd0\\u884c /mcp-prune \\u68c0\\u67e5\\u96f6\\u8c03\\u7528 MCP \\u670d\\u52a1\\u5668\\u5e76\\u8003\\u8651\\u7981\\u7528\\u4ee5\\u51cf\\u5c11\\u542f\\u52a8\\u5ef6\\u8fdf\\u3002';
|
||
|
|
|
||
|
|
process.stdout.write(JSON.stringify({
|
||
|
|
continue: true,
|
||
|
|
hookSpecificOutput: { hookEventName: 'PostToolUse', additionalContext: msg }
|
||
|
|
}));
|
||
|
|
}
|
||
|
|
|
||
|
|
process.exit(0);
|
||
|
|
} catch { process.exit(0); }
|
||
|
|
})();
|
||
|
|
`;
|
||
|
|
|
||
|
|
// ═══════════════════════════════════════════════════════════════════════
|
||
|
|
// P2: Stop — 会话效率报告
|
||
|
|
// ═══════════════════════════════════════════════════════════════════════
|
||
|
|
const P2_CODE = `#!/usr/bin/env node
|
||
|
|
/**
|
||
|
|
* token-saver-session-report.js · TSE Layer 5 · 2026-04-27
|
||
|
|
* Stop · 会话效率报告
|
||
|
|
* 解析 transcript, 统计效率指标, 写入 tse-efficiency-log.jsonl
|
||
|
|
* 行为: fail-open
|
||
|
|
*/
|
||
|
|
'use strict';
|
||
|
|
|
||
|
|
var fs = require('fs');
|
||
|
|
var path = require('path');
|
||
|
|
var CLAUDE_ROOT = require('./lib/root.js');
|
||
|
|
var readStdin = require('./lib/read-stdin.js');
|
||
|
|
|
||
|
|
var STATE_DIR = path.join(CLAUDE_ROOT, 'session-state');
|
||
|
|
var LOG_PATH = path.join(STATE_DIR, 'tse-efficiency-log.jsonl');
|
||
|
|
var MAX_TRANSCRIPT = 20 * 1024 * 1024;
|
||
|
|
|
||
|
|
(async () => {
|
||
|
|
try {
|
||
|
|
var hookData = {};
|
||
|
|
try { hookData = await readStdin({ maxSize: 128 * 1024 }); } catch {}
|
||
|
|
|
||
|
|
var tp = hookData.transcript_path;
|
||
|
|
if (!tp || !fs.existsSync(tp)) process.exit(0);
|
||
|
|
|
||
|
|
var stat = fs.statSync(tp);
|
||
|
|
if (stat.size > MAX_TRANSCRIPT || stat.size < 100) process.exit(0);
|
||
|
|
|
||
|
|
var raw = fs.readFileSync(tp, 'utf8');
|
||
|
|
var lines = raw.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') {
|
||
|
|
if (content.indexOf('[TSE') !== -1) 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' && t.indexOf('[TSE') !== -1) countTse(t, m);
|
||
|
|
if (typeof t === 'string' && 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: hookData.session_id || 'unknown',
|
||
|
|
score: score,
|
||
|
|
grade: grade,
|
||
|
|
metrics: m
|
||
|
|
};
|
||
|
|
|
||
|
|
if (!fs.existsSync(STATE_DIR)) fs.mkdirSync(STATE_DIR, { recursive: true });
|
||
|
|
fs.appendFileSync(LOG_PATH, JSON.stringify(report) + '\\n', 'utf8');
|
||
|
|
|
||
|
|
process.exit(0);
|
||
|
|
} catch { process.exit(0); }
|
||
|
|
})();
|
||
|
|
|
||
|
|
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++;
|
||
|
|
}
|
||
|
|
`;
|
||
|
|
|
||
|
|
// ═══════════════════════════════════════════════════════════════════════
|
||
|
|
// P3: lib/tse-retention-extractor.js — PreCompact 智能保留指令
|
||
|
|
// ═══════════════════════════════════════════════════════════════════════
|
||
|
|
const P3_LIB_CODE = `/**
|
||
|
|
* tse-retention-extractor.js · TSE v2.0 · 2026-04-27
|
||
|
|
* 从 transcript 提取文件路径/函数名/TODO/决策点
|
||
|
|
* 用于 PreCompact 生成保留指令, 必须 <2s 完成
|
||
|
|
*/
|
||
|
|
'use strict';
|
||
|
|
|
||
|
|
var fs = require('fs');
|
||
|
|
|
||
|
|
function extract(transcriptPath) {
|
||
|
|
if (!transcriptPath || !fs.existsSync(transcriptPath)) return null;
|
||
|
|
|
||
|
|
var deadline = Date.now() + 2000;
|
||
|
|
|
||
|
|
try {
|
||
|
|
var stat = fs.statSync(transcriptPath);
|
||
|
|
if (stat.size > 30 * 1024 * 1024) return null;
|
||
|
|
|
||
|
|
var raw = fs.readFileSync(transcriptPath, 'utf8');
|
||
|
|
var lines = raw.split('\\n').filter(Boolean);
|
||
|
|
var recent = lines.slice(-200);
|
||
|
|
|
||
|
|
var filePaths = {};
|
||
|
|
var functions = {};
|
||
|
|
var todos = [];
|
||
|
|
var decisions = [];
|
||
|
|
|
||
|
|
for (var i = 0; i < recent.length; i++) {
|
||
|
|
if (Date.now() > deadline) break;
|
||
|
|
|
||
|
|
var obj;
|
||
|
|
try { obj = JSON.parse(recent[i]); } catch { continue; }
|
||
|
|
|
||
|
|
var content = (obj && obj.message && obj.message.content) || (obj && obj.content);
|
||
|
|
if (!Array.isArray(content)) continue;
|
||
|
|
|
||
|
|
for (var j = 0; j < content.length; j++) {
|
||
|
|
var part = content[j];
|
||
|
|
if (!part) continue;
|
||
|
|
|
||
|
|
if (part.type === 'tool_use') {
|
||
|
|
var inp = part.input || {};
|
||
|
|
var fp = inp.file_path;
|
||
|
|
if (fp && typeof fp === 'string') {
|
||
|
|
filePaths[fp] = (filePaths[fp] || 0) + 1;
|
||
|
|
}
|
||
|
|
if (part.name === 'Grep' && inp.pattern) {
|
||
|
|
var match = inp.pattern.match(/[a-zA-Z_][a-zA-Z0-9_]{3,40}/);
|
||
|
|
if (match) functions[match[0]] = (functions[match[0]] || 0) + 1;
|
||
|
|
}
|
||
|
|
if (part.name === 'Edit' && inp.file_path) {
|
||
|
|
filePaths[inp.file_path] = (filePaths[inp.file_path] || 0) + 2;
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
var text = part.text || (typeof part === 'string' ? part : '');
|
||
|
|
if (typeof text === 'string' && text.length > 10) {
|
||
|
|
var todoRe = /(?:TODO|FIXME|HACK)[:\\s](.{5,80})/gi;
|
||
|
|
var tm;
|
||
|
|
while ((tm = todoRe.exec(text)) !== null && todos.length < 5) {
|
||
|
|
todos.push(tm[1].trim());
|
||
|
|
}
|
||
|
|
var decRe = /(?:\\u51b3\\u5b9a|\\u9009\\u62e9|\\u65b9\\u6848|\\u786e\\u8ba4|decided|agreed|choose)[:\\s](.{5,80})/gi;
|
||
|
|
var dm;
|
||
|
|
while ((dm = decRe.exec(text)) !== null && decisions.length < 5) {
|
||
|
|
decisions.push(dm[1].trim());
|
||
|
|
}
|
||
|
|
}
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
var out = ['[TSE\\xb7RETENTION] Compact \\u4fdd\\u7559\\u4ee5\\u4e0b\\u5173\\u952e\\u4e0a\\u4e0b\\u6587:'];
|
||
|
|
|
||
|
|
var sorted = Object.entries(filePaths).sort(function(a, b) { return b[1] - a[1]; }).slice(0, 10);
|
||
|
|
if (sorted.length > 0) {
|
||
|
|
out.push('## \\u6d3b\\u8dc3\\u6587\\u4ef6');
|
||
|
|
for (var si = 0; si < sorted.length; si++) {
|
||
|
|
var bn = sorted[si][0].split(/[\\\\/]/).pop();
|
||
|
|
out.push('- ' + bn + ' (' + sorted[si][1] + 'x) ' + sorted[si][0]);
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
var fnArr = Object.keys(functions).slice(0, 15);
|
||
|
|
if (fnArr.length > 0) {
|
||
|
|
out.push('## \\u5173\\u952e\\u6807\\u8bc6\\u7b26');
|
||
|
|
out.push(fnArr.join(', '));
|
||
|
|
}
|
||
|
|
|
||
|
|
if (todos.length > 0) {
|
||
|
|
out.push('## \\u5f85\\u529e');
|
||
|
|
for (var ti = 0; ti < todos.length; ti++) out.push('- ' + todos[ti]);
|
||
|
|
}
|
||
|
|
|
||
|
|
if (decisions.length > 0) {
|
||
|
|
out.push('## \\u5173\\u952e\\u51b3\\u7b56');
|
||
|
|
for (var di = 0; di < decisions.length; di++) out.push('- ' + decisions[di]);
|
||
|
|
}
|
||
|
|
|
||
|
|
return out.length > 1 ? out.join('\\n') : null;
|
||
|
|
} catch {
|
||
|
|
return null;
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
module.exports = { extract: extract };
|
||
|
|
`;
|
||
|
|
|
||
|
|
|
||
|
|
// ═══════════════════════════════════════════════════════════════════════
|
||
|
|
// 安装逻辑
|
||
|
|
// ═══════════════════════════════════════════════════════════════════════
|
||
|
|
|
||
|
|
function writeHook(name, code) {
|
||
|
|
const p = path.join(HOOKS_DIR, name);
|
||
|
|
if (fs.existsSync(p)) {
|
||
|
|
const existing = fs.readFileSync(p, 'utf8');
|
||
|
|
if (existing.includes('TSE Layer')) {
|
||
|
|
console.log(' [skip] ' + name + ' (already installed)');
|
||
|
|
return;
|
||
|
|
}
|
||
|
|
const bak = p + '.bak.' + Date.now();
|
||
|
|
fs.copyFileSync(p, bak);
|
||
|
|
console.log(' [backup] ' + name + ' -> ' + path.basename(bak));
|
||
|
|
}
|
||
|
|
fs.writeFileSync(p, code, 'utf8');
|
||
|
|
console.log(' [write] ' + name);
|
||
|
|
}
|
||
|
|
|
||
|
|
function writeLib(name, code) {
|
||
|
|
if (!fs.existsSync(LIB_DIR)) fs.mkdirSync(LIB_DIR, { recursive: true });
|
||
|
|
const p = path.join(LIB_DIR, name);
|
||
|
|
if (fs.existsSync(p)) {
|
||
|
|
const existing = fs.readFileSync(p, 'utf8');
|
||
|
|
if (existing.includes('TSE v2.0')) {
|
||
|
|
console.log(' [skip] lib/' + name + ' (already installed)');
|
||
|
|
return;
|
||
|
|
}
|
||
|
|
fs.copyFileSync(p, p + '.bak.' + Date.now());
|
||
|
|
}
|
||
|
|
fs.writeFileSync(p, code, 'utf8');
|
||
|
|
console.log(' [write] lib/' + name);
|
||
|
|
}
|
||
|
|
|
||
|
|
function patchPreCompact() {
|
||
|
|
const p = path.join(HOOKS_DIR, 'pre-compact-handoff.js');
|
||
|
|
if (!fs.existsSync(p)) {
|
||
|
|
console.log(' [skip] P3: pre-compact-handoff.js not found');
|
||
|
|
return;
|
||
|
|
}
|
||
|
|
let code = fs.readFileSync(p, 'utf8');
|
||
|
|
|
||
|
|
if (code.includes('TSE_V2_RETENTION')) {
|
||
|
|
console.log(' [skip] P3: pre-compact-handoff.js (already patched)');
|
||
|
|
return;
|
||
|
|
}
|
||
|
|
|
||
|
|
// Backup
|
||
|
|
const bak = p + '.bak.' + Date.now();
|
||
|
|
fs.copyFileSync(p, bak);
|
||
|
|
console.log(' [backup] pre-compact-handoff.js -> ' + path.basename(bak));
|
||
|
|
|
||
|
|
// Normalize line endings for matching
|
||
|
|
code = code.replace(/\r\n/g, '\n');
|
||
|
|
|
||
|
|
// Find the systemMessage anchor
|
||
|
|
const OLD_MSG = "systemMessage: '[PRE_COMPACT] 上下文即将压缩。handoff.json 已写入。请在压缩前将当前任务的关键决策和待完成步骤总结到 handoff 记录中。'";
|
||
|
|
|
||
|
|
if (!code.includes(OLD_MSG)) {
|
||
|
|
console.log(' [warn] P3: systemMessage anchor not found, skipping');
|
||
|
|
return;
|
||
|
|
}
|
||
|
|
|
||
|
|
// Replace the console.log block with retention-aware version
|
||
|
|
const OLD_BLOCK = [
|
||
|
|
" console.log(JSON.stringify({",
|
||
|
|
" continue: true,",
|
||
|
|
" suppressOutput: false,",
|
||
|
|
" systemMessage: '[PRE_COMPACT] 上下文即将压缩。handoff.json 已写入。请在压缩前将当前任务的关键决策和待完成步骤总结到 handoff 记录中。'",
|
||
|
|
" }));"
|
||
|
|
].join('\n');
|
||
|
|
|
||
|
|
const NEW_BLOCK = [
|
||
|
|
" // TSE_V2_RETENTION: 智能保留指令",
|
||
|
|
" let _retMsg = '[PRE_COMPACT] 上下文即将压缩。handoff.json 已写入。';",
|
||
|
|
" try {",
|
||
|
|
" const _ret = require('./lib/tse-retention-extractor.js').extract(hookData.transcript_path);",
|
||
|
|
" if (_ret) _retMsg += '\\n' + _ret;",
|
||
|
|
" } catch {}",
|
||
|
|
" _retMsg += '\\n请保留关键上下文: 当前任务目标、活跃文件路径、关键决策和待完成步骤。';",
|
||
|
|
" console.log(JSON.stringify({",
|
||
|
|
" continue: true,",
|
||
|
|
" suppressOutput: false,",
|
||
|
|
" systemMessage: _retMsg",
|
||
|
|
" }));"
|
||
|
|
].join('\n');
|
||
|
|
|
||
|
|
if (!code.includes(OLD_BLOCK)) {
|
||
|
|
// Fallback: just replace the systemMessage value
|
||
|
|
code = code.replace(
|
||
|
|
OLD_MSG,
|
||
|
|
"(function() { var r = ''; try { r = require('./lib/tse-retention-extractor.js').extract(hookData.transcript_path) || ''; } catch {} return '[PRE_COMPACT] 上下文即将压缩。handoff.json 已写入。' + (r ? '\\n' + r + '\\n' : '\\n') + '请保留关键上下文: 当前任务目标、活跃文件路径、关键决策和待完成步骤。'; })() // TSE_V2_RETENTION"
|
||
|
|
);
|
||
|
|
console.log(' [patch] P3: pre-compact-handoff.js (fallback mode)');
|
||
|
|
} else {
|
||
|
|
code = code.replace(OLD_BLOCK, NEW_BLOCK);
|
||
|
|
console.log(' [patch] P3: pre-compact-handoff.js (+TSE_V2_RETENTION)');
|
||
|
|
}
|
||
|
|
|
||
|
|
const tmp = p + '.tmp.' + process.pid;
|
||
|
|
fs.writeFileSync(tmp, code, 'utf8');
|
||
|
|
fs.renameSync(tmp, p);
|
||
|
|
}
|
||
|
|
|
||
|
|
function patchSettings() {
|
||
|
|
const raw = fs.readFileSync(SETTINGS_PATH, 'utf8');
|
||
|
|
const settings = JSON.parse(raw);
|
||
|
|
|
||
|
|
if (raw.includes('token-saver-post-output-guard')) {
|
||
|
|
console.log(' [skip] settings.json (TSE v2 hooks already registered)');
|
||
|
|
return;
|
||
|
|
}
|
||
|
|
|
||
|
|
// Backup
|
||
|
|
const bak = SETTINGS_PATH + '.bak.' + Date.now();
|
||
|
|
fs.copyFileSync(SETTINGS_PATH, bak);
|
||
|
|
console.log(' [backup] settings.json -> ' + path.basename(bak));
|
||
|
|
|
||
|
|
// P0: PostToolUse Read|Bash
|
||
|
|
if (!settings.hooks.PostToolUse) settings.hooks.PostToolUse = [];
|
||
|
|
settings.hooks.PostToolUse.push({
|
||
|
|
matcher: 'Read|Bash',
|
||
|
|
hooks: [{
|
||
|
|
type: 'command',
|
||
|
|
command: 'node C:/Users/leesu/.claude/hooks/token-saver-post-output-guard.js',
|
||
|
|
timeout: 3000
|
||
|
|
}]
|
||
|
|
});
|
||
|
|
|
||
|
|
// P1: PostToolUse mcp__
|
||
|
|
settings.hooks.PostToolUse.push({
|
||
|
|
matcher: 'mcp__',
|
||
|
|
hooks: [{
|
||
|
|
type: 'command',
|
||
|
|
command: 'node C:/Users/leesu/.claude/hooks/token-saver-mcp-tracker.js',
|
||
|
|
timeout: 2000
|
||
|
|
}]
|
||
|
|
});
|
||
|
|
|
||
|
|
// P2: Stop
|
||
|
|
if (!settings.hooks.Stop) settings.hooks.Stop = [];
|
||
|
|
settings.hooks.Stop.push({
|
||
|
|
hooks: [{
|
||
|
|
type: 'command',
|
||
|
|
command: 'node C:/Users/leesu/.claude/hooks/token-saver-session-report.js',
|
||
|
|
timeout: 5000
|
||
|
|
}]
|
||
|
|
});
|
||
|
|
|
||
|
|
const tmp = SETTINGS_PATH + '.tmp.' + process.pid;
|
||
|
|
fs.writeFileSync(tmp, JSON.stringify(settings, null, 2), 'utf8');
|
||
|
|
fs.renameSync(tmp, SETTINGS_PATH);
|
||
|
|
console.log(' [patch] settings.json (+3 TSE v2 hooks: PostToolUse x2, Stop x1)');
|
||
|
|
}
|
||
|
|
|
||
|
|
|
||
|
|
// ═══════════════════════════════════════════════════════════════════════
|
||
|
|
// Main
|
||
|
|
// ═══════════════════════════════════════════════════════════════════════
|
||
|
|
|
||
|
|
console.log('\n=== Token Saver Engine (TSE) v2.0 Installation ===\n');
|
||
|
|
|
||
|
|
console.log('[Step 1/4] Writing P0-P2 hook files...');
|
||
|
|
writeHook('token-saver-post-output-guard.js', P0_CODE);
|
||
|
|
writeHook('token-saver-mcp-tracker.js', P1_CODE);
|
||
|
|
writeHook('token-saver-session-report.js', P2_CODE);
|
||
|
|
|
||
|
|
console.log('\n[Step 2/4] Writing P3 lib module...');
|
||
|
|
writeLib('tse-retention-extractor.js', P3_LIB_CODE);
|
||
|
|
|
||
|
|
console.log('\n[Step 3/4] Patching pre-compact-handoff.js (P3)...');
|
||
|
|
patchPreCompact();
|
||
|
|
|
||
|
|
console.log('\n[Step 4/4] Registering hooks in settings.json...');
|
||
|
|
patchSettings();
|
||
|
|
|
||
|
|
console.log('\n=== TSE v2.0 Installation Complete ===');
|
||
|
|
console.log('New hooks: 3 (post-output-guard + mcp-tracker + session-report)');
|
||
|
|
console.log('New lib: 1 (tse-retention-extractor.js)');
|
||
|
|
console.log('Patched: 1 (pre-compact-handoff.js +retention)');
|
||
|
|
console.log('Settings: +3 entries (PostToolUse x2, Stop x1)');
|
||
|
|
console.log('\nRestart Claude Code to activate.\n');
|