266 lines
8.9 KiB
JavaScript
266 lines
8.9 KiB
JavaScript
|
|
#!/usr/bin/env node
|
||
|
|
'use strict';
|
||
|
|
/**
|
||
|
|
* 补丁 — SessionStart Memory Audit Hook
|
||
|
|
*
|
||
|
|
* 目的:
|
||
|
|
* 1. 创建 hooks/session-start-memory-audit.js (轻量日级记忆体检)
|
||
|
|
* 2. 注册到 settings.json 的 UserPromptSubmit (Bookworm 无 SessionStart 键)
|
||
|
|
*
|
||
|
|
* 设计:
|
||
|
|
* - 日级守卫: 今日已跑则 <5ms 快速返回
|
||
|
|
* - 静默默认: orphan<3 && ghost==0 && score>=80 完全不输出
|
||
|
|
* - 告警触发: 任一条件命中才 console.log 到 UserPromptSubmit additionalContext
|
||
|
|
* - Fail-open: 任何异常 exit 0
|
||
|
|
* - Feature flag: .bookworm-features.json.memory_audit=false 关闭
|
||
|
|
* - Timeout: 3s (比 memory-audit --json 实测 <500ms 充分)
|
||
|
|
*
|
||
|
|
* 幂等:
|
||
|
|
* - sentinel: SESSION_START_MEMORY_AUDIT_2026_04_25
|
||
|
|
* - hook 文件内容一致则跳过
|
||
|
|
* - settings.json 按 command 字符串检测
|
||
|
|
*
|
||
|
|
* 原子性:
|
||
|
|
* - hook 文件: tmp + rename
|
||
|
|
* - settings.json: .bak.memory-audit-hook + tmp + JSON 解析自检 + rename
|
||
|
|
*
|
||
|
|
* 回滚:
|
||
|
|
* 方式 1 (feature flag): echo '{"memory_audit":false}' > ~/.claude/.bookworm-features.json
|
||
|
|
* 方式 2 (完整): cp settings.json.bak.memory-audit-hook settings.json
|
||
|
|
* rm hooks/session-start-memory-audit.js
|
||
|
|
*/
|
||
|
|
|
||
|
|
const fs = require('fs');
|
||
|
|
const path = require('path');
|
||
|
|
const os = require('os');
|
||
|
|
|
||
|
|
const HOME = process.env.USERPROFILE || process.env.HOME || os.homedir();
|
||
|
|
const CLAUDE_ROOT = process.env.CLAUDE_HOME ||
|
||
|
|
(fs.existsSync(path.join(HOME, '.claude')) ? path.join(HOME, '.claude') : HOME);
|
||
|
|
const HOOKS_DIR = path.join(CLAUDE_ROOT, 'hooks');
|
||
|
|
const HOOK_TARGET = path.join(HOOKS_DIR, 'session-start-memory-audit.js');
|
||
|
|
const SETTINGS_FILE = path.join(CLAUDE_ROOT, 'settings.json');
|
||
|
|
const SETTINGS_BAK = SETTINGS_FILE + '.bak.memory-audit-hook';
|
||
|
|
const SENTINEL = 'SESSION_START_MEMORY_AUDIT_2026_04_25';
|
||
|
|
const HOOK_CMD = 'node C:/Users/leesu/.claude/hooks/session-start-memory-audit.js';
|
||
|
|
|
||
|
|
const HOOK_CONTENT = `#!/usr/bin/env node
|
||
|
|
'use strict';
|
||
|
|
/**
|
||
|
|
* SessionStart Memory Audit Hook
|
||
|
|
* sentinel: ${SENTINEL}
|
||
|
|
*
|
||
|
|
* 事件: UserPromptSubmit (Bookworm 无 SessionStart, 用 UserPromptSubmit + 日守卫)
|
||
|
|
* 目的: 每日首次会话自动体检记忆文件健康度, 异常时作为 additionalContext 提醒
|
||
|
|
*
|
||
|
|
* 预算:
|
||
|
|
* - 今日已跑: <5ms (stamp 快速返回)
|
||
|
|
* - 首次: <1000ms (memory-audit.js --json, execFileSync 3s timeout)
|
||
|
|
*
|
||
|
|
* 告警门槛 (静默默认, 命中才输出):
|
||
|
|
* - orphan >= 3 目录有文件未索引
|
||
|
|
* - ghost >= 1 索引指向已删文件
|
||
|
|
* - health.score < 80
|
||
|
|
*
|
||
|
|
* Feature flag: .bookworm-features.json.memory_audit=false 可关闭
|
||
|
|
* Fail-open: 任何异常 exit 0, 永不阻断用户输入
|
||
|
|
*/
|
||
|
|
|
||
|
|
const fs = require('fs');
|
||
|
|
const path = require('path');
|
||
|
|
const os = require('os');
|
||
|
|
|
||
|
|
const HOME = process.env.USERPROFILE || process.env.HOME || os.homedir();
|
||
|
|
const CLAUDE_ROOT = process.env.CLAUDE_HOME ||
|
||
|
|
(fs.existsSync(path.join(HOME, '.claude')) ? path.join(HOME, '.claude') : HOME);
|
||
|
|
const DEBUG_DIR = path.join(CLAUDE_ROOT, 'debug');
|
||
|
|
const STAMP_FILE = path.join(DEBUG_DIR, '.last-memory-audit');
|
||
|
|
const FEATURE_FLAGS_FILE = path.join(CLAUDE_ROOT, '.bookworm-features.json');
|
||
|
|
const AUDIT_TOOL = path.join(
|
||
|
|
CLAUDE_ROOT, 'projects', 'C--Users-leesu', 'memory', '_tools', 'memory-audit.js'
|
||
|
|
);
|
||
|
|
const TODAY = new Date().toISOString().slice(0, 10);
|
||
|
|
|
||
|
|
function safeExit() { process.exit(0); }
|
||
|
|
|
||
|
|
function main() {
|
||
|
|
try {
|
||
|
|
// Feature flag
|
||
|
|
try {
|
||
|
|
if (fs.existsSync(FEATURE_FLAGS_FILE)) {
|
||
|
|
const flags = JSON.parse(fs.readFileSync(FEATURE_FLAGS_FILE, 'utf8'));
|
||
|
|
if (flags && flags.memory_audit === false) return safeExit();
|
||
|
|
}
|
||
|
|
} catch {}
|
||
|
|
|
||
|
|
// 日级守卫
|
||
|
|
if (fs.existsSync(STAMP_FILE)) {
|
||
|
|
try {
|
||
|
|
const last = fs.readFileSync(STAMP_FILE, 'utf8').trim();
|
||
|
|
if (last === TODAY) return safeExit();
|
||
|
|
} catch {}
|
||
|
|
}
|
||
|
|
|
||
|
|
// 工具存在性
|
||
|
|
if (!fs.existsSync(AUDIT_TOOL)) return safeExit();
|
||
|
|
|
||
|
|
// 运行审计
|
||
|
|
const { execFileSync } = require('child_process');
|
||
|
|
let report;
|
||
|
|
try {
|
||
|
|
const result = execFileSync(process.execPath, [AUDIT_TOOL, '--json'], {
|
||
|
|
timeout: 3000,
|
||
|
|
encoding: 'utf8',
|
||
|
|
stdio: ['pipe', 'pipe', 'pipe'],
|
||
|
|
});
|
||
|
|
report = JSON.parse(result);
|
||
|
|
} catch {
|
||
|
|
return safeExit();
|
||
|
|
}
|
||
|
|
|
||
|
|
// 更新 stamp (不管结果, 避免失败时每次会话都重试)
|
||
|
|
try {
|
||
|
|
fs.mkdirSync(DEBUG_DIR, { recursive: true });
|
||
|
|
fs.writeFileSync(STAMP_FILE, TODAY);
|
||
|
|
} catch {}
|
||
|
|
|
||
|
|
// 判断是否需要告警
|
||
|
|
const h = (report && report.health) || {};
|
||
|
|
const orphan = h.orphanCount || 0;
|
||
|
|
const ghost = h.ghostCount || 0;
|
||
|
|
const oversize = h.oversizeCount || 0;
|
||
|
|
const score = typeof h.score === 'number' ? h.score : 100;
|
||
|
|
|
||
|
|
const needs = (orphan >= 3) || (ghost >= 1) || (score < 80);
|
||
|
|
if (!needs) return safeExit();
|
||
|
|
|
||
|
|
// 输出为 UserPromptSubmit 的 additionalContext
|
||
|
|
const lines = [
|
||
|
|
'[memory-audit] 记忆系统需要关注:',
|
||
|
|
' score=' + score + '/100 | orphan=' + orphan + ' ghost=' + ghost + ' oversize=' + oversize,
|
||
|
|
' 运行: node projects/C--Users-leesu/memory/_tools/memory-audit.js',
|
||
|
|
' 一键归档 orphan: ... --fix',
|
||
|
|
];
|
||
|
|
console.log(lines.join('\\n'));
|
||
|
|
} catch {
|
||
|
|
// fail-open
|
||
|
|
}
|
||
|
|
safeExit();
|
||
|
|
}
|
||
|
|
|
||
|
|
if (require.main === module) main();
|
||
|
|
|
||
|
|
module.exports = { main };
|
||
|
|
`;
|
||
|
|
|
||
|
|
function patchHookFile() {
|
||
|
|
if (!fs.existsSync(HOOKS_DIR)) {
|
||
|
|
console.error('[patch-memory-audit-hook] hooks 目录不存在:', HOOKS_DIR);
|
||
|
|
process.exit(1);
|
||
|
|
}
|
||
|
|
|
||
|
|
if (fs.existsSync(HOOK_TARGET)) {
|
||
|
|
const current = fs.readFileSync(HOOK_TARGET, 'utf8');
|
||
|
|
if (current.includes(SENTINEL) && current === HOOK_CONTENT) {
|
||
|
|
console.log('[patch-memory-audit-hook] hook 已落地且内容一致,跳过');
|
||
|
|
return 'skipped';
|
||
|
|
}
|
||
|
|
const bak = HOOK_TARGET + '.bak.memory-audit-hook';
|
||
|
|
fs.copyFileSync(HOOK_TARGET, bak);
|
||
|
|
console.log('[patch-memory-audit-hook] 已备份旧 hook:', bak);
|
||
|
|
}
|
||
|
|
|
||
|
|
const tmp = HOOK_TARGET + '.tmp';
|
||
|
|
fs.writeFileSync(tmp, HOOK_CONTENT, 'utf8');
|
||
|
|
fs.renameSync(tmp, HOOK_TARGET);
|
||
|
|
console.log('[patch-memory-audit-hook] 已写入 hook:', HOOK_TARGET);
|
||
|
|
|
||
|
|
// 语法自检
|
||
|
|
try {
|
||
|
|
const { execFileSync } = require('child_process');
|
||
|
|
execFileSync(process.execPath, ['--check', HOOK_TARGET], { stdio: 'pipe' });
|
||
|
|
console.log('[patch-memory-audit-hook] hook 语法自检 PASS');
|
||
|
|
} catch (e) {
|
||
|
|
console.error('[patch-memory-audit-hook] 语法自检失败:', (e.stderr || e.message || '').toString().slice(0, 500));
|
||
|
|
process.exit(3);
|
||
|
|
}
|
||
|
|
return 'written';
|
||
|
|
}
|
||
|
|
|
||
|
|
function patchSettings() {
|
||
|
|
if (!fs.existsSync(SETTINGS_FILE)) {
|
||
|
|
console.error('[patch-memory-audit-hook] settings.json 不存在');
|
||
|
|
process.exit(4);
|
||
|
|
}
|
||
|
|
|
||
|
|
const before = fs.readFileSync(SETTINGS_FILE, 'utf8');
|
||
|
|
const settings = JSON.parse(before);
|
||
|
|
|
||
|
|
if (!settings.hooks) settings.hooks = {};
|
||
|
|
if (!Array.isArray(settings.hooks.UserPromptSubmit)) settings.hooks.UserPromptSubmit = [];
|
||
|
|
|
||
|
|
const alreadyRegistered = settings.hooks.UserPromptSubmit.some(group =>
|
||
|
|
Array.isArray(group.hooks) && group.hooks.some(h => h.command === HOOK_CMD)
|
||
|
|
);
|
||
|
|
if (alreadyRegistered) {
|
||
|
|
console.log('[patch-memory-audit-hook] settings.json 已注册,跳过');
|
||
|
|
return 'skipped';
|
||
|
|
}
|
||
|
|
|
||
|
|
if (!fs.existsSync(SETTINGS_BAK)) {
|
||
|
|
fs.copyFileSync(SETTINGS_FILE, SETTINGS_BAK);
|
||
|
|
console.log('[patch-memory-audit-hook] 已备份 settings.json:', SETTINGS_BAK);
|
||
|
|
}
|
||
|
|
|
||
|
|
settings.hooks.UserPromptSubmit.push({
|
||
|
|
hooks: [
|
||
|
|
{
|
||
|
|
type: 'command',
|
||
|
|
command: HOOK_CMD,
|
||
|
|
timeout: 3000,
|
||
|
|
},
|
||
|
|
],
|
||
|
|
});
|
||
|
|
|
||
|
|
const tmp = SETTINGS_FILE + '.tmp';
|
||
|
|
fs.writeFileSync(tmp, JSON.stringify(settings, null, 2), 'utf8');
|
||
|
|
|
||
|
|
// JSON 解析自检
|
||
|
|
try {
|
||
|
|
JSON.parse(fs.readFileSync(tmp, 'utf8'));
|
||
|
|
} catch (e) {
|
||
|
|
fs.unlinkSync(tmp);
|
||
|
|
console.error('[patch-memory-audit-hook] 写出的 settings.json 无法解析,中止');
|
||
|
|
process.exit(5);
|
||
|
|
}
|
||
|
|
|
||
|
|
fs.renameSync(tmp, SETTINGS_FILE);
|
||
|
|
console.log('[patch-memory-audit-hook] settings.json 已更新');
|
||
|
|
return 'updated';
|
||
|
|
}
|
||
|
|
|
||
|
|
function main() {
|
||
|
|
const hookResult = patchHookFile();
|
||
|
|
const settingsResult = patchSettings();
|
||
|
|
|
||
|
|
console.log('');
|
||
|
|
console.log('[patch-memory-audit-hook] sentinel:', SENTINEL);
|
||
|
|
console.log('[patch-memory-audit-hook] hook:', hookResult);
|
||
|
|
console.log('[patch-memory-audit-hook] settings:', settingsResult);
|
||
|
|
console.log('[patch-memory-audit-hook] 完成。');
|
||
|
|
console.log('');
|
||
|
|
console.log('验证:');
|
||
|
|
console.log(' 1. 下次会话启动 (UserPromptSubmit 触发), 今日首次会跑记忆体检');
|
||
|
|
console.log(' 2. 健康时静默 (无输出)');
|
||
|
|
console.log(' 3. 异常时在 additionalContext 输出告警');
|
||
|
|
console.log(' 4. 查看 stamp: cat ~/.claude/debug/.last-memory-audit');
|
||
|
|
console.log('');
|
||
|
|
console.log('回滚:');
|
||
|
|
console.log(' 方式 1 (feature flag): echo \'{"memory_audit":false}\' > ~/.claude/.bookworm-features.json');
|
||
|
|
console.log(' 方式 2 (完整): cp ' + SETTINGS_BAK + ' ' + SETTINGS_FILE);
|
||
|
|
console.log(' rm ' + HOOK_TARGET);
|
||
|
|
process.exit(0);
|
||
|
|
}
|
||
|
|
|
||
|
|
main();
|