From c2950121c9dd8ced1f920f0cbb340017fd6830a7 Mon Sep 17 00:00:00 2001 From: Hiroshi Tanaka Date: Tue, 30 Jun 2026 07:55:01 +0900 Subject: [PATCH] feat(session): LLM-powered session summary via claude -p (#2388) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 Co-authored-by: Claude Sonnet 4.6 --- scripts/hooks/pre-compact.js | 102 +++++++--- scripts/hooks/session-end.js | 58 +++--- scripts/lib/llm-summary.js | 176 +++++++++++++++++ tests/hooks/hooks.test.js | 360 ++++++++++++++++------------------ tests/lib/llm-summary.test.js | 199 +++++++++++++++++++ 5 files changed, 654 insertions(+), 241 deletions(-) create mode 100644 scripts/lib/llm-summary.js create mode 100644 tests/lib/llm-summary.test.js diff --git a/scripts/hooks/pre-compact.js b/scripts/hooks/pre-compact.js index 5ea468f5..235b2b09 100644 --- a/scripts/hooks/pre-compact.js +++ b/scripts/hooks/pre-compact.js @@ -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 = ''; +const SUMMARY_END_MARKER = ''; + +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\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); -}); diff --git a/scripts/hooks/session-end.js b/scripts/hooks/session-end.js index 8d0d4a28..c224371a 100644 --- a/scripts/hooks/session-end.js +++ b/scripts/hooks/session-end.js @@ -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 = ''; const SUMMARY_END_MARKER = ''; @@ -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} diff --git a/scripts/lib/llm-summary.js b/scripts/lib/llm-summary.js new file mode 100644 index 00000000..e7d5d56a --- /dev/null +++ b/scripts/lib/llm-summary.js @@ -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 }; diff --git a/tests/hooks/hooks.test.js b/tests/hooks/hooks.test.js index 14602354..046ca904 100644 --- a/tests/hooks/hooks.test.js +++ b/tests/hooks/hooks.test.js @@ -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, { - project: 'different-project', - worktree: path.join(os.tmpdir(), 'different-project') - })); + 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, { - worktree: path.join(os.tmpdir(), 'same-project-different-worktree') - })); + 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, { - HOME: isolatedHome, - USERPROFILE: isolatedHome - })); + 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}`); diff --git a/tests/lib/llm-summary.test.js b/tests/lib/llm-summary.test.js new file mode 100644 index 00000000..fe754334 --- /dev/null +++ b/tests/lib/llm-summary.test.js @@ -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);