mirror of
https://github.com/affaan-m/everything-claude-code.git
synced 2026-06-30 19:00:57 +08:00
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>
177 lines
5.2 KiB
JavaScript
177 lines
5.2 KiB
JavaScript
#!/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 };
|