From d3aaef926b0a10564a0c347933bc7cd003b61efb Mon Sep 17 00:00:00 2001 From: Alex Newman Date: Fri, 5 Dec 2025 19:10:38 -0500 Subject: [PATCH] feat: Add worker HTTP endpoints for hook operations - Add /api/context/inject endpoint (uses context-generator service) - Add /api/sessions/observations endpoint (handles observations by claudeSessionId) - Add /api/sessions/summarize endpoint (handles summaries by claudeSessionId) - Add /api/sessions/complete endpoint (handles cleanup by claudeSessionId) - Import stripMemoryTagsFromJson for privacy tag handling - All endpoints accept claudeSessionId in request body (not path params) - Enables hooks to become pure HTTP clients with zero database dependencies --- src/services/worker-service.ts | 260 +++++++++++++++++++++++++++++++++ 1 file changed, 260 insertions(+) diff --git a/src/services/worker-service.ts b/src/services/worker-service.ts index 8cc0557f..a32d8a9f 100644 --- a/src/services/worker-service.ts +++ b/src/services/worker-service.ts @@ -19,6 +19,7 @@ import { homedir } from 'os'; import { getPackageRoot } from '../shared/paths.js'; import { getWorkerPort } from '../shared/worker-utils.js'; import { logger } from '../utils/logger.js'; +import { stripMemoryTagsFromPrompt, stripMemoryTagsFromJson } from '../utils/tag-stripping.js'; import { Client } from '@modelcontextprotocol/sdk/client/index.js'; import { StdioClientTransport } from '@modelcontextprotocol/sdk/client/stdio.js'; @@ -201,6 +202,10 @@ export class WorkerService { this.app.get('/api/search/by-concept', this.handleSearchByConcept.bind(this)); this.app.get('/api/search/by-file', this.handleSearchByFile.bind(this)); this.app.get('/api/search/by-type', this.handleSearchByType.bind(this)); + this.app.get('/api/context/inject', this.handleContextInject.bind(this)); + this.app.post('/api/sessions/observations', this.handleObservationsByClaudeId.bind(this)); + this.app.post('/api/sessions/summarize', this.handleSummarizeByClaudeId.bind(this)); + this.app.post('/api/sessions/complete', this.handleSessionCompleteByClaudeId.bind(this)); this.app.get('/api/context/recent', this.handleGetRecentContext.bind(this)); this.app.get('/api/context/timeline', this.handleGetContextTimeline.bind(this)); this.app.get('/api/context/preview', this.handleContextPreview.bind(this)); @@ -1551,6 +1556,261 @@ export class WorkerService { } } + /** + * Context injection endpoint for hooks + * GET /api/context/inject?project=...&colors=true + * + * Returns pre-formatted context string ready for display. + * Use colors=true for ANSI-colored terminal output. + */ + private async handleContextInject(req: Request, res: Response): Promise { + try { + const projectName = req.query.project as string; + const useColors = req.query.colors === 'true'; + + if (!projectName) { + res.status(400).json({ error: 'Project parameter is required' }); + return; + } + + // Import context generator (runs in worker, has access to database) + const { generateContext } = await import('./context-generator.js'); + + // Use project name as CWD (generateContext uses path.basename to get project) + const cwd = `/context/${projectName}`; + + // Generate context + const contextText = await generateContext( + { + session_id: 'context-inject-' + Date.now(), + cwd: cwd + }, + useColors + ); + + // Return as plain text + res.setHeader('Content-Type', 'text/plain; charset=utf-8'); + res.send(contextText); + } catch (error) { + logger.failure('WORKER', 'Context inject failed', {}, error as Error); + res.status(500).json({ error: (error as Error).message }); + } + } + + /** + * Queue observation by claudeSessionId (save-hook uses this) + * POST /api/sessions/observations + * Body: { claudeSessionId, tool_name, tool_input, tool_response, cwd } + * + * Checks privacy, queues observation for SDK agent + */ + private handleObservationsByClaudeId(req: Request, res: Response): void { + try { + const { claudeSessionId, tool_name, tool_input, tool_response, cwd } = req.body; + + if (!claudeSessionId) { + res.status(400).json({ error: 'Missing claudeSessionId' }); + return; + } + + const store = this.dbManager.getSessionStore(); + + // Get or create session + const sessionDbId = store.createSDKSession(claudeSessionId, '', ''); + const promptNumber = store.getPromptCounter(sessionDbId); + + // Privacy check: skip if user prompt was entirely private + const userPrompt = store.getUserPrompt(claudeSessionId, promptNumber); + if (!userPrompt || userPrompt.trim() === '') { + logger.debug('HOOK', 'Skipping observation - user prompt was entirely private', { + sessionId: sessionDbId, + promptNumber, + tool_name + }); + res.json({ status: 'skipped', reason: 'private' }); + return; + } + + // Strip memory tags from tool_input and tool_response + let cleanedToolInput = '{}'; + let cleanedToolResponse = '{}'; + + try { + cleanedToolInput = tool_input !== undefined + ? stripMemoryTagsFromJson(JSON.stringify(tool_input)) + : '{}'; + } catch (error) { + cleanedToolInput = '{"error": "Failed to serialize tool_input"}'; + } + + try { + cleanedToolResponse = tool_response !== undefined + ? stripMemoryTagsFromJson(JSON.stringify(tool_response)) + : '{}'; + } catch (error) { + cleanedToolResponse = '{"error": "Failed to serialize tool_response"}'; + } + + // Queue observation + this.sessionManager.queueObservation(sessionDbId, { + tool_name, + tool_input: cleanedToolInput, + tool_response: cleanedToolResponse, + prompt_number: promptNumber, + cwd: cwd || '' + }); + + // Ensure SDK agent is running + const session = this.sessionManager.getSession(sessionDbId); + if (session && !session.generatorPromise) { + logger.info('SESSION', 'Generator auto-starting (observation)', { + sessionId: sessionDbId, + queueDepth: session.pendingMessages.length + }); + + session.generatorPromise = this.sdkAgent.startSession(session, this) + .catch(err => { + logger.failure('SDK', 'SDK agent error', { sessionId: sessionDbId }, err); + }) + .finally(() => { + logger.info('SESSION', `Generator finished`, { sessionId: sessionDbId }); + session.generatorPromise = null; + this.broadcastProcessingStatus(); + }); + } + + // Broadcast activity status + this.broadcastProcessingStatus(); + + // Broadcast SSE event + this.sseBroadcaster.broadcast({ + type: 'observation_queued', + sessionDbId + }); + + res.json({ status: 'queued' }); + } catch (error) { + logger.failure('WORKER', 'Observation by claudeId failed', {}, error as Error); + res.status(500).json({ error: (error as Error).message }); + } + } + + /** + * Queue summarize by claudeSessionId (summary-hook uses this) + * POST /api/sessions/summarize + * Body: { claudeSessionId, last_user_message, last_assistant_message } + * + * Checks privacy, queues summarize request for SDK agent + */ + private handleSummarizeByClaudeId(req: Request, res: Response): void { + try { + const { claudeSessionId, last_user_message, last_assistant_message } = req.body; + + if (!claudeSessionId) { + res.status(400).json({ error: 'Missing claudeSessionId' }); + return; + } + + const store = this.dbManager.getSessionStore(); + + // Get or create session + const sessionDbId = store.createSDKSession(claudeSessionId, '', ''); + const promptNumber = store.getPromptCounter(sessionDbId); + + // Privacy check: skip if user prompt was entirely private + const userPrompt = store.getUserPrompt(claudeSessionId, promptNumber); + if (!userPrompt || userPrompt.trim() === '') { + logger.debug('HOOK', 'Skipping summary - user prompt was entirely private', { + sessionId: sessionDbId, + promptNumber + }); + res.json({ status: 'skipped', reason: 'private' }); + return; + } + + // Queue summarize + this.sessionManager.queueSummarize(sessionDbId, last_user_message || '', last_assistant_message); + + // Ensure SDK agent is running + const session = this.sessionManager.getSession(sessionDbId); + if (session && !session.generatorPromise) { + logger.info('SESSION', 'Generator auto-starting (summarize)', { + sessionId: sessionDbId, + queueDepth: session.pendingMessages.length + }); + + session.generatorPromise = this.sdkAgent.startSession(session, this) + .catch(err => { + logger.failure('SDK', 'SDK agent error', { sessionId: sessionDbId }, err); + }) + .finally(() => { + logger.info('SESSION', `Generator finished`, { sessionId: sessionDbId }); + session.generatorPromise = null; + this.broadcastProcessingStatus(); + }); + } + + // Broadcast activity status + this.broadcastProcessingStatus(); + + res.json({ status: 'queued' }); + } catch (error) { + logger.failure('WORKER', 'Summarize by claudeId failed', {}, error as Error); + res.status(500).json({ error: (error as Error).message }); + } + } + + /** + * Complete session by claudeSessionId (cleanup-hook uses this) + * POST /api/sessions/complete + * Body: { claudeSessionId } + * + * Marks session complete, stops SDK agent, broadcasts status + */ + private async handleSessionCompleteByClaudeId(req: Request, res: Response): Promise { + try { + const { claudeSessionId } = req.body; + + if (!claudeSessionId) { + res.status(400).json({ success: false, error: 'Missing claudeSessionId' }); + return; + } + + const store = this.dbManager.getSessionStore(); + + // Find session by claudeSessionId + const session = store.findActiveSDKSession(claudeSessionId); + if (!session) { + // No active session - nothing to clean up (may have already been completed) + res.json({ success: true, message: 'No active session found' }); + return; + } + + const sessionDbId = session.id; + + // Delete from session manager (aborts SDK agent) + await this.sessionManager.deleteSession(sessionDbId); + + // Mark session complete in database + this.dbManager.markSessionComplete(sessionDbId); + + // Broadcast processing status + this.broadcastProcessingStatus(); + + // Broadcast SSE event + this.sseBroadcaster.broadcast({ + type: 'session_completed', + timestamp: Date.now(), + sessionDbId + }); + + res.json({ success: true }); + } catch (error) { + logger.failure('WORKER', 'Session complete by claudeId failed', {}, error as Error); + res.status(500).json({ success: false, error: String(error) }); + } + } + /** * Get timeline by query (search first, then get timeline around best match) * GET /api/timeline/by-query?query=...&mode=auto&depth_before=10&depth_after=10