diff --git a/scripts/hooks/session-end.js b/scripts/hooks/session-end.js index c7431eea7..1235cf7a9 100644 --- a/scripts/hooks/session-end.js +++ b/scripts/hooks/session-end.js @@ -18,6 +18,61 @@ const SUMMARY_START_MARKER = ''; const SUMMARY_END_MARKER = ''; const SESSION_SEPARATOR = '\n---\n'; +// Harness noise: local command echoes, caveats, system reminders. +const HARNESS_NOISE = /^<(local-command-caveat|local-command-stdout|command-name|command-message|command-args|system-reminder|task-notification)/i; +const TRANSCRIPT_UUID = /([0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})\.jsonl$/i; +const NEXT_SESSION_FOOTER = '\n\n### Notes for Next Session\n-\n\n### Context to Load\n```\n[relevant files]\n```'; +const EMPTY_SESSION_BODY = '## 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```'; + +/** + * Text of a user turn, or '' when the entry is not a user ask worth keeping. + */ +function userMessageText(entry) { + if (entry.type !== 'user' && entry.role !== 'user' && entry.message?.role !== 'user') return ''; + // Support both direct content and nested message.content (Claude Code JSONL format) + const rawContent = entry.message?.content ?? entry.content; + // Skip tool_result carrier turns — they are not user asks. + if (Array.isArray(rawContent) && rawContent.some(c => c && c.type === 'tool_result')) return ''; + const text = typeof rawContent === 'string' ? rawContent : Array.isArray(rawContent) ? rawContent.map(c => (c && c.text) || '').join(' ') : ''; + const cleaned = stripAnsi(text).trim(); + // `isMeta` is also used for genuine channel- and plugin-originated + // human prompts. Exclude known structured noise instead of + // discarding every metadata-marked user turn. + return HARNESS_NOISE.test(cleaned) ? '' : cleaned; +} + +/** + * Record one tool invocation: its name, and its target when it edits a file. + */ +function recordToolUse(toolName, filePath, tally) { + if (toolName) tally.toolsUsed.add(toolName); + if (filePath && (toolName === 'Edit' || toolName === 'Write')) { + tally.filesModified.add(filePath); + } +} + +/** + * Fold one transcript entry into the running tally. + */ +function collectEntry(entry, tally) { + // Collect user messages (first 200 chars each) + const text = userMessageText(entry); + if (text) tally.userMessages.push(text.slice(0, 200)); + + // Collect tool names and modified files (direct tool_use entries) + if (entry.type === 'tool_use' || entry.tool_name) { + const toolName = entry.tool_name || entry.name || ''; + recordToolUse(toolName, entry.tool_input?.file_path || entry.input?.file_path || '', tally); + } + + // Extract tool uses from assistant message content blocks (Claude Code JSONL format) + if (entry.type === 'assistant' && Array.isArray(entry.message?.content)) { + for (const block of entry.message.content) { + if (block.type === 'tool_use') recordToolUse(block.name || '', block.input?.file_path || '', tally); + } + } +} + /** * Extract a meaningful summary from the session transcript. * Reads the JSONL transcript and pulls out key information: @@ -30,58 +85,12 @@ function extractSessionSummary(transcriptPath) { if (!content) return null; const lines = content.split('\n').filter(Boolean); - const userMessages = []; - const toolsUsed = new Set(); - const filesModified = new Set(); + const tally = { userMessages: [], toolsUsed: new Set(), filesModified: new Set() }; let parseErrors = 0; for (const line of lines) { try { - const entry = JSON.parse(line); - - // Collect user messages (first 200 chars each) - 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; - // Skip tool_result carrier turns — they are not user asks. - const isToolResult = Array.isArray(rawContent) && rawContent.some(c => c && c.type === 'tool_result'); - const text = typeof rawContent === 'string' ? rawContent : Array.isArray(rawContent) ? rawContent.map(c => (c && c.text) || '').join(' ') : ''; - const cleaned = stripAnsi(text).trim(); - // Skip harness noise: local command echoes, caveats, system reminders. - const isNoise = /^<(local-command-caveat|local-command-stdout|command-name|command-message|command-args|system-reminder|task-notification)/i.test(cleaned); - // `isMeta` is also used for genuine channel- and plugin-originated - // human prompts. Exclude known structured noise above instead of - // discarding every metadata-marked user turn. - if (cleaned && !isToolResult && !isNoise) { - userMessages.push(cleaned.slice(0, 200)); - } - } - - // Collect tool names and modified files (direct tool_use entries) - if (entry.type === 'tool_use' || entry.tool_name) { - const toolName = entry.tool_name || entry.name || ''; - if (toolName) toolsUsed.add(toolName); - - const filePath = entry.tool_input?.file_path || entry.input?.file_path || ''; - if (filePath && (toolName === 'Edit' || toolName === 'Write')) { - filesModified.add(filePath); - } - } - - // Extract tool uses from assistant message content blocks (Claude Code JSONL format) - if (entry.type === 'assistant' && Array.isArray(entry.message?.content)) { - for (const block of entry.message.content) { - if (block.type === 'tool_use') { - const toolName = block.name || ''; - if (toolName) toolsUsed.add(toolName); - - const filePath = block.input?.file_path || ''; - if (filePath && (toolName === 'Edit' || toolName === 'Write')) { - filesModified.add(filePath); - } - } - } - } + collectEntry(JSON.parse(line), tally); } catch { parseErrors++; } @@ -91,13 +100,13 @@ function extractSessionSummary(transcriptPath) { log(`[SessionEnd] Skipped ${parseErrors}/${lines.length} unparseable transcript lines`); } - if (userMessages.length === 0) return null; + if (tally.userMessages.length === 0) return null; return { - userMessages: userMessages.slice(-10), // Last 10 user messages - toolsUsed: Array.from(toolsUsed).slice(0, 20), - filesModified: Array.from(filesModified).slice(0, 30), - totalMessages: userMessages.length + userMessages: tally.userMessages.slice(-10), // Last 10 user messages + toolsUsed: Array.from(tally.toolsUsed).slice(0, 20), + filesModified: Array.from(tally.filesModified).slice(0, 30), + totalMessages: tally.userMessages.length }; } @@ -174,25 +183,130 @@ function mergeSessionHeader(content, today, currentTime, metadata) { return `${nextHeader}${SESSION_SEPARATOR}${body}`; } -async function main() { - // Parse stdin JSON to get transcript_path; fall back to env var on missing, - // empty, or non-string values as well as on malformed JSON. - let transcriptPath = null; +/** + * Parse stdin JSON to get transcript_path; fall back to env var on missing, + * empty, or non-string values as well as on malformed JSON. + */ +function resolveTranscriptPath() { try { const input = JSON.parse(stdinData); if (input && typeof input.transcript_path === 'string' && input.transcript_path.length > 0) { - transcriptPath = input.transcript_path; + return input.transcript_path; } } catch { // Malformed stdin: fall through to the env-var fallback below. } - if (!transcriptPath) { - const envTranscriptPath = process.env.CLAUDE_TRANSCRIPT_PATH; - if (typeof envTranscriptPath === 'string' && envTranscriptPath.length > 0) { - transcriptPath = envTranscriptPath; + const envTranscriptPath = process.env.CLAUDE_TRANSCRIPT_PATH; + return typeof envTranscriptPath === 'string' && envTranscriptPath.length > 0 ? envTranscriptPath : null; +} + +/** + * Derive shortId from transcript_path UUID when available, using the SAME + * last-8-chars convention as getSessionIdShort(sessionId.slice(-8)). This keeps + * backward compatibility for normal sessions (the derived shortId matches what + * getSessionIdShort() would have produced from the same UUID), while making + * every session map to a unique filename based on its own transcript UUID. + * + * Without this, a parent session and any `claude -p ...` subprocess spawned by + * another Stop hook share the project-name fallback filename, and the subprocess + * overwrites the parent's summary. See issue #1494 for full repro details. + */ +function sessionShortId(transcriptPath) { + const m = transcriptPath ? path.basename(transcriptPath).match(TRANSCRIPT_UUID) : null; + // Run through sanitizeSessionId() for byte-for-byte parity with + // getSessionIdShort(sessionId.slice(-8)). + const derived = m ? sanitizeSessionId(m[1].slice(-8).toLowerCase()) : null; + return derived || getSessionIdShort(); +} + +/** + * Decide whether to call LLM for a richer summary. + * Triggers: context remaining < 20%, or every 50 user messages as a baseline. + */ +function maybeGenerateLlmSummary(transcriptPath, summary) { + 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) return null; + + log(`[SessionEnd] LLM summary triggered (context: ${contextPct ?? 'unknown'}%, messages: ${summary.totalMessages})`); + let skipped = false; + const llmSummary = generateSessionSummary(transcriptPath, { + onSkip: reason => { + skipped = true; + log(`[SessionEnd] LLM summary skipped (${reason}); falling back to mechanical extraction`); + } + }); + if (llmSummary) { + log('[SessionEnd] LLM summary generated successfully'); + } else if (!skipped) { + log('[SessionEnd] LLM summary failed; falling back to mechanical extraction'); + } + return llmSummary; +} + +function summaryBlockFor(summary, llmSummary) { + return llmSummary ? `${SUMMARY_START_MARKER}\n${llmSummary}\n${SUMMARY_END_MARKER}` : buildSummaryBlock(summary); +} + +/** + * Replace only the generated summary block. This keeps repeated Stop + * invocations idempotent and preserves user-authored sections in the same + * session file. + */ +function replaceSummaryBlock(content, summaryBlock) { + // 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 (content.includes(SUMMARY_START_MARKER) && content.includes(SUMMARY_END_MARKER)) { + return content.replace(new RegExp(`${escapeRegExp(SUMMARY_START_MARKER)}[\\s\\S]*?${escapeRegExp(SUMMARY_END_MARKER)}`), () => summaryBlock); + } + // Migration path for files created before summary markers existed. + return content.replace( + /## (?:Session Summary|Current State)[\s\S]*?$/, + () => `${summaryBlock}${NEXT_SESSION_FOOTER}\n` + ); +} + +function updateSessionFile(sessionFile, { today, currentTime, metadata, summary, llmSummary }) { + const existing = readFile(sessionFile); + let updatedContent = existing; + + if (existing) { + const merged = mergeSessionHeader(existing, today, currentTime, metadata); + if (merged) { + updatedContent = merged; + } else { + log(`[SessionEnd] Failed to normalize header in ${sessionFile}`); } } + if (summary && updatedContent) { + updatedContent = replaceSummaryBlock(updatedContent, summaryBlockFor(summary, llmSummary)); + } + + if (updatedContent) { + writeFile(sessionFile, updatedContent); + } + + log(`[SessionEnd] Updated session file: ${sessionFile}`); +} + +function createSessionFile(sessionFile, { today, currentTime, metadata, summary, llmSummary }) { + const block = llmSummary || summary ? summaryBlockFor(summary, llmSummary) : null; + const summarySection = block ? `${block}${NEXT_SESSION_FOOTER}` : EMPTY_SESSION_BODY; + const template = `${buildSessionHeader(today, currentTime, metadata)}${SESSION_SEPARATOR}${summarySection}\n`; + + writeFile(sessionFile, template); + log(`[SessionEnd] Created session file: ${sessionFile}`); +} + +async function main() { + const transcriptPath = resolveTranscriptPath(); + // ECC's LLM summary helper launches a one-shot Claude subprocess whose Stop // hooks inherit this dedicated marker. Skip that known internal session // before touching session state. Transcript cardinality is not a safe proxy: @@ -218,111 +332,20 @@ async function main() { const sessionsDir = getSessionsDir(); const today = getDateString(); - // Derive shortId from transcript_path UUID when available, using the SAME - // last-8-chars convention as getSessionIdShort(sessionId.slice(-8)). This keeps - // backward compatibility for normal sessions (the derived shortId matches what - // getSessionIdShort() would have produced from the same UUID), while making - // every session map to a unique filename based on its own transcript UUID. - // - // Without this, a parent session and any `claude -p ...` subprocess spawned by - // another Stop hook share the project-name fallback filename, and the subprocess - // overwrites the parent's summary. See issue #1494 for full repro details. - let shortId = null; - if (transcriptPath) { - const m = path.basename(transcriptPath).match(/([0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})\.jsonl$/i); - if (m) { - // Run through sanitizeSessionId() for byte-for-byte parity with - // getSessionIdShort(sessionId.slice(-8)). - shortId = sanitizeSessionId(m[1].slice(-8).toLowerCase()); - } - } - if (!shortId) { - shortId = getSessionIdShort(); - } + const shortId = sessionShortId(transcriptPath); const sessionFile = path.join(sessionsDir, `${today}-${shortId}-session.tmp`); - const sessionMetadata = getSessionMetadata(); + const metadata = getSessionMetadata(); ensureDir(sessionsDir); const currentTime = getTimeString(); - - // 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 && transcriptExists) { - 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})`); - let skipped = false; - llmSummary = generateSessionSummary(transcriptPath, { - onSkip: reason => { - skipped = true; - log(`[SessionEnd] LLM summary skipped (${reason}); falling back to mechanical extraction`); - } - }); - if (llmSummary) { - log('[SessionEnd] LLM summary generated successfully'); - } else if (!skipped) { - log('[SessionEnd] LLM summary failed; falling back to mechanical extraction'); - } - } - } + const llmSummary = transcriptPath && summary && transcriptExists ? maybeGenerateLlmSummary(transcriptPath, summary) : null; + const context = { today, currentTime, metadata, summary, llmSummary }; if (fs.existsSync(sessionFile)) { - const existing = readFile(sessionFile); - let updatedContent = existing; - - if (existing) { - const merged = mergeSessionHeader(existing, today, currentTime, sessionMetadata); - if (merged) { - updatedContent = merged; - } else { - log(`[SessionEnd] Failed to normalize header in ${sessionFile}`); - } - } - - // If we have a new summary, update only the generated summary block. - // This keeps repeated Stop invocations idempotent and preserves - // user-authored sections in the same session file. - if (summary && updatedContent) { - 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); - } else { - // Migration path for files created before summary markers existed. - updatedContent = updatedContent.replace( - /## (?:Session Summary|Current State)[\s\S]*?$/, - () => `${summaryBlock}\n\n### Notes for Next Session\n-\n\n### Context to Load\n\`\`\`\n[relevant files]\n\`\`\`\n` - ); - } - } - - if (updatedContent) { - writeFile(sessionFile, updatedContent); - } - - log(`[SessionEnd] Updated session file: ${sessionFile}`); + updateSessionFile(sessionFile, context); } else { - // Create new session file - 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} -`; - - writeFile(sessionFile, template); - log(`[SessionEnd] Created session file: ${sessionFile}`); + createSessionFile(sessionFile, context); } process.exit(0);