194 lines
6.8 KiB
JavaScript
194 lines
6.8 KiB
JavaScript
|
|
#!/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);
|
||
|
|
}
|
||
|
|
})();
|