bookworm-smart-assistant/hooks/pre-compact-handoff.js
Bookworm Admin b7a8e29d21 release: v6.7.0 - OTA E2E test release
- 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)
2026-04-27 17:59:44 +08:00

206 lines
8.0 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
/**
* 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 };
}