8caf159d99
Adds Version Channel section to Settings sidebar allowing users to: - See current branch (main or beta/7.0) and stability status - Switch to beta branch to access Endless Mode features - Switch back to stable for production use - Pull updates for current branch Implementation: - BranchManager.ts: Git operations for branch detection/switching - worker-service.ts: /api/branch/* endpoints (status, switch, update) - Sidebar.tsx: Version Channel UI with branch state and handlers 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
1547 lines
53 KiB
TypeScript
1547 lines
53 KiB
TypeScript
/**
|
|
* Worker Service v2: Clean Object-Oriented Architecture
|
|
*
|
|
* This is a complete rewrite following the architecture document.
|
|
* Key improvements:
|
|
* - Single database connection (no open/close churn)
|
|
* - Event-driven queues (zero polling)
|
|
* - DRY utilities for pagination and settings
|
|
* - Clean separation of concerns
|
|
* - ~600-700 lines (down from 1173)
|
|
*/
|
|
|
|
import express, { Request, Response } from 'express';
|
|
import cors from 'cors';
|
|
import http from 'http';
|
|
import path from 'path';
|
|
import { readFileSync, writeFileSync, statSync, existsSync, renameSync } from 'fs';
|
|
import { homedir } from 'os';
|
|
import { getPackageRoot } from '../shared/paths.js';
|
|
import { getWorkerPort } from '../shared/worker-utils.js';
|
|
import { logger } from '../utils/logger.js';
|
|
import { Client } from '@modelcontextprotocol/sdk/client/index.js';
|
|
import { StdioClientTransport } from '@modelcontextprotocol/sdk/client/stdio.js';
|
|
|
|
// Import composed services
|
|
import { DatabaseManager } from './worker/DatabaseManager.js';
|
|
import { SessionManager } from './worker/SessionManager.js';
|
|
import { SSEBroadcaster } from './worker/SSEBroadcaster.js';
|
|
import { SDKAgent } from './worker/SDKAgent.js';
|
|
import { PaginationHelper } from './worker/PaginationHelper.js';
|
|
import { SettingsManager } from './worker/SettingsManager.js';
|
|
import { getBranchInfo, switchBranch, pullUpdates, type BranchInfo, type SwitchResult } from './worker/BranchManager.js';
|
|
|
|
export class WorkerService {
|
|
private app: express.Application;
|
|
private server: http.Server | null = null;
|
|
private startTime: number = Date.now();
|
|
private mcpClient: Client;
|
|
|
|
// Composed services
|
|
private dbManager: DatabaseManager;
|
|
private sessionManager: SessionManager;
|
|
private sseBroadcaster: SSEBroadcaster;
|
|
private sdkAgent: SDKAgent;
|
|
private paginationHelper: PaginationHelper;
|
|
private settingsManager: SettingsManager;
|
|
|
|
constructor() {
|
|
this.app = express();
|
|
|
|
// Initialize services (dependency injection)
|
|
this.dbManager = new DatabaseManager();
|
|
this.sessionManager = new SessionManager(this.dbManager);
|
|
this.sseBroadcaster = new SSEBroadcaster();
|
|
this.sdkAgent = new SDKAgent(this.dbManager, this.sessionManager);
|
|
this.paginationHelper = new PaginationHelper(this.dbManager);
|
|
this.settingsManager = new SettingsManager(this.dbManager);
|
|
|
|
// Set callback for when sessions are deleted (to update activity indicator)
|
|
this.sessionManager.setOnSessionDeleted(() => {
|
|
this.broadcastProcessingStatus();
|
|
});
|
|
|
|
this.mcpClient = new Client({
|
|
name: 'worker-search-proxy',
|
|
version: '1.0.0'
|
|
}, { capabilities: {} });
|
|
|
|
this.setupMiddleware();
|
|
this.setupRoutes();
|
|
}
|
|
|
|
/**
|
|
* Setup Express middleware
|
|
*/
|
|
private setupMiddleware(): void {
|
|
this.app.use(express.json({ limit: '50mb' }));
|
|
this.app.use(cors());
|
|
|
|
// HTTP request/response logging middleware
|
|
this.app.use((req, res, next) => {
|
|
// Skip logging for static assets and health checks
|
|
if (req.path.startsWith('/health') || req.path === '/' || req.path.includes('.')) {
|
|
return next();
|
|
}
|
|
|
|
const start = Date.now();
|
|
const requestId = `${req.method}-${Date.now()}`;
|
|
|
|
// Log incoming request with body summary
|
|
const bodySummary = this.summarizeRequestBody(req.method, req.path, req.body);
|
|
logger.info('HTTP', `→ ${req.method} ${req.path}`, { requestId }, bodySummary);
|
|
|
|
// Capture response
|
|
const originalSend = res.send.bind(res);
|
|
res.send = function(body: any) {
|
|
const duration = Date.now() - start;
|
|
logger.info('HTTP', `← ${res.statusCode} ${req.path}`, { requestId, duration: `${duration}ms` });
|
|
return originalSend(body);
|
|
};
|
|
|
|
next();
|
|
});
|
|
|
|
// Serve static files for web UI (viewer-bundle.js, logos, fonts, etc.)
|
|
const packageRoot = getPackageRoot();
|
|
const uiDir = path.join(packageRoot, 'plugin', 'ui');
|
|
this.app.use(express.static(uiDir));
|
|
}
|
|
|
|
/**
|
|
* Summarize request body for logging
|
|
*/
|
|
private summarizeRequestBody(method: string, path: string, body: any): string {
|
|
if (!body || Object.keys(body).length === 0) return '';
|
|
|
|
// Session init
|
|
if (path.includes('/init')) {
|
|
return '';
|
|
}
|
|
|
|
// Observations
|
|
if (path.includes('/observations')) {
|
|
const toolName = body.tool_name || '?';
|
|
const toolInput = body.tool_input;
|
|
const toolSummary = logger.formatTool(toolName, toolInput);
|
|
return `tool=${toolSummary}`;
|
|
}
|
|
|
|
// Summarize request
|
|
if (path.includes('/summarize')) {
|
|
return 'requesting summary';
|
|
}
|
|
|
|
return '';
|
|
}
|
|
|
|
/**
|
|
* Setup HTTP routes
|
|
*/
|
|
private setupRoutes(): void {
|
|
// Health & Viewer
|
|
this.app.get('/health', this.handleHealth.bind(this));
|
|
this.app.get('/', this.handleViewerUI.bind(this));
|
|
this.app.get('/stream', this.handleSSEStream.bind(this));
|
|
|
|
// Session endpoints
|
|
this.app.post('/sessions/:sessionDbId/init', this.handleSessionInit.bind(this));
|
|
this.app.post('/sessions/:sessionDbId/observations', this.handleObservations.bind(this));
|
|
this.app.post('/sessions/:sessionDbId/summarize', this.handleSummarize.bind(this));
|
|
this.app.get('/sessions/:sessionDbId/status', this.handleSessionStatus.bind(this));
|
|
this.app.delete('/sessions/:sessionDbId', this.handleSessionDelete.bind(this));
|
|
this.app.post('/sessions/:sessionDbId/complete', this.handleSessionComplete.bind(this));
|
|
|
|
// Data retrieval
|
|
this.app.get('/api/observations', this.handleGetObservations.bind(this));
|
|
this.app.get('/api/summaries', this.handleGetSummaries.bind(this));
|
|
this.app.get('/api/prompts', this.handleGetPrompts.bind(this));
|
|
|
|
// Fetch by ID
|
|
this.app.get('/api/observation/:id', this.handleGetObservationById.bind(this));
|
|
this.app.get('/api/session/:id', this.handleGetSessionById.bind(this));
|
|
this.app.get('/api/prompt/:id', this.handleGetPromptById.bind(this));
|
|
|
|
this.app.get('/api/stats', this.handleGetStats.bind(this));
|
|
this.app.get('/api/processing-status', this.handleGetProcessingStatus.bind(this));
|
|
this.app.post('/api/processing', this.handleSetProcessing.bind(this));
|
|
|
|
// Settings
|
|
this.app.get('/api/settings', this.handleGetSettings.bind(this));
|
|
this.app.post('/api/settings', this.handleUpdateSettings.bind(this));
|
|
|
|
// MCP toggle
|
|
this.app.get('/api/mcp/status', this.handleGetMcpStatus.bind(this));
|
|
this.app.post('/api/mcp/toggle', this.handleToggleMcp.bind(this));
|
|
|
|
// Branch switching (beta toggle)
|
|
this.app.get('/api/branch/status', this.handleGetBranchStatus.bind(this));
|
|
this.app.post('/api/branch/switch', this.handleSwitchBranch.bind(this));
|
|
this.app.post('/api/branch/update', this.handleUpdateBranch.bind(this));
|
|
|
|
// Search API endpoints (for skill-based search)
|
|
// Unified endpoints (new consolidated API)
|
|
this.app.get('/api/search', this.handleUnifiedSearch.bind(this));
|
|
this.app.get('/api/timeline', this.handleUnifiedTimeline.bind(this));
|
|
this.app.get('/api/decisions', this.handleDecisions.bind(this));
|
|
this.app.get('/api/changes', this.handleChanges.bind(this));
|
|
this.app.get('/api/how-it-works', this.handleHowItWorks.bind(this));
|
|
|
|
// Backward compatibility endpoints (use /api/search with type param instead)
|
|
this.app.get('/api/search/observations', this.handleSearchObservations.bind(this));
|
|
this.app.get('/api/search/sessions', this.handleSearchSessions.bind(this));
|
|
this.app.get('/api/search/prompts', this.handleSearchPrompts.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-type', this.handleSearchByType.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/timeline/by-query', this.handleGetTimelineByQuery.bind(this));
|
|
this.app.get('/api/search/help', this.handleSearchHelp.bind(this));
|
|
}
|
|
|
|
/**
|
|
* Cleanup orphaned MCP server processes (uvx/chroma) from previous sessions
|
|
*/
|
|
private async cleanupOrphanedProcesses(): Promise<void> {
|
|
try {
|
|
const { execSync } = await import('child_process');
|
|
|
|
// Find orphaned uvx processes (which spawn chroma servers)
|
|
try {
|
|
const processes = execSync('pgrep -fl uvx', { encoding: 'utf-8', stdio: 'pipe' }).trim();
|
|
if (processes) {
|
|
const processCount = processes.split('\n').length;
|
|
logger.info('WORKER', 'Cleaning up orphaned MCP processes', { count: processCount });
|
|
|
|
// Kill the processes
|
|
execSync('pkill -f uvx', { stdio: 'pipe' });
|
|
logger.success('WORKER', `Cleaned up ${processCount} orphaned MCP server processes`);
|
|
}
|
|
} catch (error: any) {
|
|
// pgrep returns exit code 1 if no processes found (not an error)
|
|
if (error.status === 1) {
|
|
logger.debug('WORKER', 'No orphaned MCP processes to clean up');
|
|
} else {
|
|
throw error;
|
|
}
|
|
}
|
|
} catch (error) {
|
|
// Don't fail startup if cleanup fails
|
|
logger.warn('WORKER', 'Failed to cleanup orphaned processes (non-fatal)', {}, error as Error);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Start the worker service
|
|
*/
|
|
async start(): Promise<void> {
|
|
// Cleanup orphaned processes from previous sessions
|
|
await this.cleanupOrphanedProcesses();
|
|
|
|
// Initialize database (once, stays open)
|
|
await this.dbManager.initialize();
|
|
|
|
// Connect to MCP search server
|
|
const searchServerPath = path.join(__dirname, '..', '..', 'plugin', 'scripts', 'search-server.cjs');
|
|
const transport = new StdioClientTransport({
|
|
command: 'node',
|
|
args: [searchServerPath],
|
|
env: process.env
|
|
});
|
|
|
|
await this.mcpClient.connect(transport);
|
|
logger.success('WORKER', 'Connected to MCP search server');
|
|
|
|
// Start HTTP server
|
|
const port = getWorkerPort();
|
|
this.server = await new Promise<http.Server>((resolve, reject) => {
|
|
const srv = this.app.listen(port, () => resolve(srv));
|
|
srv.on('error', reject);
|
|
});
|
|
|
|
logger.info('SYSTEM', 'Worker started', { port, pid: process.pid });
|
|
}
|
|
|
|
/**
|
|
* Shutdown the worker service
|
|
*/
|
|
async shutdown(): Promise<void> {
|
|
// Shutdown all active sessions
|
|
await this.sessionManager.shutdownAll();
|
|
|
|
// Close MCP client connection (terminates search server process)
|
|
if (this.mcpClient) {
|
|
try {
|
|
await this.mcpClient.close();
|
|
logger.info('SYSTEM', 'MCP client closed');
|
|
} catch (error) {
|
|
logger.error('SYSTEM', 'Failed to close MCP client', {}, error as Error);
|
|
}
|
|
}
|
|
|
|
// Close HTTP server
|
|
if (this.server) {
|
|
await new Promise<void>((resolve, reject) => {
|
|
this.server!.close(err => err ? reject(err) : resolve());
|
|
});
|
|
}
|
|
|
|
// Close database connection (includes ChromaSync cleanup)
|
|
await this.dbManager.close();
|
|
|
|
logger.info('SYSTEM', 'Worker shutdown complete');
|
|
}
|
|
|
|
// ============================================================================
|
|
// Route Handlers
|
|
// ============================================================================
|
|
|
|
/**
|
|
* Health check endpoint
|
|
*/
|
|
private handleHealth(req: Request, res: Response): void {
|
|
res.json({ status: 'ok', timestamp: Date.now() });
|
|
}
|
|
|
|
/**
|
|
* Serve viewer UI
|
|
*/
|
|
private handleViewerUI(req: Request, res: Response): void {
|
|
try {
|
|
const packageRoot = getPackageRoot();
|
|
const viewerPath = path.join(packageRoot, 'plugin', 'ui', 'viewer.html');
|
|
const html = readFileSync(viewerPath, 'utf-8');
|
|
res.setHeader('Content-Type', 'text/html');
|
|
res.send(html);
|
|
} catch (error) {
|
|
logger.failure('WORKER', 'Viewer UI error', {}, error as Error);
|
|
res.status(500).json({ error: 'Failed to load viewer UI' });
|
|
}
|
|
}
|
|
|
|
/**
|
|
* SSE stream endpoint
|
|
*/
|
|
private handleSSEStream(req: Request, res: Response): void {
|
|
// Setup SSE headers
|
|
res.setHeader('Content-Type', 'text/event-stream');
|
|
res.setHeader('Cache-Control', 'no-cache');
|
|
res.setHeader('Connection', 'keep-alive');
|
|
|
|
// Add client to broadcaster
|
|
this.sseBroadcaster.addClient(res);
|
|
|
|
// Send initial_load event with projects list
|
|
const allProjects = this.dbManager.getSessionStore().getAllProjects();
|
|
this.sseBroadcaster.broadcast({
|
|
type: 'initial_load',
|
|
projects: allProjects,
|
|
timestamp: Date.now()
|
|
});
|
|
|
|
// Send initial processing status (based on queue depth + active generators)
|
|
const isProcessing = this.sessionManager.isAnySessionProcessing();
|
|
const queueDepth = this.sessionManager.getTotalActiveWork(); // Includes queued + actively processing
|
|
this.sseBroadcaster.broadcast({
|
|
type: 'processing_status',
|
|
isProcessing,
|
|
queueDepth
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Initialize a new session
|
|
*/
|
|
private handleSessionInit(req: Request, res: Response): void {
|
|
try {
|
|
const sessionDbId = parseInt(req.params.sessionDbId, 10);
|
|
const { userPrompt, promptNumber } = req.body;
|
|
const session = this.sessionManager.initializeSession(sessionDbId, userPrompt, promptNumber);
|
|
|
|
// Get the latest user_prompt for this session to sync to Chroma
|
|
const db = this.dbManager.getSessionStore().db;
|
|
const latestPrompt = db.prepare(`
|
|
SELECT
|
|
up.*,
|
|
s.sdk_session_id,
|
|
s.project
|
|
FROM user_prompts up
|
|
JOIN sdk_sessions s ON up.claude_session_id = s.claude_session_id
|
|
WHERE up.claude_session_id = ?
|
|
ORDER BY up.created_at_epoch DESC
|
|
LIMIT 1
|
|
`).get(session.claudeSessionId) as any;
|
|
|
|
// Broadcast new prompt to SSE clients (for web UI)
|
|
if (latestPrompt) {
|
|
this.sseBroadcaster.broadcast({
|
|
type: 'new_prompt',
|
|
prompt: {
|
|
id: latestPrompt.id,
|
|
claude_session_id: latestPrompt.claude_session_id,
|
|
project: latestPrompt.project,
|
|
prompt_number: latestPrompt.prompt_number,
|
|
prompt_text: latestPrompt.prompt_text,
|
|
created_at_epoch: latestPrompt.created_at_epoch
|
|
}
|
|
});
|
|
|
|
// Start activity indicator immediately when prompt arrives (work is about to begin)
|
|
this.sseBroadcaster.broadcast({
|
|
type: 'processing_status',
|
|
isProcessing: true
|
|
});
|
|
|
|
// Sync user prompt to Chroma with error logging
|
|
const chromaStart = Date.now();
|
|
const promptText = latestPrompt.prompt_text;
|
|
this.dbManager.getChromaSync().syncUserPrompt(
|
|
latestPrompt.id,
|
|
latestPrompt.sdk_session_id,
|
|
latestPrompt.project,
|
|
promptText,
|
|
latestPrompt.prompt_number,
|
|
latestPrompt.created_at_epoch
|
|
).then(() => {
|
|
const chromaDuration = Date.now() - chromaStart;
|
|
const truncatedPrompt = promptText.length > 60
|
|
? promptText.substring(0, 60) + '...'
|
|
: promptText;
|
|
logger.debug('CHROMA', 'User prompt synced', {
|
|
promptId: latestPrompt.id,
|
|
duration: `${chromaDuration}ms`,
|
|
prompt: truncatedPrompt
|
|
});
|
|
}).catch(err => {
|
|
logger.error('CHROMA', 'Failed to sync user_prompt', {
|
|
promptId: latestPrompt.id,
|
|
sessionId: sessionDbId
|
|
}, err);
|
|
});
|
|
}
|
|
|
|
// Broadcast processing status (based on queue depth)
|
|
this.broadcastProcessingStatus();
|
|
|
|
// Start SDK agent in background (pass worker ref for spinner control)
|
|
logger.info('SESSION', 'Generator starting', {
|
|
sessionId: sessionDbId,
|
|
project: session.project,
|
|
promptNum: session.lastPromptNumber
|
|
});
|
|
|
|
session.generatorPromise = this.sdkAgent.startSession(session, this)
|
|
.catch(err => {
|
|
logger.failure('SDK', 'SDK agent error', { sessionId: sessionDbId }, err);
|
|
})
|
|
.finally(() => {
|
|
// Clear generator reference when completed
|
|
logger.info('SESSION', `Generator finished`, { sessionId: sessionDbId });
|
|
session.generatorPromise = null;
|
|
// Broadcast status change (generator finished, may stop spinner)
|
|
this.broadcastProcessingStatus();
|
|
});
|
|
|
|
// Broadcast SSE event
|
|
this.sseBroadcaster.broadcast({
|
|
type: 'session_started',
|
|
sessionDbId,
|
|
project: session.project
|
|
});
|
|
|
|
res.json({ status: 'initialized', sessionDbId, port: getWorkerPort() });
|
|
} catch (error) {
|
|
logger.failure('WORKER', 'Session init failed', {}, error as Error);
|
|
res.status(500).json({ error: (error as Error).message });
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Queue observations for processing
|
|
* CRITICAL: Ensures SDK agent is running to process the queue (ALWAYS SAVE EVERYTHING)
|
|
*/
|
|
private handleObservations(req: Request, res: Response): void {
|
|
try {
|
|
const sessionDbId = parseInt(req.params.sessionDbId, 10);
|
|
const { tool_name, tool_input, tool_response, prompt_number, cwd } = req.body;
|
|
|
|
this.sessionManager.queueObservation(sessionDbId, {
|
|
tool_name,
|
|
tool_input,
|
|
tool_response,
|
|
prompt_number,
|
|
cwd
|
|
});
|
|
|
|
// CRITICAL: Ensure SDK agent is running to consume the queue
|
|
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(() => {
|
|
// Clear generator reference when completed
|
|
logger.info('SESSION', `Generator finished`, { sessionId: sessionDbId });
|
|
session.generatorPromise = null;
|
|
// Broadcast status change (generator finished, may stop spinner)
|
|
this.broadcastProcessingStatus();
|
|
});
|
|
}
|
|
|
|
// Broadcast activity status (queue depth changed)
|
|
this.broadcastProcessingStatus();
|
|
|
|
// Broadcast SSE event
|
|
this.sseBroadcaster.broadcast({
|
|
type: 'observation_queued',
|
|
sessionDbId
|
|
});
|
|
|
|
res.json({ status: 'queued' });
|
|
} catch (error) {
|
|
logger.failure('WORKER', 'Observation queuing failed', {}, error as Error);
|
|
res.status(500).json({ error: (error as Error).message });
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Queue summarize request
|
|
* CRITICAL: Ensures SDK agent is running to process the queue (ALWAYS SAVE EVERYTHING)
|
|
*/
|
|
private handleSummarize(req: Request, res: Response): void {
|
|
try {
|
|
const sessionDbId = parseInt(req.params.sessionDbId, 10);
|
|
const { last_user_message, last_assistant_message } = req.body;
|
|
|
|
this.sessionManager.queueSummarize(sessionDbId, last_user_message, last_assistant_message);
|
|
|
|
// CRITICAL: Ensure SDK agent is running to consume the queue
|
|
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(() => {
|
|
// Clear generator reference when completed
|
|
logger.info('SESSION', `Generator finished`, { sessionId: sessionDbId });
|
|
session.generatorPromise = null;
|
|
// Broadcast status change (generator finished, may stop spinner)
|
|
this.broadcastProcessingStatus();
|
|
});
|
|
}
|
|
|
|
// Broadcast activity status (queue depth changed)
|
|
this.broadcastProcessingStatus();
|
|
|
|
res.json({ status: 'queued' });
|
|
} catch (error) {
|
|
logger.failure('WORKER', 'Summarize queuing failed', {}, error as Error);
|
|
res.status(500).json({ error: (error as Error).message });
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Get session status
|
|
*/
|
|
private handleSessionStatus(req: Request, res: Response): void {
|
|
try {
|
|
const sessionDbId = parseInt(req.params.sessionDbId, 10);
|
|
const session = this.sessionManager.getSession(sessionDbId);
|
|
|
|
if (!session) {
|
|
res.json({ status: 'not_found' });
|
|
return;
|
|
}
|
|
|
|
res.json({
|
|
status: 'active',
|
|
sessionDbId,
|
|
project: session.project,
|
|
queueLength: session.pendingMessages.length,
|
|
uptime: Date.now() - session.startTime
|
|
});
|
|
} catch (error) {
|
|
logger.failure('WORKER', 'Session status failed', {}, error as Error);
|
|
res.status(500).json({ error: (error as Error).message });
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Delete a session
|
|
*/
|
|
private async handleSessionDelete(req: Request, res: Response): Promise<void> {
|
|
try {
|
|
const sessionDbId = parseInt(req.params.sessionDbId, 10);
|
|
await this.sessionManager.deleteSession(sessionDbId);
|
|
|
|
// Mark session complete in database
|
|
this.dbManager.markSessionComplete(sessionDbId);
|
|
|
|
// Broadcast SSE event
|
|
this.sseBroadcaster.broadcast({
|
|
type: 'session_completed',
|
|
sessionDbId
|
|
});
|
|
|
|
res.json({ status: 'deleted' });
|
|
} catch (error) {
|
|
logger.failure('WORKER', 'Session delete failed', {}, error as Error);
|
|
res.status(500).json({ error: (error as Error).message });
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Complete a session (backward compatibility for cleanup-hook)
|
|
* cleanup-hook expects POST /sessions/:sessionDbId/complete instead of DELETE
|
|
*/
|
|
private async handleSessionComplete(req: Request, res: Response): Promise<void> {
|
|
try {
|
|
const sessionDbId = parseInt(req.params.sessionDbId, 10);
|
|
if (isNaN(sessionDbId)) {
|
|
res.status(400).json({ success: false, error: 'Invalid session ID' });
|
|
return;
|
|
}
|
|
|
|
await this.sessionManager.deleteSession(sessionDbId);
|
|
|
|
// Mark session complete in database
|
|
this.dbManager.markSessionComplete(sessionDbId);
|
|
|
|
// Broadcast processing status (based on queue depth)
|
|
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 failed', {}, error as Error);
|
|
res.status(500).json({ success: false, error: String(error) });
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Get paginated observations
|
|
*/
|
|
private handleGetObservations(req: Request, res: Response): void {
|
|
try {
|
|
const { offset, limit, project } = parsePaginationParams(req);
|
|
const result = this.paginationHelper.getObservations(offset, limit, project);
|
|
res.json(result);
|
|
} catch (error) {
|
|
logger.failure('WORKER', 'Get observations failed', {}, error as Error);
|
|
res.status(500).json({ error: (error as Error).message });
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Get paginated summaries
|
|
*/
|
|
private handleGetSummaries(req: Request, res: Response): void {
|
|
try {
|
|
const { offset, limit, project } = parsePaginationParams(req);
|
|
const result = this.paginationHelper.getSummaries(offset, limit, project);
|
|
res.json(result);
|
|
} catch (error) {
|
|
logger.failure('WORKER', 'Get summaries failed', {}, error as Error);
|
|
res.status(500).json({ error: (error as Error).message });
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Get paginated user prompts
|
|
*/
|
|
private handleGetPrompts(req: Request, res: Response): void {
|
|
try {
|
|
const { offset, limit, project } = parsePaginationParams(req);
|
|
const result = this.paginationHelper.getPrompts(offset, limit, project);
|
|
res.json(result);
|
|
} catch (error) {
|
|
logger.failure('WORKER', 'Get prompts failed', {}, error as Error);
|
|
res.status(500).json({ error: (error as Error).message });
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Get observation by ID
|
|
* GET /api/observation/:id
|
|
*/
|
|
private handleGetObservationById(req: Request, res: Response): void {
|
|
try {
|
|
const id = parseInt(req.params.id, 10);
|
|
if (isNaN(id)) {
|
|
res.status(400).json({ error: 'Invalid observation ID' });
|
|
return;
|
|
}
|
|
|
|
const store = this.dbManager.getSessionStore();
|
|
const observation = store.getObservationById(id);
|
|
|
|
if (!observation) {
|
|
res.status(404).json({ error: `Observation #${id} not found` });
|
|
return;
|
|
}
|
|
|
|
res.json(observation);
|
|
} catch (error) {
|
|
logger.failure('WORKER', 'Get observation by ID failed', {}, error as Error);
|
|
res.status(500).json({ error: (error as Error).message });
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Get session by ID
|
|
* GET /api/session/:id
|
|
*/
|
|
private handleGetSessionById(req: Request, res: Response): void {
|
|
try {
|
|
const id = parseInt(req.params.id, 10);
|
|
if (isNaN(id)) {
|
|
res.status(400).json({ error: 'Invalid session ID' });
|
|
return;
|
|
}
|
|
|
|
const store = this.dbManager.getSessionStore();
|
|
const sessions = store.getSessionSummariesByIds([id]);
|
|
|
|
if (sessions.length === 0) {
|
|
res.status(404).json({ error: `Session #${id} not found` });
|
|
return;
|
|
}
|
|
|
|
res.json(sessions[0]);
|
|
} catch (error) {
|
|
logger.failure('WORKER', 'Get session by ID failed', {}, error as Error);
|
|
res.status(500).json({ error: (error as Error).message });
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Get user prompt by ID
|
|
* GET /api/prompt/:id
|
|
*/
|
|
private handleGetPromptById(req: Request, res: Response): void {
|
|
try {
|
|
const id = parseInt(req.params.id, 10);
|
|
if (isNaN(id)) {
|
|
res.status(400).json({ error: 'Invalid prompt ID' });
|
|
return;
|
|
}
|
|
|
|
const store = this.dbManager.getSessionStore();
|
|
const prompts = store.getUserPromptsByIds([id]);
|
|
|
|
if (prompts.length === 0) {
|
|
res.status(404).json({ error: `Prompt #${id} not found` });
|
|
return;
|
|
}
|
|
|
|
res.json(prompts[0]);
|
|
} catch (error) {
|
|
logger.failure('WORKER', 'Get prompt by ID failed', {}, error as Error);
|
|
res.status(500).json({ error: (error as Error).message });
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Get database statistics (with worker metadata)
|
|
*/
|
|
private handleGetStats(req: Request, res: Response): void {
|
|
try {
|
|
const db = this.dbManager.getSessionStore().db;
|
|
|
|
// Read version from package.json
|
|
const packageRoot = getPackageRoot();
|
|
const packageJsonPath = path.join(packageRoot, 'package.json');
|
|
const packageJson = JSON.parse(readFileSync(packageJsonPath, 'utf-8'));
|
|
const version = packageJson.version;
|
|
|
|
// Get database stats
|
|
const totalObservations = db.prepare('SELECT COUNT(*) as count FROM observations').get() as { count: number };
|
|
const totalSessions = db.prepare('SELECT COUNT(*) as count FROM sdk_sessions').get() as { count: number };
|
|
const totalSummaries = db.prepare('SELECT COUNT(*) as count FROM session_summaries').get() as { count: number };
|
|
|
|
// Get database file size and path
|
|
const dbPath = path.join(homedir(), '.claude-mem', 'claude-mem.db');
|
|
let dbSize = 0;
|
|
if (existsSync(dbPath)) {
|
|
dbSize = statSync(dbPath).size;
|
|
}
|
|
|
|
// Worker metadata
|
|
const uptime = Math.floor((Date.now() - this.startTime) / 1000);
|
|
const activeSessions = this.sessionManager.getActiveSessionCount();
|
|
const sseClients = this.sseBroadcaster.getClientCount();
|
|
|
|
res.json({
|
|
worker: {
|
|
version,
|
|
uptime,
|
|
activeSessions,
|
|
sseClients,
|
|
port: getWorkerPort()
|
|
},
|
|
database: {
|
|
path: dbPath,
|
|
size: dbSize,
|
|
observations: totalObservations.count,
|
|
sessions: totalSessions.count,
|
|
summaries: totalSummaries.count
|
|
}
|
|
});
|
|
} catch (error) {
|
|
logger.failure('WORKER', 'Get stats failed', {}, error as Error);
|
|
res.status(500).json({ error: (error as Error).message });
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Get environment settings (from ~/.claude/settings.json)
|
|
*/
|
|
private handleGetSettings(req: Request, res: Response): void {
|
|
try {
|
|
const settingsPath = path.join(homedir(), '.claude', 'settings.json');
|
|
|
|
if (!existsSync(settingsPath)) {
|
|
// Return defaults if file doesn't exist
|
|
res.json({
|
|
CLAUDE_MEM_MODEL: 'claude-haiku-4-5',
|
|
CLAUDE_MEM_CONTEXT_OBSERVATIONS: '50',
|
|
CLAUDE_MEM_WORKER_PORT: '37777'
|
|
});
|
|
return;
|
|
}
|
|
|
|
const settingsData = readFileSync(settingsPath, 'utf-8');
|
|
const settings = JSON.parse(settingsData);
|
|
const env = settings.env || {};
|
|
|
|
res.json({
|
|
CLAUDE_MEM_MODEL: env.CLAUDE_MEM_MODEL || 'claude-haiku-4-5',
|
|
CLAUDE_MEM_CONTEXT_OBSERVATIONS: env.CLAUDE_MEM_CONTEXT_OBSERVATIONS || '50',
|
|
CLAUDE_MEM_WORKER_PORT: env.CLAUDE_MEM_WORKER_PORT || '37777'
|
|
});
|
|
} catch (error) {
|
|
logger.failure('WORKER', 'Get settings failed', {}, error as Error);
|
|
res.status(500).json({ error: (error as Error).message });
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Update environment settings (in ~/.claude/settings.json) with validation
|
|
*/
|
|
private handleUpdateSettings(req: Request, res: Response): void {
|
|
try {
|
|
const { CLAUDE_MEM_MODEL, CLAUDE_MEM_CONTEXT_OBSERVATIONS, CLAUDE_MEM_WORKER_PORT } = req.body;
|
|
|
|
// Validate inputs
|
|
if (CLAUDE_MEM_CONTEXT_OBSERVATIONS) {
|
|
const obsCount = parseInt(CLAUDE_MEM_CONTEXT_OBSERVATIONS, 10);
|
|
if (isNaN(obsCount) || obsCount < 1 || obsCount > 200) {
|
|
res.status(400).json({
|
|
success: false,
|
|
error: 'CLAUDE_MEM_CONTEXT_OBSERVATIONS must be between 1 and 200'
|
|
});
|
|
return;
|
|
}
|
|
}
|
|
|
|
if (CLAUDE_MEM_WORKER_PORT) {
|
|
const port = parseInt(CLAUDE_MEM_WORKER_PORT, 10);
|
|
if (isNaN(port) || port < 1024 || port > 65535) {
|
|
res.status(400).json({
|
|
success: false,
|
|
error: 'CLAUDE_MEM_WORKER_PORT must be between 1024 and 65535'
|
|
});
|
|
return;
|
|
}
|
|
}
|
|
|
|
// Read existing settings
|
|
const settingsPath = path.join(homedir(), '.claude', 'settings.json');
|
|
let settings: any = { env: {} };
|
|
|
|
if (existsSync(settingsPath)) {
|
|
const settingsData = readFileSync(settingsPath, 'utf-8');
|
|
settings = JSON.parse(settingsData);
|
|
if (!settings.env) {
|
|
settings.env = {};
|
|
}
|
|
}
|
|
|
|
// Update settings
|
|
if (CLAUDE_MEM_MODEL) {
|
|
settings.env.CLAUDE_MEM_MODEL = CLAUDE_MEM_MODEL;
|
|
}
|
|
if (CLAUDE_MEM_CONTEXT_OBSERVATIONS) {
|
|
settings.env.CLAUDE_MEM_CONTEXT_OBSERVATIONS = CLAUDE_MEM_CONTEXT_OBSERVATIONS;
|
|
}
|
|
if (CLAUDE_MEM_WORKER_PORT) {
|
|
settings.env.CLAUDE_MEM_WORKER_PORT = CLAUDE_MEM_WORKER_PORT;
|
|
}
|
|
|
|
// Write back
|
|
writeFileSync(settingsPath, JSON.stringify(settings, null, 2), 'utf-8');
|
|
|
|
logger.info('WORKER', 'Settings updated');
|
|
res.json({ success: true, message: 'Settings updated successfully' });
|
|
} catch (error) {
|
|
logger.failure('WORKER', 'Update settings failed', {}, error as Error);
|
|
res.status(500).json({ success: false, error: String(error) });
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Get processing status (for viewer UI spinner and queue indicator)
|
|
*/
|
|
private handleGetProcessingStatus(req: Request, res: Response): void {
|
|
const isProcessing = this.sessionManager.isAnySessionProcessing();
|
|
const queueDepth = this.sessionManager.getTotalActiveWork(); // Includes queued + actively processing
|
|
res.json({ isProcessing, queueDepth });
|
|
}
|
|
|
|
// ============================================================================
|
|
// Processing Status Helpers
|
|
// ============================================================================
|
|
|
|
/**
|
|
* Broadcast processing status change to SSE clients
|
|
* Checks both queue depth and active generators to prevent premature spinner stop
|
|
*/
|
|
broadcastProcessingStatus(): void {
|
|
const isProcessing = this.sessionManager.isAnySessionProcessing();
|
|
const queueDepth = this.sessionManager.getTotalActiveWork(); // Includes queued + actively processing
|
|
const activeSessions = this.sessionManager.getActiveSessionCount();
|
|
|
|
logger.info('WORKER', 'Broadcasting processing status', {
|
|
isProcessing,
|
|
queueDepth,
|
|
activeSessions
|
|
});
|
|
|
|
this.sseBroadcaster.broadcast({
|
|
type: 'processing_status',
|
|
isProcessing,
|
|
queueDepth
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Set processing status (called by hooks)
|
|
* NOTE: This now broadcasts computed status based on active processing (ignores input)
|
|
*/
|
|
private handleSetProcessing(req: Request, res: Response): void {
|
|
try {
|
|
// Broadcast current computed status (ignores manual input)
|
|
this.broadcastProcessingStatus();
|
|
|
|
const isProcessing = this.sessionManager.isAnySessionProcessing();
|
|
const queueDepth = this.sessionManager.getTotalQueueDepth();
|
|
const activeSessions = this.sessionManager.getActiveSessionCount();
|
|
logger.debug('WORKER', 'Processing status broadcast', { isProcessing, queueDepth, activeSessions });
|
|
|
|
res.json({ status: 'ok', isProcessing });
|
|
} catch (error) {
|
|
logger.failure('WORKER', 'Failed to broadcast processing status', {}, error as Error);
|
|
res.status(500).json({ error: (error as Error).message });
|
|
}
|
|
}
|
|
|
|
// ============================================================================
|
|
// MCP Toggle Handlers
|
|
// ============================================================================
|
|
|
|
/**
|
|
* GET /api/mcp/status - Check if MCP search server is enabled
|
|
*/
|
|
private handleGetMcpStatus(req: Request, res: Response): void {
|
|
try {
|
|
const enabled = this.isMcpEnabled();
|
|
res.json({ enabled });
|
|
} catch (error) {
|
|
logger.failure('WORKER', 'Get MCP status failed', {}, error as Error);
|
|
res.status(500).json({ error: (error as Error).message });
|
|
}
|
|
}
|
|
|
|
/**
|
|
* POST /api/mcp/toggle - Toggle MCP search server on/off
|
|
* Body: { enabled: boolean }
|
|
*/
|
|
private handleToggleMcp(req: Request, res: Response): void {
|
|
try {
|
|
const { enabled } = req.body;
|
|
|
|
if (typeof enabled !== 'boolean') {
|
|
res.status(400).json({ error: 'enabled must be a boolean' });
|
|
return;
|
|
}
|
|
|
|
this.toggleMcp(enabled);
|
|
res.json({ success: true, enabled: this.isMcpEnabled() });
|
|
} catch (error) {
|
|
logger.failure('WORKER', 'Toggle MCP failed', {}, error as Error);
|
|
res.status(500).json({ success: false, error: (error as Error).message });
|
|
}
|
|
}
|
|
|
|
// ============================================================================
|
|
// MCP Toggle Helpers
|
|
// ============================================================================
|
|
|
|
/**
|
|
* Check if MCP search server is enabled
|
|
*/
|
|
private isMcpEnabled(): boolean {
|
|
const packageRoot = getPackageRoot();
|
|
const mcpPath = path.join(packageRoot, 'plugin', '.mcp.json');
|
|
return existsSync(mcpPath);
|
|
}
|
|
|
|
/**
|
|
* Toggle MCP search server (rename .mcp.json <-> .mcp.json.disabled)
|
|
*/
|
|
private toggleMcp(enabled: boolean): void {
|
|
try {
|
|
const packageRoot = getPackageRoot();
|
|
const mcpPath = path.join(packageRoot, 'plugin', '.mcp.json');
|
|
const mcpDisabledPath = path.join(packageRoot, 'plugin', '.mcp.json.disabled');
|
|
|
|
if (enabled && existsSync(mcpDisabledPath)) {
|
|
// Enable: rename .mcp.json.disabled -> .mcp.json
|
|
renameSync(mcpDisabledPath, mcpPath);
|
|
logger.info('WORKER', 'MCP search server enabled');
|
|
} else if (!enabled && existsSync(mcpPath)) {
|
|
// Disable: rename .mcp.json -> .mcp.json.disabled
|
|
renameSync(mcpPath, mcpDisabledPath);
|
|
logger.info('WORKER', 'MCP search server disabled');
|
|
} else {
|
|
logger.debug('WORKER', 'MCP toggle no-op (already in desired state)', { enabled });
|
|
}
|
|
} catch (error) {
|
|
logger.failure('WORKER', 'Failed to toggle MCP', { enabled }, error as Error);
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
// ============================================================================
|
|
// Branch Switching Handlers (Beta Toggle)
|
|
// ============================================================================
|
|
|
|
/**
|
|
* GET /api/branch/status - Get current branch information
|
|
*/
|
|
private handleGetBranchStatus(req: Request, res: Response): void {
|
|
try {
|
|
const info = getBranchInfo();
|
|
res.json(info);
|
|
} catch (error) {
|
|
logger.failure('WORKER', 'Failed to get branch status', {}, error as Error);
|
|
res.status(500).json({ error: (error as Error).message });
|
|
}
|
|
}
|
|
|
|
/**
|
|
* POST /api/branch/switch - Switch to a different branch
|
|
* Body: { branch: "main" | "beta/7.0" }
|
|
*/
|
|
private async handleSwitchBranch(req: Request, res: Response): Promise<void> {
|
|
try {
|
|
const { branch } = req.body;
|
|
|
|
if (!branch) {
|
|
res.status(400).json({ success: false, error: 'Missing branch parameter' });
|
|
return;
|
|
}
|
|
|
|
// Validate branch name
|
|
const allowedBranches = ['main', 'beta/7.0'];
|
|
if (!allowedBranches.includes(branch)) {
|
|
res.status(400).json({
|
|
success: false,
|
|
error: `Invalid branch. Allowed: ${allowedBranches.join(', ')}`
|
|
});
|
|
return;
|
|
}
|
|
|
|
logger.info('WORKER', 'Branch switch requested', { branch });
|
|
|
|
const result = await switchBranch(branch);
|
|
|
|
if (result.success) {
|
|
// Schedule worker restart after response is sent
|
|
setTimeout(() => {
|
|
logger.info('WORKER', 'Restarting worker after branch switch');
|
|
process.exit(0); // PM2 will restart the worker
|
|
}, 1000);
|
|
}
|
|
|
|
res.json(result);
|
|
} catch (error) {
|
|
logger.failure('WORKER', 'Branch switch failed', {}, error as Error);
|
|
res.status(500).json({ success: false, error: (error as Error).message });
|
|
}
|
|
}
|
|
|
|
/**
|
|
* POST /api/branch/update - Pull latest updates for current branch
|
|
*/
|
|
private async handleUpdateBranch(req: Request, res: Response): Promise<void> {
|
|
try {
|
|
logger.info('WORKER', 'Branch update requested');
|
|
|
|
const result = await pullUpdates();
|
|
|
|
if (result.success) {
|
|
// Schedule worker restart after response is sent
|
|
setTimeout(() => {
|
|
logger.info('WORKER', 'Restarting worker after branch update');
|
|
process.exit(0); // PM2 will restart the worker
|
|
}, 1000);
|
|
}
|
|
|
|
res.json(result);
|
|
} catch (error) {
|
|
logger.failure('WORKER', 'Branch update failed', {}, error as Error);
|
|
res.status(500).json({ success: false, error: (error as Error).message });
|
|
}
|
|
}
|
|
|
|
// ============================================================================
|
|
// Search API Handlers (for skill-based search)
|
|
// ============================================================================
|
|
|
|
// ============================================================================
|
|
// Unified Search API Handlers (New Consolidated API)
|
|
// ============================================================================
|
|
|
|
/**
|
|
* Unified search across all memory types (observations, sessions, prompts)
|
|
* GET /api/search?query=...&format=index&limit=20
|
|
*/
|
|
private async handleUnifiedSearch(req: Request, res: Response): Promise<void> {
|
|
try {
|
|
const result = await this.mcpClient.callTool({
|
|
name: 'search',
|
|
arguments: req.query
|
|
});
|
|
res.json(result.content);
|
|
} catch (error) {
|
|
logger.failure('WORKER', 'Unified search failed', {}, error as Error);
|
|
res.status(500).json({ error: (error as Error).message });
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Unified timeline (anchor or query-based)
|
|
* GET /api/timeline?anchor=123 OR GET /api/timeline?query=...
|
|
*/
|
|
private async handleUnifiedTimeline(req: Request, res: Response): Promise<void> {
|
|
try {
|
|
const result = await this.mcpClient.callTool({
|
|
name: 'timeline',
|
|
arguments: req.query
|
|
});
|
|
res.json(result.content);
|
|
} catch (error) {
|
|
logger.failure('WORKER', 'Unified timeline failed', {}, error as Error);
|
|
res.status(500).json({ error: (error as Error).message });
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Semantic shortcut for finding decision observations
|
|
* GET /api/decisions?format=index&limit=20
|
|
*/
|
|
private async handleDecisions(req: Request, res: Response): Promise<void> {
|
|
try {
|
|
const result = await this.mcpClient.callTool({
|
|
name: 'decisions',
|
|
arguments: req.query
|
|
});
|
|
res.json(result.content);
|
|
} catch (error) {
|
|
logger.failure('WORKER', 'Decisions search failed', {}, error as Error);
|
|
res.status(500).json({ error: (error as Error).message });
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Semantic shortcut for finding change-related observations
|
|
* GET /api/changes?format=index&limit=20
|
|
*/
|
|
private async handleChanges(req: Request, res: Response): Promise<void> {
|
|
try {
|
|
const result = await this.mcpClient.callTool({
|
|
name: 'changes',
|
|
arguments: req.query
|
|
});
|
|
res.json(result.content);
|
|
} catch (error) {
|
|
logger.failure('WORKER', 'Changes search failed', {}, error as Error);
|
|
res.status(500).json({ error: (error as Error).message });
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Semantic shortcut for finding "how it works" explanations
|
|
* GET /api/how-it-works?format=index&limit=20
|
|
*/
|
|
private async handleHowItWorks(req: Request, res: Response): Promise<void> {
|
|
try {
|
|
const result = await this.mcpClient.callTool({
|
|
name: 'how_it_works',
|
|
arguments: req.query
|
|
});
|
|
res.json(result.content);
|
|
} catch (error) {
|
|
logger.failure('WORKER', 'How it works search failed', {}, error as Error);
|
|
res.status(500).json({ error: (error as Error).message });
|
|
}
|
|
}
|
|
|
|
// ============================================================================
|
|
// Backward Compatibility API Handlers
|
|
// All functionality available via /api/search with type/obs_type/concepts/files params
|
|
// ============================================================================
|
|
|
|
/**
|
|
* Search observations (use /api/search?type=observations instead)
|
|
* GET /api/search/observations?query=...&format=index&limit=20&project=...
|
|
*/
|
|
private async handleSearchObservations(req: Request, res: Response): Promise<void> {
|
|
try {
|
|
const result = await this.mcpClient.callTool({
|
|
name: 'search_observations',
|
|
arguments: req.query
|
|
});
|
|
res.json(result.content);
|
|
} catch (error) {
|
|
logger.failure('WORKER', 'Search failed', {}, error as Error);
|
|
res.status(500).json({ error: (error as Error).message });
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Search session summaries
|
|
* GET /api/search/sessions?query=...&format=index&limit=20
|
|
*/
|
|
private async handleSearchSessions(req: Request, res: Response): Promise<void> {
|
|
try {
|
|
const result = await this.mcpClient.callTool({
|
|
name: 'search_sessions',
|
|
arguments: req.query
|
|
});
|
|
res.json(result.content);
|
|
} catch (error) {
|
|
logger.failure('WORKER', 'Search failed', {}, error as Error);
|
|
res.status(500).json({ error: (error as Error).message });
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Search user prompts
|
|
* GET /api/search/prompts?query=...&format=index&limit=20
|
|
*/
|
|
private async handleSearchPrompts(req: Request, res: Response): Promise<void> {
|
|
try {
|
|
const result = await this.mcpClient.callTool({
|
|
name: 'search_user_prompts',
|
|
arguments: req.query
|
|
});
|
|
res.json(result.content);
|
|
} catch (error) {
|
|
logger.failure('WORKER', 'Search failed', {}, error as Error);
|
|
res.status(500).json({ error: (error as Error).message });
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Search observations by concept
|
|
* GET /api/search/by-concept?concept=discovery&format=index&limit=5
|
|
*/
|
|
private async handleSearchByConcept(req: Request, res: Response): Promise<void> {
|
|
try {
|
|
const result = await this.mcpClient.callTool({
|
|
name: 'find_by_concept',
|
|
arguments: req.query
|
|
});
|
|
res.json(result.content);
|
|
} catch (error) {
|
|
logger.failure('WORKER', 'Search failed', {}, error as Error);
|
|
res.status(500).json({ error: (error as Error).message });
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Search by file path
|
|
* GET /api/search/by-file?filePath=...&format=index&limit=10
|
|
*/
|
|
private async handleSearchByFile(req: Request, res: Response): Promise<void> {
|
|
try {
|
|
const result = await this.mcpClient.callTool({
|
|
name: 'find_by_file',
|
|
arguments: req.query
|
|
});
|
|
res.json(result.content);
|
|
} catch (error) {
|
|
logger.failure('WORKER', 'Search failed', {}, error as Error);
|
|
res.status(500).json({ error: (error as Error).message });
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Search observations by type
|
|
* GET /api/search/by-type?type=bugfix&format=index&limit=10
|
|
*/
|
|
private async handleSearchByType(req: Request, res: Response): Promise<void> {
|
|
try {
|
|
const result = await this.mcpClient.callTool({
|
|
name: 'find_by_type',
|
|
arguments: req.query
|
|
});
|
|
res.json(result.content);
|
|
} catch (error) {
|
|
logger.failure('WORKER', 'Search failed', {}, error as Error);
|
|
res.status(500).json({ error: (error as Error).message });
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Get recent context (summaries and observations for a project)
|
|
* GET /api/context/recent?project=...&limit=3
|
|
*/
|
|
private async handleGetRecentContext(req: Request, res: Response): Promise<void> {
|
|
try {
|
|
const result = await this.mcpClient.callTool({
|
|
name: 'get_recent_context',
|
|
arguments: req.query
|
|
});
|
|
res.json(result.content);
|
|
} catch (error) {
|
|
logger.failure('WORKER', 'Search failed', {}, error as Error);
|
|
res.status(500).json({ error: (error as Error).message });
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Get context timeline around an anchor point
|
|
* GET /api/context/timeline?anchor=123&depth_before=10&depth_after=10&project=...
|
|
*/
|
|
private async handleGetContextTimeline(req: Request, res: Response): Promise<void> {
|
|
try {
|
|
const result = await this.mcpClient.callTool({
|
|
name: 'get_context_timeline',
|
|
arguments: req.query
|
|
});
|
|
res.json(result.content);
|
|
} catch (error) {
|
|
logger.failure('WORKER', 'Search failed', {}, error as Error);
|
|
res.status(500).json({ error: (error as Error).message });
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 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
|
|
*/
|
|
private async handleGetTimelineByQuery(req: Request, res: Response): Promise<void> {
|
|
try {
|
|
const result = await this.mcpClient.callTool({
|
|
name: 'get_timeline_by_query',
|
|
arguments: req.query
|
|
});
|
|
res.json(result.content);
|
|
} catch (error) {
|
|
logger.failure('WORKER', 'Search failed', {}, error as Error);
|
|
res.status(500).json({ error: (error as Error).message });
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Get search help documentation
|
|
* GET /api/search/help
|
|
*/
|
|
private handleSearchHelp(req: Request, res: Response): void {
|
|
res.json({
|
|
title: 'Claude-Mem Search API',
|
|
description: 'HTTP API for searching persistent memory',
|
|
endpoints: [
|
|
{
|
|
path: '/api/search/observations',
|
|
method: 'GET',
|
|
description: 'Search observations using full-text search',
|
|
parameters: {
|
|
query: 'Search query (required)',
|
|
format: 'Response format: "index" or "full" (default: "full")',
|
|
limit: 'Number of results (default: 20)',
|
|
project: 'Filter by project name (optional)'
|
|
}
|
|
},
|
|
{
|
|
path: '/api/search/sessions',
|
|
method: 'GET',
|
|
description: 'Search session summaries using full-text search',
|
|
parameters: {
|
|
query: 'Search query (required)',
|
|
format: 'Response format: "index" or "full" (default: "full")',
|
|
limit: 'Number of results (default: 20)'
|
|
}
|
|
},
|
|
{
|
|
path: '/api/search/prompts',
|
|
method: 'GET',
|
|
description: 'Search user prompts using full-text search',
|
|
parameters: {
|
|
query: 'Search query (required)',
|
|
format: 'Response format: "index" or "full" (default: "full")',
|
|
limit: 'Number of results (default: 20)',
|
|
project: 'Filter by project name (optional)'
|
|
}
|
|
},
|
|
{
|
|
path: '/api/search/by-concept',
|
|
method: 'GET',
|
|
description: 'Find observations by concept tag',
|
|
parameters: {
|
|
concept: 'Concept tag (required): discovery, decision, bugfix, feature, refactor',
|
|
format: 'Response format: "index" or "full" (default: "full")',
|
|
limit: 'Number of results (default: 10)',
|
|
project: 'Filter by project name (optional)'
|
|
}
|
|
},
|
|
{
|
|
path: '/api/search/by-file',
|
|
method: 'GET',
|
|
description: 'Find observations and sessions by file path',
|
|
parameters: {
|
|
filePath: 'File path or partial path (required)',
|
|
format: 'Response format: "index" or "full" (default: "full")',
|
|
limit: 'Number of results per type (default: 10)',
|
|
project: 'Filter by project name (optional)'
|
|
}
|
|
},
|
|
{
|
|
path: '/api/search/by-type',
|
|
method: 'GET',
|
|
description: 'Find observations by type',
|
|
parameters: {
|
|
type: 'Observation type (required): discovery, decision, bugfix, feature, refactor',
|
|
format: 'Response format: "index" or "full" (default: "full")',
|
|
limit: 'Number of results (default: 10)',
|
|
project: 'Filter by project name (optional)'
|
|
}
|
|
},
|
|
{
|
|
path: '/api/context/recent',
|
|
method: 'GET',
|
|
description: 'Get recent session context including summaries and observations',
|
|
parameters: {
|
|
project: 'Project name (default: current directory)',
|
|
limit: 'Number of recent sessions (default: 3)'
|
|
}
|
|
},
|
|
{
|
|
path: '/api/context/timeline',
|
|
method: 'GET',
|
|
description: 'Get unified timeline around a specific point in time',
|
|
parameters: {
|
|
anchor: 'Anchor point: observation ID, session ID (e.g., "S123"), or ISO timestamp (required)',
|
|
depth_before: 'Number of records before anchor (default: 10)',
|
|
depth_after: 'Number of records after anchor (default: 10)',
|
|
project: 'Filter by project name (optional)'
|
|
}
|
|
},
|
|
{
|
|
path: '/api/timeline/by-query',
|
|
method: 'GET',
|
|
description: 'Search for best match, then get timeline around it',
|
|
parameters: {
|
|
query: 'Search query (required)',
|
|
mode: 'Search mode: "auto", "observations", or "sessions" (default: "auto")',
|
|
depth_before: 'Number of records before match (default: 10)',
|
|
depth_after: 'Number of records after match (default: 10)',
|
|
project: 'Filter by project name (optional)'
|
|
}
|
|
},
|
|
{
|
|
path: '/api/search/help',
|
|
method: 'GET',
|
|
description: 'Get this help documentation'
|
|
}
|
|
],
|
|
examples: [
|
|
'curl "http://localhost:37777/api/search/observations?query=authentication&format=index&limit=5"',
|
|
'curl "http://localhost:37777/api/search/by-type?type=bugfix&limit=10"',
|
|
'curl "http://localhost:37777/api/context/recent?project=claude-mem&limit=3"',
|
|
'curl "http://localhost:37777/api/context/timeline?anchor=123&depth_before=5&depth_after=5"'
|
|
]
|
|
});
|
|
}
|
|
}
|
|
|
|
// ============================================================================
|
|
// Utilities
|
|
// ============================================================================
|
|
|
|
/**
|
|
* Parse pagination parameters from request
|
|
*/
|
|
function parsePaginationParams(req: Request): { offset: number; limit: number; project?: string } {
|
|
const offset = parseInt(req.query.offset as string, 10) || 0;
|
|
const limit = Math.min(parseInt(req.query.limit as string, 10) || 20, 100); // Max 100
|
|
const project = req.query.project as string | undefined;
|
|
|
|
return { offset, limit, project };
|
|
}
|
|
|
|
// ============================================================================
|
|
// Main Entry Point
|
|
// ============================================================================
|
|
|
|
/**
|
|
* Start the worker service (if running as main module)
|
|
* Note: Using require.main check for CJS compatibility (build outputs CJS)
|
|
*/
|
|
if (require.main === module || !module.parent) {
|
|
const worker = new WorkerService();
|
|
|
|
// Graceful shutdown
|
|
process.on('SIGTERM', async () => {
|
|
logger.info('SYSTEM', 'Received SIGTERM, shutting down gracefully');
|
|
await worker.shutdown();
|
|
process.exit(0);
|
|
});
|
|
|
|
process.on('SIGINT', async () => {
|
|
logger.info('SYSTEM', 'Received SIGINT, shutting down gracefully');
|
|
await worker.shutdown();
|
|
process.exit(0);
|
|
});
|
|
|
|
// Start the worker
|
|
worker.start().catch(error => {
|
|
logger.failure('SYSTEM', 'Worker startup failed', {}, error);
|
|
process.exit(1);
|
|
});
|
|
}
|
|
|
|
export default WorkerService;
|