2026-01-23 15:08:07 +08:00
#!/usr/bin/env node
/**
2026-03-04 14:48:06 -08:00
* Stop Hook (Session End) - Persist learnings during active sessions
2026-01-23 15:08:07 +08:00
*
* Cross-platform (Windows, macOS, Linux)
*
2026-03-04 14:48:06 -08:00
* 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.
2026-01-23 15:08:07 +08:00
*/
const path = require ( 'path' );
const fs = require ( 'fs' );
2026-06-30 07:55:01 +09:00
const { getSessionsDir , getDateString , getTimeString , getSessionIdShort , sanitizeSessionId , getProjectName , ensureDir , readFile , writeFile , runCommand , stripAnsi , log } = require ( '../lib/utils' );
const { generateSessionSummary , getContextRemainingPct , getContextThreshold } = require ( '../lib/llm-summary' );
2026-01-23 15:08:07 +08:00
2026-03-04 14:48:06 -08:00
const SUMMARY_START_MARKER = '<!-- ECC:SUMMARY:START -->' ;
const SUMMARY_END_MARKER = '<!-- ECC:SUMMARY:END -->' ;
2026-03-12 08:50:24 -07:00
const SESSION_SEPARATOR = '\n---\n' ;
2026-03-04 14:48:06 -08:00
2026-02-11 23:56:41 -08:00
/**
* 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 ();
2026-02-12 07:06:53 -08:00
let parseErrors = 0 ;
2026-02-11 23:56:41 -08:00
for ( const line of lines ) {
try {
const entry = JSON . parse ( line );
// Collect user messages (first 200 chars each)
2026-02-13 18:04:27 +09:00
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 ;
2026-08-12 13:53:34 -03:00
// Skip tool_result carrier turns — they are not user asks.
const isToolResult = Array . isArray ( rawContent ) && rawContent . some ( c => c && c . type === 'tool_result' );
2026-06-30 07:55:01 +09:00
const text = typeof rawContent === 'string' ? rawContent : Array . isArray ( rawContent ) ? rawContent . map ( c => ( c && c . text ) || '' ). join ( ' ' ) : '' ;
2026-03-20 01:38:11 -07:00
const cleaned = stripAnsi ( text ). trim ();
2026-08-12 13:53:34 -03:00
// 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 );
2026-08-28 16:28:12 -04:00
// `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 ) {
2026-03-20 01:38:11 -07:00
userMessages . push ( cleaned . slice ( 0 , 200 ));
2026-02-11 23:56:41 -08:00
}
}
2026-02-13 18:04:27 +09:00
// Collect tool names and modified files (direct tool_use entries)
2026-02-11 23:56:41 -08:00
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 );
}
}
2026-02-13 18:04:27 +09:00
// 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 );
}
}
}
}
2026-02-11 23:56:41 -08:00
} catch {
2026-02-12 07:06:53 -08:00
parseErrors ++ ;
2026-02-11 23:56:41 -08:00
}
}
2026-02-12 07:06:53 -08:00
if ( parseErrors > 0 ) {
log ( `[SessionEnd] Skipped ${ parseErrors } / ${ lines . length } unparseable transcript lines` );
}
2026-02-11 23:56:41 -08:00
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
};
}
2026-02-12 15:33:55 -08:00
// Read hook input from stdin (Claude Code provides transcript_path via stdin JSON)
const MAX_STDIN = 1024 * 1024 ;
let stdinData = '' ;
2026-02-12 16:08:49 -08:00
process . stdin . setEncoding ( 'utf8' );
2026-02-12 15:33:55 -08:00
process . stdin . on ( 'data' , chunk => {
if ( stdinData . length < MAX_STDIN ) {
2026-02-18 07:40:12 +00:00
const remaining = MAX_STDIN - stdinData . length ;
stdinData += chunk . substring ( 0 , remaining );
2026-02-12 15:33:55 -08:00
}
});
process . stdin . on ( 'end' , () => {
runMain ();
});
function runMain () {
main (). catch ( err => {
console . error ( '[SessionEnd] Error:' , err . message );
process . exit ( 0 );
});
}
2026-03-12 08:50:24 -07:00
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 } ` ;
}
2026-01-23 15:08:07 +08:00
async function main () {
2026-04-19 14:35:21 +09:00
// 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.
2026-02-12 15:33:55 -08:00
let transcriptPath = null ;
try {
const input = JSON . parse ( stdinData );
2026-04-19 14:35:21 +09:00
if ( input && typeof input . transcript_path === 'string' && input . transcript_path . length > 0 ) {
transcriptPath = input . transcript_path ;
}
2026-02-12 15:33:55 -08:00
} catch {
2026-04-19 14:35:21 +09:00
// 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 ;
}
2026-02-12 15:33:55 -08:00
}
2026-08-28 16:08:19 -04:00
// 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:
// an ordinary user session may legitimately contain one prompt and no tools.
if ( process . env . ECC_LLM_SUMMARY_SUBPROCESS === '1' ) {
log ( '[SessionEnd] Skipped ECC LLM summary subprocess' );
return ;
}
// Read known transcripts before resolving session metadata or touching the
2026-08-10 00:44:38 -07:00
// session directory. Missing, unreadable, or unparseable transcript data keeps
// the established fallback behavior because it cannot be classified reliably.
let summary = null ;
let transcriptExists = false ;
if ( transcriptPath ) {
transcriptExists = fs . existsSync ( transcriptPath );
if ( transcriptExists ) {
summary = extractSessionSummary ( transcriptPath );
} else {
log ( `[SessionEnd] Transcript not found: ${ transcriptPath } ` );
}
}
2026-01-23 15:08:07 +08:00
const sessionsDir = getSessionsDir ();
const today = getDateString ();
2026-04-19 14:19:29 +09:00
// 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.
//
2026-04-19 11:37:32 +09:00
// Without this, a parent session and any `claude -p ...` subprocess spawned by
2026-04-19 14:19:29 +09:00
// another Stop hook share the project-name fallback filename, and the subprocess
2026-04-19 11:37:32 +09:00
// overwrites the parent's summary. See issue #1494 for full repro details.
let shortId = null ;
if ( transcriptPath ) {
2026-04-19 14:19:29 +09:00
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 );
2026-04-19 14:30:00 +09:00
if ( m ) {
// Run through sanitizeSessionId() for byte-for-byte parity with
// getSessionIdShort(sessionId.slice(-8)).
shortId = sanitizeSessionId ( m [ 1 ]. slice ( - 8 ). toLowerCase ());
}
2026-04-19 11:37:32 +09:00
}
2026-06-30 07:55:01 +09:00
if ( ! shortId ) {
shortId = getSessionIdShort ();
}
2026-01-25 18:21:27 -08:00
const sessionFile = path . join ( sessionsDir , ` ${ today } - ${ shortId } -session.tmp` );
2026-03-12 08:50:24 -07:00
const sessionMetadata = getSessionMetadata ();
2026-01-23 15:08:07 +08:00
ensureDir ( sessionsDir );
const currentTime = getTimeString ();
2026-06-30 07:55:01 +09:00
// Decide whether to call LLM for a richer summary.
// Triggers: context remaining < 20%, or every 50 user messages as a baseline.
let llmSummary = null ;
2026-08-10 00:44:38 -07:00
if ( transcriptPath && summary && transcriptExists ) {
2026-06-30 07:55:01 +09:00
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' );
}
}
}
2026-01-23 15:08:07 +08:00
if ( fs . existsSync ( sessionFile )) {
2026-03-12 08:50:24 -07:00
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 } ` );
}
2026-02-12 13:40:14 -08:00
}
2026-01-23 15:08:07 +08:00
2026-03-04 14:48:06 -08:00
// 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.
2026-03-12 08:50:24 -07:00
if ( summary && updatedContent ) {
2026-06-30 07:55:01 +09:00
const summaryBlock = llmSummary ? ` ${ SUMMARY_START_MARKER } \n ${ llmSummary } \n ${ SUMMARY_END_MARKER } ` : buildSummaryBlock ( summary );
2026-03-04 14:48:06 -08:00
2026-06-07 13:25:36 +08:00
// 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.
2026-03-12 08:50:24 -07:00
if ( updatedContent . includes ( SUMMARY_START_MARKER ) && updatedContent . includes ( SUMMARY_END_MARKER )) {
2026-06-30 07:55:01 +09:00
updatedContent = updatedContent . replace ( new RegExp ( ` ${ escapeRegExp ( SUMMARY_START_MARKER ) } [\\s\\S]*? ${ escapeRegExp ( SUMMARY_END_MARKER ) } ` ), () => summaryBlock );
2026-03-12 08:50:24 -07:00
} else {
// Migration path for files created before summary markers existed.
updatedContent = updatedContent . replace (
/## (?:Session Summary|Current State)[\s\S]*?$/ ,
2026-06-07 13:25:36 +08:00
() => ` ${ summaryBlock } \n\n### Notes for Next Session\n-\n\n### Context to Load\n\`\`\`\n[relevant files]\n\`\`\`\n`
2026-03-12 08:50:24 -07:00
);
2026-02-11 23:56:41 -08:00
}
2026-01-23 15:08:07 +08:00
}
2026-02-11 23:56:41 -08:00
2026-03-12 08:50:24 -07:00
if ( updatedContent ) {
writeFile ( sessionFile , updatedContent );
}
2026-02-11 23:56:41 -08:00
log ( `[SessionEnd] Updated session file: ${ sessionFile } ` );
2026-01-23 15:08:07 +08:00
} else {
2026-02-11 23:56:41 -08:00
// Create new session file
2026-06-30 07:55:01 +09:00
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\`\`\``
2026-02-11 23:56:41 -08:00
: `## 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\`\`\`` ;
2026-03-12 08:50:24 -07:00
const template = ` ${ buildSessionHeader ( today , currentTime , sessionMetadata ) }${ SESSION_SEPARATOR }${ summarySection }
2026-01-23 15:08:07 +08:00
` ;
writeFile ( sessionFile , template );
log ( `[SessionEnd] Created session file: ${ sessionFile } ` );
}
process . exit ( 0 );
}
2026-02-11 23:56:41 -08:00
function buildSummarySection ( summary ) {
let section = '## Session Summary\n\n' ;
2026-02-13 04:28:50 -08:00
// Tasks (from user messages — collapse newlines and escape backticks to prevent markdown breaks)
2026-02-11 23:56:41 -08:00
section += '### Tasks\n' ;
for ( const msg of summary . userMessages ) {
2026-02-13 04:28:50 -08:00
section += `- ${ msg . replace ( /\n/g , ' ' ). replace ( /`/g , '\\`' ) } \n` ;
2026-02-11 23:56:41 -08:00
}
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 ;
}
2026-03-04 14:48:06 -08:00
function buildSummaryBlock ( summary ) {
return ` ${ SUMMARY_START_MARKER } \n ${ buildSummarySection ( summary ). trim () } \n ${ SUMMARY_END_MARKER } ` ;
}
function escapeRegExp ( value ) {
return String ( value ). replace ( /[.*+?^${}()|[\]\\]/g , '\\$&' );
}