mirror of
https://github.com/kochetkov-ma/claude-brewcode.git
synced 2026-09-14 20:16:41 +08:00
244 lines
9.7 KiB
JavaScript
244 lines
9.7 KiB
JavaScript
#!/usr/bin/env node
|
|
/**
|
|
* Compact Recall Hook - re-anchor plan, original intent and task graph after compaction.
|
|
*
|
|
* Event: SessionStart, matcher "compact"
|
|
* Channel: hookSpecificOutput.additionalContext — the ONLY channel that reaches
|
|
* the model after a compact. PostCompact stdout lands in the UI only.
|
|
* Why: compaction drops the plan reference entirely and the built-in
|
|
* task_reminder returns empty for several turns, so the session drifts
|
|
* off the user's original task and starts a NEW task graph.
|
|
* Cost: measured on an 8.13 MB transcript - scan (one buffer read + five
|
|
* substring scans) ~6 ms, full process wall clock ~30 ms standalone and
|
|
* ~55 ms spawned from a node parent, i.e. node startup dominates.
|
|
* Compaction awaits this hook, so it stays cheap and stateless —
|
|
* auto-compaction can chain and fire it repeatedly.
|
|
* Contract: on source "compact" it ALWAYS injects something; every failure path
|
|
* degrades to the INTENT fragment, never to silence and never to a guess.
|
|
*/
|
|
|
|
import { statSync, readFileSync } from 'fs';
|
|
import { join } from 'path';
|
|
import { readStdin, output, capText, log, projectRoot } from './lib/utils.mjs';
|
|
|
|
const MAX_TRANSCRIPT_BYTES = 64 * 1024 * 1024;
|
|
const PLAN_KEY = '"planFilePath":"';
|
|
const TASK_KEY = '"name":"TaskCreate"';
|
|
// Plan mode leaves these even when no plan file was ever recorded. Every key
|
|
// carries its JSON quotes: prose that merely NAMES a key arrives with escaped
|
|
// quotes (\"), so a quoted key cannot match a transcript's own text about itself.
|
|
// A bare 'plan_mode_reentry' matched this repo's design discussion and claimed a
|
|
// plan that never existed.
|
|
const PLAN_MODE_KEYS = ['"type":"plan_mode"', '"type":"plan_mode_reentry"', '"permissionMode":"plan"'];
|
|
// Project-local plan link maintained by session-start.mjs (must stay in sync).
|
|
const LATEST_PLAN_NAME = 'LATEST.md';
|
|
|
|
/**
|
|
* Scan this session's transcript for plan + task signals.
|
|
* Transcripts are append-only and compaction does not truncate them, so the LAST
|
|
* planFilePath is the current plan. Trade-off accepted: a session that plans once
|
|
* and later re-plans outside plan mode still gets pointed at the older plan.
|
|
* @param {string} path - transcript_path from stdin
|
|
* @returns {{planPath: string|null, planMode: boolean, hadTasks: boolean}}
|
|
*/
|
|
function scanTranscript(path, root, sessionId) {
|
|
const signals = { planPath: null, planMode: false, hadTasks: false };
|
|
if (typeof path !== 'string' || path === '') return signals; // non-string fs args warn on stderr
|
|
let buf;
|
|
try {
|
|
// isFile() guards the size gate: a FIFO or char device reports size 0 and
|
|
// then blocks readFileSync forever (/dev/zero hung the hook).
|
|
const st = statSync(path);
|
|
if (!st.isFile() || st.size > MAX_TRANSCRIPT_BYTES) {
|
|
log('warn', '[compact-recall]',
|
|
`Transcript skipped: ${st.isFile() ? `${st.size}B over the ${MAX_TRANSCRIPT_BYTES}B cap` : 'not a regular file'} (${path})`,
|
|
root, sessionId);
|
|
return signals;
|
|
}
|
|
buf = readFileSync(path);
|
|
} catch {
|
|
return signals;
|
|
}
|
|
signals.hadTasks = buf.includes(TASK_KEY);
|
|
signals.planMode = PLAN_MODE_KEYS.some(k => buf.includes(k));
|
|
signals.planPath = extractPlanPath(buf);
|
|
return signals;
|
|
}
|
|
|
|
/**
|
|
* Extract the last plan path from a raw transcript buffer.
|
|
* lastIndexOf on the Buffer avoids parsing 3 MB of JSONL. The closing quote is
|
|
* searched within the record's own line only: a partially flushed final record
|
|
* would otherwise run across \n and yield a nonsense path.
|
|
* @param {Buffer} buf - raw transcript
|
|
* @returns {string|null} decoded absolute path, or null
|
|
*/
|
|
function extractPlanPath(buf) {
|
|
let at = buf.lastIndexOf(PLAN_KEY);
|
|
while (at >= 0) {
|
|
const from = at + PLAN_KEY.length;
|
|
const eol = buf.indexOf(0x0a, from);
|
|
const limit = eol < 0 ? buf.length : eol;
|
|
const end = findClosingQuote(buf, from, limit);
|
|
if (end >= 0) {
|
|
const decoded = decodeJsonString(buf.toString('utf8', from, end));
|
|
if (isSanePath(decoded)) return decoded;
|
|
}
|
|
// Truncated, undecodable or insane record: fall back to an earlier one.
|
|
// Stop at offset 0 - lastIndexOf(key, -1) resolves to buf.length and would
|
|
// re-find the same hit forever.
|
|
if (at === 0) break;
|
|
at = buf.lastIndexOf(PLAN_KEY, at - 1);
|
|
}
|
|
return null;
|
|
}
|
|
|
|
/** Absolute, bounded, control-char free - a raw \n would inject extra directive lines */
|
|
function isSanePath(p) {
|
|
return typeof p === 'string' && p.startsWith('/') && p.length <= 1024 && !/[\x00-\x1f\x7f]/.test(p);
|
|
}
|
|
|
|
/** Index of the unescaped `"` in [from, limit), or -1 if the value is unterminated */
|
|
function findClosingQuote(buf, from, limit) {
|
|
for (let i = from; i < limit; i++) {
|
|
if (buf[i] === 0x22) return i;
|
|
if (buf[i] === 0x5c) i++; // skip the escaped char
|
|
}
|
|
return -1;
|
|
}
|
|
|
|
/** Decode a raw JSON string body (\\, \uXXXX, ...) - Windows paths arrive escaped */
|
|
function decodeJsonString(raw) {
|
|
try {
|
|
return JSON.parse(`"${raw}"`) || null;
|
|
} catch {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
/** True only for an existing regular file - existsSync would accept a directory */
|
|
function isFile(path) {
|
|
try {
|
|
return statSync(path).isFile();
|
|
} catch {
|
|
return false;
|
|
}
|
|
}
|
|
|
|
// --- Fragments: independent, order-stable. Add a new builder to the list. ---
|
|
|
|
const INTENT_FRAGMENT = [
|
|
'[INTENT] Re-read the user ORIGINAL task and intent from the compact summary and keep executing THAT.',
|
|
'Do not continue from the most recently remembered fragment, and do not re-scope the work.'
|
|
].join('\n');
|
|
|
|
/**
|
|
* @param {{planPath: string|null, planMode: boolean}} signals
|
|
* @param {string} root - project root, for the LATEST.md rung
|
|
* @returns {{text: string, branch: string}} plan fragment; first match in the ladder wins
|
|
*/
|
|
function planFragment({ planPath, planMode }, root) {
|
|
if (planPath && isFile(planPath)) {
|
|
return {
|
|
branch: 'plan-file',
|
|
text: [
|
|
`[PLAN] Read ${planPath} with the Read tool before doing any work.`,
|
|
'It holds the role model and the delegation split for this session: follow it, do not re-derive it.'
|
|
].join('\n')
|
|
};
|
|
}
|
|
// A plan that PREDATES the transcript (--resume, post-/clear) leaves no
|
|
// planFilePath to scan for - the first occurrence is the compaction attachment
|
|
// this hook is reacting to. The project-local LATEST.md is hook-owned (BC-H02:
|
|
// symlink plus target containment) and project-scoped, so it can never be
|
|
// another repo's plan. Sits above plan-missing: a real file beats a dead path.
|
|
const latestPlan = join(root, '.claude', 'plans', LATEST_PLAN_NAME);
|
|
if (isFile(latestPlan)) {
|
|
return {
|
|
branch: 'plan-latest',
|
|
text: [
|
|
`[PLAN] Read ${latestPlan} with the Read tool before doing any work.`,
|
|
'It is this project\'s latest plan, carried over from before the compact: follow its role model and delegation split, do not re-derive them.'
|
|
].join('\n')
|
|
};
|
|
}
|
|
if (planPath) {
|
|
return {
|
|
branch: 'plan-missing',
|
|
text: [
|
|
`[PLAN] The plan file for this session is gone or unreadable at ${planPath}.`,
|
|
'Rebuild the frame from the compact summary plus TaskList, not from scratch.'
|
|
].join('\n')
|
|
};
|
|
}
|
|
// Plan-mode markers are stamped on ENTERING plan mode, before any approval, so
|
|
// this branch must not claim an approved plan exists.
|
|
if (planMode) {
|
|
return {
|
|
branch: 'plan-in-summary',
|
|
text: [
|
|
'[PLAN] This session ran in plan mode; no plan file is available.',
|
|
'A plan in the compact summary -> follow it and its delegation split, do not re-derive them.',
|
|
'No plan there -> re-read the user ORIGINAL task and intent from the summary and keep executing THAT.'
|
|
].join('\n')
|
|
};
|
|
}
|
|
return { branch: 'intent', text: INTENT_FRAGMENT };
|
|
}
|
|
|
|
// Claims only what one TaskCreate hit proves: a graph exists. Count, status and
|
|
// recency are unknown here, so the model is told to read it, not what it holds.
|
|
// "Then" keeps the order aligned with the injection order (plan fragment first) -
|
|
// two fragments each claiming "first" is a contradiction the model has to guess at.
|
|
const TASK_FRAGMENT = [
|
|
'[TASKS] Then call TaskList: a task graph created before the compact ALREADY EXISTS in this session.',
|
|
'Re-read it, do NOT create a new graph. The built-in reminder lags several turns and may show empty, so TaskList is the authority. Then resume the work.'
|
|
].join('\n');
|
|
|
|
// --- Main ---
|
|
|
|
async function main() {
|
|
let root, sessionId;
|
|
try {
|
|
const input = await readStdin();
|
|
// Stable project root for the log path: hook cwd drifts mid-session and a
|
|
// non-string would throw inside log(), discarding an already-computed fragment.
|
|
root = projectRoot(typeof input.cwd === 'string' ? input.cwd : undefined);
|
|
sessionId = input.session_id;
|
|
|
|
// Belt and braces on top of the "compact" matcher.
|
|
if (input.source !== 'compact') {
|
|
output({});
|
|
return;
|
|
}
|
|
|
|
const signals = scanTranscript(input.transcript_path, root, sessionId);
|
|
const plan = planFragment(signals, root);
|
|
const fragments = [plan.text];
|
|
if (signals.hadTasks) fragments.push(TASK_FRAGMENT);
|
|
|
|
log('info', '[compact-recall]',
|
|
`branch=${plan.branch} tasks=${signals.hadTasks}${signals.planPath ? ` plan=${signals.planPath}` : ''}`,
|
|
root, sessionId);
|
|
|
|
output({
|
|
hookSpecificOutput: {
|
|
hookEventName: 'SessionStart',
|
|
additionalContext: capText(fragments.join('\n'))
|
|
}
|
|
});
|
|
|
|
} catch (error) {
|
|
// Never silent after a compact: degrade to the intent reminder.
|
|
log('warn', '[compact-recall]', `Error: ${error.message}`, root, sessionId);
|
|
output({
|
|
hookSpecificOutput: {
|
|
hookEventName: 'SessionStart',
|
|
additionalContext: INTENT_FRAGMENT
|
|
}
|
|
});
|
|
}
|
|
}
|
|
|
|
main();
|