Files
claude-mem/src/services/worker-service.ts
T
Alex Newman 129c22c48d Add comprehensive tests for Cursor functionality
- Implement tests for cursor context updates in `cursor-context-update.test.ts`, validating context file creation, content structure, and edge cases.
- Create tests for cursor hook outputs in `cursor-hook-outputs.test.ts`, ensuring correct JSON output from hook scripts and handling of various input scenarios.
- Add tests for JSON utility functions in `cursor-hooks-json-utils.test.ts`, covering parsing, project name extraction, and URL encoding.
- Introduce tests for MCP configuration in `cursor-mcp-config.test.ts`, verifying configuration creation, updates, and format validation.
- Develop tests for the cursor project registry in `cursor-registry.test.ts`, ensuring correct registration, unregistration, and JSON format compliance.
2025-12-29 22:58:42 -05:00

2051 lines
70 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* Worker Service - Slim Orchestrator
*
* Refactored from 2000-line monolith to ~150-line orchestrator.
* Routes organized by feature area in http/routes/*.ts
* See src/services/worker/README.md for architecture details.
*/
import express from 'express';
import http from 'http';
import path from 'path';
import * as fs from 'fs';
import { Client } from '@modelcontextprotocol/sdk/client/index.js';
import { StdioClientTransport } from '@modelcontextprotocol/sdk/client/stdio.js';
import { getWorkerPort, getWorkerHost } from '../shared/worker-utils.js';
import { logger } from '../utils/logger.js';
import { exec, execSync, spawn } from 'child_process';
import { homedir } from 'os';
import { existsSync, writeFileSync, readFileSync, unlinkSync, mkdirSync } from 'fs';
import * as readline from 'readline';
import { promisify } from 'util';
import {
readCursorRegistry as readCursorRegistryFromFile,
writeCursorRegistry as writeCursorRegistryToFile,
writeContextFile,
type CursorProjectRegistry
} from '../utils/cursor-utils.js';
const execAsync = promisify(exec);
// Build-time injected version constant (set by esbuild define)
declare const __DEFAULT_PACKAGE_VERSION__: string;
const BUILT_IN_VERSION = typeof __DEFAULT_PACKAGE_VERSION__ !== 'undefined'
? __DEFAULT_PACKAGE_VERSION__
: 'development';
// PID file management for self-spawn pattern
const DATA_DIR = path.join(homedir(), '.claude-mem');
const PID_FILE = path.join(DATA_DIR, 'worker.pid');
const CURSOR_REGISTRY_FILE = path.join(DATA_DIR, 'cursor-projects.json');
const HOOK_RESPONSE = '{"continue": true, "suppressOutput": true}';
interface PidInfo {
pid: number;
port: number;
startedAt: string;
}
// PID file utility functions
function writePidFile(info: PidInfo): void {
mkdirSync(DATA_DIR, { recursive: true });
writeFileSync(PID_FILE, JSON.stringify(info, null, 2));
}
function readPidFile(): PidInfo | null {
try {
if (!existsSync(PID_FILE)) return null;
return JSON.parse(readFileSync(PID_FILE, 'utf-8'));
} catch (error) {
logger.warn('SYSTEM', 'Failed to read PID file', { path: PID_FILE, error: (error as Error).message });
return null;
}
}
function removePidFile(): void {
try {
if (existsSync(PID_FILE)) unlinkSync(PID_FILE);
} catch (error) {
logger.warn('SYSTEM', 'Failed to remove PID file', { path: PID_FILE, error: (error as Error).message });
}
}
// ============================================================================
// Cursor Project Registry
// Tracks which projects have Cursor hooks installed for auto-context updates
// Uses pure functions from cursor-utils.ts for testability
// ============================================================================
function readCursorRegistry(): CursorProjectRegistry {
return readCursorRegistryFromFile(CURSOR_REGISTRY_FILE);
}
function writeCursorRegistry(registry: CursorProjectRegistry): void {
writeCursorRegistryToFile(CURSOR_REGISTRY_FILE, registry);
}
function registerCursorProject(projectName: string, workspacePath: string): void {
const registry = readCursorRegistry();
registry[projectName] = {
workspacePath,
installedAt: new Date().toISOString()
};
writeCursorRegistry(registry);
logger.info('CURSOR', 'Registered project for auto-context updates', { projectName, workspacePath });
}
function unregisterCursorProject(projectName: string): void {
const registry = readCursorRegistry();
if (registry[projectName]) {
delete registry[projectName];
writeCursorRegistry(registry);
logger.info('CURSOR', 'Unregistered project', { projectName });
}
}
/**
* Update Cursor context files for all registered projects matching this project name.
* Called by SDK agents after saving a summary.
*/
export async function updateCursorContextForProject(projectName: string, port: number): Promise<void> {
const registry = readCursorRegistry();
const entry = registry[projectName];
if (!entry) return; // Project doesn't have Cursor hooks installed
try {
// Fetch fresh context from worker
const response = await fetch(
`http://127.0.0.1:${port}/api/context/inject?project=${encodeURIComponent(projectName)}`
);
if (!response.ok) return;
const context = await response.text();
if (!context || !context.trim()) return;
// Write to the project's Cursor rules file using shared utility
writeContextFile(entry.workspacePath, context);
logger.debug('CURSOR', 'Updated context file', { projectName, workspacePath: entry.workspacePath });
} catch (error) {
logger.warn('CURSOR', 'Failed to update context file', { projectName, error: (error as Error).message });
}
}
// No lock file needed - health checks and port binding provide coordination
/**
* Get platform-adjusted timeout (Windows socket cleanup is slower)
*/
function getPlatformTimeout(baseMs: number): number {
const WINDOWS_MULTIPLIER = 2.0;
return process.platform === 'win32' ? Math.round(baseMs * WINDOWS_MULTIPLIER) : baseMs;
}
async function isPortInUse(port: number): Promise<boolean> {
try {
// Note: Removed AbortSignal.timeout to avoid Windows Bun cleanup issue (libuv assertion)
const response = await fetch(`http://127.0.0.1:${port}/api/health`);
return response.ok;
} catch { return false; }
}
async function waitForHealth(port: number, timeoutMs: number = 30000): Promise<boolean> {
const start = Date.now();
while (Date.now() - start < timeoutMs) {
try {
// Note: Removed AbortSignal.timeout to avoid Windows Bun cleanup issue (libuv assertion)
const response = await fetch(`http://127.0.0.1:${port}/api/readiness`);
if (response.ok) return true;
} catch {
// Not ready yet
}
await new Promise(r => setTimeout(r, 500));
}
return false;
}
async function httpShutdown(port: number): Promise<boolean> {
try {
// Note: Removed AbortSignal.timeout to avoid Windows Bun cleanup issue (libuv assertion)
const response = await fetch(`http://127.0.0.1:${port}/api/admin/shutdown`, {
method: 'POST'
});
if (!response.ok) {
logger.warn('SYSTEM', 'Shutdown request returned error', { port, status: response.status });
return false;
}
return true;
} catch (error) {
// Connection refused is expected if worker already stopped
const isConnectionRefused = (error as Error).message?.includes('ECONNREFUSED');
if (!isConnectionRefused) {
logger.warn('SYSTEM', 'Shutdown request failed', { port, error: (error as Error).message });
}
return false;
}
}
async function waitForPortFree(port: number, timeoutMs: number = 10000): Promise<boolean> {
const start = Date.now();
while (Date.now() - start < timeoutMs) {
if (!(await isPortInUse(port))) return true;
await new Promise(r => setTimeout(r, 500));
}
return false;
}
/**
* Get the plugin version from the installed marketplace package.json
*/
function getInstalledPluginVersion(): string {
const marketplaceRoot = path.join(homedir(), '.claude', 'plugins', 'marketplaces', 'thedotmack');
const packageJsonPath = path.join(marketplaceRoot, 'package.json');
const packageJson = JSON.parse(readFileSync(packageJsonPath, 'utf-8'));
return packageJson.version;
}
/**
* Get the running worker's version via API
*/
async function getRunningWorkerVersion(port: number): Promise<string | null> {
try {
const response = await fetch(`http://127.0.0.1:${port}/api/version`);
if (!response.ok) return null;
const data = await response.json() as { version: string };
return data.version;
} catch {
return null;
}
}
/**
* Check if worker version matches plugin version
* Returns true if versions match or if we can't determine (assume match)
*/
async function checkVersionMatch(port: number): Promise<{ matches: boolean; pluginVersion: string; workerVersion: string | null }> {
const pluginVersion = getInstalledPluginVersion();
const workerVersion = await getRunningWorkerVersion(port);
// If we can't get worker version, assume it matches (graceful degradation)
if (!workerVersion) {
return { matches: true, pluginVersion, workerVersion };
}
return { matches: pluginVersion === workerVersion, pluginVersion, workerVersion };
}
// Import composed service layer
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 { GeminiAgent } from './worker/GeminiAgent.js';
import { OpenRouterAgent } from './worker/OpenRouterAgent.js';
import { PaginationHelper } from './worker/PaginationHelper.js';
import { SettingsManager } from './worker/SettingsManager.js';
import { SearchManager } from './worker/SearchManager.js';
import { FormattingService } from './worker/FormattingService.js';
import { TimelineService } from './worker/TimelineService.js';
import { SessionEventBroadcaster } from './worker/events/SessionEventBroadcaster.js';
// Import HTTP layer
import { createMiddleware, summarizeRequestBody as summarizeBody, requireLocalhost } from './worker/http/middleware.js';
import { ViewerRoutes } from './worker/http/routes/ViewerRoutes.js';
import { SessionRoutes } from './worker/http/routes/SessionRoutes.js';
import { DataRoutes } from './worker/http/routes/DataRoutes.js';
import { SearchRoutes } from './worker/http/routes/SearchRoutes.js';
import { SettingsRoutes } from './worker/http/routes/SettingsRoutes.js';
export class WorkerService {
private app: express.Application;
private server: http.Server | null = null;
private startTime: number = Date.now();
private mcpClient: Client;
// Initialization flags for MCP/SDK readiness tracking
private mcpReady: boolean = false;
private initializationCompleteFlag: boolean = false;
private isShuttingDown: boolean = false;
// Service layer
private dbManager: DatabaseManager;
private sessionManager: SessionManager;
private sseBroadcaster: SSEBroadcaster;
private sdkAgent: SDKAgent;
private geminiAgent: GeminiAgent;
private openRouterAgent: OpenRouterAgent;
private paginationHelper: PaginationHelper;
private settingsManager: SettingsManager;
private sessionEventBroadcaster: SessionEventBroadcaster;
// Route handlers
private viewerRoutes: ViewerRoutes;
private sessionRoutes: SessionRoutes;
private dataRoutes: DataRoutes;
private searchRoutes: SearchRoutes | null;
private settingsRoutes: SettingsRoutes;
// Initialization tracking
private initializationComplete: Promise<void>;
private resolveInitialization!: () => void;
constructor() {
this.app = express();
// Initialize the promise that will resolve when background initialization completes
this.initializationComplete = new Promise((resolve) => {
this.resolveInitialization = resolve;
});
// Initialize service layer
this.dbManager = new DatabaseManager();
this.sessionManager = new SessionManager(this.dbManager);
this.sseBroadcaster = new SSEBroadcaster();
this.sdkAgent = new SDKAgent(this.dbManager, this.sessionManager);
this.geminiAgent = new GeminiAgent(this.dbManager, this.sessionManager);
this.geminiAgent.setFallbackAgent(this.sdkAgent); // Enable fallback to Claude on Gemini API failure
this.openRouterAgent = new OpenRouterAgent(this.dbManager, this.sessionManager);
this.openRouterAgent.setFallbackAgent(this.sdkAgent); // Enable fallback to Claude on OpenRouter API failure
this.paginationHelper = new PaginationHelper(this.dbManager);
this.settingsManager = new SettingsManager(this.dbManager);
this.sessionEventBroadcaster = new SessionEventBroadcaster(this.sseBroadcaster, this);
// Set callback for when sessions are deleted (to update activity indicator)
this.sessionManager.setOnSessionDeleted(() => {
this.broadcastProcessingStatus();
});
// Initialize MCP client
this.mcpClient = new Client({
name: 'worker-search-proxy',
version: '1.0.0'
}, { capabilities: {} });
// Initialize route handlers (SearchRoutes will use MCP client initially, then switch to SearchManager after DB init)
this.viewerRoutes = new ViewerRoutes(this.sseBroadcaster, this.dbManager, this.sessionManager);
this.sessionRoutes = new SessionRoutes(this.sessionManager, this.dbManager, this.sdkAgent, this.geminiAgent, this.openRouterAgent, this.sessionEventBroadcaster, this);
this.dataRoutes = new DataRoutes(this.paginationHelper, this.dbManager, this.sessionManager, this.sseBroadcaster, this, this.startTime);
// SearchRoutes needs SearchManager which requires initialized DB - will be created in initializeBackground()
this.searchRoutes = null;
this.settingsRoutes = new SettingsRoutes(this.settingsManager);
this.setupMiddleware();
this.setupRoutes();
// Register signal handlers early to ensure cleanup even if start() hasn't completed
// The shutdown() method is defensive and safe to call at any initialization stage
this.registerSignalHandlers();
}
/**
* Register signal handlers for graceful shutdown
* Called in constructor to ensure cleanup even if start() hasn't completed
*/
private registerSignalHandlers(): void {
const handleShutdown = async (signal: string) => {
if (this.isShuttingDown) {
logger.warn('SYSTEM', `Received ${signal} but shutdown already in progress`);
return;
}
this.isShuttingDown = true;
logger.info('SYSTEM', `Received ${signal}, shutting down...`);
try {
await this.shutdown();
process.exit(0);
} catch (error) {
logger.error('SYSTEM', 'Error during shutdown', {}, error as Error);
process.exit(1);
}
};
process.on('SIGTERM', () => handleShutdown('SIGTERM'));
process.on('SIGINT', () => handleShutdown('SIGINT'));
}
/**
* Setup Express middleware
*/
private setupMiddleware(): void {
const middlewares = createMiddleware(this.summarizeRequestBody.bind(this));
middlewares.forEach(mw => this.app.use(mw));
}
/**
* Setup HTTP routes (delegate to route classes)
*/
private setupRoutes(): void {
// Health check endpoint
// TEST_BUILD_ID helps verify which build is running during debugging
const TEST_BUILD_ID = 'TEST-008-wrapper-ipc';
this.app.get('/api/health', (_req, res) => {
res.status(200).json({
status: 'ok',
build: TEST_BUILD_ID,
managed: process.env.CLAUDE_MEM_MANAGED === 'true',
hasIpc: typeof process.send === 'function',
platform: process.platform,
pid: process.pid,
initialized: this.initializationCompleteFlag,
mcpReady: this.mcpReady,
});
});
// Readiness check endpoint - returns 503 until full initialization completes
// Used by ProcessManager and worker-utils to ensure worker is fully ready before routing requests
this.app.get('/api/readiness', (_req, res) => {
if (this.initializationCompleteFlag) {
res.status(200).json({
status: 'ready',
mcpReady: this.mcpReady,
});
} else {
res.status(503).json({
status: 'initializing',
message: 'Worker is still initializing, please retry',
});
}
});
// Version endpoint - returns the worker's built-in version (compiled at build time)
// This is critical for detecting version mismatch when plugin is updated but worker is still running old code
this.app.get('/api/version', (_req, res) => {
res.status(200).json({ version: BUILT_IN_VERSION });
});
// Instructions endpoint - loads SKILL.md sections on-demand for progressive instruction loading
this.app.get('/api/instructions', async (req, res) => {
const topic = (req.query.topic as string) || 'all';
const operation = req.query.operation as string | undefined;
// Path resolution: __dirname is build output directory (plugin/scripts/)
// SKILL.md is at plugin/skills/mem-search/SKILL.md
// Operations are at plugin/skills/mem-search/operations/*.md
try {
let content: string;
if (operation) {
// Load specific operation file
const operationPath = path.join(__dirname, '../skills/mem-search/operations', `${operation}.md`);
content = await fs.promises.readFile(operationPath, 'utf-8');
} else {
// Load SKILL.md and extract section based on topic (backward compatibility)
const skillPath = path.join(__dirname, '../skills/mem-search/SKILL.md');
const fullContent = await fs.promises.readFile(skillPath, 'utf-8');
content = this.extractInstructionSection(fullContent, topic);
}
// Return in MCP format
res.json({
content: [{
type: 'text',
text: content
}]
});
} catch (error) {
logger.error('WORKER', 'Failed to load instructions', { topic, operation }, error as Error);
res.status(500).json({
content: [{
type: 'text',
text: `Error loading instructions: ${error instanceof Error ? error.message : 'Unknown error'}`
}],
isError: true
});
}
});
// Admin endpoints for process management (localhost-only)
this.app.post('/api/admin/restart', requireLocalhost, async (_req, res) => {
res.json({ status: 'restarting' });
// On Windows, if managed by wrapper, send message to parent to handle restart
// This solves the Windows zombie port problem where sockets aren't properly released
const isWindowsManaged = process.platform === 'win32' &&
process.env.CLAUDE_MEM_MANAGED === 'true' &&
process.send;
if (isWindowsManaged) {
logger.info('SYSTEM', 'Sending restart request to wrapper');
process.send!({ type: 'restart' });
} else {
// Unix or standalone Windows - handle restart ourselves
setTimeout(async () => {
await this.shutdown();
process.exit(0);
}, 100);
}
});
this.app.post('/api/admin/shutdown', requireLocalhost, async (_req, res) => {
res.json({ status: 'shutting_down' });
// On Windows, if managed by wrapper, send message to parent to handle shutdown
const isWindowsManaged = process.platform === 'win32' &&
process.env.CLAUDE_MEM_MANAGED === 'true' &&
process.send;
if (isWindowsManaged) {
logger.info('SYSTEM', 'Sending shutdown request to wrapper');
process.send!({ type: 'shutdown' });
} else {
// Unix or standalone Windows - handle shutdown ourselves
setTimeout(async () => {
await this.shutdown();
process.exit(0);
}, 100);
}
});
this.viewerRoutes.setupRoutes(this.app);
this.sessionRoutes.setupRoutes(this.app);
this.dataRoutes.setupRoutes(this.app);
// searchRoutes is set up after database initialization in initializeBackground()
this.settingsRoutes.setupRoutes(this.app);
// Register early handler for /api/context/inject to avoid 404 during startup
// This handler waits for initialization to complete before delegating to SearchRoutes
// NOTE: This duplicates logic from SearchRoutes.handleContextInject by design,
// as we need the route available immediately before SearchRoutes is initialized
this.app.get('/api/context/inject', async (req, res, next) => {
try {
// Wait for initialization to complete (with timeout)
const timeoutMs = 300000; // 5 minute timeout for slow systems
const timeoutPromise = new Promise((_, reject) =>
setTimeout(() => reject(new Error('Initialization timeout')), timeoutMs)
);
await Promise.race([this.initializationComplete, timeoutPromise]);
// If searchRoutes is still null after initialization, something went wrong
if (!this.searchRoutes) {
res.status(503).json({ error: 'Search routes not initialized' });
return;
}
// Delegate to the SearchRoutes handler which is registered after this one
// This avoids code duplication and "headers already sent" errors
next();
} catch (error) {
logger.error('WORKER', 'Context inject handler failed', {}, error as Error);
if (!res.headersSent) {
res.status(500).json({ error: error instanceof Error ? error.message : 'Internal server error' });
}
}
});
}
/**
* Clean up orphaned chroma-mcp processes from previous worker sessions
* Prevents process accumulation and memory leaks
*/
private async cleanupOrphanedProcesses(): Promise<void> {
const isWindows = process.platform === 'win32';
const pids: number[] = [];
if (isWindows) {
// Windows: Use PowerShell Get-CimInstance to find chroma-mcp processes
const cmd = `powershell -Command "Get-CimInstance Win32_Process | Where-Object { $_.Name -like '*python*' -and $_.CommandLine -like '*chroma-mcp*' } | Select-Object -ExpandProperty ProcessId"`;
const { stdout } = await execAsync(cmd, { timeout: 60000 });
if (!stdout.trim()) {
logger.debug('SYSTEM', 'No orphaned chroma-mcp processes found (Windows)');
return;
}
const pidStrings = stdout.trim().split('\n');
for (const pidStr of pidStrings) {
const pid = parseInt(pidStr.trim(), 10);
// SECURITY: Validate PID is positive integer before adding to list
if (!isNaN(pid) && Number.isInteger(pid) && pid > 0) {
pids.push(pid);
}
}
} else {
// Unix: Use ps aux | grep
const { stdout } = await execAsync('ps aux | grep "chroma-mcp" | grep -v grep || true');
if (!stdout.trim()) {
logger.debug('SYSTEM', 'No orphaned chroma-mcp processes found (Unix)');
return;
}
const lines = stdout.trim().split('\n');
for (const line of lines) {
const parts = line.trim().split(/\s+/);
if (parts.length > 1) {
const pid = parseInt(parts[1], 10);
// SECURITY: Validate PID is positive integer before adding to list
if (!isNaN(pid) && Number.isInteger(pid) && pid > 0) {
pids.push(pid);
}
}
}
}
if (pids.length === 0) {
return;
}
logger.info('SYSTEM', 'Cleaning up orphaned chroma-mcp processes', {
platform: isWindows ? 'Windows' : 'Unix',
count: pids.length,
pids
});
// Kill all found processes
if (isWindows) {
for (const pid of pids) {
// SECURITY: Double-check PID validation before using in taskkill command
if (!Number.isInteger(pid) || pid <= 0) {
logger.warn('SYSTEM', 'Skipping invalid PID', { pid });
continue;
}
try {
execSync(`taskkill /PID ${pid} /T /F`, { timeout: 60000, stdio: 'ignore' });
} catch {
// Process may have already exited - continue cleanup
}
}
} else {
for (const pid of pids) {
try {
process.kill(pid, 'SIGKILL');
} catch {
// Process already exited - that's fine
}
}
}
logger.info('SYSTEM', 'Orphaned processes cleaned up', { count: pids.length });
}
/**
* Start the worker service
*/
async start(): Promise<void> {
// Start HTTP server FIRST - make port available immediately
const port = getWorkerPort();
const host = getWorkerHost();
this.server = await new Promise<http.Server>((resolve, reject) => {
const srv = this.app.listen(port, host, () => resolve(srv));
srv.on('error', reject);
});
logger.info('SYSTEM', 'Worker started', { host, port, pid: process.pid });
// Do slow initialization in background (non-blocking)
this.initializeBackground().catch((error) => {
logger.error('SYSTEM', 'Background initialization failed', {}, error as Error);
});
}
/**
* Background initialization - runs after HTTP server is listening
*/
private async initializeBackground(): Promise<void> {
try {
// Clean up any orphaned chroma-mcp processes BEFORE starting our own
await this.cleanupOrphanedProcesses();
// Load mode configuration (must happen before database to set observation types)
const { ModeManager } = await import('./domain/ModeManager.js');
const { SettingsDefaultsManager } = await import('../shared/SettingsDefaultsManager.js');
const { USER_SETTINGS_PATH } = await import('../shared/paths.js');
const settings = SettingsDefaultsManager.loadFromFile(USER_SETTINGS_PATH);
const modeId = settings.CLAUDE_MEM_MODE;
ModeManager.getInstance().loadMode(modeId);
logger.info('SYSTEM', `Mode loaded: ${modeId}`);
// Initialize database (once, stays open)
await this.dbManager.initialize();
// Recover stuck messages from previous crashes
// Messages stuck in 'processing' state are reset to 'pending' for reprocessing
const { PendingMessageStore } = await import('./sqlite/PendingMessageStore.js');
const pendingStore = new PendingMessageStore(this.dbManager.getSessionStore().db, 3);
const STUCK_THRESHOLD_MS = 5 * 60 * 1000; // 5 minutes
const resetCount = pendingStore.resetStuckMessages(STUCK_THRESHOLD_MS);
if (resetCount > 0) {
logger.info('SYSTEM', `Recovered ${resetCount} stuck messages from previous session`, { thresholdMinutes: 5 });
}
// Initialize search services (requires initialized database)
const formattingService = new FormattingService();
const timelineService = new TimelineService();
const searchManager = new SearchManager(
this.dbManager.getSessionSearch(),
this.dbManager.getSessionStore(),
this.dbManager.getChromaSync(),
formattingService,
timelineService
);
this.searchRoutes = new SearchRoutes(searchManager);
this.searchRoutes.setupRoutes(this.app); // Setup search routes now that SearchManager is ready
logger.info('WORKER', 'SearchManager initialized and search routes registered');
// Connect to MCP server with timeout guard
const mcpServerPath = path.join(__dirname, 'mcp-server.cjs');
const transport = new StdioClientTransport({
command: 'node',
args: [mcpServerPath],
env: process.env
});
// Add timeout guard to prevent hanging on MCP connection (5 minutes for slow systems)
const MCP_INIT_TIMEOUT_MS = 300000;
const mcpConnectionPromise = this.mcpClient.connect(transport);
const timeoutPromise = new Promise<never>((_, reject) =>
setTimeout(() => reject(new Error('MCP connection timeout after 5 minutes')), MCP_INIT_TIMEOUT_MS)
);
await Promise.race([mcpConnectionPromise, timeoutPromise]);
this.mcpReady = true;
logger.success('WORKER', 'Connected to MCP server');
// Signal that initialization is complete
this.initializationCompleteFlag = true;
this.resolveInitialization();
logger.info('SYSTEM', 'Background initialization complete');
// Auto-recover orphaned queues on startup (process pending work from previous sessions)
this.processPendingQueues(50).then(result => {
if (result.sessionsStarted > 0) {
logger.info('SYSTEM', `Auto-recovered ${result.sessionsStarted} sessions with pending work`, {
totalPending: result.totalPendingSessions,
started: result.sessionsStarted,
sessionIds: result.startedSessionIds
});
}
}).catch(error => {
logger.warn('SYSTEM', 'Auto-recovery of pending queues failed', {}, error as Error);
});
} catch (error) {
logger.error('SYSTEM', 'Background initialization failed', {}, error as Error);
// Don't resolve - let the promise remain pending so readiness check continues to fail
throw error;
}
}
/**
* Start a session processor
* It will run continuously until the session is deleted/aborted
*/
private startSessionProcessor(
session: ReturnType<typeof this.sessionManager.getSession>,
source: string
): void {
if (!session) return;
const sid = session.sessionDbId;
logger.info('SYSTEM', `Starting generator (${source})`, {
sessionId: sid
});
session.generatorPromise = this.sdkAgent.startSession(session, this)
.catch(error => {
// Only log if not aborted
if (session.abortController.signal.aborted) return;
logger.error('SYSTEM', `Generator failed (${source})`, {
sessionId: sid,
error: error.message
}, error);
})
.finally(() => {
session.generatorPromise = null;
this.broadcastProcessingStatus();
// Crash recovery: if not aborted, check if we should restart
if (!session.abortController.signal.aborted) {
// We can check if there are pending messages to decide if restart is urgent
// But generally, if it crashed, we might want to restart?
// For now, let's just log. The user/system can trigger restart if needed.
logger.warn('SYSTEM', `Session processor exited unexpectedly`, { sessionId: sid });
}
});
}
/**
* Process pending session queues
* Starts SDK agents for sessions that have pending messages but no active processor
* @param sessionLimit Maximum number of sessions to start processing (default: 10)
* @returns Info about what was started
*/
async processPendingQueues(sessionLimit: number = 10): Promise<{
totalPendingSessions: number;
sessionsStarted: number;
sessionsSkipped: number;
startedSessionIds: number[];
}> {
const { PendingMessageStore } = await import('./sqlite/PendingMessageStore.js');
const pendingStore = new PendingMessageStore(this.dbManager.getSessionStore().db, 3);
const orphanedSessionIds = pendingStore.getSessionsWithPendingMessages();
const result = {
totalPendingSessions: orphanedSessionIds.length,
sessionsStarted: 0,
sessionsSkipped: 0,
startedSessionIds: [] as number[]
};
if (orphanedSessionIds.length === 0) {
return result;
}
logger.info('SYSTEM', `Processing up to ${sessionLimit} of ${orphanedSessionIds.length} pending session queues`);
// Process each session sequentially up to the limit
for (const sessionDbId of orphanedSessionIds) {
if (result.sessionsStarted >= sessionLimit) {
break;
}
try {
// Skip if session already has an active generator
const existingSession = this.sessionManager.getSession(sessionDbId);
if (existingSession?.generatorPromise) {
result.sessionsSkipped++;
continue;
}
// Initialize session and start SDK agent
const session = this.sessionManager.initializeSession(sessionDbId);
logger.info('SYSTEM', `Starting processor for session ${sessionDbId}`, {
project: session.project,
pendingCount: pendingStore.getPendingCount(sessionDbId)
});
// Start SDK agent (non-blocking)
this.startSessionProcessor(session, 'startup-recovery');
result.sessionsStarted++;
result.startedSessionIds.push(sessionDbId);
// Small delay between sessions to avoid rate limiting
await new Promise(resolve => setTimeout(resolve, 100));
} catch (error) {
logger.warn('SYSTEM', `Failed to process session ${sessionDbId}`, {}, error as Error);
result.sessionsSkipped++;
}
}
return result;
}
/**
* Extract a specific section from instruction content
* Used by /api/instructions endpoint for progressive instruction loading
*/
private extractInstructionSection(content: string, topic: string): string {
const sections: Record<string, string> = {
'workflow': this.extractBetween(content, '## The Workflow', '## Search Parameters'),
'search_params': this.extractBetween(content, '## Search Parameters', '## Examples'),
'examples': this.extractBetween(content, '## Examples', '## Why This Workflow'),
'all': content
};
return sections[topic] || sections['all'];
}
/**
* Extract text between two markers
* Helper for extractInstructionSection
*/
private extractBetween(content: string, startMarker: string, endMarker: string): string {
const startIdx = content.indexOf(startMarker);
const endIdx = content.indexOf(endMarker);
if (startIdx === -1) return content;
if (endIdx === -1) return content.substring(startIdx);
return content.substring(startIdx, endIdx).trim();
}
/**
* Shutdown the worker service
*
* IMPORTANT: On Windows, we must kill all child processes before exiting
* to prevent zombie ports. The socket handle can be inherited by children,
* and if not properly closed, the port stays bound after process death.
*/
async shutdown(): Promise<void> {
logger.info('SYSTEM', 'Shutdown initiated');
// Clean up PID file on shutdown
removePidFile();
// STEP 1: Enumerate all child processes BEFORE we start closing things
const childPids = await this.getChildProcesses(process.pid);
logger.info('SYSTEM', 'Found child processes', { count: childPids.length, pids: childPids });
// STEP 2: Close HTTP server first
if (this.server) {
this.server.closeAllConnections();
// Give Windows time to close connections before closing server (prevents zombie ports)
if (process.platform === 'win32') {
await new Promise(r => setTimeout(r, 500));
}
await new Promise<void>((resolve, reject) => {
this.server!.close(err => err ? reject(err) : resolve());
});
this.server = null;
logger.info('SYSTEM', 'HTTP server closed');
// Extra delay on Windows to ensure port is fully released
if (process.platform === 'win32') {
await new Promise(r => setTimeout(r, 500));
logger.info('SYSTEM', 'Waited for Windows port cleanup');
}
}
// STEP 3: Shutdown active sessions
await this.sessionManager.shutdownAll();
// STEP 4: Close MCP client connection (signals child to exit gracefully)
if (this.mcpClient) {
await this.mcpClient.close();
logger.info('SYSTEM', 'MCP client closed');
}
// STEP 5: Close database connection (includes ChromaSync cleanup)
await this.dbManager.close();
// STEP 6: Force kill any remaining child processes (Windows zombie port fix)
if (childPids.length > 0) {
logger.info('SYSTEM', 'Force killing remaining children');
for (const pid of childPids) {
await this.forceKillProcess(pid);
}
// Wait for children to fully exit
await this.waitForProcessesExit(childPids, 5000);
}
logger.info('SYSTEM', 'Worker shutdown complete');
}
/**
* Get all child process PIDs (Windows-specific)
*/
private async getChildProcesses(parentPid: number): Promise<number[]> {
if (process.platform !== 'win32') {
return [];
}
// SECURITY: Validate PID is a positive integer to prevent command injection
if (!Number.isInteger(parentPid) || parentPid <= 0) {
logger.warn('SYSTEM', 'Invalid parent PID for child process enumeration', { parentPid });
return [];
}
try {
const cmd = `powershell -Command "Get-CimInstance Win32_Process | Where-Object { $_.ParentProcessId -eq ${parentPid} } | Select-Object -ExpandProperty ProcessId"`;
const { stdout } = await execAsync(cmd, { timeout: 60000 });
return stdout
.trim()
.split('\n')
.map(s => parseInt(s.trim(), 10))
.filter(n => !isNaN(n) && Number.isInteger(n) && n > 0); // SECURITY: Validate each PID
} catch (error) {
logger.warn('SYSTEM', 'Failed to enumerate child processes', { parentPid, error: (error as Error).message });
return []; // Fail safely - continue shutdown without child process cleanup
}
}
/**
* Force kill a process by PID (Windows: uses taskkill /F /T)
*/
private async forceKillProcess(pid: number): Promise<void> {
// SECURITY: Validate PID is a positive integer to prevent command injection
if (!Number.isInteger(pid) || pid <= 0) {
logger.warn('SYSTEM', 'Invalid PID for force kill', { pid });
return;
}
try {
if (process.platform === 'win32') {
// /T kills entire process tree, /F forces termination
await execAsync(`taskkill /PID ${pid} /T /F`, { timeout: 60000 });
} else {
process.kill(pid, 'SIGKILL');
}
logger.info('SYSTEM', 'Killed process', { pid });
} catch {
// Process may have already exited - continue shutdown
logger.debug('SYSTEM', 'Process already exited during force kill', { pid });
}
}
/**
* Wait for processes to fully exit
*/
private async waitForProcessesExit(pids: number[], timeoutMs: number): Promise<void> {
const start = Date.now();
while (Date.now() - start < timeoutMs) {
const stillAlive = pids.filter(pid => {
try {
process.kill(pid, 0);
return true;
} catch {
return false;
}
});
if (stillAlive.length === 0) {
logger.info('SYSTEM', 'All child processes exited');
return;
}
logger.debug('SYSTEM', 'Waiting for processes to exit', { stillAlive });
await new Promise(r => setTimeout(r, 100));
}
logger.warn('SYSTEM', 'Timeout waiting for child processes to exit');
}
/**
* Summarize request body for logging
* Used to avoid logging sensitive data or large payloads
*/
private summarizeRequestBody(method: string, path: string, body: any): string {
return summarizeBody(method, path, body);
}
/**
* Broadcast processing status change to SSE clients
* Checks both queue depth and active generators to prevent premature spinner stop
*
* PUBLIC: Called by route handlers (SessionRoutes, DataRoutes)
*/
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
});
}
}
// ============================================================================
// Cursor Hooks Installation
// ============================================================================
/**
* Interactive setup wizard for Cursor users
* Guides through provider selection and API key configuration
*/
async function runInteractiveSetup(): Promise<number> {
const rl = readline.createInterface({
input: process.stdin,
output: process.stdout
});
const question = (prompt: string): Promise<string> => {
return new Promise(resolve => rl.question(prompt, resolve));
};
console.log(`
╔══════════════════════════════════════════════════════════════════╗
║ Claude-Mem Cursor Setup Wizard ║
║ ║
║ This wizard will guide you through setting up claude-mem ║
║ for use with Cursor IDE. ║
╚══════════════════════════════════════════════════════════════════╝
`);
try {
// Step 1: Check environment
console.log('Step 1: Checking environment...\n');
const hasClaudeCode = await detectClaudeCode();
const settingsPath = path.join(homedir(), '.claude-mem', 'settings.json');
let settings: Record<string, unknown> = {};
// Load existing settings if present
if (existsSync(settingsPath)) {
try {
settings = JSON.parse(readFileSync(settingsPath, 'utf-8'));
} catch {
// Start fresh if corrupt
}
}
const currentProvider = settings['CLAUDE_MEM_PROVIDER'] as string || (hasClaudeCode ? 'claude-sdk' : 'none');
if (hasClaudeCode) {
console.log('✅ Claude Code detected\n');
} else {
console.log('️ Claude Code not detected\n');
}
console.log(`Current provider: ${currentProvider}\n`);
// Step 2: Provider selection (always show)
console.log('Step 2: Choose AI Provider\n');
if (hasClaudeCode) {
console.log(' [1] Claude SDK (Recommended - uses your Claude Code subscription)');
} else {
console.log(' [1] Claude SDK (requires Claude Code subscription)');
}
console.log(' [2] Gemini (1500 free requests/day)');
console.log(' [3] OpenRouter (100+ models, some free)');
console.log(' [4] Keep current settings\n');
const providerChoice = await question('Enter choice [1-4]: ');
if (providerChoice === '1') {
settings['CLAUDE_MEM_PROVIDER'] = 'claude-sdk';
mkdirSync(path.dirname(settingsPath), { recursive: true });
writeFileSync(settingsPath, JSON.stringify(settings, null, 2));
console.log('\n✅ Claude SDK configured!\n');
} else if (providerChoice === '2') {
console.log('\n📝 Configuring Gemini...\n');
console.log(' Get your free API key at: https://aistudio.google.com/apikey\n');
const apiKey = await question('Enter your Gemini API key: ');
if (!apiKey.trim()) {
console.log('\n⚠️ No API key provided. You can add it later in ~/.claude-mem/settings.json\n');
} else {
settings['CLAUDE_MEM_PROVIDER'] = 'gemini';
settings['CLAUDE_MEM_GEMINI_API_KEY'] = apiKey.trim();
mkdirSync(path.dirname(settingsPath), { recursive: true });
writeFileSync(settingsPath, JSON.stringify(settings, null, 2));
console.log('\n✅ Gemini configured successfully!\n');
}
} else if (providerChoice === '3') {
console.log('\n📝 Configuring OpenRouter...\n');
console.log(' Get your API key at: https://openrouter.ai/keys\n');
const apiKey = await question('Enter your OpenRouter API key: ');
if (!apiKey.trim()) {
console.log('\n⚠️ No API key provided. You can add it later in ~/.claude-mem/settings.json\n');
} else {
settings['CLAUDE_MEM_PROVIDER'] = 'openrouter';
settings['CLAUDE_MEM_OPENROUTER_API_KEY'] = apiKey.trim();
mkdirSync(path.dirname(settingsPath), { recursive: true });
writeFileSync(settingsPath, JSON.stringify(settings, null, 2));
console.log('\n✅ OpenRouter configured successfully!\n');
}
} else {
console.log('\n✅ Keeping current settings.\n');
}
// Step 3: Install location
console.log('Step 3: Choose installation scope\n');
console.log(' [1] Project (current directory only) - Recommended');
console.log(' [2] User (all projects for current user)');
console.log(' [3] Skip hook installation\n');
const scopeChoice = await question('Enter choice [1-3]: ');
let installTarget: string | null = null;
if (scopeChoice === '1') {
installTarget = 'project';
} else if (scopeChoice === '2') {
installTarget = 'user';
} else {
console.log('\n⚠️ Skipping hook installation.\n');
}
// Step 4: Install hooks (if target selected)
if (installTarget) {
console.log(`Step 4: Installing Cursor hooks (${installTarget})...\n`);
const cursorHooksDir = findCursorHooksDir();
if (!cursorHooksDir) {
console.error('❌ Could not find cursor-hooks directory');
console.error(' Make sure you ran npm run build first.');
rl.close();
return 1;
}
const installResult = await installCursorHooks(cursorHooksDir, installTarget);
if (installResult !== 0) {
rl.close();
return installResult;
}
// Step 5: Configure MCP server for memory search
console.log('\nStep 5: Configuring MCP server for memory search...\n');
const mcpResult = configureCursorMcp(installTarget);
if (mcpResult !== 0) {
console.warn('⚠️ MCP configuration failed, but hooks are installed.');
console.warn(' You can manually configure MCP later.\n');
} else {
console.log('');
}
}
// Step 6: Start worker
console.log('\nStep 6: Starting claude-mem worker...\n');
const port = getWorkerPort();
const alreadyRunning = await waitForHealth(port, 1000);
if (alreadyRunning) {
console.log('✅ Worker is already running!\n');
} else {
console.log(' Starting worker in background...');
// Spawn worker daemon
const child = spawn(process.execPath, [__filename, '--daemon'], {
detached: true,
stdio: 'ignore',
windowsHide: true,
env: { ...process.env, CLAUDE_MEM_WORKER_PORT: String(port) }
});
if (child.pid === undefined) {
console.error('❌ Failed to start worker');
rl.close();
return 1;
}
child.unref();
writePidFile({ pid: child.pid, port, startedAt: new Date().toISOString() });
// Wait for health
const healthy = await waitForHealth(port, getPlatformTimeout(30000));
if (!healthy) {
removePidFile();
console.error('❌ Worker failed to start');
rl.close();
return 1;
}
console.log('✅ Worker started successfully!\n');
}
// Final summary
console.log(`
╔══════════════════════════════════════════════════════════════════╗
║ Setup Complete! 🎉 ║
╚══════════════════════════════════════════════════════════════════╝
What's installed:
✓ Cursor hooks - Automatically capture sessions
✓ Context injection - Past work injected into new chats
✓ MCP search server - Ask "what did I work on last week?"
Next steps:
1. Restart Cursor to load the hooks and MCP server
2. Start chatting - your sessions will be remembered!
3. Use natural language to search: "find where I fixed the auth bug"
Useful commands:
npm run cursor:status Check installation status
npm run worker:status Check worker status
npm run worker:logs View worker logs
Memory viewer:
http://localhost:${port}
Documentation:
https://docs.claude-mem.ai/cursor
`);
rl.close();
return 0;
} catch (error) {
rl.close();
console.error(`\n❌ Setup failed: ${(error as Error).message}`);
return 1;
}
}
/**
* Detect if Claude Code is available
* Checks for the Claude Code CLI and plugin directory
*/
async function detectClaudeCode(): Promise<boolean> {
try {
// Check for Claude Code CLI
const { stdout } = await execAsync('which claude || where claude', { timeout: 5000 });
if (stdout.trim()) {
return true;
}
} catch {
// CLI not found
}
// Check for Claude Code plugin directory
const pluginDir = path.join(homedir(), '.claude', 'plugins');
if (existsSync(pluginDir)) {
return true;
}
return false;
}
/**
* Find cursor-hooks directory
* Searches in order: marketplace install, source repo
* Checks for both bash (common.sh) and PowerShell (common.ps1) scripts
*/
function findCursorHooksDir(): string | null {
const possiblePaths = [
// Marketplace install location
path.join(homedir(), '.claude', 'plugins', 'marketplaces', 'thedotmack', 'cursor-hooks'),
// Development/source location (relative to built worker-service.cjs in plugin/scripts/)
path.join(path.dirname(__filename), '..', '..', 'cursor-hooks'),
// Alternative dev location
path.join(process.cwd(), 'cursor-hooks'),
];
for (const p of possiblePaths) {
// Check for either bash or PowerShell common script
if (existsSync(path.join(p, 'common.sh')) || existsSync(path.join(p, 'common.ps1'))) {
return p;
}
}
return null;
}
/**
* Find MCP server script path
* Searches in order: marketplace install, source repo
*/
function findMcpServerPath(): string | null {
const possiblePaths = [
// Marketplace install location
path.join(homedir(), '.claude', 'plugins', 'marketplaces', 'thedotmack', 'plugin', 'scripts', 'mcp-server.cjs'),
// Development/source location (relative to built worker-service.cjs in plugin/scripts/)
path.join(path.dirname(__filename), 'mcp-server.cjs'),
// Alternative dev location
path.join(process.cwd(), 'plugin', 'scripts', 'mcp-server.cjs'),
];
for (const p of possiblePaths) {
if (existsSync(p)) {
return p;
}
}
return null;
}
interface CursorMcpConfig {
mcpServers: {
[name: string]: {
command: string;
args?: string[];
env?: Record<string, string>;
};
};
}
/**
* Configure MCP server in Cursor's mcp.json
* @param target 'project' or 'user'
* @returns 0 on success, 1 on failure
*/
function configureCursorMcp(target: string): number {
const mcpServerPath = findMcpServerPath();
if (!mcpServerPath) {
console.error('❌ Could not find MCP server script');
console.error(' Expected at: ~/.claude/plugins/marketplaces/thedotmack/plugin/scripts/mcp-server.cjs');
return 1;
}
let mcpJsonDir: string;
let mcpJsonPath: string;
switch (target) {
case 'project':
mcpJsonDir = path.join(process.cwd(), '.cursor');
mcpJsonPath = path.join(mcpJsonDir, 'mcp.json');
break;
case 'user':
mcpJsonDir = path.join(homedir(), '.cursor');
mcpJsonPath = path.join(mcpJsonDir, 'mcp.json');
break;
default:
console.error(`❌ Invalid target: ${target}. Use: project or user`);
return 1;
}
try {
// Create directory if needed
mkdirSync(mcpJsonDir, { recursive: true });
// Load existing config or create new
let config: CursorMcpConfig = { mcpServers: {} };
if (existsSync(mcpJsonPath)) {
try {
config = JSON.parse(readFileSync(mcpJsonPath, 'utf-8'));
if (!config.mcpServers) {
config.mcpServers = {};
}
} catch {
// Start fresh if corrupt
config = { mcpServers: {} };
}
}
// Add claude-mem MCP server
config.mcpServers['claude-mem'] = {
command: 'node',
args: [mcpServerPath]
};
writeFileSync(mcpJsonPath, JSON.stringify(config, null, 2));
console.log(` ✓ Configured MCP server in ${target === 'user' ? '~/.cursor' : '.cursor'}/mcp.json`);
console.log(` Server path: ${mcpServerPath}`);
return 0;
} catch (error) {
console.error(`❌ Failed to configure MCP: ${(error as Error).message}`);
return 1;
}
}
/**
* Handle cursor subcommand for hooks installation
*/
async function handleCursorCommand(subcommand: string, args: string[]): Promise<number> {
switch (subcommand) {
case 'install': {
const target = args[0] || 'project';
const cursorHooksDir = findCursorHooksDir();
if (!cursorHooksDir) {
console.error('❌ Could not find cursor-hooks directory');
console.error(' Expected at: ~/.claude/plugins/marketplaces/thedotmack/cursor-hooks/');
return 1;
}
return installCursorHooks(cursorHooksDir, target);
}
case 'uninstall': {
const target = args[0] || 'project';
return uninstallCursorHooks(target);
}
case 'status': {
return checkCursorHooksStatus();
}
case 'setup': {
// Interactive guided setup for Cursor users
return await runInteractiveSetup();
}
default: {
console.log(`
Claude-Mem Cursor Integration
Usage: claude-mem cursor <command> [options]
Commands:
setup Interactive guided setup (recommended for first-time users)
install [target] Install Cursor hooks
target: project (default), user, or enterprise
uninstall [target] Remove Cursor hooks
target: project (default), user, or enterprise
status Check installation status
Examples:
npm run cursor:setup # Interactive wizard (recommended)
npm run cursor:install # Install for current project
claude-mem cursor install user # Install globally for user
claude-mem cursor uninstall # Remove from current project
claude-mem cursor status # Check if hooks are installed
For more info: https://docs.claude-mem.ai/cursor
`);
return 0;
}
}
}
/**
* Detect platform for script selection
*/
function detectPlatform(): 'windows' | 'unix' {
return process.platform === 'win32' ? 'windows' : 'unix';
}
/**
* Get script extension based on platform
*/
function getScriptExtension(): string {
return detectPlatform() === 'windows' ? '.ps1' : '.sh';
}
/**
* Install Cursor hooks
*/
async function installCursorHooks(sourceDir: string, target: string): Promise<number> {
const platform = detectPlatform();
const scriptExt = getScriptExtension();
console.log(`\n📦 Installing Claude-Mem Cursor hooks (${target} level, ${platform})...\n`);
let targetDir: string;
let hooksDir: string;
let workspaceRoot: string = process.cwd();
switch (target) {
case 'project':
targetDir = path.join(process.cwd(), '.cursor');
hooksDir = path.join(targetDir, 'hooks');
break;
case 'user':
targetDir = path.join(homedir(), '.cursor');
hooksDir = path.join(targetDir, 'hooks');
break;
case 'enterprise':
if (process.platform === 'darwin') {
targetDir = '/Library/Application Support/Cursor';
hooksDir = path.join(targetDir, 'hooks');
} else if (process.platform === 'linux') {
targetDir = '/etc/cursor';
hooksDir = path.join(targetDir, 'hooks');
} else if (process.platform === 'win32') {
targetDir = path.join(process.env.ProgramData || 'C:\\ProgramData', 'Cursor');
hooksDir = path.join(targetDir, 'hooks');
} else {
console.error('❌ Enterprise installation not supported on this platform');
return 1;
}
break;
default:
console.error(`❌ Invalid target: ${target}. Use: project, user, or enterprise`);
return 1;
}
try {
// Create directories
mkdirSync(hooksDir, { recursive: true });
// Determine which scripts to copy based on platform
const commonScript = platform === 'windows' ? 'common.ps1' : 'common.sh';
const hookScripts = [
`session-init${scriptExt}`,
`context-inject${scriptExt}`,
`save-observation${scriptExt}`,
`save-file-edit${scriptExt}`,
`session-summary${scriptExt}`
];
const scripts = [commonScript, ...hookScripts];
for (const script of scripts) {
const srcPath = path.join(sourceDir, script);
const dstPath = path.join(hooksDir, script);
if (existsSync(srcPath)) {
const content = readFileSync(srcPath, 'utf-8');
// Unix scripts need execute permission; Windows PowerShell doesn't need it
const mode = platform === 'windows' ? undefined : 0o755;
writeFileSync(dstPath, content, mode ? { mode } : undefined);
console.log(` ✓ Copied ${script}`);
} else {
console.warn(`${script} not found in source`);
}
}
// Generate hooks.json with correct paths and platform-appropriate commands
const hooksJsonPath = path.join(targetDir, 'hooks.json');
const hookPrefix = target === 'project' ? './.cursor/hooks/' : `${hooksDir}/`;
// For PowerShell, we need to invoke via powershell.exe
const makeHookCommand = (scriptName: string) => {
const scriptPath = `${hookPrefix}${scriptName}${scriptExt}`;
if (platform === 'windows') {
// PowerShell execution: use -ExecutionPolicy Bypass to ensure scripts run
return `powershell.exe -ExecutionPolicy Bypass -File "${scriptPath}"`;
}
return scriptPath;
};
const hooksJson = {
version: 1,
hooks: {
beforeSubmitPrompt: [
{ command: makeHookCommand('session-init') },
{ command: makeHookCommand('context-inject') }
],
afterMCPExecution: [
{ command: makeHookCommand('save-observation') }
],
afterShellExecution: [
{ command: makeHookCommand('save-observation') }
],
afterFileEdit: [
{ command: makeHookCommand('save-file-edit') }
],
stop: [
{ command: makeHookCommand('session-summary') }
]
}
};
writeFileSync(hooksJsonPath, JSON.stringify(hooksJson, null, 2));
console.log(` ✓ Created hooks.json (${platform} mode)`);
// For project-level: create initial context file
if (target === 'project') {
const rulesDir = path.join(targetDir, 'rules');
mkdirSync(rulesDir, { recursive: true });
// Try to generate initial context from existing memory
const port = getWorkerPort();
const projectName = path.basename(workspaceRoot);
let contextGenerated = false;
console.log(` ⏳ Generating initial context...`);
try {
// Check if worker is running
const healthResponse = await fetch(`http://127.0.0.1:${port}/api/readiness`);
if (healthResponse.ok) {
// Fetch context
const contextResponse = await fetch(
`http://127.0.0.1:${port}/api/context/inject?project=${encodeURIComponent(projectName)}`
);
if (contextResponse.ok) {
const context = await contextResponse.text();
if (context && context.trim()) {
const rulesFile = path.join(rulesDir, 'claude-mem-context.mdc');
const contextContent = `---
alwaysApply: true
description: "Claude-mem context from past sessions (auto-updated)"
---
# Memory Context from Past Sessions
The following context is from claude-mem, a persistent memory system that tracks your coding sessions.
${context}
---
*This context is updated after each session. Use claude-mem's MCP search tools for more detailed queries.*
`;
writeFileSync(rulesFile, contextContent);
contextGenerated = true;
console.log(` ✓ Generated initial context from existing memory`);
}
}
}
} catch {
// Worker not running - that's ok, context will be generated after first session
}
if (!contextGenerated) {
// Create placeholder context file
const rulesFile = path.join(rulesDir, 'claude-mem-context.mdc');
const placeholderContent = `---
alwaysApply: true
description: "Claude-mem context from past sessions (auto-updated)"
---
# Memory Context from Past Sessions
*No context yet. Complete your first session and context will appear here.*
Use claude-mem's MCP search tools for manual memory queries.
`;
writeFileSync(rulesFile, placeholderContent);
console.log(` ✓ Created placeholder context file (will populate after first session)`);
}
// Register project for automatic context updates after summaries
registerCursorProject(projectName, workspaceRoot);
console.log(` ✓ Registered for auto-context updates`);
}
console.log(`
✅ Installation complete!
Hooks installed to: ${targetDir}/hooks.json
Scripts installed to: ${hooksDir}
Next steps:
1. Start claude-mem worker: claude-mem start
2. Restart Cursor to load the hooks
3. Check Cursor Settings → Hooks tab to verify
Context Injection:
Context from past sessions is stored in .cursor/rules/claude-mem-context.mdc
and automatically included in every chat. It updates after each session ends.
`);
return 0;
} catch (error) {
console.error(`\n❌ Installation failed: ${(error as Error).message}`);
if (target === 'enterprise') {
console.error(' Tip: Enterprise installation may require sudo/admin privileges');
}
return 1;
}
}
/**
* Uninstall Cursor hooks
*/
function uninstallCursorHooks(target: string): number {
console.log(`\n🗑️ Uninstalling Claude-Mem Cursor hooks (${target} level)...\n`);
let targetDir: string;
switch (target) {
case 'project':
targetDir = path.join(process.cwd(), '.cursor');
break;
case 'user':
targetDir = path.join(homedir(), '.cursor');
break;
case 'enterprise':
if (process.platform === 'darwin') {
targetDir = '/Library/Application Support/Cursor';
} else if (process.platform === 'linux') {
targetDir = '/etc/cursor';
} else {
console.error('❌ Enterprise not supported on Windows');
return 1;
}
break;
default:
console.error(`❌ Invalid target: ${target}`);
return 1;
}
try {
const hooksDir = path.join(targetDir, 'hooks');
const hooksJsonPath = path.join(targetDir, 'hooks.json');
// Remove hook scripts for both platforms (in case user switches platforms)
const bashScripts = ['common.sh', 'session-init.sh', 'context-inject.sh',
'save-observation.sh', 'save-file-edit.sh', 'session-summary.sh'];
const psScripts = ['common.ps1', 'session-init.ps1', 'context-inject.ps1',
'save-observation.ps1', 'save-file-edit.ps1', 'session-summary.ps1'];
const allScripts = [...bashScripts, ...psScripts];
for (const script of allScripts) {
const scriptPath = path.join(hooksDir, script);
if (existsSync(scriptPath)) {
unlinkSync(scriptPath);
console.log(` ✓ Removed ${script}`);
}
}
// Remove hooks.json
if (existsSync(hooksJsonPath)) {
unlinkSync(hooksJsonPath);
console.log(` ✓ Removed hooks.json`);
}
// Remove context file and unregister if project-level
if (target === 'project') {
const contextFile = path.join(targetDir, 'rules', 'claude-mem-context.mdc');
if (existsSync(contextFile)) {
unlinkSync(contextFile);
console.log(` ✓ Removed context file`);
}
// Unregister from auto-context updates
const projectName = path.basename(process.cwd());
unregisterCursorProject(projectName);
console.log(` ✓ Unregistered from auto-context updates`);
}
console.log(`\n✅ Uninstallation complete!\n`);
console.log('Restart Cursor to apply changes.');
return 0;
} catch (error) {
console.error(`\n❌ Uninstallation failed: ${(error as Error).message}`);
return 1;
}
}
/**
* Check Cursor hooks installation status
*/
function checkCursorHooksStatus(): number {
console.log('\n🔍 Claude-Mem Cursor Hooks Status\n');
const locations = [
{ name: 'Project', dir: path.join(process.cwd(), '.cursor') },
{ name: 'User', dir: path.join(homedir(), '.cursor') },
];
if (process.platform === 'darwin') {
locations.push({ name: 'Enterprise', dir: '/Library/Application Support/Cursor' });
} else if (process.platform === 'linux') {
locations.push({ name: 'Enterprise', dir: '/etc/cursor' });
}
let anyInstalled = false;
for (const loc of locations) {
const hooksJson = path.join(loc.dir, 'hooks.json');
const hooksDir = path.join(loc.dir, 'hooks');
if (existsSync(hooksJson)) {
anyInstalled = true;
console.log(`${loc.name}: Installed`);
console.log(` Config: ${hooksJson}`);
// Detect which platform's scripts are installed
const bashScripts = ['session-init.sh', 'context-inject.sh', 'save-observation.sh'];
const psScripts = ['session-init.ps1', 'context-inject.ps1', 'save-observation.ps1'];
const hasBash = bashScripts.some(s => existsSync(path.join(hooksDir, s)));
const hasPs = psScripts.some(s => existsSync(path.join(hooksDir, s)));
if (hasBash && hasPs) {
console.log(` Platform: Both (bash + PowerShell)`);
} else if (hasBash) {
console.log(` Platform: Unix (bash)`);
} else if (hasPs) {
console.log(` Platform: Windows (PowerShell)`);
} else {
console.log(` ⚠ No hook scripts found`);
}
// Check for appropriate scripts based on current platform
const platform = detectPlatform();
const scripts = platform === 'windows' ? psScripts : bashScripts;
const missing = scripts.filter(s => !existsSync(path.join(hooksDir, s)));
if (missing.length > 0) {
console.log(` ⚠ Missing ${platform} scripts: ${missing.join(', ')}`);
} else {
console.log(` Scripts: All present for ${platform}`);
}
// Check for context file (project only)
if (loc.name === 'Project') {
const contextFile = path.join(loc.dir, 'rules', 'claude-mem-context.mdc');
if (existsSync(contextFile)) {
console.log(` Context: Active`);
} else {
console.log(` Context: Not yet generated (will be created on first prompt)`);
}
}
} else {
console.log(`${loc.name}: Not installed`);
}
console.log('');
}
if (!anyInstalled) {
console.log('No hooks installed. Run: claude-mem cursor install\n');
}
return 0;
}
// ============================================================================
// CLI Entry Point
// ============================================================================
async function main() {
const command = process.argv[2];
const port = getWorkerPort();
switch (command) {
case 'start': {
// Health-check-first approach: simple, fast, reliable
// Check if worker is already healthy
if (await waitForHealth(port, 1000)) {
// Worker is healthy - check for version mismatch (issue #484)
const versionCheck = await checkVersionMatch(port);
if (!versionCheck.matches) {
logger.info('SYSTEM', 'Worker version mismatch detected - auto-restarting', {
pluginVersion: versionCheck.pluginVersion,
workerVersion: versionCheck.workerVersion
});
// Shutdown the old worker
await httpShutdown(port);
const freed = await waitForPortFree(port, getPlatformTimeout(15000));
if (!freed) {
logger.error('SYSTEM', 'Port did not free up after shutdown for version mismatch restart', { port });
process.exit(1);
}
removePidFile();
// Fall through to spawn new daemon below
} else {
logger.info('SYSTEM', 'Worker already running and healthy');
process.exit(0);
}
}
// Worker not healthy - check if port is in use
const portInUse = await isPortInUse(port);
if (portInUse) {
// Port in use but not healthy - wait a bit longer in case it's starting up
logger.info('SYSTEM', 'Port in use, waiting for worker to become healthy');
const healthy = await waitForHealth(port, getPlatformTimeout(15000));
if (healthy) {
logger.info('SYSTEM', 'Worker is now healthy');
process.exit(0);
}
logger.error('SYSTEM', 'Port in use but worker not responding to health checks');
process.exit(1);
}
// Port not in use - spawn daemon
logger.info('SYSTEM', 'Starting worker daemon');
const child = spawn(process.execPath, [__filename, '--daemon'], {
detached: true,
stdio: 'ignore',
windowsHide: true,
env: { ...process.env, CLAUDE_MEM_WORKER_PORT: String(port) }
});
if (child.pid === undefined) {
logger.error('SYSTEM', 'Failed to spawn worker daemon');
process.exit(1);
}
child.unref();
// Write PID file
writePidFile({ pid: child.pid, port, startedAt: new Date().toISOString() });
// Wait for health with platform-adjusted timeout
const healthy = await waitForHealth(port, getPlatformTimeout(30000));
if (!healthy) {
removePidFile();
logger.error('SYSTEM', 'Worker failed to start (health check timeout)');
process.exit(1);
}
logger.info('SYSTEM', 'Worker started successfully');
process.exit(0);
}
case 'stop': {
// Simple stop: send shutdown request, wait for port to free
await httpShutdown(port);
const freed = await waitForPortFree(port, getPlatformTimeout(15000));
if (!freed) {
logger.warn('SYSTEM', 'Port did not free up after shutdown', { port });
// Could force kill here if we knew the PID, but for now just warn
}
removePidFile();
logger.info('SYSTEM', 'Worker stopped successfully');
process.exit(0);
}
case 'restart': {
// Simple restart: stop, then start
logger.info('SYSTEM', 'Restarting worker');
await httpShutdown(port);
const freed = await waitForPortFree(port, getPlatformTimeout(15000));
if (!freed) {
logger.error('SYSTEM', 'Port did not free up after shutdown, aborting restart', { port });
process.exit(1);
}
removePidFile();
// Spawn new daemon
const child = spawn(process.execPath, [__filename, '--daemon'], {
detached: true,
stdio: 'ignore',
windowsHide: true,
env: { ...process.env, CLAUDE_MEM_WORKER_PORT: String(port) }
});
if (child.pid === undefined) {
logger.error('SYSTEM', 'Failed to spawn worker daemon during restart');
process.exit(1);
}
child.unref();
writePidFile({ pid: child.pid, port, startedAt: new Date().toISOString() });
// Wait for health
const healthy = await waitForHealth(port, getPlatformTimeout(30000));
if (!healthy) {
removePidFile();
logger.error('SYSTEM', 'Worker failed to restart');
process.exit(1);
}
logger.info('SYSTEM', 'Worker restarted successfully');
process.exit(0);
}
case 'status': {
const running = await isPortInUse(port);
const pidInfo = readPidFile();
if (running && pidInfo) {
console.log('Worker is running');
console.log(` PID: ${pidInfo.pid}`);
console.log(` Port: ${pidInfo.port}`);
console.log(` Started: ${pidInfo.startedAt}`);
} else {
console.log('Worker is not running');
}
process.exit(0);
}
case 'cursor': {
// Cursor hooks installation subcommand
const subcommand = process.argv[3];
const cursorResult = await handleCursorCommand(subcommand, process.argv.slice(4));
process.exit(cursorResult);
}
case '--daemon':
default: {
// Run server directly
const worker = new WorkerService();
worker.start().catch((error) => {
logger.failure('SYSTEM', 'Worker failed to start', {}, error as Error);
removePidFile();
process.exit(1);
});
}
}
}
if (require.main === module || !module.parent) {
main();
}