/** * Context Generator - generates context injection for SessionStart * * This module contains all the logic for building the context injection string. * It's used by the worker service and called via HTTP from the context-hook. */ import path from 'path'; import { homedir } from 'os'; import { existsSync, readFileSync, unlinkSync } from 'fs'; import { SessionStore } from './sqlite/SessionStore.js'; import { logger } from '../utils/logger.js'; import { SettingsDefaultsManager } from '../shared/SettingsDefaultsManager.js'; import { parseJsonArray, formatDateTime, formatTime, formatDate, toRelativePath, extractFirstFile } from '../shared/timeline-formatting.js'; import { getProjectName } from '../utils/project-name.js'; import { ModeManager } from './domain/ModeManager.js'; // Version marker path - use homedir-based path that works in both CJS and ESM contexts const VERSION_MARKER_PATH = path.join(homedir(), '.claude', 'plugins', 'marketplaces', 'thedotmack', 'plugin', '.install-version'); interface ContextConfig { // Display counts totalObservationCount: number; fullObservationCount: number; sessionCount: number; // Token display toggles showReadTokens: boolean; showWorkTokens: boolean; showSavingsAmount: boolean; showSavingsPercent: boolean; // Filters observationTypes: Set; observationConcepts: Set; // Display options fullObservationField: 'narrative' | 'facts'; showLastSummary: boolean; showLastMessage: boolean; } /** * Load all context configuration settings * Priority: ~/.claude-mem/settings.json > env var > defaults */ function loadContextConfig(): ContextConfig { const settingsPath = path.join(homedir(), '.claude-mem', 'settings.json'); const settings = SettingsDefaultsManager.loadFromFile(settingsPath); // For non-code modes, use all types/concepts from active mode instead of settings const modeId = settings.CLAUDE_MEM_MODE; const isCodeMode = modeId === 'code' || modeId.startsWith('code--'); let observationTypes: Set; let observationConcepts: Set; if (isCodeMode) { // Code mode: use settings-based filtering observationTypes = new Set( settings.CLAUDE_MEM_CONTEXT_OBSERVATION_TYPES.split(',').map((t: string) => t.trim()).filter(Boolean) ); observationConcepts = new Set( settings.CLAUDE_MEM_CONTEXT_OBSERVATION_CONCEPTS.split(',').map((c: string) => c.trim()).filter(Boolean) ); } else { // Non-code modes: use all types/concepts from active mode const mode = ModeManager.getInstance().getActiveMode(); observationTypes = new Set(mode.observation_types.map(t => t.id)); observationConcepts = new Set(mode.observation_concepts.map(c => c.id)); } return { totalObservationCount: parseInt(settings.CLAUDE_MEM_CONTEXT_OBSERVATIONS, 10), fullObservationCount: parseInt(settings.CLAUDE_MEM_CONTEXT_FULL_COUNT, 10), sessionCount: parseInt(settings.CLAUDE_MEM_CONTEXT_SESSION_COUNT, 10), showReadTokens: settings.CLAUDE_MEM_CONTEXT_SHOW_READ_TOKENS === 'true', showWorkTokens: settings.CLAUDE_MEM_CONTEXT_SHOW_WORK_TOKENS === 'true', showSavingsAmount: settings.CLAUDE_MEM_CONTEXT_SHOW_SAVINGS_AMOUNT === 'true', showSavingsPercent: settings.CLAUDE_MEM_CONTEXT_SHOW_SAVINGS_PERCENT === 'true', observationTypes, observationConcepts, fullObservationField: settings.CLAUDE_MEM_CONTEXT_FULL_FIELD as 'narrative' | 'facts', showLastSummary: settings.CLAUDE_MEM_CONTEXT_SHOW_LAST_SUMMARY === 'true', showLastMessage: settings.CLAUDE_MEM_CONTEXT_SHOW_LAST_MESSAGE === 'true', }; } // Configuration constants const CHARS_PER_TOKEN_ESTIMATE = 4; const SUMMARY_LOOKAHEAD = 1; export interface ContextInput { 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; discovery_tokens: number | null; created_at: string; created_at_epoch: number; } interface SessionSummary { id: number; sdk_session_id: string; request: string | null; investigated: string | null; learned: string | null; completed: string | null; next_steps: string | null; created_at: string; created_at_epoch: number; } // Helper: Render a summary field function renderSummaryField(label: string, value: string | null, color: string, useColors: boolean): string[] { if (!value) return []; if (useColors) { return [`${color}${label}:${colors.reset} ${value}`, '']; } return [`**${label}**: ${value}`, '']; } // Helper: Convert cwd path to dashed format function cwdToDashed(cwd: string): string { return cwd.replace(/\//g, '-'); } // Helper: Extract last assistant message from transcript file function extractPriorMessages(transcriptPath: string): { userMessage: string; assistantMessage: string } { try { if (!existsSync(transcriptPath)) { return { userMessage: '', assistantMessage: '' }; } const content = readFileSync(transcriptPath, 'utf-8').trim(); if (!content) { return { userMessage: '', assistantMessage: '' }; } const lines = content.split('\n').filter(line => line.trim()); let lastAssistantMessage = ''; for (let i = lines.length - 1; i >= 0; i--) { try { const line = lines[i]; if (!line.includes('"type":"assistant"')) { continue; } const entry = JSON.parse(line); if (entry.type === 'assistant' && entry.message?.content && Array.isArray(entry.message.content)) { let text = ''; for (const block of entry.message.content) { if (block.type === 'text') { text += block.text; } } text = text.replace(/[\s\S]*?<\/system-reminder>/g, '').trim(); if (text) { lastAssistantMessage = text; break; } } } catch (parseError) { continue; } } return { userMessage: '', assistantMessage: lastAssistantMessage }; } catch (error) { logger.failure('WORKER', `Failed to extract prior messages from transcript`, { transcriptPath }, error as Error); return { userMessage: '', assistantMessage: '' }; } } /** * Generate context for a project */ export async function generateContext(input?: ContextInput, useColors: boolean = false): Promise { const config = loadContextConfig(); const cwd = input?.cwd ?? process.cwd(); const project = getProjectName(cwd); let db: SessionStore | null = null; try { db = new SessionStore(); } catch (error: any) { if (error.code === 'ERR_DLOPEN_FAILED') { try { unlinkSync(VERSION_MARKER_PATH); } catch (unlinkError) { // Marker might not exist } console.error('Native module rebuild needed - restart Claude Code to auto-fix'); return ''; } throw error; } // Build SQL WHERE clause for observation types const typeArray = Array.from(config.observationTypes); const typePlaceholders = typeArray.map(() => '?').join(','); // Build SQL WHERE clause for concepts const conceptArray = Array.from(config.observationConcepts); const conceptPlaceholders = conceptArray.map(() => '?').join(','); // Get recent observations const observations = db.db.prepare(` SELECT id, sdk_session_id, type, title, subtitle, narrative, facts, concepts, files_read, files_modified, discovery_tokens, created_at, created_at_epoch FROM observations WHERE project = ? AND type IN (${typePlaceholders}) AND EXISTS ( SELECT 1 FROM json_each(concepts) WHERE value IN (${conceptPlaceholders}) ) ORDER BY created_at_epoch DESC LIMIT ? `).all(project, ...typeArray, ...conceptArray, config.totalObservationCount) as Observation[]; // Get recent summaries const recentSummaries = db.db.prepare(` SELECT id, sdk_session_id, request, investigated, learned, completed, next_steps, created_at, created_at_epoch FROM session_summaries WHERE project = ? ORDER BY created_at_epoch DESC LIMIT ? `).all(project, config.sessionCount + SUMMARY_LOOKAHEAD) as SessionSummary[]; // Retrieve prior session messages if enabled let priorUserMessage = ''; let priorAssistantMessage = ''; if (config.showLastMessage && observations.length > 0) { const currentSessionId = input?.session_id; const priorSessionObs = observations.find(obs => obs.sdk_session_id !== currentSessionId); if (priorSessionObs) { const priorSessionId = priorSessionObs.sdk_session_id; const dashedCwd = cwdToDashed(cwd); const transcriptPath = path.join(homedir(), '.claude', 'projects', dashedCwd, `${priorSessionId}.jsonl`); const messages = extractPriorMessages(transcriptPath); priorUserMessage = messages.userMessage; priorAssistantMessage = messages.assistantMessage; } } // If we have neither observations nor summaries, show empty state if (observations.length === 0 && 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.`; } const displaySummaries = recentSummaries.slice(0, config.sessionCount); const timelineObs = observations; // 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 - generate dynamically from active mode const mode = ModeManager.getInstance().getActiveMode(); const typeLegendItems = mode.observation_types.map(t => `${t.emoji} ${t.id}`).join(' | '); if (useColors) { output.push(`${colors.dim}Legend: 🎯 session-request | ${typeLegendItems}${colors.reset}`); } else { output.push(`**Legend:** 🎯 session-request | ${typeLegendItems}`); } output.push(''); // Column Key if (useColors) { output.push(`${colors.bright}💡 Column Key${colors.reset}`); output.push(`${colors.dim} Read: Tokens to read this observation (cost to learn it now)${colors.reset}`); output.push(`${colors.dim} Work: Tokens spent on work that produced this record (🔍 research, 🛠️ building, ⚖️ deciding)${colors.reset}`); } else { output.push(`💡 **Column Key**:`); output.push(`- **Read**: Tokens to read this observation (cost to learn it now)`); output.push(`- **Work**: Tokens spent on work that produced this record (🔍 research, 🛠️ building, ⚖️ deciding)`); } output.push(''); // Context Index Instructions if (useColors) { output.push(`${colors.dim}💡 Context Index: This semantic index (titles, types, files, tokens) is usually sufficient to understand past work.${colors.reset}`); output.push(''); output.push(`${colors.dim}When you need implementation details, rationale, or debugging context:${colors.reset}`); output.push(`${colors.dim} - Use the mem-search skill to fetch full observations on-demand${colors.reset}`); output.push(`${colors.dim} - Critical types (🔴 bugfix, ⚖️ decision) often need detailed fetching${colors.reset}`); output.push(`${colors.dim} - Trust this index over re-reading code for past decisions and learnings${colors.reset}`); } else { output.push(`💡 **Context Index:** This semantic index (titles, types, files, tokens) is usually sufficient to understand past work.`); output.push(''); output.push(`When you need implementation details, rationale, or debugging context:`); output.push(`- Use the mem-search skill to fetch full observations on-demand`); output.push(`- Critical types (🔴 bugfix, ⚖️ decision) often need detailed fetching`); output.push(`- Trust this index over re-reading code for past decisions and learnings`); } output.push(''); // Context Economics const totalObservations = observations.length; const totalReadTokens = observations.reduce((sum, obs) => { const obsSize = (obs.title?.length || 0) + (obs.subtitle?.length || 0) + (obs.narrative?.length || 0) + JSON.stringify(obs.facts || []).length; return sum + Math.ceil(obsSize / CHARS_PER_TOKEN_ESTIMATE); }, 0); const totalDiscoveryTokens = observations.reduce((sum, obs) => sum + (obs.discovery_tokens || 0), 0); const savings = totalDiscoveryTokens - totalReadTokens; const savingsPercent = totalDiscoveryTokens > 0 ? Math.round((savings / totalDiscoveryTokens) * 100) : 0; const showContextEconomics = config.showReadTokens || config.showWorkTokens || config.showSavingsAmount || config.showSavingsPercent; if (showContextEconomics) { if (useColors) { output.push(`${colors.bright}${colors.cyan}📊 Context Economics${colors.reset}`); output.push(`${colors.dim} Loading: ${totalObservations} observations (${totalReadTokens.toLocaleString()} tokens to read)${colors.reset}`); output.push(`${colors.dim} Work investment: ${totalDiscoveryTokens.toLocaleString()} tokens spent on research, building, and decisions${colors.reset}`); if (totalDiscoveryTokens > 0 && (config.showSavingsAmount || config.showSavingsPercent)) { let savingsLine = ' Your savings: '; if (config.showSavingsAmount && config.showSavingsPercent) { savingsLine += `${savings.toLocaleString()} tokens (${savingsPercent}% reduction from reuse)`; } else if (config.showSavingsAmount) { savingsLine += `${savings.toLocaleString()} tokens`; } else { savingsLine += `${savingsPercent}% reduction from reuse`; } output.push(`${colors.green}${savingsLine}${colors.reset}`); } output.push(''); } else { output.push(`📊 **Context Economics**:`); output.push(`- Loading: ${totalObservations} observations (${totalReadTokens.toLocaleString()} tokens to read)`); output.push(`- Work investment: ${totalDiscoveryTokens.toLocaleString()} tokens spent on research, building, and decisions`); if (totalDiscoveryTokens > 0 && (config.showSavingsAmount || config.showSavingsPercent)) { let savingsLine = '- Your savings: '; if (config.showSavingsAmount && config.showSavingsPercent) { savingsLine += `${savings.toLocaleString()} tokens (${savingsPercent}% reduction from reuse)`; } else if (config.showSavingsAmount) { savingsLine += `${savings.toLocaleString()} tokens`; } else { savingsLine += `${savingsPercent}% reduction from reuse`; } output.push(savingsLine); } output.push(''); } } // Prepare summaries for timeline display const mostRecentSummaryId = recentSummaries[0]?.id; interface SummaryTimelineItem extends SessionSummary { displayEpoch: number; displayTime: string; shouldShowLink: boolean; } const summariesForTimeline: SummaryTimelineItem[] = displaySummaries.map((summary, i) => { const olderSummary = i === 0 ? null : recentSummaries[i + 1]; return { ...summary, displayEpoch: olderSummary ? olderSummary.created_at_epoch : summary.created_at_epoch, displayTime: olderSummary ? olderSummary.created_at : summary.created_at, shouldShowLink: summary.id !== mostRecentSummaryId }; }); // Identify which observations should show full details const fullObservationIds = new Set( observations .slice(0, config.fullObservationCount) .map(obs => obs.id) ); type TimelineItem = | { type: 'observation'; data: Observation } | { type: 'summary'; data: SummaryTimelineItem }; const timeline: TimelineItem[] = [ ...timelineObs.map(obs => ({ type: 'observation' as const, data: obs })), ...summariesForTimeline.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 const itemsByDay = new Map(); for (const item of timeline) { const itemDate = item.type === 'observation' ? item.data.created_at : item.data.displayTime; const day = formatDate(itemDate); if (!itemsByDay.has(day)) { itemsByDay.set(day, []); } itemsByDay.get(day)!.push(item); } // Sort days chronologically const sortedDays = Array.from(itemsByDay.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) { if (useColors) { output.push(`${colors.bright}${colors.cyan}${day}${colors.reset}`); output.push(''); } else { output.push(`### ${day}`); output.push(''); } let currentFile: string | null = null; let lastTime = ''; let tableOpen = false; for (const item of dayItems) { if (item.type === 'summary') { if (tableOpen) { output.push(''); tableOpen = false; currentFile = null; lastTime = ''; } const summary = item.data; const summaryTitle = `${summary.request || 'Session started'} (${formatDateTime(summary.displayTime)})`; if (useColors) { output.push(`🎯 ${colors.yellow}#S${summary.id}${colors.reset} ${summaryTitle}`); } else { output.push(`**🎯 #S${summary.id}** ${summaryTitle}`); } output.push(''); } else { const obs = item.data; const file = extractFirstFile(obs.files_modified, cwd); if (file !== currentFile) { if (tableOpen) { output.push(''); } if (useColors) { output.push(`${colors.dim}${file}${colors.reset}`); } else { output.push(`**${file}**`); } if (!useColors) { output.push(`| ID | Time | T | Title | Read | Work |`); output.push(`|----|------|---|-------|------|------|`); } currentFile = file; tableOpen = true; lastTime = ''; } const time = formatTime(obs.created_at); const title = obs.title || 'Untitled'; const icon = ModeManager.getInstance().getTypeIcon(obs.type); const obsSize = (obs.title?.length || 0) + (obs.subtitle?.length || 0) + (obs.narrative?.length || 0) + JSON.stringify(obs.facts || []).length; const readTokens = Math.ceil(obsSize / CHARS_PER_TOKEN_ESTIMATE); const discoveryTokens = obs.discovery_tokens || 0; const workEmoji = ModeManager.getInstance().getWorkEmoji(obs.type); const discoveryDisplay = discoveryTokens > 0 ? `${workEmoji} ${discoveryTokens.toLocaleString()}` : '-'; const showTime = time !== lastTime; const timeDisplay = showTime ? time : ''; lastTime = time; const shouldShowFull = fullObservationIds.has(obs.id); if (shouldShowFull) { const detailField = config.fullObservationField === 'narrative' ? obs.narrative : (obs.facts ? parseJsonArray(obs.facts).join('\n') : null); if (useColors) { const timePart = showTime ? `${colors.dim}${time}${colors.reset}` : ' '.repeat(time.length); const readPart = (config.showReadTokens && readTokens > 0) ? `${colors.dim}(~${readTokens}t)${colors.reset}` : ''; const discoveryPart = (config.showWorkTokens && discoveryTokens > 0) ? `${colors.dim}(${workEmoji} ${discoveryTokens.toLocaleString()}t)${colors.reset}` : ''; output.push(` ${colors.dim}#${obs.id}${colors.reset} ${timePart} ${icon} ${colors.bright}${title}${colors.reset}`); if (detailField) { output.push(` ${colors.dim}${detailField}${colors.reset}`); } if (readPart || discoveryPart) { output.push(` ${readPart} ${discoveryPart}`); } output.push(''); } else { if (tableOpen) { output.push(''); tableOpen = false; } output.push(`**#${obs.id}** ${timeDisplay || '″'} ${icon} **${title}**`); if (detailField) { output.push(''); output.push(detailField); output.push(''); } const tokenParts: string[] = []; if (config.showReadTokens) { tokenParts.push(`Read: ~${readTokens}`); } if (config.showWorkTokens) { tokenParts.push(`Work: ${discoveryDisplay}`); } if (tokenParts.length > 0) { output.push(tokenParts.join(', ')); } output.push(''); currentFile = null; } } else { if (useColors) { const timePart = showTime ? `${colors.dim}${time}${colors.reset}` : ' '.repeat(time.length); const readPart = (config.showReadTokens && readTokens > 0) ? `${colors.dim}(~${readTokens}t)${colors.reset}` : ''; const discoveryPart = (config.showWorkTokens && discoveryTokens > 0) ? `${colors.dim}(${workEmoji} ${discoveryTokens.toLocaleString()}t)${colors.reset}` : ''; output.push(` ${colors.dim}#${obs.id}${colors.reset} ${timePart} ${icon} ${title} ${readPart} ${discoveryPart}`); } else { const readCol = config.showReadTokens ? `~${readTokens}` : ''; const workCol = config.showWorkTokens ? discoveryDisplay : ''; output.push(`| #${obs.id} | ${timeDisplay || '″'} | ${icon} | ${title} | ${readCol} | ${workCol} |`); } } } } if (tableOpen) { output.push(''); } } // Add full summary details for most recent session const mostRecentSummary = recentSummaries[0]; const mostRecentObservation = observations[0]; const shouldShowSummary = config.showLastSummary && mostRecentSummary && (mostRecentSummary.investigated || mostRecentSummary.learned || mostRecentSummary.completed || mostRecentSummary.next_steps) && (!mostRecentObservation || mostRecentSummary.created_at_epoch > mostRecentObservation.created_at_epoch); if (shouldShowSummary) { output.push(...renderSummaryField('Investigated', mostRecentSummary.investigated, colors.blue, useColors)); output.push(...renderSummaryField('Learned', mostRecentSummary.learned, colors.yellow, useColors)); output.push(...renderSummaryField('Completed', mostRecentSummary.completed, colors.green, useColors)); output.push(...renderSummaryField('Next Steps', mostRecentSummary.next_steps, colors.magenta, useColors)); } // Previously section if (priorAssistantMessage) { output.push(''); output.push('---'); output.push(''); if (useColors) { output.push(`${colors.bright}${colors.magenta}📋 Previously${colors.reset}`); output.push(''); output.push(`${colors.dim}A: ${priorAssistantMessage}${colors.reset}`); } else { output.push(`**📋 Previously**`); output.push(''); output.push(`A: ${priorAssistantMessage}`); } output.push(''); } // Footer if (showContextEconomics && totalDiscoveryTokens > 0 && savings > 0) { const workTokensK = Math.round(totalDiscoveryTokens / 1000); output.push(''); if (useColors) { output.push(`${colors.dim}💰 Access ${workTokensK}k tokens of past research & decisions for just ${totalReadTokens.toLocaleString()}t. Use the mem-search skill to access memories by ID instead of re-reading files.${colors.reset}`); } else { output.push(`💰 Access ${workTokensK}k tokens of past research & decisions for just ${totalReadTokens.toLocaleString()}t. Use the mem-search skill to access memories by ID instead of re-reading files.`); } } } db?.close(); return output.join('\n').trimEnd(); }