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:
Hiroshi Tanaka
2026-06-30 07:55:01 +09:00
committed by GitHub
parent 64797fd895
commit c2950121c9
5 changed files with 654 additions and 241 deletions
+31 -27
View File
@@ -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}