mirror of
https://github.com/affaan-m/everything-claude-code.git
synced 2026-07-01 04:21:27 +08:00
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>
This commit is contained in:
@@ -1,48 +1,100 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* PreCompact Hook - Save state before context compaction
|
||||
* PreCompact Hook - Save LLM-generated summary before context compaction
|
||||
*
|
||||
* Cross-platform (Windows, macOS, Linux)
|
||||
*
|
||||
* Runs before Claude compacts context, giving you a chance to
|
||||
* preserve important state that might get lost in summarization.
|
||||
* Runs before Claude compacts context. Generates a rich LLM summary of the
|
||||
* current session and writes it to the active session .tmp file so that the
|
||||
* next session start gets a high-quality summary even after lossy compaction.
|
||||
*
|
||||
* Falls back to a plain log entry when transcript_path is unavailable or the
|
||||
* LLM call fails.
|
||||
*/
|
||||
|
||||
const path = require('path');
|
||||
const {
|
||||
getSessionsDir,
|
||||
getDateTimeString,
|
||||
getTimeString,
|
||||
findFiles,
|
||||
ensureDir,
|
||||
appendFile,
|
||||
log
|
||||
} = require('../lib/utils');
|
||||
const fs = require('fs');
|
||||
const { getSessionsDir, getDateTimeString, getTimeString, findFiles, ensureDir, appendFile, readFile, writeFile, log } = require('../lib/utils');
|
||||
const { generateSessionSummary } = require('../lib/llm-summary');
|
||||
|
||||
const SUMMARY_START_MARKER = '<!-- ECC:SUMMARY:START -->';
|
||||
const SUMMARY_END_MARKER = '<!-- ECC:SUMMARY:END -->';
|
||||
|
||||
function escapeRegExp(value) {
|
||||
return String(value).replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
||||
}
|
||||
|
||||
const MAX_STDIN = 1024 * 1024;
|
||||
let stdinData = '';
|
||||
process.stdin.setEncoding('utf8');
|
||||
|
||||
process.stdin.on('data', chunk => {
|
||||
if (stdinData.length < MAX_STDIN) {
|
||||
stdinData += chunk.substring(0, MAX_STDIN - stdinData.length);
|
||||
}
|
||||
});
|
||||
|
||||
process.stdin.on('end', () => {
|
||||
main().catch(err => {
|
||||
log(`[PreCompact] Error: ${err.message}`);
|
||||
process.exit(0);
|
||||
});
|
||||
});
|
||||
|
||||
async function main() {
|
||||
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 {
|
||||
// stdin not JSON or missing — proceed without transcript
|
||||
}
|
||||
|
||||
const sessionsDir = getSessionsDir();
|
||||
const compactionLog = path.join(sessionsDir, 'compaction-log.txt');
|
||||
|
||||
ensureDir(sessionsDir);
|
||||
|
||||
// Log compaction event with timestamp
|
||||
const timestamp = getDateTimeString();
|
||||
appendFile(compactionLog, `[${timestamp}] Context compaction triggered\n`);
|
||||
|
||||
// If there's an active session file, note the compaction
|
||||
const sessions = findFiles(sessionsDir, '*-session.tmp');
|
||||
|
||||
if (sessions.length > 0) {
|
||||
const activeSession = sessions[0].path;
|
||||
const timeStr = getTimeString();
|
||||
appendFile(activeSession, `\n---\n**[Compaction occurred at ${timeStr}]** - Context was summarized\n`);
|
||||
if (sessions.length === 0) {
|
||||
log('[PreCompact] No active session file found');
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
const activeSession = sessions[0].path;
|
||||
const timeStr = getTimeString();
|
||||
|
||||
if (!transcriptPath || !fs.existsSync(transcriptPath)) {
|
||||
appendFile(activeSession, `\n---\n**[Compaction occurred at ${timeStr}]** - Context was summarized\n`);
|
||||
log('[PreCompact] No transcript available; logged compaction event only');
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
// Generate LLM summary right before compaction — most critical timing
|
||||
log('[PreCompact] Generating LLM summary before compaction...');
|
||||
const llmSummary = generateSessionSummary(transcriptPath);
|
||||
|
||||
if (!llmSummary) {
|
||||
appendFile(activeSession, `\n---\n**[Compaction occurred at ${timeStr}]** - Context was summarized\n`);
|
||||
log('[PreCompact] LLM summary unavailable; logged compaction event only');
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
const existing = readFile(activeSession);
|
||||
if (existing && existing.includes(SUMMARY_START_MARKER) && existing.includes(SUMMARY_END_MARKER)) {
|
||||
const newBlock = `${SUMMARY_START_MARKER}\n${llmSummary}\n<!-- LLM_SUMMARY:pre-compact:${timeStr} -->\n${SUMMARY_END_MARKER}`;
|
||||
const updated = existing.replace(new RegExp(`${escapeRegExp(SUMMARY_START_MARKER)}[\\s\\S]*?${escapeRegExp(SUMMARY_END_MARKER)}`), () => newBlock);
|
||||
writeFile(activeSession, updated);
|
||||
log('[PreCompact] LLM summary written to session file before compaction');
|
||||
} else {
|
||||
appendFile(activeSession, `\n---\n**[Compaction at ${timeStr}]**\n\n${llmSummary}\n`);
|
||||
log('[PreCompact] LLM summary appended (no summary markers found)');
|
||||
}
|
||||
|
||||
log('[PreCompact] State saved before compaction');
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
main().catch(err => {
|
||||
console.error('[PreCompact] Error:', err.message);
|
||||
process.exit(0);
|
||||
});
|
||||
|
||||
@@ -11,20 +11,8 @@
|
||||
|
||||
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 { 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 -->';
|
||||
@@ -55,11 +43,7 @@ function extractSessionSummary(transcriptPath) {
|
||||
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 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));
|
||||
@@ -217,7 +201,9 @@ async function main() {
|
||||
shortId = sanitizeSessionId(m[1].slice(-8).toLowerCase());
|
||||
}
|
||||
}
|
||||
if (!shortId) { shortId = getSessionIdShort(); }
|
||||
if (!shortId) {
|
||||
shortId = getSessionIdShort();
|
||||
}
|
||||
const sessionFile = path.join(sessionsDir, `${today}-${shortId}-session.tmp`);
|
||||
const sessionMetadata = getSessionMetadata();
|
||||
|
||||
@@ -236,6 +222,26 @@ async function main() {
|
||||
}
|
||||
}
|
||||
|
||||
// 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;
|
||||
@@ -253,17 +259,14 @@ async function main() {
|
||||
// This keeps repeated Stop invocations idempotent and preserves
|
||||
// user-authored sections in the same session file.
|
||||
if (summary && updatedContent) {
|
||||
const summaryBlock = buildSummaryBlock(summary);
|
||||
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
|
||||
);
|
||||
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(
|
||||
@@ -280,8 +283,9 @@ async function main() {
|
||||
log(`[SessionEnd] Updated session file: ${sessionFile}`);
|
||||
} else {
|
||||
// Create new session file
|
||||
const summarySection = summary
|
||||
? `${buildSummaryBlock(summary)}\n\n### Notes for Next Session\n-\n\n### Context to Load\n\`\`\`\n[relevant files]\n\`\`\``
|
||||
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}
|
||||
|
||||
@@ -0,0 +1,176 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* LLM-powered session summary generator
|
||||
*
|
||||
* Uses `claude -p` (Claude Code CLI) to generate rich, contextual session
|
||||
* summaries from JSONL transcripts. Requires no API key — reuses Claude Code's
|
||||
* own authentication.
|
||||
*
|
||||
* Recursion guard: sets ECC_SKIP_LLM_SUMMARY=1 in subprocess env so any Stop
|
||||
* hooks fired by the subprocess do NOT re-enter LLM summarization.
|
||||
*/
|
||||
|
||||
'use strict';
|
||||
|
||||
const { spawnSync } = require('child_process');
|
||||
const fs = require('fs');
|
||||
|
||||
const MAX_TRANSCRIPT_CHARS = 7000;
|
||||
const MAX_TURNS = 25;
|
||||
const LLM_TIMEOUT_MS = 90000;
|
||||
|
||||
function getLLMModel() {
|
||||
return process.env.ECC_LLM_SUMMARY_MODEL || 'haiku';
|
||||
}
|
||||
|
||||
function getContextThreshold() {
|
||||
const raw = parseInt(process.env.ECC_LLM_SUMMARY_CONTEXT_THRESHOLD || '20', 10);
|
||||
return Number.isFinite(raw) && raw > 0 && raw <= 100 ? raw : 20;
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract the last MAX_TURNS user+assistant turns from a JSONL transcript.
|
||||
* Returns null when the transcript is missing or has no parseable turns.
|
||||
*/
|
||||
function extractConversationText(transcriptPath) {
|
||||
let content;
|
||||
try {
|
||||
content = fs.readFileSync(transcriptPath, 'utf8');
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
|
||||
const lines = content.split('\n').filter(Boolean);
|
||||
const turns = [];
|
||||
|
||||
for (const line of lines) {
|
||||
try {
|
||||
const entry = JSON.parse(line);
|
||||
const isUser = entry.type === 'user' || entry.message?.role === 'user';
|
||||
const isAssistant = entry.type === 'assistant';
|
||||
|
||||
if (isUser) {
|
||||
const rawContent = entry.message?.content ?? entry.content;
|
||||
const text =
|
||||
typeof rawContent === 'string'
|
||||
? rawContent
|
||||
: Array.isArray(rawContent)
|
||||
? rawContent
|
||||
.filter(c => c?.type === 'text')
|
||||
.map(c => c.text)
|
||||
.join(' ')
|
||||
: '';
|
||||
const cleaned = text.replace(/\n+/g, ' ').trim();
|
||||
if (cleaned) {
|
||||
turns.push({ role: 'User', text: cleaned.slice(0, 400) });
|
||||
}
|
||||
}
|
||||
|
||||
if (isAssistant && Array.isArray(entry.message?.content)) {
|
||||
const textParts = entry.message.content
|
||||
.filter(b => b?.type === 'text')
|
||||
.map(b => b.text)
|
||||
.join(' ')
|
||||
.replace(/\n+/g, ' ')
|
||||
.trim();
|
||||
if (textParts) {
|
||||
turns.push({ role: 'Claude', text: textParts.slice(0, 600) });
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// Skip unparseable lines
|
||||
}
|
||||
}
|
||||
|
||||
if (turns.length === 0) return null;
|
||||
|
||||
const recent = turns.slice(-MAX_TURNS);
|
||||
const formatted = recent.map(t => `**${t.role}:** ${t.text}`).join('\n\n');
|
||||
return formatted.length > MAX_TRANSCRIPT_CHARS ? '...(前略)\n\n' + formatted.slice(-MAX_TRANSCRIPT_CHARS) : formatted;
|
||||
}
|
||||
|
||||
/**
|
||||
* Read the context remaining percentage from a transcript's latest usage record.
|
||||
* Returns null when unavailable.
|
||||
*/
|
||||
function getContextRemainingPct(transcriptPath) {
|
||||
try {
|
||||
const { readLatestContextTokens, resolveContextWindowTokens } = require('./transcript-context');
|
||||
const usage = readLatestContextTokens(transcriptPath);
|
||||
if (!usage) return null;
|
||||
const windowTokens = resolveContextWindowTokens(usage.tokens, usage.model);
|
||||
return Math.round((1 - usage.tokens / windowTokens) * 100);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate a session summary using `claude -p`.
|
||||
* Returns the summary string, or null on failure or when recursion guard is active.
|
||||
*/
|
||||
function generateSessionSummary(transcriptPath) {
|
||||
if (process.env.ECC_SKIP_LLM_SUMMARY) return null;
|
||||
|
||||
const conversation = extractConversationText(transcriptPath);
|
||||
if (!conversation) return null;
|
||||
|
||||
const prompt = [
|
||||
'Below is a conversation log from a Claude Code coding session.',
|
||||
'Create a summary to help the next session quickly understand the context.',
|
||||
'',
|
||||
'## Prioritize including',
|
||||
'- Design decisions and technology choices made this session',
|
||||
'- Bugs and problems solved',
|
||||
'- Files changed or created, with a brief description of changes',
|
||||
'- Unfinished tasks and work to continue in the next session',
|
||||
'- Important context the next session needs to know',
|
||||
'',
|
||||
'## Conversation log',
|
||||
conversation,
|
||||
'',
|
||||
'## Output format (Markdown only, no preamble)',
|
||||
'',
|
||||
'## Session Summary',
|
||||
'',
|
||||
'### Tasks',
|
||||
'(main tasks worked on this session)',
|
||||
'',
|
||||
'### Decisions Made',
|
||||
'(design decisions and technology choices)',
|
||||
'',
|
||||
'### Files Modified',
|
||||
'(files changed or created)',
|
||||
'',
|
||||
'### Unresolved Issues',
|
||||
'(unfinished tasks and work to continue)',
|
||||
'',
|
||||
'### Next Session Context',
|
||||
'(important context for the next session)'
|
||||
].join('\n');
|
||||
|
||||
try {
|
||||
const result = spawnSync('claude', ['--model', getLLMModel(), '-p'], {
|
||||
input: prompt,
|
||||
encoding: 'utf8',
|
||||
env: {
|
||||
...process.env,
|
||||
CLAUDECODE: '',
|
||||
ECC_SKIP_LLM_SUMMARY: '1'
|
||||
},
|
||||
timeout: LLM_TIMEOUT_MS,
|
||||
shell: process.platform === 'win32'
|
||||
});
|
||||
|
||||
if (result.error || result.status !== 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const output = (result.stdout || '').trim();
|
||||
return output || null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = { generateSessionSummary, extractConversationText, getContextRemainingPct, getContextThreshold, getLLMModel };
|
||||
Reference in New Issue
Block a user