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:
@@ -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);
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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));
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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, '..', '..');
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
Reference in New Issue
Block a user