bookworm-smart-assistant/hooks/project-context-injector.js.bak-lstat-1777232396585

132 lines
4.0 KiB
Plaintext
Raw Permalink Normal View History

#!/usr/bin/env node
/**
* project-context-injector.js · R3 · 2026-04-26
*
* UserPromptSubmit Hook · 项目级稳定上下文自动注入
*
* 触发条件 (全部满足):
* 1. cwd 是项目根 (含 .git/package.json/pyproject.toml/go.mod/Cargo.toml/CLAUDE.md 之一)
* 2. <cwd>/.bookworm-context.md 文件存在
* 3. 本会话尚未为该项目注入过 (per-session-per-project 去重)
*
* 注入内容: .bookworm-context.md 头 100 行 (可被文件首行 `<!-- max-lines: N -->` 覆盖)
*
* 行为约束:
* - 始终 exit 0 (fail-open, 不阻断 prompt)
* - 失败/无文件时无输出
* - 单次 IO ≤ 50KB, 超大文件被截断
* - 去重缓存路径: ~/.claude/session-state/project-context-injected.json
*/
'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 CACHE_PATH = path.join(STATE_DIR, 'project-context-injected.json');
const CONTEXT_FILENAME = '.bookworm-context.md';
const MAX_BYTES = 50 * 1024;
const DEFAULT_MAX_LINES = 100;
const ROOT_MARKERS = ['.git', 'package.json', 'pyproject.toml', 'go.mod', 'Cargo.toml', 'CLAUDE.md'];
function isProjectRoot(dir) {
for (const m of ROOT_MARKERS) {
if (fs.existsSync(path.join(dir, m))) return true;
}
return false;
}
function loadCache() {
try {
if (!fs.existsSync(CACHE_PATH)) return {};
const raw = fs.readFileSync(CACHE_PATH, 'utf8');
return JSON.parse(raw) || {};
} catch {
return {};
}
}
function saveCache(cache) {
try {
if (!fs.existsSync(STATE_DIR)) fs.mkdirSync(STATE_DIR, { recursive: true });
const tmp = CACHE_PATH + '.tmp.' + process.pid;
fs.writeFileSync(tmp, JSON.stringify(cache, null, 2), 'utf8');
fs.renameSync(tmp, CACHE_PATH);
} catch {}
}
function pruneStaleCache(cache) {
const now = Date.now();
const TTL = 7 * 24 * 3600 * 1000;
for (const k of Object.keys(cache)) {
if (!cache[k] || !cache[k].ts || now - cache[k].ts > TTL) delete cache[k];
}
return cache;
}
(async () => {
try {
let hookData = {};
try { hookData = await readStdin(); } catch {}
const cwd = hookData.cwd || process.cwd();
if (!cwd || !isProjectRoot(cwd)) {
process.exit(0);
}
const ctxPath = path.join(cwd, CONTEXT_FILENAME);
if (!fs.existsSync(ctxPath)) {
process.exit(0);
}
// R3-FALLBACK-V2: 无 session_id 直接放弃, 防 'unknown-session' 跨会话缓存污染
if (!hookData.session_id) process.exit(0);
const sessionId = hookData.session_id;
const cacheKey = sessionId + '::' + cwd;
const cache = pruneStaleCache(loadCache());
if (cache[cacheKey]) {
process.exit(0);
}
let raw = fs.readFileSync(ctxPath, 'utf8');
if (Buffer.byteLength(raw, 'utf8') > MAX_BYTES) {
raw = raw.slice(0, MAX_BYTES);
}
let maxLines = DEFAULT_MAX_LINES;
const m = raw.match(/^<!--\s*max-lines:\s*(\d+)\s*-->/);
if (m) maxLines = Math.min(parseInt(m[1], 10) || DEFAULT_MAX_LINES, 500);
const lines = raw.split(/\r?\n/);
const truncated = lines.length > maxLines;
const body = lines.slice(0, maxLines).join('\n');
const tail = truncated
? '\n\n... [项目上下文截断, 完整内容见 ' + CONTEXT_FILENAME + ' (' + lines.length + ' 行)]'
: '';
const additionalContext = '[PROJECT_CONTEXT · ' + path.basename(cwd) + ']\n' +
'源文件: ' + ctxPath + '\n' +
'─────────────────────────────────────\n' +
body + tail;
cache[cacheKey] = { ts: Date.now(), ctxPath: ctxPath, lines: lines.length };
saveCache(cache);
process.stdout.write(JSON.stringify({
continue: true,
suppressOutput: true,
hookSpecificOutput: {
hookEventName: 'UserPromptSubmit',
additionalContext: additionalContext
}
}));
process.exit(0);
} catch {
process.exit(0);
}
})();