- 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)
206 lines
8.0 KiB
JavaScript
206 lines
8.0 KiB
JavaScript
#!/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 };
|
||
}
|