mirror of
https://github.com/affaan-m/everything-claude-code.git
synced 2026-06-30 19:00:57 +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:
parent
64797fd895
commit
c2950121c9
@ -1,48 +1,100 @@
|
|||||||
#!/usr/bin/env node
|
#!/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)
|
* Cross-platform (Windows, macOS, Linux)
|
||||||
*
|
*
|
||||||
* Runs before Claude compacts context, giving you a chance to
|
* Runs before Claude compacts context. Generates a rich LLM summary of the
|
||||||
* preserve important state that might get lost in summarization.
|
* 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 path = require('path');
|
||||||
const {
|
const fs = require('fs');
|
||||||
getSessionsDir,
|
const { getSessionsDir, getDateTimeString, getTimeString, findFiles, ensureDir, appendFile, readFile, writeFile, log } = require('../lib/utils');
|
||||||
getDateTimeString,
|
const { generateSessionSummary } = require('../lib/llm-summary');
|
||||||
getTimeString,
|
|
||||||
findFiles,
|
const SUMMARY_START_MARKER = '<!-- ECC:SUMMARY:START -->';
|
||||||
ensureDir,
|
const SUMMARY_END_MARKER = '<!-- ECC:SUMMARY:END -->';
|
||||||
appendFile,
|
|
||||||
log
|
function escapeRegExp(value) {
|
||||||
} = require('../lib/utils');
|
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() {
|
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 sessionsDir = getSessionsDir();
|
||||||
const compactionLog = path.join(sessionsDir, 'compaction-log.txt');
|
const compactionLog = path.join(sessionsDir, 'compaction-log.txt');
|
||||||
|
|
||||||
ensureDir(sessionsDir);
|
ensureDir(sessionsDir);
|
||||||
|
|
||||||
// Log compaction event with timestamp
|
|
||||||
const timestamp = getDateTimeString();
|
const timestamp = getDateTimeString();
|
||||||
appendFile(compactionLog, `[${timestamp}] Context compaction triggered\n`);
|
appendFile(compactionLog, `[${timestamp}] Context compaction triggered\n`);
|
||||||
|
|
||||||
// If there's an active session file, note the compaction
|
|
||||||
const sessions = findFiles(sessionsDir, '*-session.tmp');
|
const sessions = findFiles(sessionsDir, '*-session.tmp');
|
||||||
|
if (sessions.length === 0) {
|
||||||
if (sessions.length > 0) {
|
log('[PreCompact] No active session file found');
|
||||||
const activeSession = sessions[0].path;
|
process.exit(0);
|
||||||
const timeStr = getTimeString();
|
}
|
||||||
appendFile(activeSession, `\n---\n**[Compaction occurred at ${timeStr}]** - Context was summarized\n`);
|
|
||||||
|
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);
|
process.exit(0);
|
||||||
}
|
}
|
||||||
|
|
||||||
main().catch(err => {
|
|
||||||
console.error('[PreCompact] Error:', err.message);
|
|
||||||
process.exit(0);
|
|
||||||
});
|
|
||||||
|
|||||||
@ -11,20 +11,8 @@
|
|||||||
|
|
||||||
const path = require('path');
|
const path = require('path');
|
||||||
const fs = require('fs');
|
const fs = require('fs');
|
||||||
const {
|
const { getSessionsDir, getDateString, getTimeString, getSessionIdShort, sanitizeSessionId, getProjectName, ensureDir, readFile, writeFile, runCommand, stripAnsi, log } = require('../lib/utils');
|
||||||
getSessionsDir,
|
const { generateSessionSummary, getContextRemainingPct, getContextThreshold } = require('../lib/llm-summary');
|
||||||
getDateString,
|
|
||||||
getTimeString,
|
|
||||||
getSessionIdShort,
|
|
||||||
sanitizeSessionId,
|
|
||||||
getProjectName,
|
|
||||||
ensureDir,
|
|
||||||
readFile,
|
|
||||||
writeFile,
|
|
||||||
runCommand,
|
|
||||||
stripAnsi,
|
|
||||||
log
|
|
||||||
} = require('../lib/utils');
|
|
||||||
|
|
||||||
const SUMMARY_START_MARKER = '<!-- ECC:SUMMARY:START -->';
|
const SUMMARY_START_MARKER = '<!-- ECC:SUMMARY:START -->';
|
||||||
const SUMMARY_END_MARKER = '<!-- ECC:SUMMARY:END -->';
|
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') {
|
if (entry.type === 'user' || entry.role === 'user' || entry.message?.role === 'user') {
|
||||||
// Support both direct content and nested message.content (Claude Code JSONL format)
|
// Support both direct content and nested message.content (Claude Code JSONL format)
|
||||||
const rawContent = entry.message?.content ?? entry.content;
|
const rawContent = entry.message?.content ?? entry.content;
|
||||||
const text = typeof rawContent === 'string'
|
const text = typeof rawContent === 'string' ? rawContent : Array.isArray(rawContent) ? rawContent.map(c => (c && c.text) || '').join(' ') : '';
|
||||||
? rawContent
|
|
||||||
: Array.isArray(rawContent)
|
|
||||||
? rawContent.map(c => (c && c.text) || '').join(' ')
|
|
||||||
: '';
|
|
||||||
const cleaned = stripAnsi(text).trim();
|
const cleaned = stripAnsi(text).trim();
|
||||||
if (cleaned) {
|
if (cleaned) {
|
||||||
userMessages.push(cleaned.slice(0, 200));
|
userMessages.push(cleaned.slice(0, 200));
|
||||||
@ -217,7 +201,9 @@ async function main() {
|
|||||||
shortId = sanitizeSessionId(m[1].slice(-8).toLowerCase());
|
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 sessionFile = path.join(sessionsDir, `${today}-${shortId}-session.tmp`);
|
||||||
const sessionMetadata = getSessionMetadata();
|
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)) {
|
if (fs.existsSync(sessionFile)) {
|
||||||
const existing = readFile(sessionFile);
|
const existing = readFile(sessionFile);
|
||||||
let updatedContent = existing;
|
let updatedContent = existing;
|
||||||
@ -253,17 +259,14 @@ async function main() {
|
|||||||
// This keeps repeated Stop invocations idempotent and preserves
|
// This keeps repeated Stop invocations idempotent and preserves
|
||||||
// user-authored sections in the same session file.
|
// user-authored sections in the same session file.
|
||||||
if (summary && updatedContent) {
|
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
|
// Use function replacers: summaryBlock embeds raw user-message text, and a
|
||||||
// string replacement argument interprets $-sequences ($&, $$, $`, $', $n).
|
// string replacement argument interprets $-sequences ($&, $$, $`, $', $n).
|
||||||
// A $& in a user message would otherwise re-inject the entire matched block
|
// A $& in a user message would otherwise re-inject the entire matched block
|
||||||
// and corrupt the persisted summary. A function replacer is treated literally.
|
// and corrupt the persisted summary. A function replacer is treated literally.
|
||||||
if (updatedContent.includes(SUMMARY_START_MARKER) && updatedContent.includes(SUMMARY_END_MARKER)) {
|
if (updatedContent.includes(SUMMARY_START_MARKER) && updatedContent.includes(SUMMARY_END_MARKER)) {
|
||||||
updatedContent = updatedContent.replace(
|
updatedContent = updatedContent.replace(new RegExp(`${escapeRegExp(SUMMARY_START_MARKER)}[\\s\\S]*?${escapeRegExp(SUMMARY_END_MARKER)}`), () => summaryBlock);
|
||||||
new RegExp(`${escapeRegExp(SUMMARY_START_MARKER)}[\\s\\S]*?${escapeRegExp(SUMMARY_END_MARKER)}`),
|
|
||||||
() => summaryBlock
|
|
||||||
);
|
|
||||||
} else {
|
} else {
|
||||||
// Migration path for files created before summary markers existed.
|
// Migration path for files created before summary markers existed.
|
||||||
updatedContent = updatedContent.replace(
|
updatedContent = updatedContent.replace(
|
||||||
@ -280,8 +283,9 @@ async function main() {
|
|||||||
log(`[SessionEnd] Updated session file: ${sessionFile}`);
|
log(`[SessionEnd] Updated session file: ${sessionFile}`);
|
||||||
} else {
|
} else {
|
||||||
// Create new session file
|
// Create new session file
|
||||||
const summarySection = summary
|
const block = llmSummary ? `${SUMMARY_START_MARKER}\n${llmSummary}\n${SUMMARY_END_MARKER}` : summary ? buildSummaryBlock(summary) : null;
|
||||||
? `${buildSummaryBlock(summary)}\n\n### Notes for Next Session\n-\n\n### Context to Load\n\`\`\`\n[relevant files]\n\`\`\``
|
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\`\`\``;
|
: `## 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}
|
const template = `${buildSessionHeader(today, currentTime, sessionMetadata)}${SESSION_SEPARATOR}${summarySection}
|
||||||
|
|||||||
176
scripts/lib/llm-summary.js
Normal file
176
scripts/lib/llm-summary.js
Normal file
@ -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 };
|
||||||
@ -33,19 +33,14 @@ function fromBashPath(filePath) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
return execFileSync(
|
return execFileSync('bash', ['-lc', 'cygpath -w -- "$1"', 'bash', rawPath], { stdio: ['ignore', 'pipe', 'ignore'] })
|
||||||
'bash',
|
|
||||||
['-lc', 'cygpath -w -- "$1"', 'bash', rawPath],
|
|
||||||
{ stdio: ['ignore', 'pipe', 'ignore'] }
|
|
||||||
)
|
|
||||||
.toString()
|
.toString()
|
||||||
.trim();
|
.trim();
|
||||||
} catch {
|
} catch {
|
||||||
// Fall back to common Git Bash path shapes when cygpath is unavailable.
|
// Fall back to common Git Bash path shapes when cygpath is unavailable.
|
||||||
}
|
}
|
||||||
|
|
||||||
const match = rawPath.match(/^\/(?:cygdrive\/)?([A-Za-z])\/(.*)$/)
|
const match = rawPath.match(/^\/(?:cygdrive\/)?([A-Za-z])\/(.*)$/) || rawPath.match(/^\/\/([A-Za-z])\/(.*)$/);
|
||||||
|| rawPath.match(/^\/\/([A-Za-z])\/(.*)$/);
|
|
||||||
if (match) {
|
if (match) {
|
||||||
return `${match[1].toUpperCase()}:\\${match[2].replace(/\//g, '\\')}`;
|
return `${match[1].toUpperCase()}:\\${match[2].replace(/\//g, '\\')}`;
|
||||||
}
|
}
|
||||||
@ -437,10 +432,7 @@ async function runTests() {
|
|||||||
|
|
||||||
// Create a real session file
|
// Create a real session file
|
||||||
const sessionFile = path.join(sessionsDir, '2026-02-11-efgh5678-session.tmp');
|
const sessionFile = path.join(sessionsDir, '2026-02-11-efgh5678-session.tmp');
|
||||||
fs.writeFileSync(
|
fs.writeFileSync(sessionFile, buildSessionStartFixture('I worked on authentication refactor.', { title: '# Real Session' }));
|
||||||
sessionFile,
|
|
||||||
buildSessionStartFixture('I worked on authentication refactor.', { title: '# Real Session' })
|
|
||||||
);
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const result = await runScript(path.join(scriptsDir, 'session-start.js'), '', {
|
const result = await runScript(path.join(scriptsDir, 'session-start.js'), '', {
|
||||||
@ -449,22 +441,10 @@ async function runTests() {
|
|||||||
});
|
});
|
||||||
assert.strictEqual(result.code, 0);
|
assert.strictEqual(result.code, 0);
|
||||||
const additionalContext = getSessionStartAdditionalContext(result.stdout);
|
const additionalContext = getSessionStartAdditionalContext(result.stdout);
|
||||||
assert.ok(
|
assert.ok(additionalContext.includes('HISTORICAL REFERENCE ONLY'), 'Should wrap injected session with the stale-replay guard preamble');
|
||||||
additionalContext.includes('HISTORICAL REFERENCE ONLY'),
|
assert.ok(additionalContext.includes('STALE-BY-DEFAULT'), 'Should spell out the stale-by-default contract so the model does not re-execute prior ARGUMENTS');
|
||||||
'Should wrap injected session with the stale-replay guard preamble'
|
assert.ok(additionalContext.includes('--- BEGIN PRIOR-SESSION SUMMARY ---'), 'Should delimit the prior-session summary with an explicit begin marker');
|
||||||
);
|
assert.ok(additionalContext.includes('--- END PRIOR-SESSION SUMMARY ---'), 'Should delimit the prior-session summary with an explicit end marker');
|
||||||
assert.ok(
|
|
||||||
additionalContext.includes('STALE-BY-DEFAULT'),
|
|
||||||
'Should spell out the stale-by-default contract so the model does not re-execute prior ARGUMENTS'
|
|
||||||
);
|
|
||||||
assert.ok(
|
|
||||||
additionalContext.includes('--- BEGIN PRIOR-SESSION SUMMARY ---'),
|
|
||||||
'Should delimit the prior-session summary with an explicit begin marker'
|
|
||||||
);
|
|
||||||
assert.ok(
|
|
||||||
additionalContext.includes('--- END PRIOR-SESSION SUMMARY ---'),
|
|
||||||
'Should delimit the prior-session summary with an explicit end marker'
|
|
||||||
);
|
|
||||||
assert.ok(additionalContext.includes('authentication refactor'), 'Should include session content text');
|
assert.ok(additionalContext.includes('authentication refactor'), 'Should include session content text');
|
||||||
} finally {
|
} finally {
|
||||||
fs.rmSync(isoHome, { recursive: true, force: true });
|
fs.rmSync(isoHome, { recursive: true, force: true });
|
||||||
@ -482,10 +462,7 @@ async function runTests() {
|
|||||||
fs.mkdirSync(path.join(isoHome, '.claude', 'skills', 'learned'), { recursive: true });
|
fs.mkdirSync(path.join(isoHome, '.claude', 'skills', 'learned'), { recursive: true });
|
||||||
|
|
||||||
const sessionFile = path.join(sessionsDir, '2026-02-11-large000-session.tmp');
|
const sessionFile = path.join(sessionsDir, '2026-02-11-large000-session.tmp');
|
||||||
fs.writeFileSync(
|
fs.writeFileSync(sessionFile, buildSessionStartFixture(`START_MARKER\n${'A'.repeat(20000)}\nEND_MARKER`, { title: '# Large Session' }));
|
||||||
sessionFile,
|
|
||||||
buildSessionStartFixture(`START_MARKER\n${'A'.repeat(20000)}\nEND_MARKER`, { title: '# Large Session' })
|
|
||||||
);
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const result = await runScript(path.join(scriptsDir, 'session-start.js'), '', {
|
const result = await runScript(path.join(scriptsDir, 'session-start.js'), '', {
|
||||||
@ -514,10 +491,7 @@ async function runTests() {
|
|||||||
fs.mkdirSync(path.join(isoHome, '.claude', 'skills', 'learned'), { recursive: true });
|
fs.mkdirSync(path.join(isoHome, '.claude', 'skills', 'learned'), { recursive: true });
|
||||||
|
|
||||||
const sessionFile = path.join(sessionsDir, '2026-02-11-max0000-session.tmp');
|
const sessionFile = path.join(sessionsDir, '2026-02-11-max0000-session.tmp');
|
||||||
fs.writeFileSync(
|
fs.writeFileSync(sessionFile, buildSessionStartFixture('B'.repeat(1200), { title: '# Sized Session' }));
|
||||||
sessionFile,
|
|
||||||
buildSessionStartFixture('B'.repeat(1200), { title: '# Sized Session' })
|
|
||||||
);
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const result = await runScript(path.join(scriptsDir, 'session-start.js'), '', {
|
const result = await runScript(path.join(scriptsDir, 'session-start.js'), '', {
|
||||||
@ -581,14 +555,8 @@ async function runTests() {
|
|||||||
fs.mkdirSync(legacyDir, { recursive: true });
|
fs.mkdirSync(legacyDir, { recursive: true });
|
||||||
fs.mkdirSync(path.join(isoHome, '.claude', 'skills', 'learned'), { recursive: true });
|
fs.mkdirSync(path.join(isoHome, '.claude', 'skills', 'learned'), { recursive: true });
|
||||||
|
|
||||||
fs.writeFileSync(
|
fs.writeFileSync(canonicalFile, buildSessionStartFixture('Use the canonical session-data copy.', { title: '# Canonical Session' }));
|
||||||
canonicalFile,
|
fs.writeFileSync(legacyFile, buildSessionStartFixture('Do not prefer the legacy duplicate.', { title: '# Legacy Session' }));
|
||||||
buildSessionStartFixture('Use the canonical session-data copy.', { title: '# Canonical Session' })
|
|
||||||
);
|
|
||||||
fs.writeFileSync(
|
|
||||||
legacyFile,
|
|
||||||
buildSessionStartFixture('Do not prefer the legacy duplicate.', { title: '# Legacy Session' })
|
|
||||||
);
|
|
||||||
fs.utimesSync(canonicalFile, canonicalTime, canonicalTime);
|
fs.utimesSync(canonicalFile, canonicalTime, canonicalTime);
|
||||||
fs.utimesSync(legacyFile, legacyTime, legacyTime);
|
fs.utimesSync(legacyFile, legacyTime, legacyTime);
|
||||||
|
|
||||||
@ -617,13 +585,7 @@ async function runTests() {
|
|||||||
fs.mkdirSync(path.join(isoHome, '.claude', 'skills', 'learned'), { recursive: true });
|
fs.mkdirSync(path.join(isoHome, '.claude', 'skills', 'learned'), { recursive: true });
|
||||||
|
|
||||||
const sessionFile = path.join(sessionsDir, '2026-02-11-winansi00-session.tmp');
|
const sessionFile = path.join(sessionsDir, '2026-02-11-winansi00-session.tmp');
|
||||||
fs.writeFileSync(
|
fs.writeFileSync(sessionFile, buildSessionStartFixture('I worked on \x1b[1;36mWindows terminal handling\x1b[0m.\x1b[K', { title: '\x1b[H\x1b[2J\x1b[3J# Real Session' }));
|
||||||
sessionFile,
|
|
||||||
buildSessionStartFixture(
|
|
||||||
'I worked on \x1b[1;36mWindows terminal handling\x1b[0m.\x1b[K',
|
|
||||||
{ title: '\x1b[H\x1b[2J\x1b[3J# Real Session' }
|
|
||||||
)
|
|
||||||
);
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const result = await runScript(path.join(scriptsDir, 'session-start.js'), '', {
|
const result = await runScript(path.join(scriptsDir, 'session-start.js'), '', {
|
||||||
@ -632,10 +594,7 @@ async function runTests() {
|
|||||||
});
|
});
|
||||||
assert.strictEqual(result.code, 0);
|
assert.strictEqual(result.code, 0);
|
||||||
const additionalContext = getSessionStartAdditionalContext(result.stdout);
|
const additionalContext = getSessionStartAdditionalContext(result.stdout);
|
||||||
assert.ok(
|
assert.ok(additionalContext.includes('HISTORICAL REFERENCE ONLY'), 'Should wrap injected session with the stale-replay guard preamble');
|
||||||
additionalContext.includes('HISTORICAL REFERENCE ONLY'),
|
|
||||||
'Should wrap injected session with the stale-replay guard preamble'
|
|
||||||
);
|
|
||||||
assert.ok(additionalContext.includes('Windows terminal handling'), 'Should preserve sanitized session text');
|
assert.ok(additionalContext.includes('Windows terminal handling'), 'Should preserve sanitized session text');
|
||||||
assert.ok(!additionalContext.includes('\x1b['), 'Should not emit ANSI escape codes');
|
assert.ok(!additionalContext.includes('\x1b['), 'Should not emit ANSI escape codes');
|
||||||
} finally {
|
} finally {
|
||||||
@ -657,11 +616,7 @@ async function runTests() {
|
|||||||
fs.writeFileSync(sessionFile, buildSessionStartFixture(RESUME_SESSION_SENTINEL));
|
fs.writeFileSync(sessionFile, buildSessionStartFixture(RESUME_SESSION_SENTINEL));
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const result = await runScript(
|
const result = await runScript(path.join(scriptsDir, 'session-start.js'), JSON.stringify({ hookName: 'SessionStart:resume' }), { HOME: isoHome, USERPROFILE: isoHome });
|
||||||
path.join(scriptsDir, 'session-start.js'),
|
|
||||||
JSON.stringify({ hookName: 'SessionStart:resume' }),
|
|
||||||
{ HOME: isoHome, USERPROFILE: isoHome }
|
|
||||||
);
|
|
||||||
assert.strictEqual(result.code, 0);
|
assert.strictEqual(result.code, 0);
|
||||||
const additionalContext = getSessionStartAdditionalContext(result.stdout);
|
const additionalContext = getSessionStartAdditionalContext(result.stdout);
|
||||||
assert.ok(!additionalContext.includes('HISTORICAL REFERENCE ONLY'), 'Should not inject a previous summary on resume');
|
assert.ok(!additionalContext.includes('HISTORICAL REFERENCE ONLY'), 'Should not inject a previous summary on resume');
|
||||||
@ -686,11 +641,7 @@ async function runTests() {
|
|||||||
fs.writeFileSync(sessionFile, buildSessionStartFixture(CLI_RESUME_SESSION_SENTINEL));
|
fs.writeFileSync(sessionFile, buildSessionStartFixture(CLI_RESUME_SESSION_SENTINEL));
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const result = await runScript(
|
const result = await runScript(path.join(scriptsDir, 'session-start.js'), JSON.stringify({ hook_event_name: 'SessionStart', source: 'resume' }), { HOME: isoHome, USERPROFILE: isoHome });
|
||||||
path.join(scriptsDir, 'session-start.js'),
|
|
||||||
JSON.stringify({ hook_event_name: 'SessionStart', source: 'resume' }),
|
|
||||||
{ HOME: isoHome, USERPROFILE: isoHome }
|
|
||||||
);
|
|
||||||
assert.strictEqual(result.code, 0);
|
assert.strictEqual(result.code, 0);
|
||||||
const additionalContext = getSessionStartAdditionalContext(result.stdout);
|
const additionalContext = getSessionStartAdditionalContext(result.stdout);
|
||||||
assert.ok(!additionalContext.includes(CLI_RESUME_SESSION_SENTINEL), 'Should not inject CLI resume session content');
|
assert.ok(!additionalContext.includes(CLI_RESUME_SESSION_SENTINEL), 'Should not inject CLI resume session content');
|
||||||
@ -714,20 +665,12 @@ async function runTests() {
|
|||||||
fs.writeFileSync(desktopFile, buildSessionStartFixture(`${DESKTOP_CLEAR_SESSION_SENTINEL}\n${CLI_CLEAR_SESSION_SENTINEL}`));
|
fs.writeFileSync(desktopFile, buildSessionStartFixture(`${DESKTOP_CLEAR_SESSION_SENTINEL}\n${CLI_CLEAR_SESSION_SENTINEL}`));
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const desktopResult = await runScript(
|
const desktopResult = await runScript(path.join(scriptsDir, 'session-start.js'), JSON.stringify({ hookName: 'SessionStart:clear' }), { HOME: isoHome, USERPROFILE: isoHome });
|
||||||
path.join(scriptsDir, 'session-start.js'),
|
|
||||||
JSON.stringify({ hookName: 'SessionStart:clear' }),
|
|
||||||
{ HOME: isoHome, USERPROFILE: isoHome }
|
|
||||||
);
|
|
||||||
assert.strictEqual(desktopResult.code, 0);
|
assert.strictEqual(desktopResult.code, 0);
|
||||||
const desktopContext = getSessionStartAdditionalContext(desktopResult.stdout);
|
const desktopContext = getSessionStartAdditionalContext(desktopResult.stdout);
|
||||||
assert.ok(!desktopContext.includes(DESKTOP_CLEAR_SESSION_SENTINEL), 'Should not inject Desktop clear session content');
|
assert.ok(!desktopContext.includes(DESKTOP_CLEAR_SESSION_SENTINEL), 'Should not inject Desktop clear session content');
|
||||||
|
|
||||||
const cliResult = await runScript(
|
const cliResult = await runScript(path.join(scriptsDir, 'session-start.js'), JSON.stringify({ hook_event_name: 'SessionStart', source: 'clear' }), { HOME: isoHome, USERPROFILE: isoHome });
|
||||||
path.join(scriptsDir, 'session-start.js'),
|
|
||||||
JSON.stringify({ hook_event_name: 'SessionStart', source: 'clear' }),
|
|
||||||
{ HOME: isoHome, USERPROFILE: isoHome }
|
|
||||||
);
|
|
||||||
assert.strictEqual(cliResult.code, 0);
|
assert.strictEqual(cliResult.code, 0);
|
||||||
const cliContext = getSessionStartAdditionalContext(cliResult.stdout);
|
const cliContext = getSessionStartAdditionalContext(cliResult.stdout);
|
||||||
assert.ok(!cliContext.includes(CLI_CLEAR_SESSION_SENTINEL), 'Should not inject CLI clear session content');
|
assert.ok(!cliContext.includes(CLI_CLEAR_SESSION_SENTINEL), 'Should not inject CLI clear session content');
|
||||||
@ -778,10 +721,13 @@ async function runTests() {
|
|||||||
fs.mkdirSync(path.join(isoHome, '.claude', 'skills', 'learned'), { recursive: true });
|
fs.mkdirSync(path.join(isoHome, '.claude', 'skills', 'learned'), { recursive: true });
|
||||||
|
|
||||||
const sessionFile = path.join(sessionsDir, '2026-02-11-crossproj-session.tmp');
|
const sessionFile = path.join(sessionsDir, '2026-02-11-crossproj-session.tmp');
|
||||||
fs.writeFileSync(sessionFile, buildSessionStartFixture(CROSS_PROJECT_SESSION_SENTINEL, {
|
fs.writeFileSync(
|
||||||
project: 'different-project',
|
sessionFile,
|
||||||
worktree: path.join(os.tmpdir(), 'different-project')
|
buildSessionStartFixture(CROSS_PROJECT_SESSION_SENTINEL, {
|
||||||
}));
|
project: 'different-project',
|
||||||
|
worktree: path.join(os.tmpdir(), 'different-project')
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const result = await runScript(path.join(scriptsDir, 'session-start.js'), '', {
|
const result = await runScript(path.join(scriptsDir, 'session-start.js'), '', {
|
||||||
@ -808,9 +754,12 @@ async function runTests() {
|
|||||||
fs.mkdirSync(path.join(isoHome, '.claude', 'skills', 'learned'), { recursive: true });
|
fs.mkdirSync(path.join(isoHome, '.claude', 'skills', 'learned'), { recursive: true });
|
||||||
|
|
||||||
const sessionFile = path.join(sessionsDir, '2026-02-11-crosswt-session.tmp');
|
const sessionFile = path.join(sessionsDir, '2026-02-11-crosswt-session.tmp');
|
||||||
fs.writeFileSync(sessionFile, buildSessionStartFixture(CROSS_WORKTREE_PROJECT_SENTINEL, {
|
fs.writeFileSync(
|
||||||
worktree: path.join(os.tmpdir(), 'same-project-different-worktree')
|
sessionFile,
|
||||||
}));
|
buildSessionStartFixture(CROSS_WORKTREE_PROJECT_SENTINEL, {
|
||||||
|
worktree: path.join(os.tmpdir(), 'same-project-different-worktree')
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const result = await runScript(path.join(scriptsDir, 'session-start.js'), '', {
|
const result = await runScript(path.join(scriptsDir, 'session-start.js'), '', {
|
||||||
@ -897,19 +846,11 @@ async function runTests() {
|
|||||||
'Use for recurring flaky integration tests that need deterministic setup checks.',
|
'Use for recurring flaky integration tests that need deterministic setup checks.',
|
||||||
'',
|
'',
|
||||||
'## Solution',
|
'## Solution',
|
||||||
'Verify service readiness before running the test body.',
|
'Verify service readiness before running the test body.'
|
||||||
].join('\n'),
|
].join('\n')
|
||||||
);
|
);
|
||||||
fs.mkdirSync(path.join(learnedDir, 'debugging-pattern'), { recursive: true });
|
fs.mkdirSync(path.join(learnedDir, 'debugging-pattern'), { recursive: true });
|
||||||
fs.writeFileSync(
|
fs.writeFileSync(path.join(learnedDir, 'debugging-pattern', 'SKILL.md'), ['# Debugging Pattern', '', '## Trigger', 'Use when a CLI tool silently exits without a result payload.'].join('\n'));
|
||||||
path.join(learnedDir, 'debugging-pattern', 'SKILL.md'),
|
|
||||||
[
|
|
||||||
'# Debugging Pattern',
|
|
||||||
'',
|
|
||||||
'## Trigger',
|
|
||||||
'Use when a CLI tool silently exits without a result payload.',
|
|
||||||
].join('\n'),
|
|
||||||
);
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const result = await runScript(path.join(scriptsDir, 'session-start.js'), '', {
|
const result = await runScript(path.join(scriptsDir, 'session-start.js'), '', {
|
||||||
@ -918,20 +859,11 @@ async function runTests() {
|
|||||||
});
|
});
|
||||||
assert.strictEqual(result.code, 0);
|
assert.strictEqual(result.code, 0);
|
||||||
const additionalContext = getSessionStartAdditionalContext(result.stdout);
|
const additionalContext = getSessionStartAdditionalContext(result.stdout);
|
||||||
assert.ok(
|
assert.ok(additionalContext.includes('Available learned skills'), `Should inject learned skills into additionalContext, got: ${additionalContext}`);
|
||||||
additionalContext.includes('Available learned skills'),
|
|
||||||
`Should inject learned skills into additionalContext, got: ${additionalContext}`
|
|
||||||
);
|
|
||||||
assert.ok(additionalContext.includes('testing-patterns'), 'Should include the learned skill slug');
|
assert.ok(additionalContext.includes('testing-patterns'), 'Should include the learned skill slug');
|
||||||
assert.ok(
|
assert.ok(additionalContext.includes('Use for recurring flaky integration tests'), 'Should include the learned skill trigger text');
|
||||||
additionalContext.includes('Use for recurring flaky integration tests'),
|
|
||||||
'Should include the learned skill trigger text'
|
|
||||||
);
|
|
||||||
assert.ok(additionalContext.includes('debugging-pattern'), 'Should include directory-style learned skills');
|
assert.ok(additionalContext.includes('debugging-pattern'), 'Should include directory-style learned skills');
|
||||||
assert.ok(
|
assert.ok(additionalContext.includes('CLI tool silently exits'), 'Should summarize directory-style learned skill trigger text');
|
||||||
additionalContext.includes('CLI tool silently exits'),
|
|
||||||
'Should summarize directory-style learned skill trigger text'
|
|
||||||
);
|
|
||||||
} finally {
|
} finally {
|
||||||
fs.rmSync(isoHome, { recursive: true, force: true });
|
fs.rmSync(isoHome, { recursive: true, force: true });
|
||||||
}
|
}
|
||||||
@ -1769,10 +1701,14 @@ async function runTests() {
|
|||||||
fs.mkdirSync(path.join(isolatedHome, '.claude'), { recursive: true });
|
fs.mkdirSync(path.join(isolatedHome, '.claude'), { recursive: true });
|
||||||
|
|
||||||
const stdinJson = JSON.stringify({ tool_input: { file_path: filePath } });
|
const stdinJson = JSON.stringify({ tool_input: { file_path: filePath } });
|
||||||
const result = await runScript(path.join(scriptsDir, 'post-edit-format.js'), stdinJson, withPrependedPath(binDir, {
|
const result = await runScript(
|
||||||
HOME: isolatedHome,
|
path.join(scriptsDir, 'post-edit-format.js'),
|
||||||
USERPROFILE: isolatedHome
|
stdinJson,
|
||||||
}));
|
withPrependedPath(binDir, {
|
||||||
|
HOME: isolatedHome,
|
||||||
|
USERPROFILE: isolatedHome
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
assert.strictEqual(result.code, 0, 'Should exit 0 for config-only repo');
|
assert.strictEqual(result.code, 0, 'Should exit 0 for config-only repo');
|
||||||
const logEntries = readCommandLog(logFile);
|
const logEntries = readCommandLog(logFile);
|
||||||
@ -2463,12 +2399,8 @@ async function runTests() {
|
|||||||
assert.strictEqual(preBash[0].id, 'pre:bash:dispatcher');
|
assert.strictEqual(preBash[0].id, 'pre:bash:dispatcher');
|
||||||
assert.strictEqual(postBash[0].id, 'post:bash:dispatcher');
|
assert.strictEqual(postBash[0].id, 'post:bash:dispatcher');
|
||||||
|
|
||||||
const preCommand = Array.isArray(preBash[0].hooks[0].command)
|
const preCommand = Array.isArray(preBash[0].hooks[0].command) ? preBash[0].hooks[0].command.join(' ') : preBash[0].hooks[0].command;
|
||||||
? preBash[0].hooks[0].command.join(' ')
|
const postCommand = Array.isArray(postBash[0].hooks[0].command) ? postBash[0].hooks[0].command.join(' ') : postBash[0].hooks[0].command;
|
||||||
: preBash[0].hooks[0].command;
|
|
||||||
const postCommand = Array.isArray(postBash[0].hooks[0].command)
|
|
||||||
? postBash[0].hooks[0].command.join(' ')
|
|
||||||
: postBash[0].hooks[0].command;
|
|
||||||
|
|
||||||
assert.ok(preCommand.includes('pre-bash-dispatcher.js'), 'PreToolUse Bash hook should use the pre dispatcher');
|
assert.ok(preCommand.includes('pre-bash-dispatcher.js'), 'PreToolUse Bash hook should use the pre dispatcher');
|
||||||
assert.ok(postCommand.includes('post-bash-dispatcher.js'), 'PostToolUse Bash hook should use the post dispatcher');
|
assert.ok(postCommand.includes('post-bash-dispatcher.js'), 'PostToolUse Bash hook should use the post dispatcher');
|
||||||
@ -2500,11 +2432,7 @@ async function runTests() {
|
|||||||
for (const [eventName, hookArray] of Object.entries(hooks.hooks)) {
|
for (const [eventName, hookArray] of Object.entries(hooks.hooks)) {
|
||||||
for (const entry of hookArray) {
|
for (const entry of hookArray) {
|
||||||
for (const hook of entry.hooks) {
|
for (const hook of entry.hooks) {
|
||||||
assert.strictEqual(
|
assert.strictEqual(typeof hook.command, 'string', `${eventName}/${entry.id || entry.matcher || 'hook'} should use string command form`);
|
||||||
typeof hook.command,
|
|
||||||
'string',
|
|
||||||
`${eventName}/${entry.id || entry.matcher || 'hook'} should use string command form`,
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -2523,10 +2451,7 @@ async function runTests() {
|
|||||||
for (const hook of entry.hooks) {
|
for (const hook of entry.hooks) {
|
||||||
const commandText = Array.isArray(hook.command) ? hook.command.join(' ') : hook.command;
|
const commandText = Array.isArray(hook.command) ? hook.command.join(' ') : hook.command;
|
||||||
if (typeof commandText === 'string' && commandText.startsWith('node -e ')) {
|
if (typeof commandText === 'string' && commandText.startsWith('node -e ')) {
|
||||||
assert.ok(
|
assert.ok(!commandText.includes('\\"'), `${eventName}/${entry.id || entry.matcher || 'hook'} should not ship escaped double quotes in node -e payload`);
|
||||||
!commandText.includes('\\"'),
|
|
||||||
`${eventName}/${entry.id || entry.matcher || 'hook'} should not ship escaped double quotes in node -e payload`,
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -2550,10 +2475,7 @@ async function runTests() {
|
|||||||
const isNode = commandStart === 'node' || (typeof commandStart === 'string' && commandStart.startsWith('node'));
|
const isNode = commandStart === 'node' || (typeof commandStart === 'string' && commandStart.startsWith('node'));
|
||||||
const isNpx = commandStart === 'npx' || (typeof commandStart === 'string' && commandStart.startsWith('npx '));
|
const isNpx = commandStart === 'npx' || (typeof commandStart === 'string' && commandStart.startsWith('npx '));
|
||||||
const isSkillScript = commandText.includes('/skills/') && (/^(bash|sh)\s/.test(commandText) || commandText.includes('/skills/'));
|
const isSkillScript = commandText.includes('/skills/') && (/^(bash|sh)\s/.test(commandText) || commandText.includes('/skills/'));
|
||||||
assert.ok(
|
assert.ok(isNode || isNpx || isSkillScript, `Hook command should use node or approved shell wrapper: ${commandText.substring(0, 100)}...`);
|
||||||
isNode || isNpx || isSkillScript,
|
|
||||||
`Hook command should use node or approved shell wrapper: ${commandText.substring(0, 100)}...`
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -2576,10 +2498,7 @@ async function runTests() {
|
|||||||
assert.ok(sessionStartHook, 'Should define a SessionStart hook');
|
assert.ok(sessionStartHook, 'Should define a SessionStart hook');
|
||||||
const commandText = sessionStartHook.command;
|
const commandText = sessionStartHook.command;
|
||||||
assert.strictEqual(typeof sessionStartHook.command, 'string', 'SessionStart should use string command form for Claude Code compatibility');
|
assert.strictEqual(typeof sessionStartHook.command, 'string', 'SessionStart should use string command form for Claude Code compatibility');
|
||||||
assert.ok(
|
assert.ok(commandText.includes('session-start-bootstrap.js'), 'SessionStart should delegate to the extracted bootstrap script');
|
||||||
commandText.includes('session-start-bootstrap.js'),
|
|
||||||
'SessionStart should delegate to the extracted bootstrap script'
|
|
||||||
);
|
|
||||||
assert.ok(commandText.includes('CLAUDE_PLUGIN_ROOT'), 'SessionStart should use CLAUDE_PLUGIN_ROOT');
|
assert.ok(commandText.includes('CLAUDE_PLUGIN_ROOT'), 'SessionStart should use CLAUDE_PLUGIN_ROOT');
|
||||||
assert.ok(!commandText.includes('${CLAUDE_PLUGIN_ROOT}'), 'SessionStart should not depend on raw shell placeholder expansion');
|
assert.ok(!commandText.includes('${CLAUDE_PLUGIN_ROOT}'), 'SessionStart should not depend on raw shell placeholder expansion');
|
||||||
assert.ok(!commandText.includes('find '), 'Should not scan arbitrary plugin paths with find');
|
assert.ok(!commandText.includes('find '), 'Should not scan arbitrary plugin paths with find');
|
||||||
@ -2607,8 +2526,7 @@ async function runTests() {
|
|||||||
for (const hook of [...stopHooks, ...sessionEndHooks]) {
|
for (const hook of [...stopHooks, ...sessionEndHooks]) {
|
||||||
const commandText = Array.isArray(hook.command) ? hook.command.join(' ') : hook.command;
|
const commandText = Array.isArray(hook.command) ? hook.command.join(' ') : hook.command;
|
||||||
assert.ok(
|
assert.ok(
|
||||||
(Array.isArray(hook.command) && hook.command[0] === 'node' && hook.command[1] === '-e') ||
|
(Array.isArray(hook.command) && hook.command[0] === 'node' && hook.command[1] === '-e') || (typeof hook.command === 'string' && hook.command.startsWith('node -e "')),
|
||||||
(typeof hook.command === 'string' && hook.command.startsWith('node -e "')),
|
|
||||||
'Lifecycle hook should use inline node resolver'
|
'Lifecycle hook should use inline node resolver'
|
||||||
);
|
);
|
||||||
assert.ok(commandText.includes('run-with-flags.js'), 'Lifecycle hook should resolve the runner script');
|
assert.ok(commandText.includes('run-with-flags.js'), 'Lifecycle hook should resolve the runner script');
|
||||||
@ -2636,10 +2554,7 @@ async function runTests() {
|
|||||||
const usesInlineResolver = commandStart.startsWith('node -e') && commandText.includes('run-with-flags.js');
|
const usesInlineResolver = commandStart.startsWith('node -e') && commandText.includes('run-with-flags.js');
|
||||||
const usesPluginBootstrap = commandStart.startsWith('node -e') && commandText.includes('plugin-hook-bootstrap.js');
|
const usesPluginBootstrap = commandStart.startsWith('node -e') && commandText.includes('plugin-hook-bootstrap.js');
|
||||||
assert.ok(!commandText.includes('${CLAUDE_PLUGIN_ROOT}'), `Script paths should not depend on raw shell placeholder expansion: ${commandText.substring(0, 80)}...`);
|
assert.ok(!commandText.includes('${CLAUDE_PLUGIN_ROOT}'), `Script paths should not depend on raw shell placeholder expansion: ${commandText.substring(0, 80)}...`);
|
||||||
assert.ok(
|
assert.ok(usesInlineResolver || usesPluginBootstrap, `Script paths should use the inline resolver or plugin bootstrap: ${commandText.substring(0, 80)}...`);
|
||||||
usesInlineResolver || usesPluginBootstrap,
|
|
||||||
`Script paths should use the inline resolver or plugin bootstrap: ${commandText.substring(0, 80)}...`
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -2653,7 +2568,6 @@ async function runTests() {
|
|||||||
passed++;
|
passed++;
|
||||||
else failed++;
|
else failed++;
|
||||||
|
|
||||||
|
|
||||||
// plugin.json validation
|
// plugin.json validation
|
||||||
console.log('\nplugin.json Validation:');
|
console.log('\nplugin.json Validation:');
|
||||||
|
|
||||||
@ -3224,14 +3138,7 @@ async function runTests() {
|
|||||||
|
|
||||||
const [projectId, projectDir] = stdout.trim().split(/\r?\n/);
|
const [projectId, projectDir] = stdout.trim().split(/\r?\n/);
|
||||||
const registryPath = path.join(homeDir, '.local', 'share', 'ecc-homunculus', 'projects.json');
|
const registryPath = path.join(homeDir, '.local', 'share', 'ecc-homunculus', 'projects.json');
|
||||||
const expectedProjectDir = path.join(
|
const expectedProjectDir = path.join(homeDir, '.local', 'share', 'ecc-homunculus', 'projects', projectId);
|
||||||
homeDir,
|
|
||||||
'.local',
|
|
||||||
'share',
|
|
||||||
'ecc-homunculus',
|
|
||||||
'projects',
|
|
||||||
projectId
|
|
||||||
);
|
|
||||||
const projectMetadataPath = path.join(expectedProjectDir, 'project.json');
|
const projectMetadataPath = path.join(expectedProjectDir, 'project.json');
|
||||||
|
|
||||||
assert.ok(projectId, 'detect-project should emit a project id');
|
assert.ok(projectId, 'detect-project should emit a project id');
|
||||||
@ -3249,11 +3156,7 @@ async function runTests() {
|
|||||||
assert.ok(registry[projectId], 'registry should contain the detected project');
|
assert.ok(registry[projectId], 'registry should contain the detected project');
|
||||||
assert.strictEqual(metadata.id, projectId, 'project.json should include the detected id');
|
assert.strictEqual(metadata.id, projectId, 'project.json should include the detected id');
|
||||||
assert.strictEqual(metadata.name, path.basename(repoDir), 'project.json should include the repo name');
|
assert.strictEqual(metadata.name, path.basename(repoDir), 'project.json should include the repo name');
|
||||||
assert.strictEqual(
|
assert.strictEqual(comparableMetadataRoot, comparableRepoDir, `project.json should include the repo root (expected ${comparableRepoDir}, got ${comparableMetadataRoot})`);
|
||||||
comparableMetadataRoot,
|
|
||||||
comparableRepoDir,
|
|
||||||
`project.json should include the repo root (expected ${comparableRepoDir}, got ${comparableMetadataRoot})`
|
|
||||||
);
|
|
||||||
assert.strictEqual(metadata.remote, 'https://github.com/example/ecc-test.git', 'project.json should include the sanitized remote');
|
assert.strictEqual(metadata.remote, 'https://github.com/example/ecc-test.git', 'project.json should include the sanitized remote');
|
||||||
assert.ok(metadata.created_at, 'project.json should include created_at');
|
assert.ok(metadata.created_at, 'project.json should include created_at');
|
||||||
assert.ok(metadata.last_seen, 'project.json should include last_seen');
|
assert.ok(metadata.last_seen, 'project.json should include last_seen');
|
||||||
@ -3304,10 +3207,7 @@ async function runTests() {
|
|||||||
|
|
||||||
const homunculusDir = path.join(homeDir, '.local', 'share', 'ecc-homunculus');
|
const homunculusDir = path.join(homeDir, '.local', 'share', 'ecc-homunculus');
|
||||||
const projectsDir = path.join(homunculusDir, 'projects');
|
const projectsDir = path.join(homunculusDir, 'projects');
|
||||||
assert.ok(
|
assert.ok(!fs.existsSync(projectsDir) || fs.readdirSync(projectsDir).length === 0, 'observe.sh should not create a project-scoped directory for a non-git cwd');
|
||||||
!fs.existsSync(projectsDir) || fs.readdirSync(projectsDir).length === 0,
|
|
||||||
'observe.sh should not create a project-scoped directory for a non-git cwd'
|
|
||||||
);
|
|
||||||
|
|
||||||
const observationsPath = path.join(homunculusDir, 'observations.jsonl');
|
const observationsPath = path.join(homunculusDir, 'observations.jsonl');
|
||||||
const observations = fs.readFileSync(observationsPath, 'utf8').trim().split('\n').filter(Boolean);
|
const observations = fs.readFileSync(observationsPath, 'utf8').trim().split('\n').filter(Boolean);
|
||||||
@ -3338,7 +3238,10 @@ async function runTests() {
|
|||||||
passed++;
|
passed++;
|
||||||
else failed++;
|
else failed++;
|
||||||
|
|
||||||
if (SKIP_BASH) { console.log(" ⊘ observe.sh skips minimal hook profile (skipped on Windows)"); passed++; } else if (
|
if (SKIP_BASH) {
|
||||||
|
console.log(' ⊘ observe.sh skips minimal hook profile (skipped on Windows)');
|
||||||
|
passed++;
|
||||||
|
} else if (
|
||||||
await asyncTest('observe.sh skips minimal hook profile before project detection side effects', async () => {
|
await asyncTest('observe.sh skips minimal hook profile before project detection side effects', async () => {
|
||||||
await assertObserveSkipBeforeProjectDetection({
|
await assertObserveSkipBeforeProjectDetection({
|
||||||
name: 'minimal hook profile',
|
name: 'minimal hook profile',
|
||||||
@ -3349,7 +3252,10 @@ async function runTests() {
|
|||||||
passed++;
|
passed++;
|
||||||
else failed++;
|
else failed++;
|
||||||
|
|
||||||
if (SKIP_BASH) { console.log(" ⊘ observe.sh skips cooperative skip env (skipped on Windows)"); passed++; } else if (
|
if (SKIP_BASH) {
|
||||||
|
console.log(' ⊘ observe.sh skips cooperative skip env (skipped on Windows)');
|
||||||
|
passed++;
|
||||||
|
} else if (
|
||||||
await asyncTest('observe.sh skips cooperative skip env before project detection side effects', async () => {
|
await asyncTest('observe.sh skips cooperative skip env before project detection side effects', async () => {
|
||||||
await assertObserveSkipBeforeProjectDetection({
|
await assertObserveSkipBeforeProjectDetection({
|
||||||
name: 'cooperative skip env',
|
name: 'cooperative skip env',
|
||||||
@ -3360,7 +3266,10 @@ async function runTests() {
|
|||||||
passed++;
|
passed++;
|
||||||
else failed++;
|
else failed++;
|
||||||
|
|
||||||
if (SKIP_BASH) { console.log(" ⊘ observe.sh skips subagent payloads (skipped on Windows)"); passed++; } else if (
|
if (SKIP_BASH) {
|
||||||
|
console.log(' ⊘ observe.sh skips subagent payloads (skipped on Windows)');
|
||||||
|
passed++;
|
||||||
|
} else if (
|
||||||
await asyncTest('observe.sh skips subagent payloads before project detection side effects', async () => {
|
await asyncTest('observe.sh skips subagent payloads before project detection side effects', async () => {
|
||||||
await assertObserveSkipBeforeProjectDetection({
|
await assertObserveSkipBeforeProjectDetection({
|
||||||
name: 'subagent payload',
|
name: 'subagent payload',
|
||||||
@ -3372,7 +3281,10 @@ async function runTests() {
|
|||||||
passed++;
|
passed++;
|
||||||
else failed++;
|
else failed++;
|
||||||
|
|
||||||
if (SKIP_BASH) { console.log(" ⊘ observe.sh skips configured observer-session paths (skipped on Windows)"); passed++; } else if (
|
if (SKIP_BASH) {
|
||||||
|
console.log(' ⊘ observe.sh skips configured observer-session paths (skipped on Windows)');
|
||||||
|
passed++;
|
||||||
|
} else if (
|
||||||
await asyncTest('observe.sh skips configured observer-session paths before project detection side effects', async () => {
|
await asyncTest('observe.sh skips configured observer-session paths before project detection side effects', async () => {
|
||||||
await assertObserveSkipBeforeProjectDetection({
|
await assertObserveSkipBeforeProjectDetection({
|
||||||
name: 'cwd skip path',
|
name: 'cwd skip path',
|
||||||
@ -4938,19 +4850,13 @@ async function runTests() {
|
|||||||
|
|
||||||
// Create session file 6.9 days old (should be INCLUDED by maxAge:7)
|
// Create session file 6.9 days old (should be INCLUDED by maxAge:7)
|
||||||
const recentFile = path.join(sessionsDir, '2026-02-06-recent69-session.tmp');
|
const recentFile = path.join(sessionsDir, '2026-02-06-recent69-session.tmp');
|
||||||
fs.writeFileSync(
|
fs.writeFileSync(recentFile, buildSessionStartFixture('RECENT CONTENT HERE', { title: '# Recent Session' }));
|
||||||
recentFile,
|
|
||||||
buildSessionStartFixture('RECENT CONTENT HERE', { title: '# Recent Session' })
|
|
||||||
);
|
|
||||||
const sixPointNineDaysAgo = new Date(Date.now() - 6.9 * 24 * 60 * 60 * 1000);
|
const sixPointNineDaysAgo = new Date(Date.now() - 6.9 * 24 * 60 * 60 * 1000);
|
||||||
fs.utimesSync(recentFile, sixPointNineDaysAgo, sixPointNineDaysAgo);
|
fs.utimesSync(recentFile, sixPointNineDaysAgo, sixPointNineDaysAgo);
|
||||||
|
|
||||||
// Create session file 8 days old (should be EXCLUDED by maxAge:7)
|
// Create session file 8 days old (should be EXCLUDED by maxAge:7)
|
||||||
const oldFile = path.join(sessionsDir, '2026-02-05-old8day-session.tmp');
|
const oldFile = path.join(sessionsDir, '2026-02-05-old8day-session.tmp');
|
||||||
fs.writeFileSync(
|
fs.writeFileSync(oldFile, buildSessionStartFixture('OLD CONTENT SHOULD NOT APPEAR', { title: '# Old Session' }));
|
||||||
oldFile,
|
|
||||||
buildSessionStartFixture('OLD CONTENT SHOULD NOT APPEAR', { title: '# Old Session' })
|
|
||||||
);
|
|
||||||
const eightDaysAgo = new Date(Date.now() - 8 * 24 * 60 * 60 * 1000);
|
const eightDaysAgo = new Date(Date.now() - 8 * 24 * 60 * 60 * 1000);
|
||||||
fs.utimesSync(oldFile, eightDaysAgo, eightDaysAgo);
|
fs.utimesSync(oldFile, eightDaysAgo, eightDaysAgo);
|
||||||
|
|
||||||
@ -4993,7 +4899,7 @@ async function runTests() {
|
|||||||
const result = await runScript(path.join(scriptsDir, 'session-start.js'), '', {
|
const result = await runScript(path.join(scriptsDir, 'session-start.js'), '', {
|
||||||
HOME: isoHome,
|
HOME: isoHome,
|
||||||
USERPROFILE: isoHome,
|
USERPROFILE: isoHome,
|
||||||
ECC_SESSION_RETENTION_DAYS: '30',
|
ECC_SESSION_RETENTION_DAYS: '30'
|
||||||
});
|
});
|
||||||
|
|
||||||
assert.strictEqual(result.code, 0);
|
assert.strictEqual(result.code, 0);
|
||||||
@ -5024,13 +4930,12 @@ async function runTests() {
|
|||||||
const result = await runScript(path.join(scriptsDir, 'session-start.js'), '', {
|
const result = await runScript(path.join(scriptsDir, 'session-start.js'), '', {
|
||||||
HOME: isoHome,
|
HOME: isoHome,
|
||||||
USERPROFILE: isoHome,
|
USERPROFILE: isoHome,
|
||||||
ECC_SESSION_RETENTION_DAYS: '0',
|
ECC_SESSION_RETENTION_DAYS: '0'
|
||||||
});
|
});
|
||||||
|
|
||||||
assert.strictEqual(result.code, 0);
|
assert.strictEqual(result.code, 0);
|
||||||
assert.ok(fs.existsSync(expiredFile), 'Should keep all sessions when retention is opt-out=0');
|
assert.ok(fs.existsSync(expiredFile), 'Should keep all sessions when retention is opt-out=0');
|
||||||
assert.ok(result.stderr.includes('Pruning disabled via ECC_SESSION_RETENTION_DAYS'),
|
assert.ok(result.stderr.includes('Pruning disabled via ECC_SESSION_RETENTION_DAYS'), `Should log pruning disabled, stderr: ${result.stderr}`);
|
||||||
`Should log pruning disabled, stderr: ${result.stderr}`);
|
|
||||||
assert.ok(!result.stderr.includes('Pruned'), `Should not log any pruning, stderr: ${result.stderr}`);
|
assert.ok(!result.stderr.includes('Pruned'), `Should not log any pruning, stderr: ${result.stderr}`);
|
||||||
} finally {
|
} finally {
|
||||||
fs.rmSync(isoHome, { recursive: true, force: true });
|
fs.rmSync(isoHome, { recursive: true, force: true });
|
||||||
@ -5056,13 +4961,12 @@ async function runTests() {
|
|||||||
const result = await runScript(path.join(scriptsDir, 'session-start.js'), '', {
|
const result = await runScript(path.join(scriptsDir, 'session-start.js'), '', {
|
||||||
HOME: isoHome,
|
HOME: isoHome,
|
||||||
USERPROFILE: isoHome,
|
USERPROFILE: isoHome,
|
||||||
ECC_SESSION_RETENTION_DAYS: 'off',
|
ECC_SESSION_RETENTION_DAYS: 'off'
|
||||||
});
|
});
|
||||||
|
|
||||||
assert.strictEqual(result.code, 0);
|
assert.strictEqual(result.code, 0);
|
||||||
assert.ok(fs.existsSync(expiredFile), 'Should keep all sessions when retention is opt-out=off');
|
assert.ok(fs.existsSync(expiredFile), 'Should keep all sessions when retention is opt-out=off');
|
||||||
assert.ok(result.stderr.includes('Pruning disabled via ECC_SESSION_RETENTION_DAYS'),
|
assert.ok(result.stderr.includes('Pruning disabled via ECC_SESSION_RETENTION_DAYS'), `Should log pruning disabled, stderr: ${result.stderr}`);
|
||||||
`Should log pruning disabled, stderr: ${result.stderr}`);
|
|
||||||
} finally {
|
} finally {
|
||||||
fs.rmSync(isoHome, { recursive: true, force: true });
|
fs.rmSync(isoHome, { recursive: true, force: true });
|
||||||
}
|
}
|
||||||
@ -5087,16 +4991,13 @@ async function runTests() {
|
|||||||
const result = await runScript(path.join(scriptsDir, 'session-start.js'), '', {
|
const result = await runScript(path.join(scriptsDir, 'session-start.js'), '', {
|
||||||
HOME: isoHome,
|
HOME: isoHome,
|
||||||
USERPROFILE: isoHome,
|
USERPROFILE: isoHome,
|
||||||
ECC_SESSION_RETENTION_DAYS: 'bogus-value',
|
ECC_SESSION_RETENTION_DAYS: 'bogus-value'
|
||||||
});
|
});
|
||||||
|
|
||||||
assert.strictEqual(result.code, 0);
|
assert.strictEqual(result.code, 0);
|
||||||
assert.ok(!fs.existsSync(expiredFile),
|
assert.ok(!fs.existsSync(expiredFile), 'Should fall back to default 30-day retention and prune the 40-day-old file');
|
||||||
'Should fall back to default 30-day retention and prune the 40-day-old file');
|
assert.ok(result.stderr.includes('Pruned 1 expired session'), `Should log pruning at default retention, stderr: ${result.stderr}`);
|
||||||
assert.ok(result.stderr.includes('Pruned 1 expired session'),
|
assert.ok(!result.stderr.includes('Pruning disabled'), 'Should NOT treat garbage as opt-out');
|
||||||
`Should log pruning at default retention, stderr: ${result.stderr}`);
|
|
||||||
assert.ok(!result.stderr.includes('Pruning disabled'),
|
|
||||||
'Should NOT treat garbage as opt-out');
|
|
||||||
} finally {
|
} finally {
|
||||||
fs.rmSync(isoHome, { recursive: true, force: true });
|
fs.rmSync(isoHome, { recursive: true, force: true });
|
||||||
}
|
}
|
||||||
@ -5118,18 +5019,12 @@ async function runTests() {
|
|||||||
|
|
||||||
// Create older session (2 days ago)
|
// Create older session (2 days ago)
|
||||||
const olderSession = path.join(sessionsDir, '2026-02-11-olderabc-session.tmp');
|
const olderSession = path.join(sessionsDir, '2026-02-11-olderabc-session.tmp');
|
||||||
fs.writeFileSync(
|
fs.writeFileSync(olderSession, buildSessionStartFixture('OLDER_CONTEXT_MARKER', { title: '# Older Session' }));
|
||||||
olderSession,
|
|
||||||
buildSessionStartFixture('OLDER_CONTEXT_MARKER', { title: '# Older Session' })
|
|
||||||
);
|
|
||||||
fs.utimesSync(olderSession, new Date(now - 2 * 86400000), new Date(now - 2 * 86400000));
|
fs.utimesSync(olderSession, new Date(now - 2 * 86400000), new Date(now - 2 * 86400000));
|
||||||
|
|
||||||
// Create newer session (1 day ago)
|
// Create newer session (1 day ago)
|
||||||
const newerSession = path.join(sessionsDir, '2026-02-12-newerdef-session.tmp');
|
const newerSession = path.join(sessionsDir, '2026-02-12-newerdef-session.tmp');
|
||||||
fs.writeFileSync(
|
fs.writeFileSync(newerSession, buildSessionStartFixture('NEWER_CONTEXT_MARKER', { title: '# Newer Session' }));
|
||||||
newerSession,
|
|
||||||
buildSessionStartFixture('NEWER_CONTEXT_MARKER', { title: '# Newer Session' })
|
|
||||||
);
|
|
||||||
fs.utimesSync(newerSession, new Date(now - 1 * 86400000), new Date(now - 1 * 86400000));
|
fs.utimesSync(newerSession, new Date(now - 1 * 86400000), new Date(now - 1 * 86400000));
|
||||||
|
|
||||||
try {
|
try {
|
||||||
@ -6152,6 +6047,93 @@ Some random content without the expected ### Context to Load section
|
|||||||
passed++;
|
passed++;
|
||||||
else failed++;
|
else failed++;
|
||||||
|
|
||||||
|
// ── Round 95: pre-compact.js — ECC_SKIP_LLM_SUMMARY guard ──
|
||||||
|
console.log('\nRound 95: pre-compact.js (transcript_path provided + ECC_SKIP_LLM_SUMMARY=1 — LLM skipped):');
|
||||||
|
|
||||||
|
if (
|
||||||
|
await asyncTest('pre-compact falls back to compaction log entry when ECC_SKIP_LLM_SUMMARY=1', async () => {
|
||||||
|
const testDir = createTestDir();
|
||||||
|
const sessionsDir = path.join(testDir, '.claude', 'session-data');
|
||||||
|
fs.mkdirSync(sessionsDir, { recursive: true });
|
||||||
|
|
||||||
|
// Create a minimal session .tmp file
|
||||||
|
const sessionFile = path.join(sessionsDir, '2026-01-01-test-session.tmp');
|
||||||
|
fs.writeFileSync(sessionFile, '# Session: 2026-01-01\n');
|
||||||
|
|
||||||
|
// Create a minimal transcript with one user message
|
||||||
|
const transcriptPath = path.join(testDir, 'transcript.jsonl');
|
||||||
|
const userEntry = JSON.stringify({ type: 'user', message: { role: 'user', content: 'hello' } });
|
||||||
|
fs.writeFileSync(transcriptPath, userEntry + '\n');
|
||||||
|
|
||||||
|
const stdinJson = JSON.stringify({ transcript_path: transcriptPath });
|
||||||
|
const result = await runScript(path.join(scriptsDir, 'pre-compact.js'), stdinJson, {
|
||||||
|
HOME: testDir,
|
||||||
|
ECC_SKIP_LLM_SUMMARY: '1'
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.strictEqual(result.code, 0, 'Should exit 0');
|
||||||
|
// LLM was skipped → fallback log entry appended
|
||||||
|
assert.ok(result.stderr.includes('[PreCompact] LLM summary unavailable'), `stderr should report LLM unavailable, got: ${result.stderr}`);
|
||||||
|
// Session file should have the compaction event marker, not an LLM summary block
|
||||||
|
const content = fs.readFileSync(sessionFile, 'utf8');
|
||||||
|
assert.ok(content.includes('Compaction occurred at'), `session file should contain compaction marker, got: ${content}`);
|
||||||
|
cleanupTestDir(testDir);
|
||||||
|
})
|
||||||
|
)
|
||||||
|
passed++;
|
||||||
|
else failed++;
|
||||||
|
|
||||||
|
// ── Round 95: session-end.js — ECC_LLM_SUMMARY_INTERVAL controls trigger ──
|
||||||
|
console.log('\nRound 95: session-end.js (ECC_LLM_SUMMARY_INTERVAL — controls LLM trigger cadence):');
|
||||||
|
|
||||||
|
if (
|
||||||
|
await asyncTest('session-end triggers LLM when totalMessages % interval === 0', async () => {
|
||||||
|
const testDir = createTestDir();
|
||||||
|
const transcriptPath = path.join(testDir, 'transcript.jsonl');
|
||||||
|
|
||||||
|
// 3 user messages → totalMessages=3; interval=3 → 3%3===0 → should trigger
|
||||||
|
const lines = [1, 2, 3].map(i => JSON.stringify({ type: 'user', message: { role: 'user', content: `task ${i}` } }));
|
||||||
|
fs.writeFileSync(transcriptPath, lines.join('\n') + '\n');
|
||||||
|
|
||||||
|
const stdinJson = JSON.stringify({ transcript_path: transcriptPath });
|
||||||
|
const result = await runScript(path.join(scriptsDir, 'session-end.js'), stdinJson, {
|
||||||
|
HOME: testDir,
|
||||||
|
ECC_LLM_SUMMARY_INTERVAL: '3',
|
||||||
|
ECC_SKIP_LLM_SUMMARY: '1' // prevent actual claude -p invocation in tests
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.strictEqual(result.code, 0, 'Should exit 0');
|
||||||
|
assert.ok(result.stderr.includes('[SessionEnd] LLM summary triggered'), `stderr should report LLM triggered, got: ${result.stderr}`);
|
||||||
|
cleanupTestDir(testDir);
|
||||||
|
})
|
||||||
|
)
|
||||||
|
passed++;
|
||||||
|
else failed++;
|
||||||
|
|
||||||
|
if (
|
||||||
|
await asyncTest('session-end does NOT trigger LLM when totalMessages % interval !== 0', async () => {
|
||||||
|
const testDir = createTestDir();
|
||||||
|
const transcriptPath = path.join(testDir, 'transcript.jsonl');
|
||||||
|
|
||||||
|
// 2 user messages → totalMessages=2; interval=3 → 2%3!==0 → should NOT trigger
|
||||||
|
const lines = [1, 2].map(i => JSON.stringify({ type: 'user', message: { role: 'user', content: `task ${i}` } }));
|
||||||
|
fs.writeFileSync(transcriptPath, lines.join('\n') + '\n');
|
||||||
|
|
||||||
|
const stdinJson = JSON.stringify({ transcript_path: transcriptPath });
|
||||||
|
const result = await runScript(path.join(scriptsDir, 'session-end.js'), stdinJson, {
|
||||||
|
HOME: testDir,
|
||||||
|
ECC_LLM_SUMMARY_INTERVAL: '3',
|
||||||
|
ECC_SKIP_LLM_SUMMARY: '1'
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.strictEqual(result.code, 0, 'Should exit 0');
|
||||||
|
assert.ok(!result.stderr.includes('[SessionEnd] LLM summary triggered'), `stderr should NOT report LLM triggered, got: ${result.stderr}`);
|
||||||
|
cleanupTestDir(testDir);
|
||||||
|
})
|
||||||
|
)
|
||||||
|
passed++;
|
||||||
|
else failed++;
|
||||||
|
|
||||||
// Summary
|
// Summary
|
||||||
console.log('\n=== Test Results ===');
|
console.log('\n=== Test Results ===');
|
||||||
console.log(`Passed: ${passed}`);
|
console.log(`Passed: ${passed}`);
|
||||||
|
|||||||
199
tests/lib/llm-summary.test.js
Normal file
199
tests/lib/llm-summary.test.js
Normal file
@ -0,0 +1,199 @@
|
|||||||
|
'use strict';
|
||||||
|
/**
|
||||||
|
* Tests for scripts/lib/llm-summary.js
|
||||||
|
*
|
||||||
|
* Run with: node tests/lib/llm-summary.test.js
|
||||||
|
*/
|
||||||
|
|
||||||
|
const assert = require('assert');
|
||||||
|
const fs = require('fs');
|
||||||
|
const os = require('os');
|
||||||
|
const path = require('path');
|
||||||
|
|
||||||
|
const { extractConversationText, getContextRemainingPct, getContextThreshold, getLLMModel, generateSessionSummary } = require('../../scripts/lib/llm-summary');
|
||||||
|
|
||||||
|
console.log('=== Testing llm-summary.js ===\n');
|
||||||
|
|
||||||
|
let passed = 0;
|
||||||
|
let failed = 0;
|
||||||
|
|
||||||
|
function test(desc, fn) {
|
||||||
|
try {
|
||||||
|
fn();
|
||||||
|
console.log(` ✓ ${desc}`);
|
||||||
|
passed++;
|
||||||
|
} catch (e) {
|
||||||
|
console.log(` ✗ ${desc}: ${e.message}`);
|
||||||
|
failed++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let seq = 0;
|
||||||
|
function writeTranscript(lines) {
|
||||||
|
seq++;
|
||||||
|
const p = path.join(os.tmpdir(), `llm-summary-test-${process.pid}-${seq}.jsonl`);
|
||||||
|
fs.writeFileSync(p, lines.join('\n') + '\n');
|
||||||
|
return p;
|
||||||
|
}
|
||||||
|
|
||||||
|
function userEntry(text) {
|
||||||
|
return JSON.stringify({ type: 'user', message: { role: 'user', content: [{ type: 'text', text }] } });
|
||||||
|
}
|
||||||
|
|
||||||
|
function assistantEntry(text) {
|
||||||
|
return JSON.stringify({
|
||||||
|
type: 'assistant',
|
||||||
|
message: {
|
||||||
|
role: 'assistant',
|
||||||
|
content: [{ type: 'text', text }],
|
||||||
|
usage: { input_tokens: 1000, cache_read_input_tokens: 0, cache_creation_input_tokens: 0 }
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- getLLMModel ---
|
||||||
|
console.log('getLLMModel:');
|
||||||
|
|
||||||
|
test('returns haiku by default', () => {
|
||||||
|
const orig = process.env.ECC_LLM_SUMMARY_MODEL;
|
||||||
|
delete process.env.ECC_LLM_SUMMARY_MODEL;
|
||||||
|
assert.strictEqual(getLLMModel(), 'haiku');
|
||||||
|
if (orig !== undefined) process.env.ECC_LLM_SUMMARY_MODEL = orig;
|
||||||
|
});
|
||||||
|
|
||||||
|
test('reads ECC_LLM_SUMMARY_MODEL env var', () => {
|
||||||
|
const orig = process.env.ECC_LLM_SUMMARY_MODEL;
|
||||||
|
process.env.ECC_LLM_SUMMARY_MODEL = 'sonnet';
|
||||||
|
assert.strictEqual(getLLMModel(), 'sonnet');
|
||||||
|
if (orig !== undefined) process.env.ECC_LLM_SUMMARY_MODEL = orig;
|
||||||
|
else delete process.env.ECC_LLM_SUMMARY_MODEL;
|
||||||
|
});
|
||||||
|
|
||||||
|
// --- getContextThreshold ---
|
||||||
|
console.log('\ngetContextThreshold:');
|
||||||
|
|
||||||
|
test('returns 20 by default', () => {
|
||||||
|
const orig = process.env.ECC_LLM_SUMMARY_CONTEXT_THRESHOLD;
|
||||||
|
delete process.env.ECC_LLM_SUMMARY_CONTEXT_THRESHOLD;
|
||||||
|
assert.strictEqual(getContextThreshold(), 20);
|
||||||
|
if (orig !== undefined) process.env.ECC_LLM_SUMMARY_CONTEXT_THRESHOLD = orig;
|
||||||
|
});
|
||||||
|
|
||||||
|
test('reads ECC_LLM_SUMMARY_CONTEXT_THRESHOLD env var', () => {
|
||||||
|
const orig = process.env.ECC_LLM_SUMMARY_CONTEXT_THRESHOLD;
|
||||||
|
process.env.ECC_LLM_SUMMARY_CONTEXT_THRESHOLD = '70';
|
||||||
|
assert.strictEqual(getContextThreshold(), 70);
|
||||||
|
if (orig !== undefined) process.env.ECC_LLM_SUMMARY_CONTEXT_THRESHOLD = orig;
|
||||||
|
else delete process.env.ECC_LLM_SUMMARY_CONTEXT_THRESHOLD;
|
||||||
|
});
|
||||||
|
|
||||||
|
test('falls back to 20 on invalid value', () => {
|
||||||
|
const orig = process.env.ECC_LLM_SUMMARY_CONTEXT_THRESHOLD;
|
||||||
|
process.env.ECC_LLM_SUMMARY_CONTEXT_THRESHOLD = 'notanumber';
|
||||||
|
assert.strictEqual(getContextThreshold(), 20);
|
||||||
|
if (orig !== undefined) process.env.ECC_LLM_SUMMARY_CONTEXT_THRESHOLD = orig;
|
||||||
|
else delete process.env.ECC_LLM_SUMMARY_CONTEXT_THRESHOLD;
|
||||||
|
});
|
||||||
|
|
||||||
|
test('falls back to 20 when value exceeds 100', () => {
|
||||||
|
const orig = process.env.ECC_LLM_SUMMARY_CONTEXT_THRESHOLD;
|
||||||
|
process.env.ECC_LLM_SUMMARY_CONTEXT_THRESHOLD = '150';
|
||||||
|
assert.strictEqual(getContextThreshold(), 20);
|
||||||
|
if (orig !== undefined) process.env.ECC_LLM_SUMMARY_CONTEXT_THRESHOLD = orig;
|
||||||
|
else delete process.env.ECC_LLM_SUMMARY_CONTEXT_THRESHOLD;
|
||||||
|
});
|
||||||
|
|
||||||
|
// --- extractConversationText ---
|
||||||
|
console.log('\nextractConversationText:');
|
||||||
|
|
||||||
|
test('returns null for missing file', () => {
|
||||||
|
assert.strictEqual(extractConversationText('/nonexistent/path.jsonl'), null);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('returns null for empty transcript', () => {
|
||||||
|
const p = writeTranscript([]);
|
||||||
|
assert.strictEqual(extractConversationText(p), null);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('extracts user and assistant turns', () => {
|
||||||
|
const p = writeTranscript([userEntry('Hello, can you help?'), assistantEntry('Sure, what do you need?')]);
|
||||||
|
const result = extractConversationText(p);
|
||||||
|
assert.ok(result.includes('User:'));
|
||||||
|
assert.ok(result.includes('Claude:'));
|
||||||
|
assert.ok(result.includes('Hello, can you help?'));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('truncates user text to 400 chars', () => {
|
||||||
|
const p = writeTranscript([userEntry('x'.repeat(500))]);
|
||||||
|
const result = extractConversationText(p);
|
||||||
|
assert.ok(result !== null);
|
||||||
|
assert.ok(!result.includes('x'.repeat(401)));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('skips unparseable lines gracefully', () => {
|
||||||
|
const p = writeTranscript(['not valid json', userEntry('valid message')]);
|
||||||
|
const result = extractConversationText(p);
|
||||||
|
assert.ok(result !== null);
|
||||||
|
assert.ok(result.includes('valid message'));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('limits to last 25 turns', () => {
|
||||||
|
const lines = [];
|
||||||
|
for (let i = 0; i < 30; i++) lines.push(userEntry(`message ${i}`));
|
||||||
|
const p = writeTranscript(lines);
|
||||||
|
const result = extractConversationText(p);
|
||||||
|
assert.ok(result.includes('message 29'));
|
||||||
|
assert.ok(!result.includes('message 4'));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('collapses newlines to spaces', () => {
|
||||||
|
const p = writeTranscript([userEntry('line one\nline two')]);
|
||||||
|
const result = extractConversationText(p);
|
||||||
|
assert.ok(!result.includes('\nline two'));
|
||||||
|
assert.ok(result.includes('line one line two'));
|
||||||
|
});
|
||||||
|
|
||||||
|
// --- getContextRemainingPct ---
|
||||||
|
console.log('\ngetContextRemainingPct:');
|
||||||
|
|
||||||
|
test('returns null for missing file', () => {
|
||||||
|
assert.strictEqual(getContextRemainingPct('/nonexistent.jsonl'), null);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('returns null for transcript with no usage data', () => {
|
||||||
|
const p = writeTranscript([userEntry('hi')]);
|
||||||
|
assert.strictEqual(getContextRemainingPct(p), null);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('returns numeric percentage for transcript with usage data', () => {
|
||||||
|
const p = writeTranscript([assistantEntry('ok')]);
|
||||||
|
const pct = getContextRemainingPct(p);
|
||||||
|
assert.ok(typeof pct === 'number');
|
||||||
|
assert.ok(pct >= 0 && pct <= 100);
|
||||||
|
});
|
||||||
|
|
||||||
|
// --- generateSessionSummary ---
|
||||||
|
console.log('\ngenerateSessionSummary:');
|
||||||
|
|
||||||
|
test('returns null when ECC_SKIP_LLM_SUMMARY is set', () => {
|
||||||
|
const orig = process.env.ECC_SKIP_LLM_SUMMARY;
|
||||||
|
process.env.ECC_SKIP_LLM_SUMMARY = '1';
|
||||||
|
const p = writeTranscript([userEntry('test')]);
|
||||||
|
assert.strictEqual(generateSessionSummary(p), null);
|
||||||
|
if (orig !== undefined) process.env.ECC_SKIP_LLM_SUMMARY = orig;
|
||||||
|
else delete process.env.ECC_SKIP_LLM_SUMMARY;
|
||||||
|
});
|
||||||
|
|
||||||
|
test('returns null for missing transcript (no conversation to summarize)', () => {
|
||||||
|
const orig = process.env.ECC_SKIP_LLM_SUMMARY;
|
||||||
|
delete process.env.ECC_SKIP_LLM_SUMMARY;
|
||||||
|
assert.strictEqual(generateSessionSummary('/nonexistent.jsonl'), null);
|
||||||
|
if (orig !== undefined) process.env.ECC_SKIP_LLM_SUMMARY = orig;
|
||||||
|
});
|
||||||
|
|
||||||
|
// --- Results ---
|
||||||
|
console.log('\n=== Test Results ===');
|
||||||
|
console.log(`Passed: ${passed}`);
|
||||||
|
console.log(`Failed: ${failed}`);
|
||||||
|
console.log(`Total: ${passed + failed}`);
|
||||||
|
process.exit(failed > 0 ? 1 : 0);
|
||||||
Loading…
x
Reference in New Issue
Block a user