mirror of
https://github.com/affaan-m/ECC.git
synced 2026-08-17 21:15:40 +02:00
* fix(hooks,lib): fix hook detection and parsing edge cases
- auto-tmux-dev: dev\b -> dev(?![\w-]) so one-shot dev-build/dev-docs scripts
are not detached into tmux; align command shapes (yarn run dev, bun dev) with
pre-bash-dev-server-block.js DEV_PATTERN.
- pre-bash-commit-quality: skip obvious non-secret placeholders (env refs,
${...}, <...>, whitelisted tokens) in the api-key rule without suppressing
real high-entropy secrets; make -m message extraction quote- and
escaped-quote-aware so `-m "fix: \"x\""` / apostrophes are not truncated.
- pre-compact: annotate the CURRENT worktree's session (match **Worktree:** /
legacy **Project:**) instead of the newest *-session.tmp across all projects,
layered onto the LLM-summary flow from #2388; a present-but-blank Worktree
header is treated as non-legacy (no foreign project fallback).
- shell-substitution: stop double-appending a trailing backslash in an
unterminated backtick span.
- utils readStdinJson: on overflow, settle and resolve {} immediately (clear
timer + listeners) instead of waiting for end/timeout and parsing a partial
prefix; surface the overflow on stderr.
Regression tests added/extended (new tests/hooks/pre-compact.test.js).
Addresses review feedback on #2405. The earlier block-no-verify change was
dropped: its message-value skip on merge/cherry-pick/am/rebase would let
`git rebase -m --no-verify` bypass the hook (rebase's -m is the boolean
--merge), a false-negative worse than the contrived false-positive it fixed.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* fix(ci): align hook fixtures and drain oversized stdin
---------
Co-authored-by: djpjronline-netizen <276112803+djpjronline-netizen@users.noreply.github.com>
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
Co-authored-by: haelyra <49814733+haelyra@users.noreply.github.com>
179 lines
6.7 KiB
JavaScript
179 lines
6.7 KiB
JavaScript
#!/usr/bin/env node
|
|
/**
|
|
* PreCompact Hook - Save LLM-generated summary before context compaction
|
|
*
|
|
* Cross-platform (Windows, macOS, Linux)
|
|
*
|
|
* Runs before Claude compacts context. Generates a rich LLM summary of the
|
|
* current session and writes it to the active session .tmp file so that the
|
|
* next session start gets a high-quality summary even after lossy compaction.
|
|
*
|
|
* Falls back to a plain log entry when transcript_path is unavailable or the
|
|
* LLM call fails.
|
|
*/
|
|
|
|
const path = require('path');
|
|
const fs = require('fs');
|
|
const { getSessionsDir, getDateTimeString, getTimeString, findFiles, ensureDir, appendFile, readFile, writeFile, getProjectName, log } = require('../lib/utils');
|
|
const { generateSessionSummary } = require('../lib/llm-summary');
|
|
|
|
const SUMMARY_START_MARKER = '<!-- ECC:SUMMARY:START -->';
|
|
const SUMMARY_END_MARKER = '<!-- ECC:SUMMARY:END -->';
|
|
|
|
function escapeRegExp(value) {
|
|
return String(value).replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
}
|
|
|
|
/**
|
|
* Canonicalize a path (resolve symlinks); fall back to the input on failure.
|
|
* Mirrors session-start.js#normalizePath so worktree comparisons agree.
|
|
*/
|
|
function normalizePath(p) {
|
|
try {
|
|
return fs.realpathSync(p);
|
|
} catch {
|
|
return p;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Pick the session file that belongs to the CURRENT worktree.
|
|
*
|
|
* The sessions dir is shared across every project/worktree, so the newest
|
|
* `*-session.tmp` is frequently a DIFFERENT project's session. Matching by
|
|
* mtime (`sessions[0]`) therefore writes the compaction summary into the wrong
|
|
* project. Match on the `**Worktree:**` header (written by session-end.js)
|
|
* against cwd, mirroring session-start.js#selectMatchingSession:
|
|
* 1. exact worktree (cwd) match — newest wins
|
|
* 2. truly legacy sessions with NO Worktree header: same **Project:** name
|
|
* 3. otherwise null — do NOT annotate a foreign worktree's session
|
|
* A present-but-blank Worktree header counts as non-legacy (never a project
|
|
* fallback), so a foreign session is not matched by name.
|
|
*
|
|
* @param {Array<{path: string}>} sessions - newest-first session list
|
|
* @param {string} cwd
|
|
* @param {string} currentProject
|
|
* @param {(p: string) => (string|null)} [readFn]
|
|
* @returns {string|null} path of the chosen session, or null if none match
|
|
*/
|
|
function selectActiveSessionPath(sessions, cwd, currentProject, readFn = readFile) {
|
|
if (!sessions || sessions.length === 0) return null;
|
|
const normalizedCwd = normalizePath(cwd);
|
|
let projectMatch = null;
|
|
|
|
for (const session of sessions) {
|
|
const content = readFn(session.path);
|
|
if (!content) continue;
|
|
|
|
// (.*) not (.+): an explicit but empty header (`**Worktree:**` / `**Worktree:**\n`)
|
|
// must still register as present (hasWorktreeHeader) so it does not fall back
|
|
// to project-name matching against a foreign session.
|
|
const worktreeMatch = content.match(/\*\*Worktree:\*\*\s*(.*)$/m);
|
|
const hasWorktreeHeader = Boolean(worktreeMatch);
|
|
const sessionWorktree = worktreeMatch ? worktreeMatch[1].trim() : '';
|
|
|
|
if (sessionWorktree && normalizePath(sessionWorktree) === normalizedCwd) {
|
|
return session.path;
|
|
}
|
|
|
|
// Project-name fallback only for truly legacy sessions with NO Worktree
|
|
// header at all — a present-but-blank header is not treated as legacy.
|
|
if (!projectMatch && currentProject && !hasWorktreeHeader) {
|
|
const projectFieldMatch = content.match(/\*\*Project:\*\*\s*(.+)$/m);
|
|
const sessionProject = projectFieldMatch ? projectFieldMatch[1].trim() : '';
|
|
if (sessionProject && sessionProject === currentProject) {
|
|
projectMatch = session.path;
|
|
}
|
|
}
|
|
}
|
|
|
|
return projectMatch;
|
|
}
|
|
|
|
const MAX_STDIN = 1024 * 1024;
|
|
let stdinData = '';
|
|
|
|
if (require.main === module) {
|
|
process.stdin.setEncoding('utf8');
|
|
|
|
process.stdin.on('data', chunk => {
|
|
if (stdinData.length < MAX_STDIN) {
|
|
stdinData += chunk.substring(0, MAX_STDIN - stdinData.length);
|
|
}
|
|
});
|
|
|
|
process.stdin.on('end', () => {
|
|
main().catch(err => {
|
|
log(`[PreCompact] Error: ${err.message}`);
|
|
process.exit(0);
|
|
});
|
|
});
|
|
}
|
|
|
|
async function main() {
|
|
let transcriptPath = null;
|
|
try {
|
|
const input = JSON.parse(stdinData);
|
|
if (input && typeof input.transcript_path === 'string' && input.transcript_path.length > 0) {
|
|
transcriptPath = input.transcript_path;
|
|
}
|
|
} catch {
|
|
// stdin not JSON or missing — proceed without transcript
|
|
}
|
|
|
|
const sessionsDir = getSessionsDir();
|
|
const compactionLog = path.join(sessionsDir, 'compaction-log.txt');
|
|
|
|
ensureDir(sessionsDir);
|
|
|
|
const timestamp = getDateTimeString();
|
|
appendFile(compactionLog, `[${timestamp}] Context compaction triggered\n`);
|
|
|
|
const sessions = findFiles(sessionsDir, '*-session.tmp');
|
|
if (sessions.length === 0) {
|
|
log('[PreCompact] No active session file found');
|
|
process.exit(0);
|
|
}
|
|
|
|
// Select the session for THIS worktree, not merely the newest across all
|
|
// projects (the sessions dir is shared). Skip when none matches rather than
|
|
// writing the summary into a foreign project's session file.
|
|
const activeSession = selectActiveSessionPath(sessions, process.cwd(), getProjectName());
|
|
if (!activeSession) {
|
|
log('[PreCompact] No session matches the current worktree; skipping annotation');
|
|
process.exit(0);
|
|
}
|
|
const timeStr = getTimeString();
|
|
|
|
if (!transcriptPath || !fs.existsSync(transcriptPath)) {
|
|
appendFile(activeSession, `\n---\n**[Compaction occurred at ${timeStr}]** - Context was summarized\n`);
|
|
log('[PreCompact] No transcript available; logged compaction event only');
|
|
process.exit(0);
|
|
}
|
|
|
|
// Generate LLM summary right before compaction — most critical timing
|
|
log('[PreCompact] Generating LLM summary before compaction...');
|
|
const llmSummary = generateSessionSummary(transcriptPath);
|
|
|
|
if (!llmSummary) {
|
|
appendFile(activeSession, `\n---\n**[Compaction occurred at ${timeStr}]** - Context was summarized\n`);
|
|
log('[PreCompact] LLM summary unavailable; logged compaction event only');
|
|
process.exit(0);
|
|
}
|
|
|
|
const existing = readFile(activeSession);
|
|
if (existing && existing.includes(SUMMARY_START_MARKER) && existing.includes(SUMMARY_END_MARKER)) {
|
|
const newBlock = `${SUMMARY_START_MARKER}\n${llmSummary}\n<!-- LLM_SUMMARY:pre-compact:${timeStr} -->\n${SUMMARY_END_MARKER}`;
|
|
const updated = existing.replace(new RegExp(`${escapeRegExp(SUMMARY_START_MARKER)}[\\s\\S]*?${escapeRegExp(SUMMARY_END_MARKER)}`), () => newBlock);
|
|
writeFile(activeSession, updated);
|
|
log('[PreCompact] LLM summary written to session file before compaction');
|
|
} else {
|
|
appendFile(activeSession, `\n---\n**[Compaction at ${timeStr}]**\n\n${llmSummary}\n`);
|
|
log('[PreCompact] LLM summary appended (no summary markers found)');
|
|
}
|
|
|
|
process.exit(0);
|
|
}
|
|
|
|
module.exports = { selectActiveSessionPath, normalizePath };
|