bookworm-smart-assistant/hooks/pre-compact-handoff.js

206 lines
8.0 KiB
JavaScript
Raw Permalink Normal View History

#!/usr/bin/env node
/**
* PreCompact Hook - 上下文压缩前自动保存会话状态
* 将当前任务摘要写入 ~/.claude/session-state/handoff.json
*/
const fs = require('fs');
const path = require('path');
const CLAUDE_ROOT = require('./lib/root.js');
const readStdin = require('./lib/read-stdin.js');
const SESSION_STATE_DIR = path.join(CLAUDE_ROOT, 'session-state');
const HANDOFF_PATH = path.join(SESSION_STATE_DIR, 'handoff.json');
(async () => {
try {
let hookData = {};
try { hookData = await readStdin(); } catch (_) {}
// 确保目录存在
if (!fs.existsSync(SESSION_STATE_DIR)) {
fs.mkdirSync(SESSION_STATE_DIR, { recursive: true });
}
// TOOL_OUTPUT_TIER_V1 - 扫描 transcript 提取大工具输出分级摘要
const toolOutputTiers = scanToolOutputTiers(hookData.transcript_path);
// 构造 handoff 数据
const handoff = {
timestamp: new Date().toISOString(),
session_id: hookData.session_id || `session-${Date.now()}`,
context_hint: '会话因上下文压缩中断,以下是压缩前的状态摘要',
conversation_summary: hookData.transcript_summary || '(由 PreCompact hook 自动捕获)',
tool_call_count: hookData.tool_call_count || 'unknown',
working_directory: process.cwd(),
tool_output_tiers: toolOutputTiers,
note: '此文件由 pre-compact-handoff.js 自动生成SessionStart 时自动读取并注入恢复上下文'
};
const _tmpHandoff = HANDOFF_PATH + '.tmp.' + process.pid; // [PATCH-X13-HANDOFF-ATOMIC]
fs.writeFileSync(_tmpHandoff, JSON.stringify(handoff, null, 2), 'utf8');
fs.renameSync(_tmpHandoff, HANDOFF_PATH);
// [PATCH-P2-HANDOFF-CLEANUP] 清理过期 handoff 时间戳文件, 保留最新 5 个
try {
const files = fs.readdirSync(SESSION_STATE_DIR)
.filter(f => /^handoff-\d+\.json$/.test(f))
.map(f => ({ name: f, time: parseInt(f.match(/\d+/)[0], 10) }))
.sort((a, b) => b.time - a.time);
const toDelete = files.slice(5);
for (const f of toDelete) {
try { fs.unlinkSync(path.join(SESSION_STATE_DIR, f.name)); } catch {}
}
} catch {}
// 重置当前 session 的 heartbeat 计数器 (compact = 新起点) // [PATCH-X03-HANDOFF-RESET]
const heartbeatPath = path.join(CLAUDE_ROOT, 'debug', 'session-heartbeat.json');
if (fs.existsSync(heartbeatPath)) {
try {
const hbAll = JSON.parse(fs.readFileSync(heartbeatPath, 'utf8'));
const hbSid = hookData.session_id || 'default';
if (hbAll[hbSid]) {
hbAll[hbSid] = { count: 0, lastActivity: Date.now(), notified: [] };
const _tmpPch = heartbeatPath + '.tmp.' + process.pid; // [PATCH-X08-ATOMIC-WRITE]
fs.writeFileSync(_tmpPch, JSON.stringify(hbAll), 'utf8');
fs.renameSync(_tmpPch, heartbeatPath);
}
} catch { /* 损坏则跳过, heartbeat 超时会自然重置 */ }
}
// 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
}));
} catch {
// fail-open
console.log(JSON.stringify({ continue: true, suppressOutput: true }));
}
process.exit(0);
})();
// === TOOL_OUTPUT_TIER_V1 === // [PATCH-X04-STREAM-SCAN]
// 扫描 transcript JSONL, 按工具类型分级保留大输出, 输出 TOP-10 摘要
// X04: 流式逐行扫描, 避免大文件 OOM
function scanToolOutputTiers(transcriptPath) {
if (!transcriptPath || !fs.existsSync(transcriptPath)) {
return { applied: false, reason: 'no transcript_path' };
}
try {
const stat = fs.statSync(transcriptPath);
const MAX_FILE = 50 * 1024 * 1024; // 50MB 硬上限
if (stat.size > MAX_FILE) {
return { applied: false, reason: 'transcript_too_large: ' + (stat.size / 1024 / 1024).toFixed(1) + 'MB (limit 50MB)' };
}
const items = [];
const MAX_ITEM_BYTES = 5 * 1024 * 1024;
// 20MB 以下: 同步读取 (性能优先)
// 20MB 以上: 逐行流式读取 (内存安全)
const STREAM_THRESHOLD = 20 * 1024 * 1024;
if (stat.size <= STREAM_THRESHOLD) {
const raw = fs.readFileSync(transcriptPath, 'utf8');
const lines = raw.split('\n').filter(Boolean);
for (const line of lines) {
processLine(line, items, MAX_ITEM_BYTES);
}
} else {
// 流式: 逐块读取, 按换行切割
const fd = fs.openSync(transcriptPath, 'r');
const CHUNK = 4 * 1024 * 1024; // 4MB 块
const buf = Buffer.alloc(CHUNK);
let remainder = '';
let pos = 0;
while (pos < stat.size) {
const n = fs.readSync(fd, buf, 0, CHUNK, pos);
if (n <= 0) break;
const chunk = remainder + buf.toString('utf8', 0, n);
const parts = chunk.split('\n');
remainder = parts.pop() || '';
for (const line of parts) {
if (!line) continue;
processLine(line, items, MAX_ITEM_BYTES);
}
pos += n;
}
if (remainder) processLine(remainder, items, MAX_ITEM_BYTES);
fs.closeSync(fd);
}
items.sort((a, b) => b.size - a.size);
const top = items.slice(0, 10).map(it => tierize(it));
const totalBytes = items.reduce((s, it) => s + it.size, 0);
return {
applied: true,
total_tool_results_scanned: items.length,
total_bytes: totalBytes,
top_offenders: top,
mode: stat.size > STREAM_THRESHOLD ? 'stream' : 'sync'
};
} catch (e) {
return { applied: false, reason: 'scan_error: ' + (e.message || e) };
}
}
function processLine(line, items, MAX_ITEM_BYTES) {
let obj;
try { obj = JSON.parse(line); } catch { return; }
const content = obj?.message?.content || obj?.content;
if (!Array.isArray(content)) return;
for (const part of content) {
if (part?.type !== 'tool_result') continue;
const text = typeof part.content === 'string'
? part.content
: Array.isArray(part.content) ? part.content.map(c => c?.text || '').join('') : '';
const size = Buffer.byteLength(text, 'utf8');
if (size < 500) continue; // [PATCH-X06-CONTINUE]
const safeText = size > MAX_ITEM_BYTES ? text.slice(0, MAX_ITEM_BYTES) : text;
items.push({ size, text: safeText, tool_use_id: part.tool_use_id, capped: size > MAX_ITEM_BYTES });
}
}
function tierize(item) {
const { size, text, tool_use_id } = item;
// 启发式工具类型判定 (transcript 不直接含工具名, 用文本特征)
let kind = 'other';
if (/^(File created successfully|Wrote \d+ lines|The file .* has been (created|updated))/m.test(text)) kind = 'write';
else if (/^\s*\d+→/m.test(text) || text.startsWith(' 1\t')) kind = 'read';
else if (/<tool_use_error>|^bash:|stderr:/m.test(text) || /\$ /m.test(text.slice(0, 100))) kind = 'bash';
else if (/^(Found \d+ files?|^[a-zA-Z]:\\.*\.(ts|js|md|json))/m.test(text)) kind = 'glob_grep';
else if (size > 3000 && text.includes('agent')) kind = 'agent';
let summary;
switch (kind) {
case 'write':
summary = text.split('\n').slice(0, 2).join(' | ').slice(0, 200);
break;
case 'read':
summary = '[Read] ' + text.slice(0, 200) + ' ... [+' + (size - 200) + ' bytes]';
break;
case 'bash':
summary = text.slice(0, 1500) + '\n... [truncated ' + Math.max(0, size - 2000) + ' bytes] ...\n' + text.slice(-500);
break;
case 'agent':
summary = text.slice(0, 1000) + '\n... [Agent 完整结果已截断 ' + (size - 1000) + ' bytes]';
break;
case 'glob_grep':
summary = '[Glob/Grep] ' + text.split('\n').slice(0, 8).join(' | ').slice(0, 400);
break;
default:
summary = text.slice(0, 2000) + '\n... [+' + Math.max(0, size - 2500) + ' bytes] ...\n' + text.slice(-500);
}
return { tool_use_id, kind, original_bytes: size, summary };
}