Refactor hooks and worker service for improved error handling and initialization

- Removed try-catch blocks in new-hook, save-hook, and summary-hook for cleaner flow.
- Enhanced error handling in save and summary hooks to throw errors instead of logging and returning.
- Introduced ensureWorkerRunning utility to manage worker service lifecycle and health checks.
- Replaced dynamic port allocation with a fixed port for the worker service.
- Simplified path management and removed unused port allocator utility.
- Added database schema initialization for fresh installations and improved migration handling.
This commit is contained in:
Alex Newman
2025-10-19 00:57:49 -04:00
parent cf9d1d4a0b
commit 7ff611feb5
23 changed files with 832 additions and 434 deletions
+3 -9
View File
@@ -11,13 +11,7 @@ import { stdin } from 'process';
let input = '';
stdin.on('data', (chunk) => input += chunk);
stdin.on('end', async () => {
try {
const parsed = input.trim() ? JSON.parse(input) : undefined;
await newHook(parsed);
process.exit(0);
} catch (error: any) {
console.error(`[claude-mem new-hook error: ${error.message}]`);
console.log('{"continue": true, "suppressOutput": true}');
process.exit(0);
}
const parsed = input.trim() ? JSON.parse(input) : undefined;
await newHook(parsed);
process.exit(0);
});
+3 -9
View File
@@ -11,13 +11,7 @@ import { stdin } from 'process';
let input = '';
stdin.on('data', (chunk) => input += chunk);
stdin.on('end', async () => {
try {
const parsed = input.trim() ? JSON.parse(input) : undefined;
await saveHook(parsed);
process.exit(0);
} catch (error: any) {
console.error(`[claude-mem save-hook error: ${error.message}]`);
console.log('{"continue": true, "suppressOutput": true}');
process.exit(0);
}
const parsed = input.trim() ? JSON.parse(input) : undefined;
await saveHook(parsed);
process.exit(0);
});
+3 -9
View File
@@ -11,13 +11,7 @@ import { stdin } from 'process';
let input = '';
stdin.on('data', (chunk) => input += chunk);
stdin.on('end', async () => {
try {
const parsed = input.trim() ? JSON.parse(input) : undefined;
await summaryHook(parsed);
process.exit(0);
} catch (error: any) {
console.error(`[claude-mem summary-hook error: ${error.message}]`);
console.log('{"continue": true, "suppressOutput": true}');
process.exit(0);
}
const parsed = input.trim() ? JSON.parse(input) : undefined;
await summaryHook(parsed);
process.exit(0);
});
+1 -58
View File
@@ -1,8 +1,6 @@
import path from 'path';
import { existsSync } from 'fs';
import { spawn } from 'child_process';
import { SessionStore } from '../services/sqlite/SessionStore.js';
import { getWorkerPortFilePath, getPackageRoot } from '../shared/paths.js';
import { ensureWorkerRunning } from '../shared/worker-utils.js';
export interface SessionStartInput {
session_id?: string;
@@ -13,61 +11,6 @@ export interface SessionStartInput {
[key: string]: any;
}
/**
* Ensure worker service is running
* Auto-starts worker if not running (v4.0.0 feature)
*/
function ensureWorkerRunning(): void {
try {
const portFile = getWorkerPortFilePath();
// Check if worker is already running
if (existsSync(portFile)) {
// Worker appears to be running (port file exists)
return;
}
console.error('[claude-mem] Worker not running, starting...');
// Find worker service path
const packageRoot = getPackageRoot();
const workerPath = path.join(packageRoot, 'dist', 'worker-service.cjs');
if (!existsSync(workerPath)) {
console.error(`[claude-mem] Worker service not found at ${workerPath}`);
return;
}
// Try to start with PM2 first (preferred for production)
const ecosystemPath = path.join(packageRoot, 'ecosystem.config.cjs');
if (existsSync(ecosystemPath)) {
try {
spawn('pm2', ['start', ecosystemPath], {
detached: true,
stdio: 'ignore',
cwd: packageRoot
}).unref();
console.error('[claude-mem] Worker started with PM2');
return;
} catch (pm2Error) {
console.error('[claude-mem] PM2 not available, using direct spawn');
}
}
// Fallback: spawn worker directly
spawn('node', [workerPath], {
detached: true,
stdio: 'ignore',
env: { ...process.env, NODE_ENV: 'production' }
}).unref();
console.error('[claude-mem] Worker started in background');
} catch (error: any) {
// Don't fail the hook if worker start fails
console.error(`[claude-mem] Failed to start worker: ${error.message}`);
}
}
/**
* Context Hook - SessionStart
* Shows user what happened in recent sessions
+10 -33
View File
@@ -1,7 +1,7 @@
import path from 'path';
import { SessionStore } from '../services/sqlite/SessionStore.js';
import { createHookResponse } from './hook-response.js';
import { getWorkerPortFilePath } from '../shared/paths.js';
import { ensureWorkerRunning, getWorkerPort } from '../shared/worker-utils.js';
export interface UserPromptSubmitInput {
session_id: string;
@@ -10,26 +10,6 @@ export interface UserPromptSubmitInput {
[key: string]: any;
}
/**
* Get worker service port from file
*/
async function getWorkerPort(): Promise<number | null> {
const { readFileSync, existsSync } = await import('fs');
const portFile = getWorkerPortFilePath();
if (!existsSync(portFile)) {
return null;
}
try {
const portStr = readFileSync(portFile, 'utf8').trim();
return parseInt(portStr, 10);
} catch {
return null;
}
}
/**
* New Hook - UserPromptSubmit
* Initializes SDK memory session via HTTP POST to worker service
@@ -74,14 +54,15 @@ export async function newHook(input?: UserPromptSubmitInput): Promise<void> {
}
}
// Find worker service port
const port = await getWorkerPort();
if (!port) {
console.error('[new-hook] Worker service not running. Start with: npm run worker:start');
console.log(createHookResponse('UserPromptSubmit', true)); // Don't block Claude
return;
// Ensure worker service is running (v4.0.0 auto-start)
const workerReady = await ensureWorkerRunning();
if (!workerReady) {
throw new Error('Worker service failed to start or become healthy');
}
// Get fixed port
const port = getWorkerPort();
// Only initialize worker on new sessions
if (isNewSession) {
// Initialize session via HTTP
@@ -93,16 +74,12 @@ export async function newHook(input?: UserPromptSubmitInput): Promise<void> {
});
if (!response.ok) {
console.error('[new-hook] Failed to init session:', await response.text());
const errorText = await response.text();
throw new Error(`Failed to initialize session: ${response.status} ${errorText}`);
}
}
console.log(createHookResponse('UserPromptSubmit', true));
} catch (error: any) {
console.error('[new-hook] FATAL ERROR:', error.message);
console.error('[new-hook] Stack:', error.stack);
console.error('[new-hook] Full error:', JSON.stringify(error, Object.getOwnPropertyNames(error)));
console.log(createHookResponse('UserPromptSubmit', true)); // Don't block Claude
} finally {
db.close();
}
+27 -32
View File
@@ -44,8 +44,7 @@ export async function saveHook(input?: PostToolUseInput): Promise<void> {
if (!session.worker_port) {
db.close();
logger.error('HOOK', 'No worker port for session', { sessionId: session.id });
console.log(createHookResponse('PostToolUse', true));
return;
throw new Error('No worker port for session - session may not be properly initialized');
}
// Get current prompt number for this session
@@ -54,36 +53,32 @@ export async function saveHook(input?: PostToolUseInput): Promise<void> {
const toolStr = logger.formatTool(tool_name, tool_input);
try {
logger.dataIn('HOOK', `PostToolUse: ${toolStr}`, {
logger.dataIn('HOOK', `PostToolUse: ${toolStr}`, {
sessionId: session.id,
workerPort: session.worker_port
});
const response = await fetch(`http://127.0.0.1:${session.worker_port}/sessions/${session.id}/observations`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
tool_name,
tool_input: tool_input !== undefined ? JSON.stringify(tool_input) : '{}',
tool_output: tool_output !== undefined ? JSON.stringify(tool_output) : '{}',
prompt_number: promptNumber
}),
signal: AbortSignal.timeout(2000)
});
if (!response.ok) {
const errorText = await response.text();
logger.failure('HOOK', 'Failed to send observation', {
sessionId: session.id,
workerPort: session.worker_port
});
const response = await fetch(`http://127.0.0.1:${session.worker_port}/sessions/${session.id}/observations`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
tool_name,
tool_input: tool_input !== undefined ? JSON.stringify(tool_input) : '{}',
tool_output: tool_output !== undefined ? JSON.stringify(tool_output) : '{}',
prompt_number: promptNumber
}),
signal: AbortSignal.timeout(2000)
});
if (!response.ok) {
const errorText = await response.text();
logger.failure('HOOK', 'Failed to send observation', {
sessionId: session.id,
status: response.status
}, errorText);
} else {
logger.debug('HOOK', 'Observation sent successfully', { sessionId: session.id, toolName: tool_name });
}
} catch (error: any) {
logger.failure('HOOK', 'Error sending observation', { sessionId: session.id }, error);
} finally {
console.log(createHookResponse('PostToolUse', true));
status: response.status
}, errorText);
throw new Error(`Failed to send observation to worker: ${response.status} ${errorText}`);
}
logger.debug('HOOK', 'Observation sent successfully', { sessionId: session.id, toolName: tool_name });
console.log(createHookResponse('PostToolUse', true));
}
+23 -28
View File
@@ -30,40 +30,35 @@ export async function summaryHook(input?: StopInput): Promise<void> {
if (!session.worker_port) {
db.close();
logger.error('HOOK', 'No worker port for session', { sessionId: session.id });
console.log(createHookResponse('Stop', true));
return;
throw new Error('No worker port for session - session may not be properly initialized');
}
// Get current prompt number
const promptNumber = db.getPromptCounter(session.id);
db.close();
try {
logger.dataIn('HOOK', 'Stop: Requesting summary', {
logger.dataIn('HOOK', 'Stop: Requesting summary', {
sessionId: session.id,
workerPort: session.worker_port,
promptNumber
});
const response = await fetch(`http://127.0.0.1:${session.worker_port}/sessions/${session.id}/summarize`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ prompt_number: promptNumber }),
signal: AbortSignal.timeout(2000)
});
if (!response.ok) {
const errorText = await response.text();
logger.failure('HOOK', 'Failed to generate summary', {
sessionId: session.id,
workerPort: session.worker_port,
promptNumber
});
const response = await fetch(`http://127.0.0.1:${session.worker_port}/sessions/${session.id}/summarize`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ prompt_number: promptNumber }),
signal: AbortSignal.timeout(2000)
});
if (!response.ok) {
const errorText = await response.text();
logger.failure('HOOK', 'Failed to generate summary', {
sessionId: session.id,
status: response.status
}, errorText);
} else {
logger.debug('HOOK', 'Summary request sent successfully', { sessionId: session.id });
}
} catch (error: any) {
logger.failure('HOOK', 'Error requesting summary', { sessionId: session.id }, error);
} finally {
console.log(createHookResponse('Stop', true));
status: response.status
}, errorText);
throw new Error(`Failed to request summary from worker: ${response.status} ${errorText}`);
}
logger.debug('HOOK', 'Summary request sent successfully', { sessionId: session.id });
console.log(createHookResponse('Stop', true));
}
+140 -11
View File
@@ -18,6 +18,9 @@ export class SessionStore {
this.db.pragma('synchronous = NORMAL');
this.db.pragma('foreign_keys = ON');
// Initialize schema if needed (fresh database)
this.initializeSchema();
// Run migrations
this.ensureWorkerPortColumn();
this.ensurePromptTrackingColumns();
@@ -27,10 +30,108 @@ export class SessionStore {
}
/**
* Ensure worker_port column exists (migration)
* Initialize database schema using migrations (migration004)
* This runs the core SDK tables migration if no tables exist
*/
private initializeSchema(): void {
try {
// Create schema_versions table if it doesn't exist
this.db.exec(`
CREATE TABLE IF NOT EXISTS schema_versions (
id INTEGER PRIMARY KEY,
version INTEGER UNIQUE NOT NULL,
applied_at TEXT NOT NULL
)
`);
// Get applied migrations
const appliedVersions = this.db.prepare('SELECT version FROM schema_versions ORDER BY version').all() as Array<{version: number}>;
const maxApplied = appliedVersions.length > 0 ? Math.max(...appliedVersions.map(v => v.version)) : 0;
// Only run migration004 if no migrations have been applied
// This creates the sdk_sessions, observations, and session_summaries tables
if (maxApplied === 0) {
console.error('[SessionStore] Initializing fresh database with migration004...');
// Migration004: SDK agent architecture tables
this.db.exec(`
CREATE TABLE IF NOT EXISTS sdk_sessions (
id INTEGER PRIMARY KEY AUTOINCREMENT,
claude_session_id TEXT UNIQUE NOT NULL,
sdk_session_id TEXT UNIQUE,
project TEXT NOT NULL,
user_prompt TEXT,
started_at TEXT NOT NULL,
started_at_epoch INTEGER NOT NULL,
completed_at TEXT,
completed_at_epoch INTEGER,
status TEXT CHECK(status IN ('active', 'completed', 'failed')) NOT NULL DEFAULT 'active'
);
CREATE INDEX IF NOT EXISTS idx_sdk_sessions_claude_id ON sdk_sessions(claude_session_id);
CREATE INDEX IF NOT EXISTS idx_sdk_sessions_sdk_id ON sdk_sessions(sdk_session_id);
CREATE INDEX IF NOT EXISTS idx_sdk_sessions_project ON sdk_sessions(project);
CREATE INDEX IF NOT EXISTS idx_sdk_sessions_status ON sdk_sessions(status);
CREATE INDEX IF NOT EXISTS idx_sdk_sessions_started ON sdk_sessions(started_at_epoch DESC);
CREATE TABLE IF NOT EXISTS observations (
id INTEGER PRIMARY KEY AUTOINCREMENT,
sdk_session_id TEXT NOT NULL,
project TEXT NOT NULL,
text TEXT NOT NULL,
type TEXT NOT NULL CHECK(type IN ('decision', 'bugfix', 'feature', 'refactor', 'discovery')),
created_at TEXT NOT NULL,
created_at_epoch INTEGER NOT NULL,
FOREIGN KEY(sdk_session_id) REFERENCES sdk_sessions(sdk_session_id) ON DELETE CASCADE
);
CREATE INDEX IF NOT EXISTS idx_observations_sdk_session ON observations(sdk_session_id);
CREATE INDEX IF NOT EXISTS idx_observations_project ON observations(project);
CREATE INDEX IF NOT EXISTS idx_observations_type ON observations(type);
CREATE INDEX IF NOT EXISTS idx_observations_created ON observations(created_at_epoch DESC);
CREATE TABLE IF NOT EXISTS session_summaries (
id INTEGER PRIMARY KEY AUTOINCREMENT,
sdk_session_id TEXT UNIQUE NOT NULL,
project TEXT NOT NULL,
request TEXT,
investigated TEXT,
learned TEXT,
completed TEXT,
next_steps TEXT,
files_read TEXT,
files_edited TEXT,
notes TEXT,
created_at TEXT NOT NULL,
created_at_epoch INTEGER NOT NULL,
FOREIGN KEY(sdk_session_id) REFERENCES sdk_sessions(sdk_session_id) ON DELETE CASCADE
);
CREATE INDEX IF NOT EXISTS idx_session_summaries_sdk_session ON session_summaries(sdk_session_id);
CREATE INDEX IF NOT EXISTS idx_session_summaries_project ON session_summaries(project);
CREATE INDEX IF NOT EXISTS idx_session_summaries_created ON session_summaries(created_at_epoch DESC);
`);
// Record migration004 as applied
this.db.prepare('INSERT INTO schema_versions (version, applied_at) VALUES (?, ?)').run(4, new Date().toISOString());
console.error('[SessionStore] Migration004 applied successfully');
}
} catch (error: any) {
console.error('[SessionStore] Schema initialization error:', error.message);
throw error;
}
}
/**
* Ensure worker_port column exists (migration 5)
*/
private ensureWorkerPortColumn(): void {
try {
// Check if migration already applied
const applied = this.db.prepare('SELECT version FROM schema_versions WHERE version = ?').get(5) as {version: number} | undefined;
if (applied) return;
// Check if column exists
const tableInfo = this.db.pragma('table_info(sdk_sessions)');
const hasWorkerPort = (tableInfo as any[]).some((col: any) => col.name === 'worker_port');
@@ -39,16 +140,23 @@ export class SessionStore {
this.db.exec('ALTER TABLE sdk_sessions ADD COLUMN worker_port INTEGER');
console.error('[SessionStore] Added worker_port column to sdk_sessions table');
}
// Record migration
this.db.prepare('INSERT OR IGNORE INTO schema_versions (version, applied_at) VALUES (?, ?)').run(5, new Date().toISOString());
} catch (error: any) {
console.error('[SessionStore] Migration error:', error.message);
}
}
/**
* Ensure prompt tracking columns exist (migration 006)
* Ensure prompt tracking columns exist (migration 6)
*/
private ensurePromptTrackingColumns(): void {
try {
// Check if migration already applied
const applied = this.db.prepare('SELECT version FROM schema_versions WHERE version = ?').get(6) as {version: number} | undefined;
if (applied) return;
// Check sdk_sessions for prompt_counter
const sessionsInfo = this.db.pragma('table_info(sdk_sessions)');
const hasPromptCounter = (sessionsInfo as any[]).some((col: any) => col.name === 'prompt_counter');
@@ -76,27 +184,29 @@ export class SessionStore {
console.error('[SessionStore] Added prompt_number column to session_summaries table');
}
// Remove UNIQUE constraint on session_summaries.sdk_session_id
// SQLite doesn't support dropping constraints, so we need to check if it exists first
const summariesIndexes = this.db.pragma('index_list(session_summaries)');
const hasUniqueConstraint = (summariesIndexes as any[]).some((idx: any) => idx.unique === 1);
// Record migration
this.db.prepare('INSERT OR IGNORE INTO schema_versions (version, applied_at) VALUES (?, ?)').run(6, new Date().toISOString());
} catch (error: any) {
console.error('[SessionStore] Prompt tracking migration error:', error.message);
}
}
/**
* Remove UNIQUE constraint from session_summaries.sdk_session_id (migration 007)
* Remove UNIQUE constraint from session_summaries.sdk_session_id (migration 7)
*/
private removeSessionSummariesUniqueConstraint(): void {
try {
// Check if migration already applied
const applied = this.db.prepare('SELECT version FROM schema_versions WHERE version = ?').get(7) as {version: number} | undefined;
if (applied) return;
// Check if UNIQUE constraint exists
const summariesIndexes = this.db.pragma('index_list(session_summaries)');
const hasUniqueConstraint = (summariesIndexes as any[]).some((idx: any) => idx.unique === 1);
if (!hasUniqueConstraint) {
// Already migrated
// Already migrated (no constraint exists)
this.db.prepare('INSERT OR IGNORE INTO schema_versions (version, applied_at) VALUES (?, ?)').run(7, new Date().toISOString());
return;
}
@@ -152,6 +262,9 @@ export class SessionStore {
// Commit transaction
this.db.exec('COMMIT');
// Record migration
this.db.prepare('INSERT OR IGNORE INTO schema_versions (version, applied_at) VALUES (?, ?)').run(7, new Date().toISOString());
console.error('[SessionStore] Successfully removed UNIQUE constraint from session_summaries.sdk_session_id');
} catch (error: any) {
// Rollback on error
@@ -164,16 +277,21 @@ export class SessionStore {
}
/**
* Add hierarchical fields to observations table (migration 008)
* Add hierarchical fields to observations table (migration 8)
*/
private addObservationHierarchicalFields(): void {
try {
// Check if migration already applied
const applied = this.db.prepare('SELECT version FROM schema_versions WHERE version = ?').get(8) as {version: number} | undefined;
if (applied) return;
// Check if new fields already exist
const tableInfo = this.db.pragma('table_info(observations)');
const hasTitle = (tableInfo as any[]).some((col: any) => col.name === 'title');
if (hasTitle) {
// Already migrated
this.db.prepare('INSERT OR IGNORE INTO schema_versions (version, applied_at) VALUES (?, ?)').run(8, new Date().toISOString());
return;
}
@@ -190,6 +308,9 @@ export class SessionStore {
ALTER TABLE observations ADD COLUMN files_modified TEXT;
`);
// Record migration
this.db.prepare('INSERT OR IGNORE INTO schema_versions (version, applied_at) VALUES (?, ?)').run(8, new Date().toISOString());
console.error('[SessionStore] Successfully added hierarchical fields to observations table');
} catch (error: any) {
console.error('[SessionStore] Migration error (add hierarchical fields):', error.message);
@@ -197,17 +318,22 @@ export class SessionStore {
}
/**
* Make observations.text nullable (migration 009)
* Make observations.text nullable (migration 9)
* The text field is deprecated in favor of structured fields (title, subtitle, narrative, etc.)
*/
private makeObservationsTextNullable(): void {
try {
// Check if migration already applied
const applied = this.db.prepare('SELECT version FROM schema_versions WHERE version = ?').get(9) as {version: number} | undefined;
if (applied) return;
// Check if text column is already nullable
const tableInfo = this.db.pragma('table_info(observations)');
const textColumn = (tableInfo as any[]).find((col: any) => col.name === 'text');
if (!textColumn || textColumn.notnull === 0) {
// Already migrated or text column doesn't exist
this.db.prepare('INSERT OR IGNORE INTO schema_versions (version, applied_at) VALUES (?, ?)').run(9, new Date().toISOString());
return;
}
@@ -265,6 +391,9 @@ export class SessionStore {
// Commit transaction
this.db.exec('COMMIT');
// Record migration
this.db.prepare('INSERT OR IGNORE INTO schema_versions (version, applied_at) VALUES (?, ?)').run(9, new Date().toISOString());
console.error('[SessionStore] Successfully made observations.text nullable');
} catch (error: any) {
// Rollback on error
+11 -20
View File
@@ -10,12 +10,12 @@ import { SessionStore } from './sqlite/SessionStore.js';
import { buildInitPrompt, buildObservationPrompt, buildFinalizePrompt } from '../sdk/prompts.js';
import { parseObservations, parseSummary } from '../sdk/parser.js';
import type { SDKSession } from '../sdk/prompts.js';
import { findAvailablePort } from '../utils/port-allocator.js';
import { logger } from '../utils/logger.js';
import { getWorkerPortFilePath, ensureAllDataDirs } from '../shared/paths.js';
import { ensureAllDataDirs } from '../shared/paths.js';
const MODEL = 'claude-sonnet-4-5';
const DISALLOWED_TOOLS = ['Glob', 'Grep', 'ListMcpResourcesTool', 'WebSearch'];
const FIXED_PORT = parseInt(process.env.CLAUDE_MEM_WORKER_PORT || '37777', 10);
interface ObservationMessage {
type: 'observation';
@@ -69,13 +69,7 @@ class WorkerService {
}
async start(): Promise<void> {
// Find available port
const port = await findAvailablePort();
if (!port) {
throw new Error('No available ports in range 37000-37999');
}
this.port = port;
this.port = FIXED_PORT;
// Clean up orphaned sessions from previous worker instances
const db = new SessionStore();
@@ -87,18 +81,15 @@ class WorkerService {
}
return new Promise((resolve, reject) => {
this.app.listen(port, '127.0.0.1', () => {
logger.info('SYSTEM', `Worker started`, { port, pid: process.pid, activeSessions: this.sessions.size });
// Write port to file for hooks to discover
const { writeFileSync } = require('fs');
ensureAllDataDirs(); // Ensure data directory exists
const portFile = getWorkerPortFilePath();
writeFileSync(portFile, port.toString(), 'utf8');
logger.info('SYSTEM', `Port file written to ${portFile}`);
this.app.listen(FIXED_PORT, '127.0.0.1', () => {
logger.info('SYSTEM', `Worker started`, { port: FIXED_PORT, pid: process.pid, activeSessions: this.sessions.size });
resolve();
}).on('error', reject);
}).on('error', (err: any) => {
if (err.code === 'EADDRINUSE') {
logger.error('SYSTEM', `Port ${FIXED_PORT} already in use - worker may already be running`);
}
reject(err);
});
});
}
+18 -49
View File
@@ -4,26 +4,25 @@ import { existsSync, mkdirSync } from 'fs';
import { execSync } from 'child_process';
import { fileURLToPath } from 'url';
// Get __dirname that works in both ESM (hooks) and CJS (worker) contexts
function getDirname(): string {
// CJS context - __dirname exists
if (typeof __dirname !== 'undefined') {
return __dirname;
}
// ESM context - use import.meta.url
return dirname(fileURLToPath(import.meta.url));
}
const _dirname = getDirname();
/**
* Simple path configuration for claude-mem
* Standard paths based on Claude Code conventions
*
* v4.0.0: Data directory now uses CLAUDE_PLUGIN_ROOT when available
*/
// Base directories
// Priority: CLAUDE_PLUGIN_ROOT/data > CLAUDE_MEM_DATA_DIR > ~/.claude-mem
const getDataDir = (): string => {
if (process.env.CLAUDE_PLUGIN_ROOT) {
return join(process.env.CLAUDE_PLUGIN_ROOT, 'data');
}
if (process.env.CLAUDE_MEM_DATA_DIR) {
return process.env.CLAUDE_MEM_DATA_DIR;
}
return join(homedir(), '.claude-mem');
};
export const DATA_DIR = getDataDir();
export const DATA_DIR = process.env.CLAUDE_MEM_DATA_DIR || join(homedir(), '.claude-mem');
export const CLAUDE_CONFIG_DIR = process.env.CLAUDE_CONFIG_DIR || join(homedir(), '.claude');
// Data subdirectories
@@ -53,13 +52,6 @@ export function getWorkerSocketPath(sessionId: number): string {
return join(DATA_DIR, `worker-${sessionId}.sock`);
}
/**
* Get worker port file path
*/
export function getWorkerPortFilePath(): string {
return join(DATA_DIR, 'worker.port');
}
/**
* Ensure a directory exists
*/
@@ -103,36 +95,13 @@ export function getCurrentProjectName(): string {
}
/**
* Find package root directory (for install command)
* Find package root directory
*
* Works because bundled hooks are in plugin/scripts/,
* so package root is always two levels up
*/
export function getPackageRoot(): string {
// Method 1: Try require.resolve for package.json
try {
const packageJsonPath = require.resolve('claude-mem/package.json');
return dirname(packageJsonPath);
} catch {
// Continue to next method
}
// Method 2: Walk up from current module location
const currentFile = fileURLToPath(import.meta.url);
let currentDir = dirname(currentFile);
for (let i = 0; i < 10; i++) {
const packageJsonPath = join(currentDir, 'package.json');
if (existsSync(packageJsonPath)) {
const packageJson = require(packageJsonPath);
if (packageJson.name === 'claude-mem') {
return currentDir;
}
}
const parentDir = dirname(currentDir);
if (parentDir === currentDir) break;
currentDir = parentDir;
}
throw new Error('Cannot locate claude-mem package root. Ensure claude-mem is properly installed.');
return join(_dirname, '..', '..');
}
/**
+107
View File
@@ -0,0 +1,107 @@
import path from 'path';
import { existsSync } from 'fs';
import { spawn } from 'child_process';
import { getPackageRoot } from './paths.js';
const FIXED_PORT = parseInt(process.env.CLAUDE_MEM_WORKER_PORT || '37777', 10);
const HEALTH_CHECK_URL = `http://127.0.0.1:${FIXED_PORT}/health`;
/**
* Check if worker is responding by hitting health endpoint
*/
async function checkWorkerHealth(): Promise<boolean> {
try {
const response = await fetch(HEALTH_CHECK_URL, {
signal: AbortSignal.timeout(500)
});
return response.ok;
} catch {
return false;
}
}
/**
* Ensure worker service is running with retry logic
* Auto-starts worker if not running (v4.0.0 feature)
*
* @returns true if worker is responding, false if failed to start
*/
export async function ensureWorkerRunning(): Promise<boolean> {
try {
// Check if worker is already responding
if (await checkWorkerHealth()) {
return true;
}
console.error('[claude-mem] Worker not responding, starting...');
// Find worker service path
const packageRoot = getPackageRoot();
const workerPath = path.join(packageRoot, 'dist', 'worker-service.cjs');
if (!existsSync(workerPath)) {
console.error(`[claude-mem] Worker service not found at ${workerPath}`);
return false;
}
// Try to start with PM2 first (preferred for production)
const ecosystemPath = path.join(packageRoot, 'ecosystem.config.cjs');
if (existsSync(ecosystemPath)) {
try {
spawn('pm2', ['start', ecosystemPath], {
detached: true,
stdio: 'ignore',
cwd: packageRoot
}).unref();
console.error('[claude-mem] Worker started with PM2');
} catch (pm2Error) {
console.error('[claude-mem] PM2 not available, using direct spawn');
// Fallback: spawn worker directly
spawn('node', [workerPath], {
detached: true,
stdio: 'ignore',
env: { ...process.env, NODE_ENV: 'production', CLAUDE_MEM_WORKER_PORT: FIXED_PORT.toString() }
}).unref();
console.error('[claude-mem] Worker started in background');
}
} else {
// No PM2 config, spawn directly
spawn('node', [workerPath], {
detached: true,
stdio: 'ignore',
env: { ...process.env, NODE_ENV: 'production', CLAUDE_MEM_WORKER_PORT: FIXED_PORT.toString() }
}).unref();
console.error('[claude-mem] Worker started in background');
}
// Wait for worker to become healthy (retry 3 times with 500ms delay)
for (let i = 0; i < 3; i++) {
await new Promise(resolve => setTimeout(resolve, 500));
if (await checkWorkerHealth()) {
console.error('[claude-mem] Worker is healthy');
return true;
}
}
console.error('[claude-mem] Worker failed to become healthy after startup');
return false;
} catch (error: any) {
console.error(`[claude-mem] Failed to start worker: ${error.message}`);
return false;
}
}
/**
* Check if worker is currently running
*/
export async function isWorkerRunning(): Promise<boolean> {
return checkWorkerHealth();
}
/**
* Get the worker port number (fixed port)
*/
export function getWorkerPort(): number {
return FIXED_PORT;
}
-63
View File
@@ -1,63 +0,0 @@
import net from 'net';
/**
* Port Allocator Utility
* Finds available ports dynamically for worker service
*/
const PORT_RANGE_START = 37000;
const PORT_RANGE_END = 37999;
/**
* Check if a port is available
*/
function isPortAvailable(port: number): Promise<boolean> {
return new Promise((resolve) => {
const server = net.createServer();
server.once('error', (err: any) => {
if (err.code === 'EADDRINUSE') {
resolve(false);
} else {
resolve(false);
}
});
server.once('listening', () => {
server.close();
resolve(true);
});
server.listen(port, '127.0.0.1');
});
}
/**
* Find an available port in the configured range
* Returns a port number or null if none available
*/
export async function findAvailablePort(): Promise<number | null> {
// Try random ports first (faster for sparse allocation)
for (let i = 0; i < 10; i++) {
const randomPort = Math.floor(Math.random() * (PORT_RANGE_END - PORT_RANGE_START + 1)) + PORT_RANGE_START;
if (await isPortAvailable(randomPort)) {
return randomPort;
}
}
// Fall back to sequential search
for (let port = PORT_RANGE_START; port <= PORT_RANGE_END; port++) {
if (await isPortAvailable(port)) {
return port;
}
}
return null;
}
/**
* Check if a specific port is available
*/
export async function checkPort(port: number): Promise<boolean> {
return isPortAvailable(port);
}