forked from affaan-m/ECC
-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathpre-compact.js
More file actions
178 lines (152 loc) · 6.7 KB
/
Copy pathpre-compact.js
File metadata and controls
178 lines (152 loc) · 6.7 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
#!/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 };