bookworm-smart-assistant/hooks/context-pressure-monitor.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

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);
}
})();