mirror of
https://github.com/affaan-m/ECC.git
synced 2026-10-26 18:35:05 +00:00
refactor(hooks): break up session-end summary and main flow
extractSessionSummary did user-message filtering and two kinds of tool_use collection in one loop body six blocks deep. The per-entry work now lives in userMessageText, recordToolUse and collectEntry. main() is down to the sequence of steps: resolveTranscriptPath, sessionShortId, maybeGenerateLlmSummary, then updateSessionFile or createSessionFile, with the shared footer and empty-session template as constants. Call order, log lines, file names and the written markdown are unchanged, including the exact summary block replacement and the migration path for files without markers.
This commit is contained in:
1 parent
7b1756670f
commit
35c7681d47
1 file changed
+182
-159
+182
-159
@@ -18,6 +18,61 @@ const SUMMARY_START_MARKER = '<!-- ECC:SUMMARY:START -->';
|
||||
const SUMMARY_END_MARKER = '<!-- ECC:SUMMARY:END -->';
|
||||
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);
|
||||
|
||||
Reference in new issue
Block a user