68290a9121
* refactor: Reduce continuation prompt token usage by 95 lines Removed redundant instructions from continuation prompt that were originally added to mitigate a session continuity issue. That issue has since been resolved, making these detailed instructions unnecessary on every continuation. Changes: - Reduced continuation prompt from ~106 lines to ~11 lines (~95 line reduction) - Changed "User's Goal:" to "Next Prompt in Session:" (more accurate framing) - Removed redundant WHAT TO RECORD, WHEN TO SKIP, and OUTPUT FORMAT sections - Kept concise reminder: "Continue generating observations and progress summaries..." - Initial prompt still contains all detailed instructions Impact: - Significant token savings on every continuation prompt - Faster context injection with no loss of functionality - Instructions remain comprehensive in initial prompt Files modified: - src/sdk/prompts.ts (buildContinuationPrompt function) - plugin/scripts/worker-service.cjs (compiled output) 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com> * refactor: Enhance observation and summary prompts for clarity and token efficiency * Enhance prompt clarity and instructions in prompts.ts - Added a reminder to think about instructions before starting work. - Simplified the continuation prompt instruction by removing "for this ongoing session." * feat: Enhance settings.json with permissions and deny access to sensitive files refactor: Remove PLAN-full-observation-display.md and PR_SUMMARY.md as they are no longer needed chore: Delete SECURITY_SUMMARY.md since it is redundant after recent changes fix: Update worker-service.cjs to streamline observation generation instructions cleanup: Remove src-analysis.md and src-tree.md for a cleaner codebase refactor: Modify prompts.ts to clarify instructions for memory processing * refactor: Remove legacy worker service implementation * feat: Enhance summary hook to extract last assistant message and improve logging - Added function to extract the last assistant message from the transcript. - Updated summary hook to include last assistant message in the summary request. - Modified SDKSession interface to store last assistant message. - Adjusted buildSummaryPrompt to utilize last assistant message for generating summaries. - Updated worker service and session manager to handle last assistant message in summarize requests. - Introduced silentDebug utility for improved logging and diagnostics throughout the summary process. * docs: Add comprehensive implementation plan for ROI metrics feature Added detailed implementation plan covering: - Token usage capture from Agent SDK - Database schema changes (migration #8) - Discovery cost tracking per observation - Context hook display with ROI metrics - Testing and rollout strategy Timeline: ~20 hours over 4 days Goal: Empirical data for YC application amendment 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com> * feat: Add transcript processing scripts for analysis and formatting - Implemented `dump-transcript-readable.ts` to generate a readable markdown dump of transcripts, excluding certain entry types. - Created `extract-rich-context-examples.ts` to extract and showcase rich context examples from transcripts, highlighting user requests and assistant reasoning. - Developed `format-transcript-context.ts` to format transcript context into a structured markdown format for improved observation generation. - Added `test-transcript-parser.ts` for validating data extraction from transcript JSONL files, including statistics and error reporting. - Introduced `transcript-to-markdown.ts` for a complete representation of transcript data in markdown format, showing all context data. - Enhanced type definitions in `transcript.ts` to support new features and ensure type safety. - Built `transcript-parser.ts` to handle parsing of transcript JSONL files, including error handling and data extraction methods. * Refactor hooks and SDKAgent for improved observation handling - Updated `new-hook.ts` to clean user prompts by stripping leading slashes for better semantic clarity. - Enhanced `save-hook.ts` to include additional tools in the SKIP_TOOLS set, preventing unnecessary observations from certain command invocations. - Modified `prompts.ts` to change the structure of observation prompts, emphasizing the observational role and providing a detailed XML output format for observations. - Adjusted `SDKAgent.ts` to enforce stricter tool usage restrictions, ensuring the memory agent operates solely as an observer without any tool access. * feat: Enhance session initialization to accept user prompts and prompt numbers - Updated `handleSessionInit` in `worker-service.ts` to extract `userPrompt` and `promptNumber` from the request body and pass them to `initializeSession`. - Modified `initializeSession` in `SessionManager.ts` to handle optional `currentUserPrompt` and `promptNumber` parameters. - Added logic to update the existing session's `userPrompt` and `lastPromptNumber` if a `currentUserPrompt` is provided. - Implemented debug logging for session initialization and updates to track user prompts and prompt numbers. --------- Co-authored-by: Claude <noreply@anthropic.com>
1220 lines
42 KiB
TypeScript
1220 lines
42 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';
|
|
|
|
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));
|
|
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));
|
|
|
|
// Search API endpoints (for skill-based search)
|
|
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));
|
|
}
|
|
|
|
/**
|
|
* Start the worker service
|
|
*/
|
|
async start(): Promise<void> {
|
|
// Initialize database (once, stays open)
|
|
await this.dbManager.initialize();
|
|
|
|
// Connect to MCP search server
|
|
const searchServerPath = path.join(__dirname, '..', '..', 'plugin', 'scripts', 'search-server.mjs');
|
|
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 HTTP server
|
|
if (this.server) {
|
|
await new Promise<void>((resolve, reject) => {
|
|
this.server!.close(err => err ? reject(err) : resolve());
|
|
});
|
|
}
|
|
|
|
// Close database connection
|
|
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();
|
|
this.sseBroadcaster.broadcast({
|
|
type: 'processing_status',
|
|
isProcessing
|
|
});
|
|
}
|
|
|
|
/**
|
|
* 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 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)
|
|
*/
|
|
private handleGetProcessingStatus(req: Request, res: Response): void {
|
|
const isProcessing = this.sessionManager.isAnySessionProcessing();
|
|
res.json({ isProcessing });
|
|
}
|
|
|
|
// ============================================================================
|
|
// 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.getTotalQueueDepth();
|
|
const activeSessions = this.sessionManager.getActiveSessionCount();
|
|
|
|
logger.info('WORKER', 'Broadcasting processing status', {
|
|
isProcessing,
|
|
queueDepth,
|
|
activeSessions
|
|
});
|
|
|
|
this.sseBroadcaster.broadcast({
|
|
type: 'processing_status',
|
|
isProcessing
|
|
});
|
|
}
|
|
|
|
/**
|
|
* 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;
|
|
}
|
|
}
|
|
|
|
// ============================================================================
|
|
// Search API Handlers (for skill-based search)
|
|
// ============================================================================
|
|
|
|
/**
|
|
* Search observations
|
|
* 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;
|