#!/usr/bin/env node /** * context-pressure-monitor.js · R4 · 2026-04-26 * * UserPromptSubmit Hook · 外部上下文压力信号 * * 通过 fs.stat transcript JSONL 估算 token 占用 (bytes / 3.5), * 按阈值阶梯注入 systemMessage 到 additionalContext, 替代模型自身感知盲区. * * 阈值 (基于 200k Opus 4.7 budget): * - <50% → 静默 * - 50-70% → INFO (提示已过半) * - 70-85% → WARN (强烈建议本批结束后 /clear, 含 R1 progress + R2 handoff 提示) * - >=85% → CRITICAL (要求立即 dump 进度并 /clear) * * 节流: 每会话每阈值仅播报 1 次 (避免每条 prompt 重复) * 状态: ~/.claude/session-state/context-pressure.json * * 行为: 始终 exit 0 (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, 'context-pressure.json'); const PROJECTS_DIR = path.join(CLAUDE_ROOT, 'projects'); const TOKEN_BUDGET = 200000; // Opus 4.7 context budget const BYTES_PER_TOKEN = 3.5; // JSONL transcript 经验系数 (含中英混排) const THRESHOLD_INFO = 0.50; const THRESHOLD_COMPACT = 0.60; // TSE_COMPACT: auto-compact directive const THRESHOLD_WARN = 0.70; const THRESHOLD_CRIT = 0.85; function loadState() { try { if (!fs.existsSync(STATE_PATH)) return {}; return 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, null, 2), 'utf8'); fs.renameSync(tmp, STATE_PATH); } catch {} } function pruneState(s) { // 清理 7 天前会话, 防膨胀 const cutoff = Date.now() - 7 * 24 * 3600 * 1000; for (const k of Object.keys(s)) { if (!s[k] || !s[k].lastTs || s[k].lastTs < cutoff) delete s[k]; } return s; } // 通过 transcript_path 或 session_id 定位 JSONL function findTranscript(hookData) { // [PATCH-X11-PATH-VALIDATION] if (hookData.transcript_path) { const resolved = path.resolve(hookData.transcript_path); if (resolved.startsWith(CLAUDE_ROOT) && fs.existsSync(resolved)) { return resolved; } } const sid = hookData.session_id; if (!sid || /[\/\\]/.test(sid) || !fs.existsSync(PROJECTS_DIR)) return null; // 项目目录通常以 cwd 编码命名, 遍历找 sid.jsonl try { for (const proj of fs.readdirSync(PROJECTS_DIR)) { const cand = path.join(PROJECTS_DIR, proj, sid + '.jsonl'); if (fs.existsSync(cand)) return cand; } } catch {} return null; } function levelFor(ratio) { if (ratio >= THRESHOLD_CRIT) return 'CRITICAL'; if (ratio >= THRESHOLD_WARN) return 'WARN'; if (ratio >= THRESHOLD_COMPACT) return 'COMPACT'; if (ratio >= THRESHOLD_INFO) return 'INFO'; return null; } function buildMessage(level, ratio, tokens, bytes) { const pct = (ratio * 100).toFixed(1); const k = (tokens / 1000).toFixed(1); const head = '[CONTEXT_PRESSURE · ' + level + '] transcript ≈ ' + k + 'k tokens (' + pct + '% / 200k budget)'; switch (level) { case 'COMPACT': return head + '\n[TSE·AUTO_COMPACT] 建议立即执行 /compact 以释放 context 空间。' + '\n推荐 compact 指令: /compact 保留: 当前任务目标、已确定的方案、关键文件路径和行号。' + '\n越早 compact 摘要质量越高 (60% 优于 83% 被动触发)。'; case 'INFO': return head + '\n建议: 留意上下文规模, 避免连续 Read 大文件; 重型分析可改用 Agent 隔离.'; case 'WARN': return head + '\n建议: 本批任务结束后主动 /clear, 当前进度先写 .bookworm-progress.md (R1) 与 handoff.json (R2 PreCompact 自动) 备份.'; case 'CRITICAL': return head + '\n要求: 立即停止扩展任务, 调用 /handoff 保存当前进度到 .bookworm-progress.md, 然后请用户 /clear; 继续推进会触发自动 compact 且无法回退.'; } return head; } function sampleBytesPerToken(tp) { // [PATCH-X02-THREE-SEGMENT-SAMPLE] try { const fd = fs.openSync(tp, 'r'); const fileSize = fs.fstatSync(fd).size; if (fileSize < 200) { fs.closeSync(fd); return BYTES_PER_TOKEN; } const CHUNK = 8192; const offsets = [0]; if (fileSize > CHUNK * 3) { offsets.push(Math.floor(fileSize / 2) - Math.floor(CHUNK / 2)); offsets.push(Math.max(0, fileSize - CHUNK)); } else if (fileSize > CHUNK) { offsets.push(Math.max(0, fileSize - CHUNK)); } let totalBytes = 0, totalCjk = 0; const buf = Buffer.alloc(CHUNK); for (const off of offsets) { const n = fs.readSync(fd, buf, 0, CHUNK, off); if (n < 100) continue; let cjk = 0; for (let i = 0; i < n; i++) { const b = buf[i]; if (b >= 0xE4 && b <= 0xED) cjk += 3; // [PATCH-X09-CJK-KOREAN] } totalBytes += n; totalCjk += cjk; } fs.closeSync(fd); if (totalBytes < 200) return BYTES_PER_TOKEN; const cjkRatio = totalCjk / totalBytes; if (cjkRatio >= 0.40) return 2.2; if (cjkRatio >= 0.15) return 2.8; return 3.5; } catch { return BYTES_PER_TOKEN; } } (async () => { try { let hookData = {}; try { hookData = await readStdin(); } catch {} const tp = findTranscript(hookData); if (!tp) process.exit(0); let bytes = 0; try { bytes = fs.statSync(tp).size; } catch { process.exit(0); } if (bytes < 50000) process.exit(0); // <50KB 显然没压力, 跳过 // R4-CJK-RATIO-V2: 采样头 8KB 计算 CJK 字节占比, 动态选择 ratio const ratio_bpt = sampleBytesPerToken(tp); const tokens = Math.round(bytes / ratio_bpt); const ratio = tokens / TOKEN_BUDGET; const level = levelFor(ratio); if (!level) process.exit(0); const sid = hookData.session_id || 'unknown'; const state = pruneState(loadState()); const sessionState = state[sid] || { firedLevels: [], lastTs: 0 }; if (sessionState.firedLevels.includes(level)) { // 该会话本阈值已播报过 process.exit(0); } sessionState.firedLevels.push(level); sessionState.lastTs = Date.now(); sessionState.lastRatio = ratio; state[sid] = sessionState; saveState(state); const additionalContext = buildMessage(level, ratio, tokens, bytes); process.stdout.write(JSON.stringify({ continue: true, suppressOutput: true, hookSpecificOutput: { hookEventName: 'UserPromptSubmit', additionalContext: additionalContext } })); process.exit(0); } catch { process.exit(0); } })();