mirror of
https://github.com/affaan-m/everything-claude-code.git
synced 2026-03-30 13:43:26 +08:00
* fix(session-end): always update session summary content Previously, session-end.js would only write content to session files on first creation. Subsequent sessions would only update the timestamp, causing stale content (e.g., old tasks, resolved issues) to persist indefinitely. This fix ensures that every session end updates the summary section with fresh content from the current transcript, keeping cross-session context accurate and relevant. Fixes: #187 (partially - addresses stale content issue) Changes: - Remove the blank-template-only check - Replace entire Session Summary section on every session end - Keep timestamp update separate from content update * fix(session-end): match both summary headers and prevent duplicate stats Fixes two issues identified in PR #317 code review: 1. CodeRabbit: Updated regex to match both `## Session Summary` and `## Current State` headers, ensuring files created from blank template can be updated with fresh summaries. 2. Cubic: Changed regex lookahead `(?=### Stats|$)` to end-of-string `$` to prevent duplicate `### Stats` sections. The old pattern stopped before `### Stats` without consuming it, but buildSummarySection() also emits a `### Stats` block, causing duplication on each session update. Changes: - Regex now: `/## (?:Session Summary|Current State)[\s\S]*?$/` - Matches both header variants used in blank template and populated sessions - Matches to end-of-string to cleanly replace entire summary section --------- Co-authored-by: will <will@192.168.5.31>
237 lines
6.8 KiB
JavaScript
237 lines
6.8 KiB
JavaScript
#!/usr/bin/env node
|
|
/**
|
|
* Stop Hook (Session End) - Persist learnings when session ends
|
|
*
|
|
* Cross-platform (Windows, macOS, Linux)
|
|
*
|
|
* Runs when Claude session ends. Extracts a meaningful summary from
|
|
* the session transcript (via stdin JSON transcript_path) and saves it
|
|
* to a session file for cross-session continuity.
|
|
*/
|
|
|
|
const path = require('path');
|
|
const fs = require('fs');
|
|
const {
|
|
getSessionsDir,
|
|
getDateString,
|
|
getTimeString,
|
|
getSessionIdShort,
|
|
ensureDir,
|
|
readFile,
|
|
writeFile,
|
|
replaceInFile,
|
|
log
|
|
} = require('../lib/utils');
|
|
|
|
/**
|
|
* 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(' ')
|
|
: '';
|
|
if (text.trim()) {
|
|
userMessages.push(text.trim().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);
|
|
});
|
|
}
|
|
|
|
async function main() {
|
|
// Parse stdin JSON to get transcript_path
|
|
let transcriptPath = null;
|
|
try {
|
|
const input = JSON.parse(stdinData);
|
|
transcriptPath = input.transcript_path;
|
|
} catch {
|
|
// Fallback: try env var for backwards compatibility
|
|
transcriptPath = process.env.CLAUDE_TRANSCRIPT_PATH;
|
|
}
|
|
|
|
const sessionsDir = getSessionsDir();
|
|
const today = getDateString();
|
|
const shortId = getSessionIdShort();
|
|
const sessionFile = path.join(sessionsDir, `${today}-${shortId}-session.tmp`);
|
|
|
|
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}`);
|
|
}
|
|
}
|
|
|
|
if (fs.existsSync(sessionFile)) {
|
|
// Update existing session file
|
|
const updated = replaceInFile(
|
|
sessionFile,
|
|
/\*\*Last Updated:\*\*.*/,
|
|
`**Last Updated:** ${currentTime}`
|
|
);
|
|
if (!updated) {
|
|
log(`[SessionEnd] Failed to update timestamp in ${sessionFile}`);
|
|
}
|
|
|
|
// If we have a new summary, update the session file content
|
|
if (summary) {
|
|
const existing = readFile(sessionFile);
|
|
if (existing) {
|
|
// Use a flexible regex that matches both "## Session Summary" and "## Current State"
|
|
// Match to end-of-string to avoid duplicate ### Stats sections
|
|
const updatedContent = existing.replace(
|
|
/## (?:Session Summary|Current State)[\s\S]*?$/ ,
|
|
buildSummarySection(summary).trim() + '\n'
|
|
);
|
|
writeFile(sessionFile, updatedContent);
|
|
}
|
|
}
|
|
|
|
log(`[SessionEnd] Updated session file: ${sessionFile}`);
|
|
} else {
|
|
// Create new session file
|
|
const summarySection = summary
|
|
? buildSummarySection(summary)
|
|
: `## 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 = `# Session: ${today}
|
|
**Date:** ${today}
|
|
**Started:** ${currentTime}
|
|
**Last Updated:** ${currentTime}
|
|
|
|
---
|
|
|
|
${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;
|
|
}
|
|
|