/** * Context Hook - SessionStart * Consolidated entry point + logic (no try-catch bullshit) */ import path from 'path'; import { stdin } from 'process'; import { SessionStore } from '../services/sqlite/SessionStore.js'; import { ensureWorkerRunning } from '../shared/worker-utils.js'; export interface SessionStartInput { session_id?: string; transcript_path?: string; cwd?: string; hook_event_name?: string; source?: "startup" | "resume" | "clear" | "compact"; [key: string]: any; } // ANSI color codes for terminal output const colors = { reset: '\x1b[0m', bright: '\x1b[1m', dim: '\x1b[2m', cyan: '\x1b[36m', green: '\x1b[32m', yellow: '\x1b[33m', blue: '\x1b[34m', magenta: '\x1b[35m', gray: '\x1b[90m', red: '\x1b[31m', }; interface Observation { id: number; sdk_session_id: string; type: string; title: string | null; subtitle: string | null; narrative: string | null; facts: string | null; concepts: string | null; files_read: string | null; files_modified: string | null; created_at: string; created_at_epoch: number; } // Helper: Parse JSON array safely function parseJsonArray(json: string | null): string[] { if (!json) return []; const parsed = JSON.parse(json); return Array.isArray(parsed) ? parsed : []; } // Helper: Format date with time function formatDateTime(dateStr: string): string { const date = new Date(dateStr); return date.toLocaleString('en-US', { month: 'short', day: 'numeric', hour: 'numeric', minute: '2-digit', hour12: true }); } // Helper: Format just time (no date) function formatTime(dateStr: string): string { const date = new Date(dateStr); return date.toLocaleString('en-US', { hour: 'numeric', minute: '2-digit', hour12: true }); } // Helper: Format just date function formatDate(dateStr: string): string { const date = new Date(dateStr); return date.toLocaleString('en-US', { month: 'short', day: 'numeric', year: 'numeric' }); } // Helper: Estimate token count for text function estimateTokens(text: string | null): number { if (!text) return 0; // Rough estimate: ~4 characters per token return Math.ceil(text.length / 4); } // Helper: Convert absolute paths to relative paths function toRelativePath(filePath: string, cwd: string): string { if (path.isAbsolute(filePath)) { return path.relative(cwd, filePath); } return filePath; } // Helper: Get all observations for given sessions function getObservations(db: SessionStore, sessionIds: string[]): Observation[] { if (sessionIds.length === 0) return []; const placeholders = sessionIds.map(() => '?').join(','); const observations = db.db.prepare(` SELECT id, sdk_session_id, type, title, subtitle, narrative, facts, concepts, files_read, files_modified, created_at, created_at_epoch FROM observations WHERE sdk_session_id IN (${placeholders}) ORDER BY created_at_epoch DESC `).all(...sessionIds) as Observation[]; return observations; } /** * Context Hook Main Logic */ function contextHook(input?: SessionStartInput, useColors: boolean = false, useIndexView: boolean = false): string { ensureWorkerRunning(); const cwd = input?.cwd ?? process.cwd(); const project = cwd ? path.basename(cwd) : 'unknown-project'; const db = new SessionStore(); // Get last 4 summaries (use 4th for offset calculation) const recentSummaries = db.db.prepare(` SELECT id, sdk_session_id, request, completed, next_steps, created_at, created_at_epoch FROM session_summaries WHERE project = ? ORDER BY created_at_epoch DESC LIMIT 4 `).all(project) as Array<{ id: number; sdk_session_id: string; request: string | null; completed: string | null; next_steps: string | null; created_at: string; created_at_epoch: number }>; if (recentSummaries.length === 0) { db.close(); if (useColors) { return `\n${colors.bright}${colors.cyan}📝 [${project}] recent context${colors.reset}\n${colors.gray}${'─'.repeat(60)}${colors.reset}\n\n${colors.dim}No previous sessions found for this project yet.${colors.reset}\n`; } return `# [${project}] recent context\n\nNo previous sessions found for this project yet.`; } // Extract unique session IDs from first 3 summaries const displaySummaries = recentSummaries.slice(0, 3); const sessionIds = [...new Set(displaySummaries.map(s => s.sdk_session_id))]; // Get all observations from these sessions const observations = getObservations(db, sessionIds); // Filter observations by key concepts for timeline const timelineObs = observations.filter(obs => { const concepts = parseJsonArray(obs.concepts); return concepts.includes('what-changed') || concepts.includes('how-it-works') || concepts.includes('problem-solution') || concepts.includes('gotcha') || concepts.includes('discovery') || concepts.includes('why-it-exists') || concepts.includes('decision') || concepts.includes('trade-off'); }); // Build output const output: string[] = []; // Header if (useColors) { output.push(''); output.push(`${colors.bright}${colors.cyan}📝 [${project}] recent context${colors.reset}`); output.push(`${colors.gray}${'─'.repeat(60)}${colors.reset}`); output.push(''); } else { output.push(`# [${project}] recent context`); output.push(''); } // Chronological Timeline if (timelineObs.length > 0) { // Legend/Key if (useColors) { output.push(`${colors.dim}Legend: 🎯 session-request | 🔴 gotcha | 🟡 problem-solution | 🔵 how-it-works | 🟢 what-changed | 🟣 discovery | 🟠 why-it-exists | 🟤 decision | ⚖️ trade-off${colors.reset}`); output.push(''); } else { output.push(`**Legend:** 🎯 session-request | 🔴 gotcha | 🟡 problem-solution | 🔵 how-it-works | 🟢 what-changed | 🟣 discovery | 🟠 why-it-exists | 🟤 decision | ⚖️ trade-off`); output.push(''); } // Progressive Disclosure Usage Instructions if (useColors) { output.push(`${colors.dim}💡 Progressive Disclosure: This index shows WHAT exists (titles) and retrieval COST (token counts).${colors.reset}`); output.push(`${colors.dim} → Use MCP search tools to fetch full observation details on-demand (Layer 2)${colors.reset}`); output.push(`${colors.dim} → Prefer searching observations over re-reading code for past decisions and learnings${colors.reset}`); output.push(`${colors.dim} → Critical types (🔴 gotcha, 🟤 decision, ⚖️ trade-off) often worth fetching immediately${colors.reset}`); output.push(''); } else { output.push(`💡 **Progressive Disclosure:** This index shows WHAT exists (titles) and retrieval COST (token counts).`); output.push(`- Use MCP search tools to fetch full observation details on-demand (Layer 2)`); output.push(`- Prefer searching observations over re-reading code for past decisions and learnings`); output.push(`- Critical types (🔴 gotcha, 🟤 decision, ⚖️ trade-off) often worth fetching immediately`); output.push(''); } // Create unified timeline with both observations and summaries const mostRecentSummaryId = recentSummaries[0]?.id; // Create offset summaries const summariesWithOffset = displaySummaries.map((summary, i) => { // Most recent keeps its own time, others offset to next summary's time const nextSummary = i === 0 ? null : recentSummaries[i + 1]; return { ...summary, displayEpoch: nextSummary ? nextSummary.created_at_epoch : summary.created_at_epoch, displayTime: nextSummary ? nextSummary.created_at : summary.created_at, isMostRecent: summary.id === mostRecentSummaryId }; }); type TimelineItem = | { type: 'observation'; data: Observation } | { type: 'summary'; data: typeof summariesWithOffset[0] }; const timeline: TimelineItem[] = [ ...timelineObs.map(obs => ({ type: 'observation' as const, data: obs })), ...summariesWithOffset.map(summary => ({ type: 'summary' as const, data: summary })) ]; // Sort chronologically timeline.sort((a, b) => { const aEpoch = a.type === 'observation' ? a.data.created_at_epoch : a.data.displayEpoch; const bEpoch = b.type === 'observation' ? b.data.created_at_epoch : b.data.displayEpoch; return aEpoch - bEpoch; }); // Group by day for rendering const dayTimelines = new Map(); for (const item of timeline) { const itemDate = item.type === 'observation' ? item.data.created_at : item.data.displayTime; const day = formatDate(itemDate); if (!dayTimelines.has(day)) { dayTimelines.set(day, []); } dayTimelines.get(day)!.push(item); } // Sort days chronologically const sortedDays = Array.from(dayTimelines.entries()).sort((a, b) => { const aDate = new Date(a[0]).getTime(); const bDate = new Date(b[0]).getTime(); return aDate - bDate; }); // Render each day's timeline for (const [day, dayItems] of sortedDays) { // Day header if (useColors) { output.push(`${colors.bright}${colors.cyan}${day}${colors.reset}`); output.push(''); } else { output.push(`### ${day}`); output.push(''); } // Render items chronologically with visual file grouping let currentFile: string | null = null; let lastTime = ''; let tableOpen = false; for (const item of dayItems) { if (item.type === 'summary') { // Close any open table if (tableOpen) { output.push(''); tableOpen = false; currentFile = null; lastTime = ''; } // Render summary const summary = item.data; const summaryTitle = `${summary.request || 'Session started'} (${formatDateTime(summary.displayTime)})`; const link = summary.isMostRecent ? '' : `claude-mem://session-summary/${summary.id}`; if (useColors) { const linkPart = link ? `${colors.dim}[${link}]${colors.reset}` : ''; output.push(`🎯 ${colors.yellow}#S${summary.id}${colors.reset} ${summaryTitle} ${linkPart}`); } else { const linkPart = link ? ` [→](${link})` : ''; output.push(`**🎯 #S${summary.id}** ${summaryTitle}${linkPart}`); } output.push(''); } else { // Render observation const obs = item.data; const files = parseJsonArray(obs.files_modified); const file = files.length > 0 ? toRelativePath(files[0], cwd) : 'General'; // Check if we need a new file section if (file !== currentFile) { // Close previous table if (tableOpen) { output.push(''); } // File header if (useColors) { output.push(`${colors.dim}${file}${colors.reset}`); } else { output.push(`**${file}**`); } // Table header (markdown only) if (!useColors) { output.push(`| ID | Time | T | Title | Tokens |`); output.push(`|----|------|---|-------|--------|`); } currentFile = file; tableOpen = true; lastTime = ''; } // Render observation row const concepts = parseJsonArray(obs.concepts); let icon = '•'; // Priority order: gotcha > decision > trade-off > problem-solution > discovery > why-it-exists > how-it-works > what-changed if (concepts.includes('gotcha')) { icon = '🔴'; } else if (concepts.includes('decision')) { icon = '🟤'; } else if (concepts.includes('trade-off')) { icon = '⚖️'; } else if (concepts.includes('problem-solution')) { icon = '🟡'; } else if (concepts.includes('discovery')) { icon = '🟣'; } else if (concepts.includes('why-it-exists')) { icon = '🟠'; } else if (concepts.includes('how-it-works')) { icon = '🔵'; } else if (concepts.includes('what-changed')) { icon = '🟢'; } const time = formatTime(obs.created_at); const title = obs.title || 'Untitled'; const tokens = estimateTokens(obs.narrative); const showTime = time !== lastTime; const timeDisplay = showTime ? time : ''; lastTime = time; if (useColors) { const timePart = showTime ? `${colors.dim}${time}${colors.reset}` : ' '.repeat(time.length); const tokensPart = tokens > 0 ? `${colors.dim}(~${tokens}t)${colors.reset}` : ''; output.push(` ${colors.dim}#${obs.id}${colors.reset} ${timePart} ${icon} ${title} ${tokensPart}`); } else { output.push(`| #${obs.id} | ${timeDisplay || '″'} | ${icon} | ${title} | ~${tokens} |`); } } } // Close final table if open if (tableOpen) { output.push(''); } } // Add full summary details for most recent session const mostRecentSummary = recentSummaries[0]; if (mostRecentSummary && (mostRecentSummary.completed || mostRecentSummary.next_steps)) { if (mostRecentSummary.completed) { if (useColors) { output.push(`${colors.green}Completed:${colors.reset} ${mostRecentSummary.completed}`); } else { output.push(`**Completed**: ${mostRecentSummary.completed}`); } output.push(''); } if (mostRecentSummary.next_steps) { if (useColors) { output.push(`${colors.magenta}Next Steps:${colors.reset} ${mostRecentSummary.next_steps}`); } else { output.push(`**Next Steps**: ${mostRecentSummary.next_steps}`); } output.push(''); } } // Footer with MCP search instructions if (useColors) { output.push(`${colors.dim}Use claude-mem MCP search to access records with the given ID${colors.reset}`); } else { output.push(`*Use claude-mem MCP search to access records with the given ID*`); } output.push(''); } // Footer if (useColors) { output.push(`${colors.gray}${'─'.repeat(60)}${colors.reset}`); output.push(''); } db.close(); return output.join('\n'); } // Entry Point - handle stdin/stdout const useIndexView = process.argv.includes('--index'); if (stdin.isTTY) { // Running manually from terminal - print formatted output with colors const contextOutput = contextHook(undefined, true, useIndexView); console.log(contextOutput); process.exit(0); } else { // Running from hook - wrap in hookSpecificOutput JSON format let input = ''; stdin.on('data', (chunk) => input += chunk); stdin.on('end', () => { const parsed = input.trim() ? JSON.parse(input) : undefined; const contextOutput = contextHook(parsed, false, useIndexView); const result = { hookSpecificOutput: { hookEventName: "SessionStart", additionalContext: contextOutput } }; console.log(JSON.stringify(result)); process.exit(0); }); }