#!/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 (/|^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 }; }