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:
@@ -19,6 +19,7 @@ import { homedir } from 'os';
|
|||||||
import { getPackageRoot } from '../shared/paths.js';
|
import { getPackageRoot } from '../shared/paths.js';
|
||||||
import { getWorkerPort } from '../shared/worker-utils.js';
|
import { getWorkerPort } from '../shared/worker-utils.js';
|
||||||
import { logger } from '../utils/logger.js';
|
import { logger } from '../utils/logger.js';
|
||||||
|
import { stripMemoryTagsFromPrompt, stripMemoryTagsFromJson } from '../utils/tag-stripping.js';
|
||||||
import { Client } from '@modelcontextprotocol/sdk/client/index.js';
|
import { Client } from '@modelcontextprotocol/sdk/client/index.js';
|
||||||
import { StdioClientTransport } from '@modelcontextprotocol/sdk/client/stdio.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-concept', this.handleSearchByConcept.bind(this));
|
||||||
this.app.get('/api/search/by-file', this.handleSearchByFile.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/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/recent', this.handleGetRecentContext.bind(this));
|
||||||
this.app.get('/api/context/timeline', this.handleGetContextTimeline.bind(this));
|
this.app.get('/api/context/timeline', this.handleGetContextTimeline.bind(this));
|
||||||
this.app.get('/api/context/preview', this.handleContextPreview.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 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
|
* GET /api/timeline/by-query?query=...&mode=auto&depth_before=10&depth_after=10
|
||||||
|
|||||||
Reference in New Issue
Block a user