#!/usr/bin/env node /** * SessionStart Hook - Load previous context on new session * * Cross-platform (Windows, macOS, Linux) * * Runs when a new Claude session starts. Loads the most recent session * summary into Claude's context via stdout, and reports available * sessions and learned skills. */ const { getClaudeDir, getSessionsDir, getSessionSearchDirs, getLearnedSkillsDir, getProjectName, findFiles, ensureDir, readFile, stripAnsi, log } = require('../lib/utils'); const { resolveProjectContext, writeSessionLease, resolveSessionId } = require('../lib/observer-sessions'); const { getPackageManager, getSelectionPrompt } = require('../lib/package-manager'); const { listAliases } = require('../lib/session-aliases'); const { detectProjectType } = require('../lib/project-detect'); const path = require('path'); const fs = require('fs'); const INSTINCT_CONFIDENCE_THRESHOLD = 0.7; const MAX_INJECTED_INSTINCTS = 6; const DEFAULT_SESSION_RETENTION_DAYS = 30; /** * Resolve a filesystem path to its canonical (real) form. * * Handles symlinks and, on case-insensitive filesystems (macOS, Windows), * normalizes casing so that path comparisons are reliable. * Falls back to the original path if resolution fails (e.g. path no longer exists). * * @param {string} p - The path to normalize. * @returns {string} The canonical path, or the original if resolution fails. */ function normalizePath(p) { try { return fs.realpathSync(p); } catch { return p; } } function dedupeRecentSessions(searchDirs) { const recentSessionsByName = new Map(); for (const [dirIndex, dir] of searchDirs.entries()) { const matches = findFiles(dir, '*-session.tmp', { maxAge: 7 }); for (const match of matches) { const basename = path.basename(match.path); const current = { ...match, basename, dirIndex, }; const existing = recentSessionsByName.get(basename); if ( !existing || current.mtime > existing.mtime || (current.mtime === existing.mtime && current.dirIndex < existing.dirIndex) ) { recentSessionsByName.set(basename, current); } } } return Array.from(recentSessionsByName.values()) .sort((left, right) => right.mtime - left.mtime || left.dirIndex - right.dirIndex); } function getSessionRetentionDays() { const raw = process.env.ECC_SESSION_RETENTION_DAYS; if (!raw) return DEFAULT_SESSION_RETENTION_DAYS; const parsed = Number.parseInt(raw, 10); return Number.isInteger(parsed) && parsed > 0 ? parsed : DEFAULT_SESSION_RETENTION_DAYS; } function pruneExpiredSessions(searchDirs, retentionDays) { const uniqueDirs = Array.from(new Set(searchDirs.filter(dir => typeof dir === 'string' && dir.length > 0))); let removed = 0; for (const dir of uniqueDirs) { if (!fs.existsSync(dir)) continue; let entries; try { entries = fs.readdirSync(dir, { withFileTypes: true }); } catch { continue; } for (const entry of entries) { if (!entry.isFile() || !entry.name.endsWith('-session.tmp')) continue; const fullPath = path.join(dir, entry.name); let stats; try { stats = fs.statSync(fullPath); } catch { continue; } const ageInDays = (Date.now() - stats.mtimeMs) / (1000 * 60 * 60 * 24); if (ageInDays <= retentionDays) continue; try { fs.rmSync(fullPath, { force: true }); removed += 1; } catch (error) { log(`[SessionStart] Warning: failed to prune expired session ${fullPath}: ${error.message}`); } } } return removed; } /** * Select the best matching session for the current working directory. * * Session files written by session-end.js contain header fields like: * **Project:** my-project * **Worktree:** /path/to/project * * This function reads each session file once, caching its content, and * returns both the selected session object and its already-read content * to avoid duplicate I/O in the caller. * * Priority (highest to lowest): * 1. Exact worktree (cwd) match — most recent * 2. Same project name match — most recent * 3. Fallback to overall most recent (original behavior) * * Sessions are already sorted newest-first, so the first match in each * category wins. * * @param {Array} sessions - Deduplicated session list, sorted newest-first. * @param {string} cwd - Current working directory (process.cwd()). * @param {string} currentProject - Current project name from getProjectName(). * @returns {{ session: Object, content: string, matchReason: string } | null} * The best matching session with its cached content and match reason, * or null if the sessions array is empty or all files are unreadable. */ function selectMatchingSession(sessions, cwd, currentProject) { if (sessions.length === 0) return null; // Normalize cwd once outside the loop to avoid repeated syscalls const normalizedCwd = normalizePath(cwd); let projectMatch = null; let projectMatchContent = null; let fallbackSession = null; let fallbackContent = null; for (const session of sessions) { const content = readFile(session.path); if (!content) continue; // Cache first readable session+content pair for fallback if (!fallbackSession) { fallbackSession = session; fallbackContent = content; } // Extract **Worktree:** field const worktreeMatch = content.match(/\*\*Worktree:\*\*\s*(.+)$/m); const sessionWorktree = worktreeMatch ? worktreeMatch[1].trim() : ''; // Exact worktree match — best possible, return immediately // Normalize both paths to handle symlinks and case-insensitive filesystems if (sessionWorktree && normalizePath(sessionWorktree) === normalizedCwd) { return { session, content, matchReason: 'worktree' }; } // Project name match — keep searching for a worktree match if (!projectMatch && currentProject) { const projectFieldMatch = content.match(/\*\*Project:\*\*\s*(.+)$/m); const sessionProject = projectFieldMatch ? projectFieldMatch[1].trim() : ''; if (sessionProject && sessionProject === currentProject) { projectMatch = session; projectMatchContent = content; } } } if (projectMatch) { return { session: projectMatch, content: projectMatchContent, matchReason: 'project' }; } // Fallback: most recent readable session (original behavior) if (fallbackSession) { return { session: fallbackSession, content: fallbackContent, matchReason: 'recency-fallback' }; } log('[SessionStart] All session files were unreadable'); return null; } function parseInstinctFile(content) { const instincts = []; let current = null; let inFrontmatter = false; let contentLines = []; for (const line of String(content).split('\n')) { if (line.trim() === '---') { if (inFrontmatter) { inFrontmatter = false; } else { if (current && current.id) { current.content = contentLines.join('\n').trim(); instincts.push(current); } current = {}; contentLines = []; inFrontmatter = true; } continue; } if (inFrontmatter) { const separatorIndex = line.indexOf(':'); if (separatorIndex === -1) continue; const key = line.slice(0, separatorIndex).trim(); let value = line.slice(separatorIndex + 1).trim(); if ((value.startsWith('"') && value.endsWith('"')) || (value.startsWith("'") && value.endsWith("'"))) { value = value.slice(1, -1); } if (key === 'confidence') { const parsed = Number.parseFloat(value); current[key] = Number.isFinite(parsed) ? parsed : 0.5; } else { current[key] = value; } } else if (current) { contentLines.push(line); } } if (current && current.id) { current.content = contentLines.join('\n').trim(); instincts.push(current); } return instincts; } function readInstinctsFromDir(directory, scope) { if (!directory || !fs.existsSync(directory)) return []; const entries = fs.readdirSync(directory, { withFileTypes: true }) .filter(entry => entry.isFile() && /\.(ya?ml|md)$/i.test(entry.name)) .sort((left, right) => left.name.localeCompare(right.name)); const instincts = []; for (const entry of entries) { const filePath = path.join(directory, entry.name); try { const parsed = parseInstinctFile(fs.readFileSync(filePath, 'utf8')); for (const instinct of parsed) { instincts.push({ ...instinct, _scopeLabel: scope, _sourceFile: filePath, }); } } catch (error) { log(`[SessionStart] Warning: failed to parse instinct file ${filePath}: ${error.message}`); } } return instincts; } function extractInstinctAction(content) { const actionMatch = String(content || '').match(/## Action\s*\n+([\s\S]+?)(?:\n## |\n---|$)/); const actionBlock = (actionMatch ? actionMatch[1] : String(content || '')).trim(); const firstLine = actionBlock .split('\n') .map(line => line.trim()) .find(Boolean); return firstLine || ''; } function summarizeActiveInstincts(observerContext) { const homunculusDir = path.join(getClaudeDir(), 'homunculus'); const globalDirs = [ { dir: path.join(homunculusDir, 'instincts', 'personal'), scope: 'global' }, { dir: path.join(homunculusDir, 'instincts', 'inherited'), scope: 'global' }, ]; const projectDirs = observerContext.isGlobal ? [] : [ { dir: path.join(observerContext.projectDir, 'instincts', 'personal'), scope: 'project' }, { dir: path.join(observerContext.projectDir, 'instincts', 'inherited'), scope: 'project' }, ]; const scopedInstincts = [ ...projectDirs.flatMap(({ dir, scope }) => readInstinctsFromDir(dir, scope)), ...globalDirs.flatMap(({ dir, scope }) => readInstinctsFromDir(dir, scope)), ]; const deduped = new Map(); for (const instinct of scopedInstincts) { if (!instinct.id || instinct.confidence < INSTINCT_CONFIDENCE_THRESHOLD) continue; const existing = deduped.get(instinct.id); if (!existing || (existing._scopeLabel !== 'project' && instinct._scopeLabel === 'project')) { deduped.set(instinct.id, instinct); } } const ranked = Array.from(deduped.values()) .map(instinct => ({ ...instinct, action: extractInstinctAction(instinct.content), })) .filter(instinct => instinct.action) .sort((left, right) => { if (right.confidence !== left.confidence) return right.confidence - left.confidence; if (left._scopeLabel !== right._scopeLabel) return left._scopeLabel === 'project' ? -1 : 1; return String(left.id).localeCompare(String(right.id)); }) .slice(0, MAX_INJECTED_INSTINCTS); if (ranked.length === 0) { return ''; } log(`[SessionStart] Injecting ${ranked.length} instinct(s) into session context`); const lines = ranked.map(instinct => { const scope = instinct._scopeLabel === 'project' ? 'project' : 'global'; const confidence = `${Math.round(instinct.confidence * 100)}%`; return `- [${scope} ${confidence}] ${instinct.action}`; }); return `Active instincts:\n${lines.join('\n')}`; } async function main() { const sessionsDir = getSessionsDir(); const sessionSearchDirs = getSessionSearchDirs(); const learnedDir = getLearnedSkillsDir(); const additionalContextParts = []; const observerContext = resolveProjectContext(); // Ensure directories exist ensureDir(sessionsDir); ensureDir(learnedDir); const retentionDays = getSessionRetentionDays(); const prunedSessions = pruneExpiredSessions(sessionSearchDirs, retentionDays); if (prunedSessions > 0) { log(`[SessionStart] Pruned ${prunedSessions} expired session(s) older than ${retentionDays} day(s)`); } const observerSessionId = resolveSessionId(); if (observerSessionId) { writeSessionLease(observerContext, observerSessionId, { hook: 'SessionStart', projectRoot: observerContext.projectRoot }); log(`[SessionStart] Registered observer lease for ${observerSessionId}`); } else { log('[SessionStart] No CLAUDE_SESSION_ID available; skipping observer lease registration'); } const instinctSummary = summarizeActiveInstincts(observerContext); if (instinctSummary) { additionalContextParts.push(instinctSummary); } // Check for recent session files (last 7 days) const recentSessions = dedupeRecentSessions(sessionSearchDirs); if (recentSessions.length > 0) { log(`[SessionStart] Found ${recentSessions.length} recent session(s)`); // Prefer a session that matches the current working directory or project. // Session files contain **Project:** and **Worktree:** header fields written // by session-end.js, so we can match against them. const cwd = process.cwd(); const currentProject = getProjectName() || ''; const result = selectMatchingSession(recentSessions, cwd, currentProject); if (result) { log(`[SessionStart] Selected: ${result.session.path} (match: ${result.matchReason})`); // Use the already-read content from selectMatchingSession (no duplicate I/O) const content = stripAnsi(result.content); if (content && !content.includes('[Session context goes here]')) { additionalContextParts.push(`Previous session summary:\n${content}`); } } else { log('[SessionStart] No matching session found'); } } // Check for learned skills const learnedSkills = findFiles(learnedDir, '*.md'); if (learnedSkills.length > 0) { log(`[SessionStart] ${learnedSkills.length} learned skill(s) available in ${learnedDir}`); } // Check for available session aliases const aliases = listAliases({ limit: 5 }); if (aliases.length > 0) { const aliasNames = aliases.map(a => a.name).join(', '); log(`[SessionStart] ${aliases.length} session alias(es) available: ${aliasNames}`); log(`[SessionStart] Use /sessions load to continue a previous session`); } // Detect and report package manager const pm = getPackageManager(); log(`[SessionStart] Package manager: ${pm.name} (${pm.source})`); // If no explicit package manager config was found, show selection prompt if (pm.source === 'default') { log('[SessionStart] No package manager preference found.'); log(getSelectionPrompt()); } // Detect project type and frameworks (#293) const projectInfo = detectProjectType(); if (projectInfo.languages.length > 0 || projectInfo.frameworks.length > 0) { const parts = []; if (projectInfo.languages.length > 0) { parts.push(`languages: ${projectInfo.languages.join(', ')}`); } if (projectInfo.frameworks.length > 0) { parts.push(`frameworks: ${projectInfo.frameworks.join(', ')}`); } log(`[SessionStart] Project detected — ${parts.join('; ')}`); additionalContextParts.push(`Project type: ${JSON.stringify(projectInfo)}`); } else { log('[SessionStart] No specific project type detected'); } await writeSessionStartPayload(additionalContextParts.join('\n\n')); } function writeSessionStartPayload(additionalContext) { return new Promise((resolve, reject) => { let settled = false; const payload = JSON.stringify({ hookSpecificOutput: { hookEventName: 'SessionStart', additionalContext } }); const handleError = (err) => { if (settled) return; settled = true; if (err) { log(`[SessionStart] stdout write error: ${err.message}`); } reject(err || new Error('stdout stream error')); }; process.stdout.once('error', handleError); process.stdout.write(payload, (err) => { process.stdout.removeListener('error', handleError); if (settled) return; settled = true; if (err) { log(`[SessionStart] stdout write error: ${err.message}`); reject(err); return; } resolve(); }); }); } main().catch(err => { console.error('[SessionStart] Error:', err.message); process.exitCode = 0; // Don't block on errors });