Hiroshi Tanaka c2950121c9
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>
2026-06-29 15:55:01 -07:00

337 lines
12 KiB
JavaScript

#!/usr/bin/env node
/**
* Stop Hook (Session End) - Persist learnings during active sessions
*
* Cross-platform (Windows, macOS, Linux)
*
* Runs on Stop events (after each response). Extracts a meaningful summary
* from the session transcript (via stdin JSON transcript_path) and updates a
* session file for cross-session continuity.
*/
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 { generateSessionSummary, getContextRemainingPct, getContextThreshold } = require('../lib/llm-summary');
const SUMMARY_START_MARKER = '<!-- ECC:SUMMARY:START -->';
const SUMMARY_END_MARKER = '<!-- ECC:SUMMARY:END -->';
const SESSION_SEPARATOR = '\n---\n';
/**
* Extract a meaningful summary from the session transcript.
* Reads the JSONL transcript and pulls out key information:
* - User messages (tasks requested)
* - Tools used
* - Files modified
*/
function extractSessionSummary(transcriptPath) {
const content = readFile(transcriptPath);
if (!content) return null;
const lines = content.split('\n').filter(Boolean);
const userMessages = [];
const toolsUsed = new Set();
const 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;
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));
}
}
// 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);
}
}
}
}
} catch {
parseErrors++;
}
}
if (parseErrors > 0) {
log(`[SessionEnd] Skipped ${parseErrors}/${lines.length} unparseable transcript lines`);
}
if (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
};
}
// Read hook input from stdin (Claude Code provides transcript_path via stdin JSON)
const MAX_STDIN = 1024 * 1024;
let stdinData = '';
process.stdin.setEncoding('utf8');
process.stdin.on('data', chunk => {
if (stdinData.length < MAX_STDIN) {
const remaining = MAX_STDIN - stdinData.length;
stdinData += chunk.substring(0, remaining);
}
});
process.stdin.on('end', () => {
runMain();
});
function runMain() {
main().catch(err => {
console.error('[SessionEnd] Error:', err.message);
process.exit(0);
});
}
function getSessionMetadata() {
const branchResult = runCommand('git rev-parse --abbrev-ref HEAD');
return {
project: getProjectName() || 'unknown',
branch: branchResult.success ? branchResult.output : 'unknown',
worktree: process.cwd()
};
}
function extractHeaderField(header, label) {
const match = header.match(new RegExp(`\\*\\*${escapeRegExp(label)}:\\*\\*\\s*(.+)$`, 'm'));
return match ? match[1].trim() : null;
}
function buildSessionHeader(today, currentTime, metadata, existingContent = '') {
const headingMatch = existingContent.match(/^#\s+.+$/m);
const heading = headingMatch ? headingMatch[0] : `# Session: ${today}`;
const date = extractHeaderField(existingContent, 'Date') || today;
const started = extractHeaderField(existingContent, 'Started') || currentTime;
return [
heading,
`**Date:** ${date}`,
`**Started:** ${started}`,
`**Last Updated:** ${currentTime}`,
`**Project:** ${metadata.project}`,
`**Branch:** ${metadata.branch}`,
`**Worktree:** ${metadata.worktree}`,
''
].join('\n');
}
function mergeSessionHeader(content, today, currentTime, metadata) {
const separatorIndex = content.indexOf(SESSION_SEPARATOR);
if (separatorIndex === -1) {
return null;
}
const existingHeader = content.slice(0, separatorIndex);
const body = content.slice(separatorIndex + SESSION_SEPARATOR.length);
const nextHeader = buildSessionHeader(today, currentTime, metadata, existingHeader);
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;
try {
const input = JSON.parse(stdinData);
if (input && typeof input.transcript_path === 'string' && input.transcript_path.length > 0) {
transcriptPath = 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 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 sessionFile = path.join(sessionsDir, `${today}-${shortId}-session.tmp`);
const sessionMetadata = getSessionMetadata();
ensureDir(sessionsDir);
const currentTime = getTimeString();
// Try to extract summary from transcript
let summary = null;
if (transcriptPath) {
if (fs.existsSync(transcriptPath)) {
summary = extractSessionSummary(transcriptPath);
} else {
log(`[SessionEnd] Transcript not found: ${transcriptPath}`);
}
}
// 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;
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}`);
} 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}`);
}
process.exit(0);
}
function buildSummarySection(summary) {
let section = '## Session Summary\n\n';
// Tasks (from user messages — collapse newlines and escape backticks to prevent markdown breaks)
section += '### Tasks\n';
for (const msg of summary.userMessages) {
section += `- ${msg.replace(/\n/g, ' ').replace(/`/g, '\\`')}\n`;
}
section += '\n';
// Files modified
if (summary.filesModified.length > 0) {
section += '### Files Modified\n';
for (const f of summary.filesModified) {
section += `- ${f}\n`;
}
section += '\n';
}
// Tools used
if (summary.toolsUsed.length > 0) {
section += `### Tools Used\n${summary.toolsUsed.join(', ')}\n\n`;
}
section += `### Stats\n- Total user messages: ${summary.totalMessages}\n`;
return section;
}
function buildSummaryBlock(summary) {
return `${SUMMARY_START_MARKER}\n${buildSummarySection(summary).trim()}\n${SUMMARY_END_MARKER}`;
}
function escapeRegExp(value) {
return String(value).replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
}