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
This commit is contained in:
Alex Newman
2025-12-05 19:10:38 -05:00
parent 42fde819a3
commit d3aaef926b
+260
View File
@@ -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<void> {
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<void> {
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