From 8e0b1ee4e1482f9d7ed56108dbcd90244a01a28b Mon Sep 17 00:00:00 2001 From: Alex Newman Date: Fri, 5 Dec 2025 19:10:51 -0500 Subject: [PATCH] refactor: Convert all hooks to HTTP clients (remove all SQL) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Architecture transformation: Hooks → HTTP → Worker → Database **context-hook.ts** (843 → 104 lines, 88% reduction) - Remove all database imports and raw SQL queries - HTTP GET to /api/context/inject - Returns both formatted (stderr) and unformatted (stdout) context - Dual output: colored display for users, plain text for model **user-message-hook.ts** (updated, 113 lines) - HTTP GET to /api/context/inject with colors=true - Displays formatted context to users via stderr - No database dependencies **save-hook.ts** (418 → 99 lines, 76% reduction) - Remove all SessionStore database methods - HTTP POST to /api/sessions/observations - Worker handles privacy checks and observation creation - Fire-and-forget pattern with 2s timeout **summary-hook.ts** (435 → 200 lines, 54% reduction) - Remove all SessionStore database methods - Keep local transcript parsing (hook has file access) - HTTP POST to /api/sessions/summarize - Worker handles privacy checks and summary generation **cleanup-hook.ts** (414 → 90 lines, 78% reduction) - Remove all SessionStore database methods - HTTP POST to /api/sessions/complete - Worker handles session completion and DB cleanup - Non-fatal if worker unavailable **Benefits:** - Zero native module dependencies in hooks (Node.js or Bun compatible) - Hooks can run in any runtime without recompilation - All database operations centralized in worker service - Simpler, more maintainable hook code - Complete separation of concerns: I/O vs business logic --- src/hooks/cleanup-hook.ts | 76 ++- src/hooks/context-hook.ts | 841 +++------------------------------ src/hooks/save-hook.ts | 75 +-- src/hooks/summary-hook.ts | 79 +--- src/hooks/user-message-hook.ts | 25 +- 5 files changed, 128 insertions(+), 968 deletions(-) diff --git a/src/hooks/cleanup-hook.ts b/src/hooks/cleanup-hook.ts index ad9799cc..cfb43602 100644 --- a/src/hooks/cleanup-hook.ts +++ b/src/hooks/cleanup-hook.ts @@ -1,11 +1,14 @@ /** * Cleanup Hook - SessionEnd - * Consolidated entry point + logic + * + * Pure HTTP client - sends data to worker, worker handles all database operations. + * This allows the hook to run under any runtime (Node.js or Bun) since it has no + * native module dependencies. */ import { stdin } from 'process'; -import { SessionStore } from '../services/sqlite/SessionStore.js'; import { getWorkerPort } from '../shared/worker-utils.js'; +import { silentDebug } from '../utils/silent-debug.js'; export interface SessionEndInput { session_id: string; @@ -16,16 +19,13 @@ export interface SessionEndInput { } /** - * Cleanup Hook Main Logic + * Cleanup Hook Main Logic - Fire-and-forget HTTP client */ async function cleanupHook(input?: SessionEndInput): Promise { - // Log hook entry point - console.error('[claude-mem cleanup] Hook fired', { - input: input ? { - session_id: input.session_id, - cwd: input.cwd, - reason: input.reason - } : null + silentDebug('[cleanup-hook] Hook fired', { + session_id: input?.session_id, + cwd: input?.cwd, + reason: input?.reason }); // Handle standalone execution (no input provided) @@ -43,47 +43,35 @@ async function cleanupHook(input?: SessionEndInput): Promise { } const { session_id, reason } = input; - console.error('[claude-mem cleanup] Searching for active SDK session', { session_id, reason }); - // Find active SDK session - const db = new SessionStore(); - const session = db.findActiveSDKSession(session_id); + const port = getWorkerPort(); - if (!session) { - // No active session - nothing to clean up - console.error('[claude-mem cleanup] No active SDK session found', { session_id }); - db.close(); - console.log('{"continue": true, "suppressOutput": true}'); - process.exit(0); - } - - console.error('[claude-mem cleanup] Active SDK session found', { - session_id: session.id, - sdk_session_id: session.sdk_session_id, - project: session.project, - worker_port: session.worker_port - }); - - // Mark session as completed in DB - db.markSessionCompleted(session.id); - console.error('[claude-mem cleanup] Session marked as completed in database'); - - db.close(); - - // Tell worker to stop spinner try { - const workerPort = session.worker_port || getWorkerPort(); - await fetch(`http://127.0.0.1:${workerPort}/sessions/${session.id}/complete`, { + // Send to worker - worker handles finding session, marking complete, and stopping spinner + const response = await fetch(`http://127.0.0.1:${port}/api/sessions/complete`, { method: 'POST', - signal: AbortSignal.timeout(1000) + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + claudeSessionId: session_id, + reason + }), + signal: AbortSignal.timeout(2000) + }); + + if (response.ok) { + const result = await response.json(); + silentDebug('[cleanup-hook] Session cleanup completed', result); + } else { + // Non-fatal - session might not exist + silentDebug('[cleanup-hook] Session not found or already cleaned up'); + } + } catch (error: any) { + // Worker might not be running - that's okay + silentDebug('[cleanup-hook] Worker not reachable (non-critical)', { + error: error.message }); - console.error('[claude-mem cleanup] Worker notified to stop processing indicator'); - } catch (err) { - // Non-critical - worker might be down - console.error('[claude-mem cleanup] Failed to notify worker (non-critical):', err); } - console.error('[claude-mem cleanup] Cleanup completed successfully'); console.log('{"continue": true, "suppressOutput": true}'); process.exit(0); } diff --git a/src/hooks/context-hook.ts b/src/hooks/context-hook.ts index 96d2097b..7738f4b2 100644 --- a/src/hooks/context-hook.ts +++ b/src/hooks/context-hook.ts @@ -1,111 +1,15 @@ /** * Context Hook - SessionStart - * Consolidated entry point + logic + * + * Pure HTTP client - calls worker to generate context. + * This allows the hook to run under any runtime (Node.js or Bun) since it has no + * native module dependencies. */ import path from 'path'; -import { homedir } from 'os'; -import { existsSync, readFileSync, unlinkSync } from 'fs'; import { stdin } from 'process'; -import { fileURLToPath } from 'url'; -import { dirname } from 'path'; -import { SessionStore } from '../services/sqlite/SessionStore.js'; -import { - OBSERVATION_TYPES, - OBSERVATION_CONCEPTS, - TYPE_ICON_MAP, - TYPE_WORK_EMOJI_MAP, - DEFAULT_OBSERVATION_TYPES_STRING, - DEFAULT_OBSERVATION_CONCEPTS_STRING -} from '../constants/observation-metadata.js'; -import { logger } from '../utils/logger.js'; - -// Get __dirname equivalent in ESM -const __filename = fileURLToPath(import.meta.url); -const __dirname = dirname(__filename); - -// Version marker path (same as smart-install.js) -// From src/hooks/ we need to go up to plugin root: ../../ -const VERSION_MARKER_PATH = path.join(__dirname, '../../.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 defaults = { - totalObservationCount: parseInt(process.env.CLAUDE_MEM_CONTEXT_OBSERVATIONS || '50', 10), - fullObservationCount: 5, - sessionCount: 10, - showReadTokens: true, - showWorkTokens: true, - showSavingsAmount: true, - showSavingsPercent: true, - observationTypes: new Set(OBSERVATION_TYPES), - observationConcepts: new Set(OBSERVATION_CONCEPTS), - fullObservationField: 'narrative' as const, - showLastSummary: true, - showLastMessage: false, - }; - - try { - const settingsPath = path.join(homedir(), '.claude-mem', 'settings.json'); - if (!existsSync(settingsPath)) return defaults; - - const settings = JSON.parse(readFileSync(settingsPath, 'utf-8')); - const env = settings.env || {}; - - return { - totalObservationCount: parseInt(env.CLAUDE_MEM_CONTEXT_OBSERVATIONS || '50', 10), - fullObservationCount: parseInt(env.CLAUDE_MEM_CONTEXT_FULL_COUNT || '5', 10), - sessionCount: parseInt(env.CLAUDE_MEM_CONTEXT_SESSION_COUNT || '10', 10), - showReadTokens: env.CLAUDE_MEM_CONTEXT_SHOW_READ_TOKENS !== 'false', - showWorkTokens: env.CLAUDE_MEM_CONTEXT_SHOW_WORK_TOKENS !== 'false', - showSavingsAmount: env.CLAUDE_MEM_CONTEXT_SHOW_SAVINGS_AMOUNT !== 'false', - showSavingsPercent: env.CLAUDE_MEM_CONTEXT_SHOW_SAVINGS_PERCENT !== 'false', - observationTypes: new Set( - (env.CLAUDE_MEM_CONTEXT_OBSERVATION_TYPES || DEFAULT_OBSERVATION_TYPES_STRING) - .split(',').map((t: string) => t.trim()).filter(Boolean) - ), - observationConcepts: new Set( - (env.CLAUDE_MEM_CONTEXT_OBSERVATION_CONCEPTS || DEFAULT_OBSERVATION_CONCEPTS_STRING) - .split(',').map((c: string) => c.trim()).filter(Boolean) - ), - fullObservationField: (env.CLAUDE_MEM_CONTEXT_FULL_FIELD || 'narrative') as 'narrative' | 'facts', - showLastSummary: env.CLAUDE_MEM_CONTEXT_SHOW_LAST_SUMMARY !== 'false', - showLastMessage: env.CLAUDE_MEM_CONTEXT_SHOW_LAST_MESSAGE === 'true', - }; - } catch (error) { - logger.warn('HOOK', 'Failed to load context settings, using defaults', {}, error as Error); - return defaults; - } -} - -// Configuration constants -const CHARS_PER_TOKEN_ESTIMATE = 4; // Rough estimate for token counting -const SUMMARY_LOOKAHEAD = 1; // Fetch one extra summary for offset calculation +import { getWorkerPort } from '../shared/worker-utils.js'; +import { silentDebug } from '../utils/silent-debug.js'; export interface SessionStartInput { session_id?: string; @@ -116,719 +20,82 @@ export interface SessionStartInput { [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', -}; +/** + * Fetch context from worker + */ +async function fetchContext(project: string, port: number, useFormatting: boolean): Promise { + const formatParam = useFormatting ? '&colors=true' : ''; + const response = await fetch( + `http://127.0.0.1:${port}/api/context/inject?project=${encodeURIComponent(project)}${formatParam}`, + { method: 'GET', signal: AbortSignal.timeout(5000) } + ); -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: Parse JSON array safely -function parseJsonArray(json: string | null): string[] { - if (!json) return []; - try { - const parsed = JSON.parse(json); - return Array.isArray(parsed) ? parsed : []; - } catch (err) { - return []; + if (!response.ok) { + const errorText = await response.text(); + throw new Error(`Worker error ${response.status}: ${errorText}`); } -} -// 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: 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: Render a summary field (investigated, learned, etc.) -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 for transcript directory name -function cwdToDashed(cwd: string): string { - // Convert all slashes to dashes (including leading slash) - 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()); - - // Find the last assistant message by filtering for assistant type and taking the last one - let lastAssistantMessage = ''; - - // Iterate backwards to find the most recent assistant message with text content - for (let i = lines.length - 1; i >= 0; i--) { - try { - const line = lines[i]; - - // Quick check if this line is an assistant message - 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; - } - } - // Remove system-reminder tags - text = text.replace(/[\s\S]*?<\/system-reminder>/g, '').trim(); - if (text) { - lastAssistantMessage = text; - break; // Found it, stop searching - } - } - } catch (parseError) { - // Skip malformed lines - continue; - } - } - - return { userMessage: '', assistantMessage: lastAssistantMessage }; - } catch (error) { - logger.failure('HOOK', `Failed to extract prior messages from transcript`, { transcriptPath }, error as Error); - return { userMessage: '', assistantMessage: '' }; - } + return response.text(); } /** - * Context Hook Main Logic + * Context Hook Main Logic - Fire-and-forget HTTP client + * Returns { unformatted, formatted } for dual output (stderr for user, stdout for model) */ -async function contextHook(input?: SessionStartInput, useColors: boolean = false): Promise { - const config = loadContextConfig(); +async function contextHook(input?: SessionStartInput): Promise<{ unformatted: string; formatted: string }> { const cwd = input?.cwd ?? process.cwd(); const project = cwd ? path.basename(cwd) : 'unknown-project'; - let db: SessionStore | null = null; + const port = getWorkerPort(); + + silentDebug('[context-hook] Requesting context from worker', { + project, + workerPort: port + }); + try { - db = new SessionStore(); + // Fetch both versions in parallel + const [unformatted, formatted] = await Promise.all([ + fetchContext(project, port, false), + fetchContext(project, port, true) + ]); + + silentDebug('[context-hook] Context received', { unformattedLength: unformatted.length, formattedLength: formatted.length }); + return { unformatted, formatted }; } catch (error: any) { - if (error.code === 'ERR_DLOPEN_FAILED') { - // Native module ABI mismatch - delete version marker to trigger reinstall - try { - unlinkSync(VERSION_MARKER_PATH); - } catch (unlinkError) { - // Marker might not exist, that's okay - } - - // Log once (not error spam) and exit cleanly - console.error('⚠️ Native module rebuild needed - restart Claude Code to auto-fix'); - console.error(' (This happens after Node.js version upgrades)'); - process.exit(0); // Exit cleanly to avoid error spam - } - - // Other errors should still throw - throw error; + // Worker might not be running + silentDebug('[context-hook] Worker not reachable', { error: error.message }); + const fallback = `# [${project}] recent context\n\nWorker not available. Start with: pm2 start claude-mem-worker`; + return { unformatted: fallback, formatted: fallback }; } - - // 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 filtered by type and concepts at SQL level - // This ensures we show observations even when summaries haven't been generated - // Configurable via settings (default: 50) - 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 (optional - may not exist for recent sessions) - // Fetch one extra for offset calculation - 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 = ''; - // let debugInfo: string[] = []; - - if (config.showLastMessage && observations.length > 0) { - try { - const currentSessionId = input?.session_id; - - // Find the first observation from a different session (the prior session) - const priorSessionObs = observations.find(obs => obs.sdk_session_id !== currentSessionId); - - if (priorSessionObs) { - const priorSessionId = priorSessionObs.sdk_session_id; - - // Construct transcript path: ~/.claude/projects/{dashed-cwd}/{session_id}.jsonl - const dashedCwd = cwdToDashed(cwd); - const transcriptPath = path.join(homedir(), '.claude', 'projects', dashedCwd, `${priorSessionId}.jsonl`); - - // debugInfo.push(`📋 Prior Message Retrieval:`); - // debugInfo.push(` Session ID: ${priorSessionId}`); - // debugInfo.push(` Transcript: ${transcriptPath}`); - // debugInfo.push(` Exists: ${existsSync(transcriptPath)}`); - - // Extract messages from transcript - const messages = extractPriorMessages(transcriptPath); - priorUserMessage = messages.userMessage; - priorAssistantMessage = messages.assistantMessage; - - // if (!priorUserMessage && !priorAssistantMessage) { - // debugInfo.push(` ⚠️ No messages extracted from transcript`); - // } else { - // debugInfo.push(` ✅ Found user message: ${!!priorUserMessage}`); - // debugInfo.push(` ✅ Found assistant message: ${!!priorAssistantMessage}`); - // } - } // else { - // debugInfo.push(`📋 Prior Message Retrieval: No prior session found (all observations from current session)`); - // } - } catch (error) { - // debugInfo.push(`📋 Prior Message Retrieval Error: ${(error as Error).message}`); - } - } - - // 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); - - // All filtered observations are shown in timeline - 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/Key - if (useColors) { - output.push(`${colors.dim}Legend: 🎯 session-request | 🔴 bugfix | 🟣 feature | 🔄 refactor | ✅ change | 🔵 discovery | ⚖️ decision${colors.reset}`); - } else { - output.push(`**Legend:** 🎯 session-request | 🔴 bugfix | 🟣 feature | 🔄 refactor | ✅ change | 🔵 discovery | ⚖️ decision`); - } - 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 Usage 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(''); - - // Section 1: Aggregate ROI Metrics - const totalObservations = observations.length; - const totalReadTokens = observations.reduce((sum, obs) => { - // Estimate read tokens from observation size - 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; - - // Display Context Economics section only if at least one token setting is enabled - 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 - // The most recent summary shows full details (investigated, learned, etc.) - // Older summaries only show as timeline markers (no link needed) - const mostRecentSummaryId = recentSummaries[0]?.id; - - interface SummaryTimelineItem extends SessionSummary { - displayEpoch: number; - displayTime: string; - shouldShowLink: boolean; - } - - const summariesForTimeline: SummaryTimelineItem[] = displaySummaries.map((summary, i) => { - // For visual grouping, display each summary at the time range it covers - // Most recent: shows at its own time (current session) - // Older: shows at the previous (older) summary's time to mark the session range - 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 (most recent N) - 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 for rendering - 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) { - // 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.shouldShowLink ? `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 && files[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 | Read | Work |`); - output.push(`|----|------|---|-------|------|------|`); - } - - currentFile = file; - tableOpen = true; - lastTime = ''; - } - - const time = formatTime(obs.created_at); - const title = obs.title || 'Untitled'; - - // Map observation type to emoji icon - const icon = TYPE_ICON_MAP[obs.type as keyof typeof TYPE_ICON_MAP] || '•'; - - // Section 2: Calculate read tokens (estimate from observation size) - 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); - - // Get discovery tokens (handle old observations without this field) - const discoveryTokens = obs.discovery_tokens || 0; - - // Map observation type to work emoji - const workEmoji = TYPE_WORK_EMOJI_MAP[obs.type as keyof typeof TYPE_WORK_EMOJI_MAP] || '🔍'; - - const discoveryDisplay = discoveryTokens > 0 ? `${workEmoji} ${discoveryTokens.toLocaleString()}` : '-'; - - const showTime = time !== lastTime; - const timeDisplay = showTime ? time : ''; - lastTime = time; - - // Check if this observation should show full details - const shouldShowFull = fullObservationIds.has(obs.id); - - if (shouldShowFull) { - // Render with full details (narrative or facts) - 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 { - // Close table for full observation - 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(''); - - // Reopen table for next items if in same file - currentFile = null; - } - } else { - // Compact index rendering (existing code) - 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} |`); - } - } - } - } - - // Close final table if open - if (tableOpen) { - output.push(''); - } - } - - // Add full summary details for most recent session - // Only show if summary was generated AFTER the last observation - const mostRecentSummary = recentSummaries[0]; - const mostRecentObservation = observations[0]; // observations are DESC by created_at_epoch - - 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 (last assistant message from prior session) - positioned at bottom for chronological sense - 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 with token savings message (only show if token economics is visible) - 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(); - - // Add debug info directly to output - // if (debugInfo.length > 0) { - // output.push(''); - // output.push('---'); - // output.push(''); - // output.push(...debugInfo); - // } - - return output.join('\n').trimEnd(); } -// Export for use by worker service +// Export for use by worker service (compatibility) export { contextHook }; // Entry Point - handle stdin/stdout -const forceColors = process.argv.includes('--colors'); - -if (stdin.isTTY || forceColors) { - // Running manually from terminal - print formatted output with colors - contextHook(undefined, true).then(contextOutput => { - console.log(contextOutput); +if (stdin.isTTY) { + // Running manually from terminal - show formatted output + contextHook(undefined).then(({ formatted }) => { + console.log(formatted); process.exit(0); }); } else { - // Running from hook - wrap in hookSpecificOutput JSON format + // Running from hook - formatted to stderr (user display), unformatted to stdout (model context) let input = ''; stdin.on('data', (chunk) => input += chunk); stdin.on('end', async () => { const parsed = input.trim() ? JSON.parse(input) : undefined; - const contextOutput = await contextHook(parsed, false); + const { unformatted, formatted } = await contextHook(parsed); + + // Write formatted version to stderr for user display + process.stderr.write(formatted + '\n'); + + // Write unformatted version to stdout as JSON for model context const result = { hookSpecificOutput: { hookEventName: "SessionStart", - additionalContext: contextOutput + additionalContext: unformatted } }; console.log(JSON.stringify(result)); diff --git a/src/hooks/save-hook.ts b/src/hooks/save-hook.ts index aa7aaf41..d6dd96ef 100644 --- a/src/hooks/save-hook.ts +++ b/src/hooks/save-hook.ts @@ -1,15 +1,15 @@ /** * Save Hook - PostToolUse - * Consolidated entry point + logic + * + * Pure HTTP client - sends data to worker, worker handles all database operations + * including privacy checks. This allows the hook to run under any runtime + * (Node.js or Bun) since it has no native module dependencies. */ import { stdin } from 'process'; -import { SessionStore } from '../services/sqlite/SessionStore.js'; import { createHookResponse } from './hook-response.js'; import { logger } from '../utils/logger.js'; import { ensureWorkerRunning, getWorkerPort } from '../shared/worker-utils.js'; -import { silentDebug } from '../utils/silent-debug.js'; -import { stripMemoryTagsFromJson } from '../utils/tag-stripping.js'; export interface PostToolUseInput { session_id: string; @@ -29,9 +29,8 @@ const SKIP_TOOLS = new Set([ 'AskUserQuestion' // User interaction, not substantive work ]); - /** - * Save Hook Main Logic + * Save Hook Main Logic - Fire-and-forget HTTP client */ async function saveHook(input?: PostToolUseInput): Promise { if (!input) { @@ -48,72 +47,24 @@ async function saveHook(input?: PostToolUseInput): Promise { // Ensure worker is running await ensureWorkerRunning(); - const db = new SessionStore(); - - // Get or create session - const sessionDbId = db.createSDKSession(session_id, '', ''); - const promptNumber = db.getPromptCounter(sessionDbId); - - // Skip observation if user prompt was entirely private - // This respects the user's intent: if they marked the entire prompt as , - // they don't want ANY observations from that interaction - const userPrompt = db.getUserPrompt(session_id, promptNumber); - if (!userPrompt || userPrompt.trim() === '') { - silentDebug('[save-hook] Skipping observation - user prompt was entirely private', { - session_id, - promptNumber, - tool_name - }); - db.close(); - console.log(createHookResponse('PostToolUse', true)); - return; - } - - db.close(); + const port = getWorkerPort(); const toolStr = logger.formatTool(tool_name, tool_input); - const port = getWorkerPort(); - logger.dataIn('HOOK', `PostToolUse: ${toolStr}`, { - sessionId: sessionDbId, workerPort: port }); try { - // Serialize and strip memory tags from tool_input and tool_response - // This prevents recursive storage of context and respects tags - let cleanedToolInput = '{}'; - let cleanedToolResponse = '{}'; - - try { - cleanedToolInput = tool_input !== undefined - ? stripMemoryTagsFromJson(JSON.stringify(tool_input)) - : '{}'; - } catch (error) { - // Handle circular references or other JSON.stringify errors - silentDebug('[save-hook] Failed to stringify tool_input:', { error, tool_name }); - cleanedToolInput = '{"error": "Failed to serialize tool_input"}'; - } - - try { - cleanedToolResponse = tool_response !== undefined - ? stripMemoryTagsFromJson(JSON.stringify(tool_response)) - : '{}'; - } catch (error) { - // Handle circular references or other JSON.stringify errors - silentDebug('[save-hook] Failed to stringify tool_response:', { error, tool_name }); - cleanedToolResponse = '{"error": "Failed to serialize tool_response"}'; - } - - const response = await fetch(`http://127.0.0.1:${port}/sessions/${sessionDbId}/observations`, { + // Send to worker - worker handles privacy check and database operations + const response = await fetch(`http://127.0.0.1:${port}/api/sessions/observations`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ + claudeSessionId: session_id, tool_name, - tool_input: cleanedToolInput, - tool_response: cleanedToolResponse, - prompt_number: promptNumber, + tool_input, + tool_response, cwd: cwd || '' }), signal: AbortSignal.timeout(2000) @@ -122,19 +73,17 @@ async function saveHook(input?: PostToolUseInput): Promise { if (!response.ok) { const errorText = await response.text(); logger.failure('HOOK', 'Failed to send observation', { - sessionId: sessionDbId, status: response.status }, errorText); throw new Error(`Failed to send observation to worker: ${response.status} ${errorText}`); } - logger.debug('HOOK', 'Observation sent successfully', { sessionId: sessionDbId, toolName: tool_name }); + logger.debug('HOOK', 'Observation sent successfully', { toolName: tool_name }); } catch (error: any) { // Only show restart message for connection errors, not HTTP errors if (error.cause?.code === 'ECONNREFUSED' || error.name === 'TimeoutError' || error.message.includes('fetch failed')) { throw new Error("There's a problem with the worker. If you just updated, type `pm2 restart claude-mem-worker` in your terminal to continue"); } - // Re-throw HTTP errors and other errors as-is throw error; } diff --git a/src/hooks/summary-hook.ts b/src/hooks/summary-hook.ts index cf574cd6..0cfc2c0f 100644 --- a/src/hooks/summary-hook.ts +++ b/src/hooks/summary-hook.ts @@ -1,15 +1,19 @@ /** * Summary Hook - Stop - * Consolidated entry point + logic + * + * Pure HTTP client - sends data to worker, worker handles all database operations + * including privacy checks. This allows the hook to run under any runtime + * (Node.js or Bun) since it has no native module dependencies. + * + * Transcript parsing stays in the hook because only the hook has access to + * the transcript file path. */ import { stdin } from 'process'; import { readFileSync, existsSync } from 'fs'; -import { SessionStore } from '../services/sqlite/SessionStore.js'; import { createHookResponse } from './hook-response.js'; import { logger } from '../utils/logger.js'; import { ensureWorkerRunning, getWorkerPort } from '../shared/worker-utils.js'; -import { silentDebug } from '../utils/silent-debug.js'; export interface StopInput { session_id: string; @@ -123,7 +127,7 @@ function extractLastAssistantMessage(transcriptPath: string): string { } /** - * Summary Hook Main Logic + * Summary Hook Main Logic - Fire-and-forget HTTP client */ async function summaryHook(input?: StopInput): Promise { if (!input) { @@ -135,77 +139,25 @@ async function summaryHook(input?: StopInput): Promise { // Ensure worker is running await ensureWorkerRunning(); - const db = new SessionStore(); - - // Get or create session - const sessionDbId = db.createSDKSession(session_id, '', ''); - const promptNumber = db.getPromptCounter(sessionDbId); - - // Skip summary if user prompt was entirely private - // This respects the user's intent: if they marked the entire prompt as , - // they don't want ANY memory operations including summaries - const userPrompt = db.getUserPrompt(session_id, promptNumber); - if (!userPrompt || userPrompt.trim() === '') { - silentDebug('[summary-hook] Skipping summary - user prompt was entirely private', { - session_id, - promptNumber - }); - db.close(); - console.log(createHookResponse('Stop', true)); - return; - } - - // DIAGNOSTIC: Check session and observations - const sessionInfo = db.db.prepare(` - SELECT id, claude_session_id, sdk_session_id, project - FROM sdk_sessions WHERE id = ? - `).get(sessionDbId) as any; - - const obsCount = db.db.prepare(` - SELECT COUNT(*) as count - FROM observations - WHERE sdk_session_id = ? - `).get(sessionInfo?.sdk_session_id) as { count: number }; - - silentDebug('[summary-hook] Session diagnostics', { - claudeSessionId: session_id, - sessionDbId, - sdkSessionId: sessionInfo?.sdk_session_id, - project: sessionInfo?.project, - promptNumber, - observationCount: obsCount?.count || 0, - transcriptPath: input.transcript_path - }); - - db.close(); - const port = getWorkerPort(); // Extract last user AND assistant messages from transcript const lastUserMessage = extractLastUserMessage(input.transcript_path || ''); const lastAssistantMessage = extractLastAssistantMessage(input.transcript_path || ''); - silentDebug('[summary-hook] Extracted messages', { - hasLastUserMessage: !!lastUserMessage, - hasLastAssistantMessage: !!lastAssistantMessage, - lastAssistantPreview: lastAssistantMessage.substring(0, 200), - lastAssistantLength: lastAssistantMessage.length - }); - logger.dataIn('HOOK', 'Stop: Requesting summary', { - sessionId: sessionDbId, workerPort: port, - promptNumber, hasLastUserMessage: !!lastUserMessage, hasLastAssistantMessage: !!lastAssistantMessage }); try { - const response = await fetch(`http://127.0.0.1:${port}/sessions/${sessionDbId}/summarize`, { + // Send to worker - worker handles privacy check and database operations + const response = await fetch(`http://127.0.0.1:${port}/api/sessions/summarize`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ - prompt_number: promptNumber, + claudeSessionId: session_id, last_user_message: lastUserMessage, last_assistant_message: lastAssistantMessage }), @@ -215,26 +167,25 @@ async function summaryHook(input?: StopInput): Promise { if (!response.ok) { const errorText = await response.text(); logger.failure('HOOK', 'Failed to generate summary', { - sessionId: sessionDbId, status: response.status }, errorText); throw new Error(`Failed to request summary from worker: ${response.status} ${errorText}`); } - logger.debug('HOOK', 'Summary request sent successfully', { sessionId: sessionDbId }); + logger.debug('HOOK', 'Summary request sent successfully'); } catch (error: any) { // Only show restart message for connection errors, not HTTP errors if (error.cause?.code === 'ECONNREFUSED' || error.name === 'TimeoutError' || error.message.includes('fetch failed')) { throw new Error("There's a problem with the worker. If you just updated, type `pm2 restart claude-mem-worker` in your terminal to continue"); } - // Re-throw HTTP errors and other errors as-is throw error; } finally { - await fetch(`http://127.0.0.1:${port}/api/processing`, { + // Notify worker to stop spinner (fire-and-forget) + fetch(`http://127.0.0.1:${port}/api/processing`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ isProcessing: false }) - }); + }).catch(() => {}); } console.log(createHookResponse('Stop', true)); diff --git a/src/hooks/user-message-hook.ts b/src/hooks/user-message-hook.ts index 7f35a789..828c3b5b 100644 --- a/src/hooks/user-message-hook.ts +++ b/src/hooks/user-message-hook.ts @@ -6,8 +6,7 @@ * has been loaded into their session. Uses stderr as the communication channel * since it's currently the only way to display messages in Claude Code UI. */ -import { execSync } from "child_process"; -import { join } from "path"; +import { join, basename } from "path"; import { homedir } from "os"; import { existsSync } from "fs"; import { getWorkerPort } from "../shared/worker-utils.js"; @@ -20,7 +19,7 @@ if (!existsSync(nodeModulesPath)) { // First-time installation - dependencies not yet installed console.error(` --- -🎉 Note: This appears under Plugin Hook Error, but it's not an error. That's the only option for +🎉 Note: This appears under Plugin Hook Error, but it's not an error. That's the only option for user messages in Claude Code UI until a better method is provided. --- @@ -41,14 +40,20 @@ This message was not added to your startup context, so you can continue working } try { - // Cross-platform path to context-hook.js in the installed plugin - const contextHookPath = join(homedir(), '.claude', 'plugins', 'marketplaces', 'thedotmack', 'plugin', 'scripts', 'context-hook.js'); - const output = execSync(`node "${contextHookPath}" --colors`, { - encoding: 'utf8', - windowsHide: true - }); - const port = getWorkerPort(); + const project = basename(process.cwd()); + + // Fetch formatted context directly from worker API + const response = await fetch( + `http://127.0.0.1:${port}/api/context/inject?project=${encodeURIComponent(project)}&colors=true`, + { method: 'GET', signal: AbortSignal.timeout(5000) } + ); + + if (!response.ok) { + throw new Error(`Worker error ${response.status}`); + } + + const output = await response.text(); // If it's after Dec 5, 2025 7pm EST, patch this out const now = new Date();