feat(session): LLM-powered session summary via claude -p (#2388)

Replace mechanical text extraction in session-end.js and pre-compact.js
with LLM-generated summaries using `claude -p`. Summaries now capture
design decisions, resolved bugs, changed files, and carry-over context
rather than just truncated user message snippets.

- Add scripts/lib/llm-summary.js: generateSessionSummary, extractConversationText,
  getContextRemainingPct, getContextThreshold, getLLMModel
- Update scripts/hooks/session-end.js: trigger LLM when context < 20% or
  every 50 messages (env-configurable via ECC_LLM_SUMMARY_*)
- Update scripts/hooks/pre-compact.js: generate LLM summary right before
  compaction and write it to the active session .tmp file
- Add tests/lib/llm-summary.test.js: 18 unit tests
- Update tests/hooks/hooks.test.js: 3 integration tests for new behaviour

Recursion guard: sets ECC_SKIP_LLM_SUMMARY=1 in subprocess env so Stop
hooks fired by the claude -p subprocess do not re-enter summarisation.
Requires no ANTHROPIC_API_KEY — reuses Claude Code's own authentication.

Co-authored-by: Hiroshi Tanaka <hiroshi_tanaka@MBAM3.local>
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Hiroshi Tanaka 2026-06-30 07:55:01 +09:00 committed by GitHub
parent 64797fd895
commit c2950121c9
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
5 changed files with 654 additions and 241 deletions

View File

@ -1,48 +1,100 @@
#!/usr/bin/env node
/**
* PreCompact Hook - Save state before context compaction
* PreCompact Hook - Save LLM-generated summary before context compaction
*
* Cross-platform (Windows, macOS, Linux)
*
* Runs before Claude compacts context, giving you a chance to
* preserve important state that might get lost in summarization.
* 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 {
getSessionsDir,
getDateTimeString,
getTimeString,
findFiles,
ensureDir,
appendFile,
log
} = require('../lib/utils');
const fs = require('fs');
const { getSessionsDir, getDateTimeString, getTimeString, findFiles, ensureDir, appendFile, readFile, writeFile, 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, '\\$&');
}
const MAX_STDIN = 1024 * 1024;
let stdinData = '';
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);
// Log compaction event with timestamp
const timestamp = getDateTimeString();
appendFile(compactionLog, `[${timestamp}] Context compaction triggered\n`);
// If there's an active session file, note the compaction
const sessions = findFiles(sessionsDir, '*-session.tmp');
if (sessions.length > 0) {
const activeSession = sessions[0].path;
const timeStr = getTimeString();
appendFile(activeSession, `\n---\n**[Compaction occurred at ${timeStr}]** - Context was summarized\n`);
if (sessions.length === 0) {
log('[PreCompact] No active session file found');
process.exit(0);
}
const activeSession = sessions[0].path;
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)');
}
log('[PreCompact] State saved before compaction');
process.exit(0);
}
main().catch(err => {
console.error('[PreCompact] Error:', err.message);
process.exit(0);
});

View File

@ -11,20 +11,8 @@
const path = require('path');
const fs = require('fs');
const {
getSessionsDir,
getDateString,
getTimeString,
getSessionIdShort,
sanitizeSessionId,
getProjectName,
ensureDir,
readFile,
writeFile,
runCommand,
stripAnsi,
log
} = require('../lib/utils');
const { getSessionsDir, getDateString, getTimeString, getSessionIdShort, sanitizeSessionId, getProjectName, ensureDir, readFile, writeFile, runCommand, stripAnsi, log } = require('../lib/utils');
const { generateSessionSummary, getContextRemainingPct, getContextThreshold } = require('../lib/llm-summary');
const SUMMARY_START_MARKER = '<!-- ECC:SUMMARY:START -->';
const SUMMARY_END_MARKER = '<!-- ECC:SUMMARY:END -->';
@ -55,11 +43,7 @@ function extractSessionSummary(transcriptPath) {
if (entry.type === 'user' || entry.role === 'user' || entry.message?.role === 'user') {
// Support both direct content and nested message.content (Claude Code JSONL format)
const rawContent = entry.message?.content ?? entry.content;
const text = typeof rawContent === 'string'
? rawContent
: Array.isArray(rawContent)
? rawContent.map(c => (c && c.text) || '').join(' ')
: '';
const text = typeof rawContent === 'string' ? rawContent : Array.isArray(rawContent) ? rawContent.map(c => (c && c.text) || '').join(' ') : '';
const cleaned = stripAnsi(text).trim();
if (cleaned) {
userMessages.push(cleaned.slice(0, 200));
@ -217,7 +201,9 @@ async function main() {
shortId = sanitizeSessionId(m[1].slice(-8).toLowerCase());
}
}
if (!shortId) { shortId = getSessionIdShort(); }
if (!shortId) {
shortId = getSessionIdShort();
}
const sessionFile = path.join(sessionsDir, `${today}-${shortId}-session.tmp`);
const sessionMetadata = getSessionMetadata();
@ -236,6 +222,26 @@ async function main() {
}
}
// Decide whether to call LLM for a richer summary.
// Triggers: context remaining < 20%, or every 50 user messages as a baseline.
let llmSummary = null;
if (transcriptPath && summary && fs.existsSync(transcriptPath)) {
const contextPct = getContextRemainingPct(transcriptPath);
const isContextLow = contextPct !== null && contextPct < getContextThreshold();
const interval = parseInt(process.env.ECC_LLM_SUMMARY_INTERVAL || '50', 10);
const safeInterval = Number.isFinite(interval) && interval > 0 ? interval : 50;
const isPeriodicTurn = summary.totalMessages > 0 && summary.totalMessages % safeInterval === 0;
if (isContextLow || isPeriodicTurn) {
log(`[SessionEnd] LLM summary triggered (context: ${contextPct ?? 'unknown'}%, messages: ${summary.totalMessages})`);
llmSummary = generateSessionSummary(transcriptPath);
if (llmSummary) {
log('[SessionEnd] LLM summary generated successfully');
} else {
log('[SessionEnd] LLM summary failed; falling back to mechanical extraction');
}
}
}
if (fs.existsSync(sessionFile)) {
const existing = readFile(sessionFile);
let updatedContent = existing;
@ -253,17 +259,14 @@ async function main() {
// This keeps repeated Stop invocations idempotent and preserves
// user-authored sections in the same session file.
if (summary && updatedContent) {
const summaryBlock = buildSummaryBlock(summary);
const summaryBlock = llmSummary ? `${SUMMARY_START_MARKER}\n${llmSummary}\n${SUMMARY_END_MARKER}` : buildSummaryBlock(summary);
// Use function replacers: summaryBlock embeds raw user-message text, and a
// string replacement argument interprets $-sequences ($&, $$, $`, $', $n).
// A $& in a user message would otherwise re-inject the entire matched block
// and corrupt the persisted summary. A function replacer is treated literally.
if (updatedContent.includes(SUMMARY_START_MARKER) && updatedContent.includes(SUMMARY_END_MARKER)) {
updatedContent = updatedContent.replace(
new RegExp(`${escapeRegExp(SUMMARY_START_MARKER)}[\\s\\S]*?${escapeRegExp(SUMMARY_END_MARKER)}`),
() => summaryBlock
);
updatedContent = updatedContent.replace(new RegExp(`${escapeRegExp(SUMMARY_START_MARKER)}[\\s\\S]*?${escapeRegExp(SUMMARY_END_MARKER)}`), () => summaryBlock);
} else {
// Migration path for files created before summary markers existed.
updatedContent = updatedContent.replace(
@ -280,8 +283,9 @@ async function main() {
log(`[SessionEnd] Updated session file: ${sessionFile}`);
} else {
// Create new session file
const summarySection = summary
? `${buildSummaryBlock(summary)}\n\n### Notes for Next Session\n-\n\n### Context to Load\n\`\`\`\n[relevant files]\n\`\`\``
const block = llmSummary ? `${SUMMARY_START_MARKER}\n${llmSummary}\n${SUMMARY_END_MARKER}` : summary ? buildSummaryBlock(summary) : null;
const summarySection = block
? `${block}\n\n### Notes for Next Session\n-\n\n### Context to Load\n\`\`\`\n[relevant files]\n\`\`\``
: `## Current State\n\n[Session context goes here]\n\n### Completed\n- [ ]\n\n### In Progress\n- [ ]\n\n### Notes for Next Session\n-\n\n### Context to Load\n\`\`\`\n[relevant files]\n\`\`\``;
const template = `${buildSessionHeader(today, currentTime, sessionMetadata)}${SESSION_SEPARATOR}${summarySection}

176
scripts/lib/llm-summary.js Normal file
View File

@ -0,0 +1,176 @@
#!/usr/bin/env node
/**
* LLM-powered session summary generator
*
* Uses `claude -p` (Claude Code CLI) to generate rich, contextual session
* summaries from JSONL transcripts. Requires no API key reuses Claude Code's
* own authentication.
*
* Recursion guard: sets ECC_SKIP_LLM_SUMMARY=1 in subprocess env so any Stop
* hooks fired by the subprocess do NOT re-enter LLM summarization.
*/
'use strict';
const { spawnSync } = require('child_process');
const fs = require('fs');
const MAX_TRANSCRIPT_CHARS = 7000;
const MAX_TURNS = 25;
const LLM_TIMEOUT_MS = 90000;
function getLLMModel() {
return process.env.ECC_LLM_SUMMARY_MODEL || 'haiku';
}
function getContextThreshold() {
const raw = parseInt(process.env.ECC_LLM_SUMMARY_CONTEXT_THRESHOLD || '20', 10);
return Number.isFinite(raw) && raw > 0 && raw <= 100 ? raw : 20;
}
/**
* Extract the last MAX_TURNS user+assistant turns from a JSONL transcript.
* Returns null when the transcript is missing or has no parseable turns.
*/
function extractConversationText(transcriptPath) {
let content;
try {
content = fs.readFileSync(transcriptPath, 'utf8');
} catch {
return null;
}
const lines = content.split('\n').filter(Boolean);
const turns = [];
for (const line of lines) {
try {
const entry = JSON.parse(line);
const isUser = entry.type === 'user' || entry.message?.role === 'user';
const isAssistant = entry.type === 'assistant';
if (isUser) {
const rawContent = entry.message?.content ?? entry.content;
const text =
typeof rawContent === 'string'
? rawContent
: Array.isArray(rawContent)
? rawContent
.filter(c => c?.type === 'text')
.map(c => c.text)
.join(' ')
: '';
const cleaned = text.replace(/\n+/g, ' ').trim();
if (cleaned) {
turns.push({ role: 'User', text: cleaned.slice(0, 400) });
}
}
if (isAssistant && Array.isArray(entry.message?.content)) {
const textParts = entry.message.content
.filter(b => b?.type === 'text')
.map(b => b.text)
.join(' ')
.replace(/\n+/g, ' ')
.trim();
if (textParts) {
turns.push({ role: 'Claude', text: textParts.slice(0, 600) });
}
}
} catch {
// Skip unparseable lines
}
}
if (turns.length === 0) return null;
const recent = turns.slice(-MAX_TURNS);
const formatted = recent.map(t => `**${t.role}:** ${t.text}`).join('\n\n');
return formatted.length > MAX_TRANSCRIPT_CHARS ? '...(前略)\n\n' + formatted.slice(-MAX_TRANSCRIPT_CHARS) : formatted;
}
/**
* Read the context remaining percentage from a transcript's latest usage record.
* Returns null when unavailable.
*/
function getContextRemainingPct(transcriptPath) {
try {
const { readLatestContextTokens, resolveContextWindowTokens } = require('./transcript-context');
const usage = readLatestContextTokens(transcriptPath);
if (!usage) return null;
const windowTokens = resolveContextWindowTokens(usage.tokens, usage.model);
return Math.round((1 - usage.tokens / windowTokens) * 100);
} catch {
return null;
}
}
/**
* Generate a session summary using `claude -p`.
* Returns the summary string, or null on failure or when recursion guard is active.
*/
function generateSessionSummary(transcriptPath) {
if (process.env.ECC_SKIP_LLM_SUMMARY) return null;
const conversation = extractConversationText(transcriptPath);
if (!conversation) return null;
const prompt = [
'Below is a conversation log from a Claude Code coding session.',
'Create a summary to help the next session quickly understand the context.',
'',
'## Prioritize including',
'- Design decisions and technology choices made this session',
'- Bugs and problems solved',
'- Files changed or created, with a brief description of changes',
'- Unfinished tasks and work to continue in the next session',
'- Important context the next session needs to know',
'',
'## Conversation log',
conversation,
'',
'## Output format (Markdown only, no preamble)',
'',
'## Session Summary',
'',
'### Tasks',
'(main tasks worked on this session)',
'',
'### Decisions Made',
'(design decisions and technology choices)',
'',
'### Files Modified',
'(files changed or created)',
'',
'### Unresolved Issues',
'(unfinished tasks and work to continue)',
'',
'### Next Session Context',
'(important context for the next session)'
].join('\n');
try {
const result = spawnSync('claude', ['--model', getLLMModel(), '-p'], {
input: prompt,
encoding: 'utf8',
env: {
...process.env,
CLAUDECODE: '',
ECC_SKIP_LLM_SUMMARY: '1'
},
timeout: LLM_TIMEOUT_MS,
shell: process.platform === 'win32'
});
if (result.error || result.status !== 0) {
return null;
}
const output = (result.stdout || '').trim();
return output || null;
} catch {
return null;
}
}
module.exports = { generateSessionSummary, extractConversationText, getContextRemainingPct, getContextThreshold, getLLMModel };

View File

@ -33,19 +33,14 @@ function fromBashPath(filePath) {
}
try {
return execFileSync(
'bash',
['-lc', 'cygpath -w -- "$1"', 'bash', rawPath],
{ stdio: ['ignore', 'pipe', 'ignore'] }
)
return execFileSync('bash', ['-lc', 'cygpath -w -- "$1"', 'bash', rawPath], { stdio: ['ignore', 'pipe', 'ignore'] })
.toString()
.trim();
} catch {
// Fall back to common Git Bash path shapes when cygpath is unavailable.
}
const match = rawPath.match(/^\/(?:cygdrive\/)?([A-Za-z])\/(.*)$/)
|| rawPath.match(/^\/\/([A-Za-z])\/(.*)$/);
const match = rawPath.match(/^\/(?:cygdrive\/)?([A-Za-z])\/(.*)$/) || rawPath.match(/^\/\/([A-Za-z])\/(.*)$/);
if (match) {
return `${match[1].toUpperCase()}:\\${match[2].replace(/\//g, '\\')}`;
}
@ -437,10 +432,7 @@ async function runTests() {
// Create a real session file
const sessionFile = path.join(sessionsDir, '2026-02-11-efgh5678-session.tmp');
fs.writeFileSync(
sessionFile,
buildSessionStartFixture('I worked on authentication refactor.', { title: '# Real Session' })
);
fs.writeFileSync(sessionFile, buildSessionStartFixture('I worked on authentication refactor.', { title: '# Real Session' }));
try {
const result = await runScript(path.join(scriptsDir, 'session-start.js'), '', {
@ -449,22 +441,10 @@ async function runTests() {
});
assert.strictEqual(result.code, 0);
const additionalContext = getSessionStartAdditionalContext(result.stdout);
assert.ok(
additionalContext.includes('HISTORICAL REFERENCE ONLY'),
'Should wrap injected session with the stale-replay guard preamble'
);
assert.ok(
additionalContext.includes('STALE-BY-DEFAULT'),
'Should spell out the stale-by-default contract so the model does not re-execute prior ARGUMENTS'
);
assert.ok(
additionalContext.includes('--- BEGIN PRIOR-SESSION SUMMARY ---'),
'Should delimit the prior-session summary with an explicit begin marker'
);
assert.ok(
additionalContext.includes('--- END PRIOR-SESSION SUMMARY ---'),
'Should delimit the prior-session summary with an explicit end marker'
);
assert.ok(additionalContext.includes('HISTORICAL REFERENCE ONLY'), 'Should wrap injected session with the stale-replay guard preamble');
assert.ok(additionalContext.includes('STALE-BY-DEFAULT'), 'Should spell out the stale-by-default contract so the model does not re-execute prior ARGUMENTS');
assert.ok(additionalContext.includes('--- BEGIN PRIOR-SESSION SUMMARY ---'), 'Should delimit the prior-session summary with an explicit begin marker');
assert.ok(additionalContext.includes('--- END PRIOR-SESSION SUMMARY ---'), 'Should delimit the prior-session summary with an explicit end marker');
assert.ok(additionalContext.includes('authentication refactor'), 'Should include session content text');
} finally {
fs.rmSync(isoHome, { recursive: true, force: true });
@ -482,10 +462,7 @@ async function runTests() {
fs.mkdirSync(path.join(isoHome, '.claude', 'skills', 'learned'), { recursive: true });
const sessionFile = path.join(sessionsDir, '2026-02-11-large000-session.tmp');
fs.writeFileSync(
sessionFile,
buildSessionStartFixture(`START_MARKER\n${'A'.repeat(20000)}\nEND_MARKER`, { title: '# Large Session' })
);
fs.writeFileSync(sessionFile, buildSessionStartFixture(`START_MARKER\n${'A'.repeat(20000)}\nEND_MARKER`, { title: '# Large Session' }));
try {
const result = await runScript(path.join(scriptsDir, 'session-start.js'), '', {
@ -514,10 +491,7 @@ async function runTests() {
fs.mkdirSync(path.join(isoHome, '.claude', 'skills', 'learned'), { recursive: true });
const sessionFile = path.join(sessionsDir, '2026-02-11-max0000-session.tmp');
fs.writeFileSync(
sessionFile,
buildSessionStartFixture('B'.repeat(1200), { title: '# Sized Session' })
);
fs.writeFileSync(sessionFile, buildSessionStartFixture('B'.repeat(1200), { title: '# Sized Session' }));
try {
const result = await runScript(path.join(scriptsDir, 'session-start.js'), '', {
@ -581,14 +555,8 @@ async function runTests() {
fs.mkdirSync(legacyDir, { recursive: true });
fs.mkdirSync(path.join(isoHome, '.claude', 'skills', 'learned'), { recursive: true });
fs.writeFileSync(
canonicalFile,
buildSessionStartFixture('Use the canonical session-data copy.', { title: '# Canonical Session' })
);
fs.writeFileSync(
legacyFile,
buildSessionStartFixture('Do not prefer the legacy duplicate.', { title: '# Legacy Session' })
);
fs.writeFileSync(canonicalFile, buildSessionStartFixture('Use the canonical session-data copy.', { title: '# Canonical Session' }));
fs.writeFileSync(legacyFile, buildSessionStartFixture('Do not prefer the legacy duplicate.', { title: '# Legacy Session' }));
fs.utimesSync(canonicalFile, canonicalTime, canonicalTime);
fs.utimesSync(legacyFile, legacyTime, legacyTime);
@ -617,13 +585,7 @@ async function runTests() {
fs.mkdirSync(path.join(isoHome, '.claude', 'skills', 'learned'), { recursive: true });
const sessionFile = path.join(sessionsDir, '2026-02-11-winansi00-session.tmp');
fs.writeFileSync(
sessionFile,
buildSessionStartFixture(
'I worked on \x1b[1;36mWindows terminal handling\x1b[0m.\x1b[K',
{ title: '\x1b[H\x1b[2J\x1b[3J# Real Session' }
)
);
fs.writeFileSync(sessionFile, buildSessionStartFixture('I worked on \x1b[1;36mWindows terminal handling\x1b[0m.\x1b[K', { title: '\x1b[H\x1b[2J\x1b[3J# Real Session' }));
try {
const result = await runScript(path.join(scriptsDir, 'session-start.js'), '', {
@ -632,10 +594,7 @@ async function runTests() {
});
assert.strictEqual(result.code, 0);
const additionalContext = getSessionStartAdditionalContext(result.stdout);
assert.ok(
additionalContext.includes('HISTORICAL REFERENCE ONLY'),
'Should wrap injected session with the stale-replay guard preamble'
);
assert.ok(additionalContext.includes('HISTORICAL REFERENCE ONLY'), 'Should wrap injected session with the stale-replay guard preamble');
assert.ok(additionalContext.includes('Windows terminal handling'), 'Should preserve sanitized session text');
assert.ok(!additionalContext.includes('\x1b['), 'Should not emit ANSI escape codes');
} finally {
@ -657,11 +616,7 @@ async function runTests() {
fs.writeFileSync(sessionFile, buildSessionStartFixture(RESUME_SESSION_SENTINEL));
try {
const result = await runScript(
path.join(scriptsDir, 'session-start.js'),
JSON.stringify({ hookName: 'SessionStart:resume' }),
{ HOME: isoHome, USERPROFILE: isoHome }
);
const result = await runScript(path.join(scriptsDir, 'session-start.js'), JSON.stringify({ hookName: 'SessionStart:resume' }), { HOME: isoHome, USERPROFILE: isoHome });
assert.strictEqual(result.code, 0);
const additionalContext = getSessionStartAdditionalContext(result.stdout);
assert.ok(!additionalContext.includes('HISTORICAL REFERENCE ONLY'), 'Should not inject a previous summary on resume');
@ -686,11 +641,7 @@ async function runTests() {
fs.writeFileSync(sessionFile, buildSessionStartFixture(CLI_RESUME_SESSION_SENTINEL));
try {
const result = await runScript(
path.join(scriptsDir, 'session-start.js'),
JSON.stringify({ hook_event_name: 'SessionStart', source: 'resume' }),
{ HOME: isoHome, USERPROFILE: isoHome }
);
const result = await runScript(path.join(scriptsDir, 'session-start.js'), JSON.stringify({ hook_event_name: 'SessionStart', source: 'resume' }), { HOME: isoHome, USERPROFILE: isoHome });
assert.strictEqual(result.code, 0);
const additionalContext = getSessionStartAdditionalContext(result.stdout);
assert.ok(!additionalContext.includes(CLI_RESUME_SESSION_SENTINEL), 'Should not inject CLI resume session content');
@ -714,20 +665,12 @@ async function runTests() {
fs.writeFileSync(desktopFile, buildSessionStartFixture(`${DESKTOP_CLEAR_SESSION_SENTINEL}\n${CLI_CLEAR_SESSION_SENTINEL}`));
try {
const desktopResult = await runScript(
path.join(scriptsDir, 'session-start.js'),
JSON.stringify({ hookName: 'SessionStart:clear' }),
{ HOME: isoHome, USERPROFILE: isoHome }
);
const desktopResult = await runScript(path.join(scriptsDir, 'session-start.js'), JSON.stringify({ hookName: 'SessionStart:clear' }), { HOME: isoHome, USERPROFILE: isoHome });
assert.strictEqual(desktopResult.code, 0);
const desktopContext = getSessionStartAdditionalContext(desktopResult.stdout);
assert.ok(!desktopContext.includes(DESKTOP_CLEAR_SESSION_SENTINEL), 'Should not inject Desktop clear session content');
const cliResult = await runScript(
path.join(scriptsDir, 'session-start.js'),
JSON.stringify({ hook_event_name: 'SessionStart', source: 'clear' }),
{ HOME: isoHome, USERPROFILE: isoHome }
);
const cliResult = await runScript(path.join(scriptsDir, 'session-start.js'), JSON.stringify({ hook_event_name: 'SessionStart', source: 'clear' }), { HOME: isoHome, USERPROFILE: isoHome });
assert.strictEqual(cliResult.code, 0);
const cliContext = getSessionStartAdditionalContext(cliResult.stdout);
assert.ok(!cliContext.includes(CLI_CLEAR_SESSION_SENTINEL), 'Should not inject CLI clear session content');
@ -778,10 +721,13 @@ async function runTests() {
fs.mkdirSync(path.join(isoHome, '.claude', 'skills', 'learned'), { recursive: true });
const sessionFile = path.join(sessionsDir, '2026-02-11-crossproj-session.tmp');
fs.writeFileSync(sessionFile, buildSessionStartFixture(CROSS_PROJECT_SESSION_SENTINEL, {
fs.writeFileSync(
sessionFile,
buildSessionStartFixture(CROSS_PROJECT_SESSION_SENTINEL, {
project: 'different-project',
worktree: path.join(os.tmpdir(), 'different-project')
}));
})
);
try {
const result = await runScript(path.join(scriptsDir, 'session-start.js'), '', {
@ -808,9 +754,12 @@ async function runTests() {
fs.mkdirSync(path.join(isoHome, '.claude', 'skills', 'learned'), { recursive: true });
const sessionFile = path.join(sessionsDir, '2026-02-11-crosswt-session.tmp');
fs.writeFileSync(sessionFile, buildSessionStartFixture(CROSS_WORKTREE_PROJECT_SENTINEL, {
fs.writeFileSync(
sessionFile,
buildSessionStartFixture(CROSS_WORKTREE_PROJECT_SENTINEL, {
worktree: path.join(os.tmpdir(), 'same-project-different-worktree')
}));
})
);
try {
const result = await runScript(path.join(scriptsDir, 'session-start.js'), '', {
@ -897,19 +846,11 @@ async function runTests() {
'Use for recurring flaky integration tests that need deterministic setup checks.',
'',
'## Solution',
'Verify service readiness before running the test body.',
].join('\n'),
'Verify service readiness before running the test body.'
].join('\n')
);
fs.mkdirSync(path.join(learnedDir, 'debugging-pattern'), { recursive: true });
fs.writeFileSync(
path.join(learnedDir, 'debugging-pattern', 'SKILL.md'),
[
'# Debugging Pattern',
'',
'## Trigger',
'Use when a CLI tool silently exits without a result payload.',
].join('\n'),
);
fs.writeFileSync(path.join(learnedDir, 'debugging-pattern', 'SKILL.md'), ['# Debugging Pattern', '', '## Trigger', 'Use when a CLI tool silently exits without a result payload.'].join('\n'));
try {
const result = await runScript(path.join(scriptsDir, 'session-start.js'), '', {
@ -918,20 +859,11 @@ async function runTests() {
});
assert.strictEqual(result.code, 0);
const additionalContext = getSessionStartAdditionalContext(result.stdout);
assert.ok(
additionalContext.includes('Available learned skills'),
`Should inject learned skills into additionalContext, got: ${additionalContext}`
);
assert.ok(additionalContext.includes('Available learned skills'), `Should inject learned skills into additionalContext, got: ${additionalContext}`);
assert.ok(additionalContext.includes('testing-patterns'), 'Should include the learned skill slug');
assert.ok(
additionalContext.includes('Use for recurring flaky integration tests'),
'Should include the learned skill trigger text'
);
assert.ok(additionalContext.includes('Use for recurring flaky integration tests'), 'Should include the learned skill trigger text');
assert.ok(additionalContext.includes('debugging-pattern'), 'Should include directory-style learned skills');
assert.ok(
additionalContext.includes('CLI tool silently exits'),
'Should summarize directory-style learned skill trigger text'
);
assert.ok(additionalContext.includes('CLI tool silently exits'), 'Should summarize directory-style learned skill trigger text');
} finally {
fs.rmSync(isoHome, { recursive: true, force: true });
}
@ -1769,10 +1701,14 @@ async function runTests() {
fs.mkdirSync(path.join(isolatedHome, '.claude'), { recursive: true });
const stdinJson = JSON.stringify({ tool_input: { file_path: filePath } });
const result = await runScript(path.join(scriptsDir, 'post-edit-format.js'), stdinJson, withPrependedPath(binDir, {
const result = await runScript(
path.join(scriptsDir, 'post-edit-format.js'),
stdinJson,
withPrependedPath(binDir, {
HOME: isolatedHome,
USERPROFILE: isolatedHome
}));
})
);
assert.strictEqual(result.code, 0, 'Should exit 0 for config-only repo');
const logEntries = readCommandLog(logFile);
@ -2463,12 +2399,8 @@ async function runTests() {
assert.strictEqual(preBash[0].id, 'pre:bash:dispatcher');
assert.strictEqual(postBash[0].id, 'post:bash:dispatcher');
const preCommand = Array.isArray(preBash[0].hooks[0].command)
? preBash[0].hooks[0].command.join(' ')
: preBash[0].hooks[0].command;
const postCommand = Array.isArray(postBash[0].hooks[0].command)
? postBash[0].hooks[0].command.join(' ')
: postBash[0].hooks[0].command;
const preCommand = Array.isArray(preBash[0].hooks[0].command) ? preBash[0].hooks[0].command.join(' ') : preBash[0].hooks[0].command;
const postCommand = Array.isArray(postBash[0].hooks[0].command) ? postBash[0].hooks[0].command.join(' ') : postBash[0].hooks[0].command;
assert.ok(preCommand.includes('pre-bash-dispatcher.js'), 'PreToolUse Bash hook should use the pre dispatcher');
assert.ok(postCommand.includes('post-bash-dispatcher.js'), 'PostToolUse Bash hook should use the post dispatcher');
@ -2500,11 +2432,7 @@ async function runTests() {
for (const [eventName, hookArray] of Object.entries(hooks.hooks)) {
for (const entry of hookArray) {
for (const hook of entry.hooks) {
assert.strictEqual(
typeof hook.command,
'string',
`${eventName}/${entry.id || entry.matcher || 'hook'} should use string command form`,
);
assert.strictEqual(typeof hook.command, 'string', `${eventName}/${entry.id || entry.matcher || 'hook'} should use string command form`);
}
}
}
@ -2523,10 +2451,7 @@ async function runTests() {
for (const hook of entry.hooks) {
const commandText = Array.isArray(hook.command) ? hook.command.join(' ') : hook.command;
if (typeof commandText === 'string' && commandText.startsWith('node -e ')) {
assert.ok(
!commandText.includes('\\"'),
`${eventName}/${entry.id || entry.matcher || 'hook'} should not ship escaped double quotes in node -e payload`,
);
assert.ok(!commandText.includes('\\"'), `${eventName}/${entry.id || entry.matcher || 'hook'} should not ship escaped double quotes in node -e payload`);
}
}
}
@ -2550,10 +2475,7 @@ async function runTests() {
const isNode = commandStart === 'node' || (typeof commandStart === 'string' && commandStart.startsWith('node'));
const isNpx = commandStart === 'npx' || (typeof commandStart === 'string' && commandStart.startsWith('npx '));
const isSkillScript = commandText.includes('/skills/') && (/^(bash|sh)\s/.test(commandText) || commandText.includes('/skills/'));
assert.ok(
isNode || isNpx || isSkillScript,
`Hook command should use node or approved shell wrapper: ${commandText.substring(0, 100)}...`
);
assert.ok(isNode || isNpx || isSkillScript, `Hook command should use node or approved shell wrapper: ${commandText.substring(0, 100)}...`);
}
}
}
@ -2576,10 +2498,7 @@ async function runTests() {
assert.ok(sessionStartHook, 'Should define a SessionStart hook');
const commandText = sessionStartHook.command;
assert.strictEqual(typeof sessionStartHook.command, 'string', 'SessionStart should use string command form for Claude Code compatibility');
assert.ok(
commandText.includes('session-start-bootstrap.js'),
'SessionStart should delegate to the extracted bootstrap script'
);
assert.ok(commandText.includes('session-start-bootstrap.js'), 'SessionStart should delegate to the extracted bootstrap script');
assert.ok(commandText.includes('CLAUDE_PLUGIN_ROOT'), 'SessionStart should use CLAUDE_PLUGIN_ROOT');
assert.ok(!commandText.includes('${CLAUDE_PLUGIN_ROOT}'), 'SessionStart should not depend on raw shell placeholder expansion');
assert.ok(!commandText.includes('find '), 'Should not scan arbitrary plugin paths with find');
@ -2607,8 +2526,7 @@ async function runTests() {
for (const hook of [...stopHooks, ...sessionEndHooks]) {
const commandText = Array.isArray(hook.command) ? hook.command.join(' ') : hook.command;
assert.ok(
(Array.isArray(hook.command) && hook.command[0] === 'node' && hook.command[1] === '-e') ||
(typeof hook.command === 'string' && hook.command.startsWith('node -e "')),
(Array.isArray(hook.command) && hook.command[0] === 'node' && hook.command[1] === '-e') || (typeof hook.command === 'string' && hook.command.startsWith('node -e "')),
'Lifecycle hook should use inline node resolver'
);
assert.ok(commandText.includes('run-with-flags.js'), 'Lifecycle hook should resolve the runner script');
@ -2636,10 +2554,7 @@ async function runTests() {
const usesInlineResolver = commandStart.startsWith('node -e') && commandText.includes('run-with-flags.js');
const usesPluginBootstrap = commandStart.startsWith('node -e') && commandText.includes('plugin-hook-bootstrap.js');
assert.ok(!commandText.includes('${CLAUDE_PLUGIN_ROOT}'), `Script paths should not depend on raw shell placeholder expansion: ${commandText.substring(0, 80)}...`);
assert.ok(
usesInlineResolver || usesPluginBootstrap,
`Script paths should use the inline resolver or plugin bootstrap: ${commandText.substring(0, 80)}...`
);
assert.ok(usesInlineResolver || usesPluginBootstrap, `Script paths should use the inline resolver or plugin bootstrap: ${commandText.substring(0, 80)}...`);
}
}
}
@ -2653,7 +2568,6 @@ async function runTests() {
passed++;
else failed++;
// plugin.json validation
console.log('\nplugin.json Validation:');
@ -3224,14 +3138,7 @@ async function runTests() {
const [projectId, projectDir] = stdout.trim().split(/\r?\n/);
const registryPath = path.join(homeDir, '.local', 'share', 'ecc-homunculus', 'projects.json');
const expectedProjectDir = path.join(
homeDir,
'.local',
'share',
'ecc-homunculus',
'projects',
projectId
);
const expectedProjectDir = path.join(homeDir, '.local', 'share', 'ecc-homunculus', 'projects', projectId);
const projectMetadataPath = path.join(expectedProjectDir, 'project.json');
assert.ok(projectId, 'detect-project should emit a project id');
@ -3249,11 +3156,7 @@ async function runTests() {
assert.ok(registry[projectId], 'registry should contain the detected project');
assert.strictEqual(metadata.id, projectId, 'project.json should include the detected id');
assert.strictEqual(metadata.name, path.basename(repoDir), 'project.json should include the repo name');
assert.strictEqual(
comparableMetadataRoot,
comparableRepoDir,
`project.json should include the repo root (expected ${comparableRepoDir}, got ${comparableMetadataRoot})`
);
assert.strictEqual(comparableMetadataRoot, comparableRepoDir, `project.json should include the repo root (expected ${comparableRepoDir}, got ${comparableMetadataRoot})`);
assert.strictEqual(metadata.remote, 'https://github.com/example/ecc-test.git', 'project.json should include the sanitized remote');
assert.ok(metadata.created_at, 'project.json should include created_at');
assert.ok(metadata.last_seen, 'project.json should include last_seen');
@ -3304,10 +3207,7 @@ async function runTests() {
const homunculusDir = path.join(homeDir, '.local', 'share', 'ecc-homunculus');
const projectsDir = path.join(homunculusDir, 'projects');
assert.ok(
!fs.existsSync(projectsDir) || fs.readdirSync(projectsDir).length === 0,
'observe.sh should not create a project-scoped directory for a non-git cwd'
);
assert.ok(!fs.existsSync(projectsDir) || fs.readdirSync(projectsDir).length === 0, 'observe.sh should not create a project-scoped directory for a non-git cwd');
const observationsPath = path.join(homunculusDir, 'observations.jsonl');
const observations = fs.readFileSync(observationsPath, 'utf8').trim().split('\n').filter(Boolean);
@ -3338,7 +3238,10 @@ async function runTests() {
passed++;
else failed++;
if (SKIP_BASH) { console.log(" ⊘ observe.sh skips minimal hook profile (skipped on Windows)"); passed++; } else if (
if (SKIP_BASH) {
console.log(' ⊘ observe.sh skips minimal hook profile (skipped on Windows)');
passed++;
} else if (
await asyncTest('observe.sh skips minimal hook profile before project detection side effects', async () => {
await assertObserveSkipBeforeProjectDetection({
name: 'minimal hook profile',
@ -3349,7 +3252,10 @@ async function runTests() {
passed++;
else failed++;
if (SKIP_BASH) { console.log(" ⊘ observe.sh skips cooperative skip env (skipped on Windows)"); passed++; } else if (
if (SKIP_BASH) {
console.log(' ⊘ observe.sh skips cooperative skip env (skipped on Windows)');
passed++;
} else if (
await asyncTest('observe.sh skips cooperative skip env before project detection side effects', async () => {
await assertObserveSkipBeforeProjectDetection({
name: 'cooperative skip env',
@ -3360,7 +3266,10 @@ async function runTests() {
passed++;
else failed++;
if (SKIP_BASH) { console.log(" ⊘ observe.sh skips subagent payloads (skipped on Windows)"); passed++; } else if (
if (SKIP_BASH) {
console.log(' ⊘ observe.sh skips subagent payloads (skipped on Windows)');
passed++;
} else if (
await asyncTest('observe.sh skips subagent payloads before project detection side effects', async () => {
await assertObserveSkipBeforeProjectDetection({
name: 'subagent payload',
@ -3372,7 +3281,10 @@ async function runTests() {
passed++;
else failed++;
if (SKIP_BASH) { console.log(" ⊘ observe.sh skips configured observer-session paths (skipped on Windows)"); passed++; } else if (
if (SKIP_BASH) {
console.log(' ⊘ observe.sh skips configured observer-session paths (skipped on Windows)');
passed++;
} else if (
await asyncTest('observe.sh skips configured observer-session paths before project detection side effects', async () => {
await assertObserveSkipBeforeProjectDetection({
name: 'cwd skip path',
@ -4938,19 +4850,13 @@ async function runTests() {
// Create session file 6.9 days old (should be INCLUDED by maxAge:7)
const recentFile = path.join(sessionsDir, '2026-02-06-recent69-session.tmp');
fs.writeFileSync(
recentFile,
buildSessionStartFixture('RECENT CONTENT HERE', { title: '# Recent Session' })
);
fs.writeFileSync(recentFile, buildSessionStartFixture('RECENT CONTENT HERE', { title: '# Recent Session' }));
const sixPointNineDaysAgo = new Date(Date.now() - 6.9 * 24 * 60 * 60 * 1000);
fs.utimesSync(recentFile, sixPointNineDaysAgo, sixPointNineDaysAgo);
// Create session file 8 days old (should be EXCLUDED by maxAge:7)
const oldFile = path.join(sessionsDir, '2026-02-05-old8day-session.tmp');
fs.writeFileSync(
oldFile,
buildSessionStartFixture('OLD CONTENT SHOULD NOT APPEAR', { title: '# Old Session' })
);
fs.writeFileSync(oldFile, buildSessionStartFixture('OLD CONTENT SHOULD NOT APPEAR', { title: '# Old Session' }));
const eightDaysAgo = new Date(Date.now() - 8 * 24 * 60 * 60 * 1000);
fs.utimesSync(oldFile, eightDaysAgo, eightDaysAgo);
@ -4993,7 +4899,7 @@ async function runTests() {
const result = await runScript(path.join(scriptsDir, 'session-start.js'), '', {
HOME: isoHome,
USERPROFILE: isoHome,
ECC_SESSION_RETENTION_DAYS: '30',
ECC_SESSION_RETENTION_DAYS: '30'
});
assert.strictEqual(result.code, 0);
@ -5024,13 +4930,12 @@ async function runTests() {
const result = await runScript(path.join(scriptsDir, 'session-start.js'), '', {
HOME: isoHome,
USERPROFILE: isoHome,
ECC_SESSION_RETENTION_DAYS: '0',
ECC_SESSION_RETENTION_DAYS: '0'
});
assert.strictEqual(result.code, 0);
assert.ok(fs.existsSync(expiredFile), 'Should keep all sessions when retention is opt-out=0');
assert.ok(result.stderr.includes('Pruning disabled via ECC_SESSION_RETENTION_DAYS'),
`Should log pruning disabled, stderr: ${result.stderr}`);
assert.ok(result.stderr.includes('Pruning disabled via ECC_SESSION_RETENTION_DAYS'), `Should log pruning disabled, stderr: ${result.stderr}`);
assert.ok(!result.stderr.includes('Pruned'), `Should not log any pruning, stderr: ${result.stderr}`);
} finally {
fs.rmSync(isoHome, { recursive: true, force: true });
@ -5056,13 +4961,12 @@ async function runTests() {
const result = await runScript(path.join(scriptsDir, 'session-start.js'), '', {
HOME: isoHome,
USERPROFILE: isoHome,
ECC_SESSION_RETENTION_DAYS: 'off',
ECC_SESSION_RETENTION_DAYS: 'off'
});
assert.strictEqual(result.code, 0);
assert.ok(fs.existsSync(expiredFile), 'Should keep all sessions when retention is opt-out=off');
assert.ok(result.stderr.includes('Pruning disabled via ECC_SESSION_RETENTION_DAYS'),
`Should log pruning disabled, stderr: ${result.stderr}`);
assert.ok(result.stderr.includes('Pruning disabled via ECC_SESSION_RETENTION_DAYS'), `Should log pruning disabled, stderr: ${result.stderr}`);
} finally {
fs.rmSync(isoHome, { recursive: true, force: true });
}
@ -5087,16 +4991,13 @@ async function runTests() {
const result = await runScript(path.join(scriptsDir, 'session-start.js'), '', {
HOME: isoHome,
USERPROFILE: isoHome,
ECC_SESSION_RETENTION_DAYS: 'bogus-value',
ECC_SESSION_RETENTION_DAYS: 'bogus-value'
});
assert.strictEqual(result.code, 0);
assert.ok(!fs.existsSync(expiredFile),
'Should fall back to default 30-day retention and prune the 40-day-old file');
assert.ok(result.stderr.includes('Pruned 1 expired session'),
`Should log pruning at default retention, stderr: ${result.stderr}`);
assert.ok(!result.stderr.includes('Pruning disabled'),
'Should NOT treat garbage as opt-out');
assert.ok(!fs.existsSync(expiredFile), 'Should fall back to default 30-day retention and prune the 40-day-old file');
assert.ok(result.stderr.includes('Pruned 1 expired session'), `Should log pruning at default retention, stderr: ${result.stderr}`);
assert.ok(!result.stderr.includes('Pruning disabled'), 'Should NOT treat garbage as opt-out');
} finally {
fs.rmSync(isoHome, { recursive: true, force: true });
}
@ -5118,18 +5019,12 @@ async function runTests() {
// Create older session (2 days ago)
const olderSession = path.join(sessionsDir, '2026-02-11-olderabc-session.tmp');
fs.writeFileSync(
olderSession,
buildSessionStartFixture('OLDER_CONTEXT_MARKER', { title: '# Older Session' })
);
fs.writeFileSync(olderSession, buildSessionStartFixture('OLDER_CONTEXT_MARKER', { title: '# Older Session' }));
fs.utimesSync(olderSession, new Date(now - 2 * 86400000), new Date(now - 2 * 86400000));
// Create newer session (1 day ago)
const newerSession = path.join(sessionsDir, '2026-02-12-newerdef-session.tmp');
fs.writeFileSync(
newerSession,
buildSessionStartFixture('NEWER_CONTEXT_MARKER', { title: '# Newer Session' })
);
fs.writeFileSync(newerSession, buildSessionStartFixture('NEWER_CONTEXT_MARKER', { title: '# Newer Session' }));
fs.utimesSync(newerSession, new Date(now - 1 * 86400000), new Date(now - 1 * 86400000));
try {
@ -6152,6 +6047,93 @@ Some random content without the expected ### Context to Load section
passed++;
else failed++;
// ── Round 95: pre-compact.js — ECC_SKIP_LLM_SUMMARY guard ──
console.log('\nRound 95: pre-compact.js (transcript_path provided + ECC_SKIP_LLM_SUMMARY=1 — LLM skipped):');
if (
await asyncTest('pre-compact falls back to compaction log entry when ECC_SKIP_LLM_SUMMARY=1', async () => {
const testDir = createTestDir();
const sessionsDir = path.join(testDir, '.claude', 'session-data');
fs.mkdirSync(sessionsDir, { recursive: true });
// Create a minimal session .tmp file
const sessionFile = path.join(sessionsDir, '2026-01-01-test-session.tmp');
fs.writeFileSync(sessionFile, '# Session: 2026-01-01\n');
// Create a minimal transcript with one user message
const transcriptPath = path.join(testDir, 'transcript.jsonl');
const userEntry = JSON.stringify({ type: 'user', message: { role: 'user', content: 'hello' } });
fs.writeFileSync(transcriptPath, userEntry + '\n');
const stdinJson = JSON.stringify({ transcript_path: transcriptPath });
const result = await runScript(path.join(scriptsDir, 'pre-compact.js'), stdinJson, {
HOME: testDir,
ECC_SKIP_LLM_SUMMARY: '1'
});
assert.strictEqual(result.code, 0, 'Should exit 0');
// LLM was skipped → fallback log entry appended
assert.ok(result.stderr.includes('[PreCompact] LLM summary unavailable'), `stderr should report LLM unavailable, got: ${result.stderr}`);
// Session file should have the compaction event marker, not an LLM summary block
const content = fs.readFileSync(sessionFile, 'utf8');
assert.ok(content.includes('Compaction occurred at'), `session file should contain compaction marker, got: ${content}`);
cleanupTestDir(testDir);
})
)
passed++;
else failed++;
// ── Round 95: session-end.js — ECC_LLM_SUMMARY_INTERVAL controls trigger ──
console.log('\nRound 95: session-end.js (ECC_LLM_SUMMARY_INTERVAL — controls LLM trigger cadence):');
if (
await asyncTest('session-end triggers LLM when totalMessages % interval === 0', async () => {
const testDir = createTestDir();
const transcriptPath = path.join(testDir, 'transcript.jsonl');
// 3 user messages → totalMessages=3; interval=3 → 3%3===0 → should trigger
const lines = [1, 2, 3].map(i => JSON.stringify({ type: 'user', message: { role: 'user', content: `task ${i}` } }));
fs.writeFileSync(transcriptPath, lines.join('\n') + '\n');
const stdinJson = JSON.stringify({ transcript_path: transcriptPath });
const result = await runScript(path.join(scriptsDir, 'session-end.js'), stdinJson, {
HOME: testDir,
ECC_LLM_SUMMARY_INTERVAL: '3',
ECC_SKIP_LLM_SUMMARY: '1' // prevent actual claude -p invocation in tests
});
assert.strictEqual(result.code, 0, 'Should exit 0');
assert.ok(result.stderr.includes('[SessionEnd] LLM summary triggered'), `stderr should report LLM triggered, got: ${result.stderr}`);
cleanupTestDir(testDir);
})
)
passed++;
else failed++;
if (
await asyncTest('session-end does NOT trigger LLM when totalMessages % interval !== 0', async () => {
const testDir = createTestDir();
const transcriptPath = path.join(testDir, 'transcript.jsonl');
// 2 user messages → totalMessages=2; interval=3 → 2%3!==0 → should NOT trigger
const lines = [1, 2].map(i => JSON.stringify({ type: 'user', message: { role: 'user', content: `task ${i}` } }));
fs.writeFileSync(transcriptPath, lines.join('\n') + '\n');
const stdinJson = JSON.stringify({ transcript_path: transcriptPath });
const result = await runScript(path.join(scriptsDir, 'session-end.js'), stdinJson, {
HOME: testDir,
ECC_LLM_SUMMARY_INTERVAL: '3',
ECC_SKIP_LLM_SUMMARY: '1'
});
assert.strictEqual(result.code, 0, 'Should exit 0');
assert.ok(!result.stderr.includes('[SessionEnd] LLM summary triggered'), `stderr should NOT report LLM triggered, got: ${result.stderr}`);
cleanupTestDir(testDir);
})
)
passed++;
else failed++;
// Summary
console.log('\n=== Test Results ===');
console.log(`Passed: ${passed}`);

View File

@ -0,0 +1,199 @@
'use strict';
/**
* Tests for scripts/lib/llm-summary.js
*
* Run with: node tests/lib/llm-summary.test.js
*/
const assert = require('assert');
const fs = require('fs');
const os = require('os');
const path = require('path');
const { extractConversationText, getContextRemainingPct, getContextThreshold, getLLMModel, generateSessionSummary } = require('../../scripts/lib/llm-summary');
console.log('=== Testing llm-summary.js ===\n');
let passed = 0;
let failed = 0;
function test(desc, fn) {
try {
fn();
console.log(`${desc}`);
passed++;
} catch (e) {
console.log(`${desc}: ${e.message}`);
failed++;
}
}
let seq = 0;
function writeTranscript(lines) {
seq++;
const p = path.join(os.tmpdir(), `llm-summary-test-${process.pid}-${seq}.jsonl`);
fs.writeFileSync(p, lines.join('\n') + '\n');
return p;
}
function userEntry(text) {
return JSON.stringify({ type: 'user', message: { role: 'user', content: [{ type: 'text', text }] } });
}
function assistantEntry(text) {
return JSON.stringify({
type: 'assistant',
message: {
role: 'assistant',
content: [{ type: 'text', text }],
usage: { input_tokens: 1000, cache_read_input_tokens: 0, cache_creation_input_tokens: 0 }
}
});
}
// --- getLLMModel ---
console.log('getLLMModel:');
test('returns haiku by default', () => {
const orig = process.env.ECC_LLM_SUMMARY_MODEL;
delete process.env.ECC_LLM_SUMMARY_MODEL;
assert.strictEqual(getLLMModel(), 'haiku');
if (orig !== undefined) process.env.ECC_LLM_SUMMARY_MODEL = orig;
});
test('reads ECC_LLM_SUMMARY_MODEL env var', () => {
const orig = process.env.ECC_LLM_SUMMARY_MODEL;
process.env.ECC_LLM_SUMMARY_MODEL = 'sonnet';
assert.strictEqual(getLLMModel(), 'sonnet');
if (orig !== undefined) process.env.ECC_LLM_SUMMARY_MODEL = orig;
else delete process.env.ECC_LLM_SUMMARY_MODEL;
});
// --- getContextThreshold ---
console.log('\ngetContextThreshold:');
test('returns 20 by default', () => {
const orig = process.env.ECC_LLM_SUMMARY_CONTEXT_THRESHOLD;
delete process.env.ECC_LLM_SUMMARY_CONTEXT_THRESHOLD;
assert.strictEqual(getContextThreshold(), 20);
if (orig !== undefined) process.env.ECC_LLM_SUMMARY_CONTEXT_THRESHOLD = orig;
});
test('reads ECC_LLM_SUMMARY_CONTEXT_THRESHOLD env var', () => {
const orig = process.env.ECC_LLM_SUMMARY_CONTEXT_THRESHOLD;
process.env.ECC_LLM_SUMMARY_CONTEXT_THRESHOLD = '70';
assert.strictEqual(getContextThreshold(), 70);
if (orig !== undefined) process.env.ECC_LLM_SUMMARY_CONTEXT_THRESHOLD = orig;
else delete process.env.ECC_LLM_SUMMARY_CONTEXT_THRESHOLD;
});
test('falls back to 20 on invalid value', () => {
const orig = process.env.ECC_LLM_SUMMARY_CONTEXT_THRESHOLD;
process.env.ECC_LLM_SUMMARY_CONTEXT_THRESHOLD = 'notanumber';
assert.strictEqual(getContextThreshold(), 20);
if (orig !== undefined) process.env.ECC_LLM_SUMMARY_CONTEXT_THRESHOLD = orig;
else delete process.env.ECC_LLM_SUMMARY_CONTEXT_THRESHOLD;
});
test('falls back to 20 when value exceeds 100', () => {
const orig = process.env.ECC_LLM_SUMMARY_CONTEXT_THRESHOLD;
process.env.ECC_LLM_SUMMARY_CONTEXT_THRESHOLD = '150';
assert.strictEqual(getContextThreshold(), 20);
if (orig !== undefined) process.env.ECC_LLM_SUMMARY_CONTEXT_THRESHOLD = orig;
else delete process.env.ECC_LLM_SUMMARY_CONTEXT_THRESHOLD;
});
// --- extractConversationText ---
console.log('\nextractConversationText:');
test('returns null for missing file', () => {
assert.strictEqual(extractConversationText('/nonexistent/path.jsonl'), null);
});
test('returns null for empty transcript', () => {
const p = writeTranscript([]);
assert.strictEqual(extractConversationText(p), null);
});
test('extracts user and assistant turns', () => {
const p = writeTranscript([userEntry('Hello, can you help?'), assistantEntry('Sure, what do you need?')]);
const result = extractConversationText(p);
assert.ok(result.includes('User:'));
assert.ok(result.includes('Claude:'));
assert.ok(result.includes('Hello, can you help?'));
});
test('truncates user text to 400 chars', () => {
const p = writeTranscript([userEntry('x'.repeat(500))]);
const result = extractConversationText(p);
assert.ok(result !== null);
assert.ok(!result.includes('x'.repeat(401)));
});
test('skips unparseable lines gracefully', () => {
const p = writeTranscript(['not valid json', userEntry('valid message')]);
const result = extractConversationText(p);
assert.ok(result !== null);
assert.ok(result.includes('valid message'));
});
test('limits to last 25 turns', () => {
const lines = [];
for (let i = 0; i < 30; i++) lines.push(userEntry(`message ${i}`));
const p = writeTranscript(lines);
const result = extractConversationText(p);
assert.ok(result.includes('message 29'));
assert.ok(!result.includes('message 4'));
});
test('collapses newlines to spaces', () => {
const p = writeTranscript([userEntry('line one\nline two')]);
const result = extractConversationText(p);
assert.ok(!result.includes('\nline two'));
assert.ok(result.includes('line one line two'));
});
// --- getContextRemainingPct ---
console.log('\ngetContextRemainingPct:');
test('returns null for missing file', () => {
assert.strictEqual(getContextRemainingPct('/nonexistent.jsonl'), null);
});
test('returns null for transcript with no usage data', () => {
const p = writeTranscript([userEntry('hi')]);
assert.strictEqual(getContextRemainingPct(p), null);
});
test('returns numeric percentage for transcript with usage data', () => {
const p = writeTranscript([assistantEntry('ok')]);
const pct = getContextRemainingPct(p);
assert.ok(typeof pct === 'number');
assert.ok(pct >= 0 && pct <= 100);
});
// --- generateSessionSummary ---
console.log('\ngenerateSessionSummary:');
test('returns null when ECC_SKIP_LLM_SUMMARY is set', () => {
const orig = process.env.ECC_SKIP_LLM_SUMMARY;
process.env.ECC_SKIP_LLM_SUMMARY = '1';
const p = writeTranscript([userEntry('test')]);
assert.strictEqual(generateSessionSummary(p), null);
if (orig !== undefined) process.env.ECC_SKIP_LLM_SUMMARY = orig;
else delete process.env.ECC_SKIP_LLM_SUMMARY;
});
test('returns null for missing transcript (no conversation to summarize)', () => {
const orig = process.env.ECC_SKIP_LLM_SUMMARY;
delete process.env.ECC_SKIP_LLM_SUMMARY;
assert.strictEqual(generateSessionSummary('/nonexistent.jsonl'), null);
if (orig !== undefined) process.env.ECC_SKIP_LLM_SUMMARY = orig;
});
// --- Results ---
console.log('\n=== Test Results ===');
console.log(`Passed: ${passed}`);
console.log(`Failed: ${failed}`);
console.log(`Total: ${passed + failed}`);
process.exit(failed > 0 ? 1 : 0);