feat: Implement Worker Service with session management and SDK integration

- Added WorkerService to handle long-running HTTP service with session management.
- Implemented endpoints for initializing, observing, finalizing, checking status, and deleting sessions.
- Integrated with Claude SDK for processing observations and generating responses.
- Added port allocator utility to dynamically find available ports for the service.
- Configured TypeScript settings for the project.
This commit is contained in:
Alex Newman
2025-10-17 15:59:36 -04:00
parent d6462919cb
commit 372854948c
57 changed files with 7055 additions and 6649 deletions
+13 -11
View File
@@ -1,4 +1,3 @@
#!/usr/bin/env bun
/**
* Cleanup Hook Entry Point - SessionEnd
@@ -6,15 +5,18 @@
*/
import { cleanupHook } from '../../hooks/cleanup.js';
import { stdin } from 'process';
// Read input from stdin
const input = await Bun.stdin.text();
try {
const parsed = input.trim() ? JSON.parse(input) : undefined;
cleanupHook(parsed);
} catch (error: any) {
console.error(`[claude-mem cleanup-hook error: ${error.message}]`);
console.log('{"continue": true, "suppressOutput": true}');
process.exit(0);
}
let input = '';
stdin.on('data', (chunk) => input += chunk);
stdin.on('end', async () => {
try {
const parsed = input.trim() ? JSON.parse(input) : undefined;
await cleanupHook(parsed);
} catch (error: any) {
console.error(`[claude-mem cleanup-hook error: ${error.message}]`);
console.log('{"continue": true, "suppressOutput": true}');
process.exit(0);
}
});
+8 -5
View File
@@ -1,4 +1,3 @@
#!/usr/bin/env bun
/**
* Context Hook Entry Point - SessionStart
@@ -6,14 +5,18 @@
*/
import { contextHook } from '../../hooks/context.js';
import { stdin } from 'process';
try {
if (process.stdin.isTTY) {
if (stdin.isTTY) {
contextHook();
} else {
const input = await Bun.stdin.text();
const parsed = input.trim() ? JSON.parse(input) : undefined;
contextHook(parsed);
let input = '';
stdin.on('data', (chunk) => input += chunk);
stdin.on('end', () => {
const parsed = input.trim() ? JSON.parse(input) : undefined;
contextHook(parsed);
});
}
} catch (error: any) {
console.error(`[claude-mem context-hook error: ${error.message}]`);
+13 -11
View File
@@ -1,4 +1,3 @@
#!/usr/bin/env bun
/**
* New Hook Entry Point - UserPromptSubmit
@@ -6,15 +5,18 @@
*/
import { newHook } from '../../hooks/new.js';
import { stdin } from 'process';
// Read input from stdin
const input = await Bun.stdin.text();
try {
const parsed = input.trim() ? JSON.parse(input) : undefined;
newHook(parsed);
} catch (error: any) {
console.error(`[claude-mem new-hook error: ${error.message}]`);
console.log('{"continue": true, "suppressOutput": true}');
process.exit(0);
}
let input = '';
stdin.on('data', (chunk) => input += chunk);
stdin.on('end', async () => {
try {
const parsed = input.trim() ? JSON.parse(input) : undefined;
await newHook(parsed);
} catch (error: any) {
console.error(`[claude-mem new-hook error: ${error.message}]`);
console.log('{"continue": true, "suppressOutput": true}');
process.exit(0);
}
});
+13 -11
View File
@@ -1,4 +1,3 @@
#!/usr/bin/env bun
/**
* Save Hook Entry Point - PostToolUse
@@ -6,15 +5,18 @@
*/
import { saveHook } from '../../hooks/save.js';
import { stdin } from 'process';
// Read input from stdin
const input = await Bun.stdin.text();
try {
const parsed = input.trim() ? JSON.parse(input) : undefined;
saveHook(parsed);
} catch (error: any) {
console.error(`[claude-mem save-hook error: ${error.message}]`);
console.log('{"continue": true, "suppressOutput": true}');
process.exit(0);
}
let input = '';
stdin.on('data', (chunk) => input += chunk);
stdin.on('end', async () => {
try {
const parsed = input.trim() ? JSON.parse(input) : undefined;
await saveHook(parsed);
} catch (error: any) {
console.error(`[claude-mem save-hook error: ${error.message}]`);
console.log('{"continue": true, "suppressOutput": true}');
process.exit(0);
}
});
+13 -11
View File
@@ -1,4 +1,3 @@
#!/usr/bin/env bun
/**
* Summary Hook Entry Point - Stop
@@ -6,15 +5,18 @@
*/
import { summaryHook } from '../../hooks/summary.js';
import { stdin } from 'process';
// Read input from stdin
const input = await Bun.stdin.text();
try {
const parsed = input.trim() ? JSON.parse(input) : undefined;
summaryHook(parsed);
} catch (error: any) {
console.error(`[claude-mem summary-hook error: ${error.message}]`);
console.log('{"continue": true, "suppressOutput": true}');
process.exit(0);
}
let input = '';
stdin.on('data', (chunk) => input += chunk);
stdin.on('end', async () => {
try {
const parsed = input.trim() ? JSON.parse(input) : undefined;
await summaryHook(parsed);
} catch (error: any) {
console.error(`[claude-mem summary-hook error: ${error.message}]`);
console.log('{"continue": true, "suppressOutput": true}');
process.exit(0);
}
});
+24 -48
View File
@@ -1,6 +1,4 @@
import { existsSync, unlinkSync } from 'fs';
import { HooksDatabase } from '../services/sqlite/HooksDatabase.js';
import { getWorkerSocketPath } from '../shared/paths.js';
export interface SessionEndInput {
session_id: string;
@@ -12,15 +10,14 @@ export interface SessionEndInput {
/**
* Cleanup Hook - SessionEnd
* Cleans up worker process and marks session as terminated
* Cleans up worker session via HTTP DELETE
*
* This hook runs when a Claude Code session ends. It:
* 1. Finds active SDK session for this Claude session
* 2. Terminates worker process if still running
* 3. Removes stale socket file
* 4. Marks session as failed (since no Stop hook completed it)
* 2. Sends DELETE request to worker service
* 3. Marks session as failed if not already completed
*/
export function cleanupHook(input?: SessionEndInput): void {
export async function cleanupHook(input?: SessionEndInput): Promise<void> {
try {
// Log hook entry point
console.error('[claude-mem cleanup] Hook fired', {
@@ -63,57 +60,36 @@ export function cleanupHook(input?: SessionEndInput): void {
console.error('[claude-mem cleanup] Active SDK session found', {
session_id: session.id,
sdk_session_id: session.sdk_session_id,
project: session.project
project: session.project,
worker_port: session.worker_port
});
// Get worker PID and socket path
const socketPath = getWorkerSocketPath(session.id);
// 1. Delete session via HTTP
if (session.worker_port) {
try {
const response = await fetch(`http://127.0.0.1:${session.worker_port}/sessions/${session.id}`, {
method: 'DELETE',
signal: AbortSignal.timeout(5000)
});
// 1. Kill worker process if it exists
try {
// Try to read PID from socket file existence
if (existsSync(socketPath)) {
console.error('[claude-mem cleanup] Socket file exists, attempting cleanup', { socketPath });
// Remove socket file
try {
unlinkSync(socketPath);
console.error('[claude-mem cleanup] Socket file removed successfully', { socketPath });
} catch (unlinkErr: any) {
console.error('[claude-mem cleanup] Failed to remove socket file', {
error: unlinkErr.message,
socketPath
});
if (response.ok) {
console.error('[claude-mem cleanup] Session deleted successfully via HTTP');
} else {
console.error('[claude-mem cleanup] Failed to delete session:', await response.text());
}
} else {
console.error('[claude-mem cleanup] Socket file does not exist', { socketPath });
} catch (error: any) {
console.error('[claude-mem cleanup] HTTP DELETE error:', error.message);
}
// Note: We don't kill the worker process here because:
// 1. Workers have a 2-hour watchdog timer that will kill them automatically
// 2. Killing by PID is fragile (PID might be reused)
// 3. The worker will exit on its own when it can't reach the socket
// We just clean up the socket file to prevent stale socket issues
} catch (cleanupErr: any) {
console.error('[claude-mem cleanup] Error during cleanup', {
error: cleanupErr.message,
stack: cleanupErr.stack
});
} else {
console.error('[claude-mem cleanup] No worker port, cannot send DELETE request');
}
// 2. Mark session as failed (since Stop hook didn't complete it)
// 2. Mark session as failed in DB (if not already completed)
try {
db.markSessionFailed(session.id);
console.error('[claude-mem cleanup] Session marked as failed', {
session_id: session.id,
reason: 'SessionEnd hook - session terminated without completion'
});
console.error('[claude-mem cleanup] Session marked as failed in database');
} catch (markErr: any) {
console.error('[claude-mem cleanup] Failed to mark session as failed', {
error: markErr.message,
session_id: session.id
});
console.error('[claude-mem cleanup] Failed to mark session as failed:', markErr);
}
db.close();
+35 -1
View File
@@ -24,8 +24,9 @@ export function contextHook(input?: SessionStartInput): void {
try {
const summaries = db.getRecentSummaries(project, 5);
const observations = db.getRecentObservations(project, 20);
if (summaries.length === 0) {
if (summaries.length === 0 && observations.length === 0) {
// Output directly to stdout for injection into context
console.log('# Recent Session Context\n\nNo previous sessions found for this project yet.');
return;
@@ -34,6 +35,39 @@ export function contextHook(input?: SessionStartInput): void {
const output: string[] = [];
output.push('# Recent Session Context');
output.push('');
// Show observations first
if (observations.length > 0) {
output.push(`## Recent Observations (${observations.length})`);
output.push('');
// Group observations by type
const byType: Record<string, Array<{text: string; created_at: string}>> = {};
for (const obs of observations) {
if (!byType[obs.type]) byType[obs.type] = [];
byType[obs.type].push({ text: obs.text, created_at: obs.created_at });
}
// Display each type
const typeOrder = ['feature', 'bugfix', 'refactor', 'discovery', 'decision'];
for (const type of typeOrder) {
if (byType[type] && byType[type].length > 0) {
output.push(`### ${type.charAt(0).toUpperCase() + type.slice(1)}s`);
for (const obs of byType[type]) {
output.push(`- ${obs.text}`);
}
output.push('');
}
}
}
if (summaries.length === 0) {
console.log(output.join('\n'));
return;
}
output.push('## Recent Sessions');
output.push('');
const sessionWord = summaries.length === 1 ? 'session' : 'sessions';
output.push(`Showing last ${summaries.length} ${sessionWord} for **${project}**:`);
output.push('');
+63 -15
View File
@@ -1,4 +1,3 @@
import { spawn } from 'child_process';
import path from 'path';
import { HooksDatabase } from '../services/sqlite/HooksDatabase.js';
import { createHookResponse } from './hook-response.js';
@@ -11,10 +10,32 @@ export interface UserPromptSubmitInput {
}
/**
* New Hook - UserPromptSubmit
* Initializes SDK memory session in background
* Get worker service port from file
*/
export function newHook(input?: UserPromptSubmitInput): void {
async function getWorkerPort(): Promise<number | null> {
const { readFileSync, existsSync } = await import('fs');
const { join } = await import('path');
const { homedir } = await import('os');
const portFile = join(homedir(), '.claude-mem', 'worker.port');
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
*/
export async function newHook(input?: UserPromptSubmitInput): Promise<void> {
if (!input) {
throw new Error('newHook requires input');
}
@@ -24,30 +45,57 @@ export function newHook(input?: UserPromptSubmitInput): void {
const db = new HooksDatabase();
try {
const existing = db.findActiveSDKSession(session_id);
// Check for any existing session (active, failed, or completed)
let existing = db.findActiveSDKSession(session_id);
let sessionDbId: number;
if (existing) {
// Session already active, just continue
sessionDbId = existing.id;
console.log(createHookResponse('UserPromptSubmit', true));
return;
}
const sessionId = db.createSDKSession(session_id, project, prompt);
// Check for inactive sessions we can reuse
const inactive = db.findAnySDKSession(session_id);
const pluginRoot = process.env.CLAUDE_PLUGIN_ROOT;
if (!pluginRoot) {
throw new Error('CLAUDE_PLUGIN_ROOT not set');
if (inactive) {
// Reactivate the existing session
sessionDbId = inactive.id;
db.reactivateSession(sessionDbId, prompt);
console.error(`[new-hook] Reactivated session ${sessionDbId} for Claude session ${session_id}`);
} else {
// Create new session
sessionDbId = db.createSDKSession(session_id, project, prompt);
console.error(`[new-hook] Created new session ${sessionDbId} for Claude session ${session_id}`);
}
const workerPath = path.join(pluginRoot, 'scripts', 'hooks', 'worker.js');
const child = spawn('bun', [workerPath, sessionId.toString()], {
detached: true,
stdio: 'ignore'
// 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;
}
// Initialize session via HTTP
const response = await fetch(`http://127.0.0.1:${port}/sessions/${sessionDbId}/init`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ project, userPrompt: prompt }),
signal: AbortSignal.timeout(5000)
});
child.unref();
if (!response.ok) {
console.error('[new-hook] Failed to init session:', await response.text());
}
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();
}
+26 -26
View File
@@ -1,6 +1,4 @@
import net from 'net';
import { HooksDatabase } from '../services/sqlite/HooksDatabase.js';
import { getWorkerSocketPath } from '../shared/paths.js';
import { createHookResponse } from './hook-response.js';
export interface PostToolUseInput {
@@ -20,9 +18,9 @@ const SKIP_TOOLS = new Set([
/**
* Save Hook - PostToolUse
* Sends tool observations to worker via Unix socket
* Sends tool observations to worker via HTTP POST
*/
export function saveHook(input?: PostToolUseInput): void {
export async function saveHook(input?: PostToolUseInput): Promise<void> {
if (!input) {
throw new Error('saveHook requires input');
}
@@ -43,28 +41,30 @@ export function saveHook(input?: PostToolUseInput): void {
return;
}
const socketPath = getWorkerSocketPath(session.id);
const message = {
type: 'observation',
tool_name,
tool_input: JSON.stringify(tool_input),
tool_output: JSON.stringify(tool_output)
};
const client = net.connect(socketPath, () => {
client.write(JSON.stringify(message) + '\n');
client.end();
});
let responded = false;
const respond = () => {
if (responded) {
return;
}
responded = true;
if (!session.worker_port) {
console.error('[save-hook] No worker port for session', session.id);
console.log(createHookResponse('PostToolUse', true));
};
return;
}
client.on('close', respond);
client.on('error', respond);
try {
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: JSON.stringify(tool_input),
tool_output: JSON.stringify(tool_output)
}),
signal: AbortSignal.timeout(2000)
});
if (!response.ok) {
console.error('[save-hook] Failed to send observation:', await response.text());
}
} catch (error: any) {
console.error('[save-hook] Error:', error.message);
} finally {
console.log(createHookResponse('PostToolUse', true));
}
}
+21 -23
View File
@@ -1,6 +1,4 @@
import net from 'net';
import { HooksDatabase } from '../services/sqlite/HooksDatabase.js';
import { getWorkerSocketPath } from '../shared/paths.js';
import { createHookResponse } from './hook-response.js';
export interface StopInput {
@@ -11,9 +9,9 @@ export interface StopInput {
/**
* Summary Hook - Stop
* Sends FINALIZE message to worker via Unix socket
* Sends FINALIZE message to worker via HTTP POST
*/
export function summaryHook(input?: StopInput): void {
export async function summaryHook(input?: StopInput): Promise<void> {
if (!input) {
throw new Error('summaryHook requires input');
}
@@ -28,25 +26,25 @@ export function summaryHook(input?: StopInput): void {
return;
}
const socketPath = getWorkerSocketPath(session.id);
const message = {
type: 'finalize'
};
const client = net.connect(socketPath, () => {
client.write(JSON.stringify(message) + '\n');
client.end();
});
let responded = false;
const respond = () => {
if (responded) {
return;
}
responded = true;
if (!session.worker_port) {
console.error('[summary-hook] No worker port for session', session.id);
console.log(createHookResponse('Stop', true));
};
return;
}
client.on('close', respond);
client.on('error', respond);
try {
const response = await fetch(`http://127.0.0.1:${session.worker_port}/sessions/${session.id}/finalize`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
signal: AbortSignal.timeout(2000)
});
if (!response.ok) {
console.error('[summary-hook] Failed to finalize:', await response.text());
}
} catch (error: any) {
console.error('[summary-hook] Error:', error.message);
} finally {
console.log(createHookResponse('Stop', true));
}
}
+59 -34
View File
@@ -22,7 +22,7 @@ export interface SDKSession {
* Build initial prompt to initialize the SDK agent
*/
export function buildInitPrompt(project: string, sessionId: string, userPrompt: string): string {
return `You are a memory assistant for the "${project}" project.
return `You are a memory processor for the "${project}" project.
SESSION CONTEXT
---------------
@@ -32,23 +32,36 @@ Date: ${new Date().toISOString().split('T')[0]}
YOUR ROLE
---------
You will observe tool executions during this Claude Code session. Your job is to:
You will PROCESS tool executions during this Claude Code session. Your job is to:
1. Extract meaningful insights (not just raw data)
2. Store atomic observations in SQLite
3. Focus on: key decisions, patterns discovered, problems solved, technical insights
1. ANALYZE each tool response for meaningful content
2. DECIDE whether it contains something worth storing
3. EXTRACT the key insight
4. STORE it as an observation in the XML format below
WHAT TO CAPTURE
----------------
✓ Architecture decisions (e.g., "chose PostgreSQL over MongoDB for ACID guarantees")
For MOST meaningful tool outputs, you should generate an observation. Only skip truly routine operations.
WHAT TO STORE
--------------
Store these:
✓ File contents with logic, algorithms, or patterns
✓ Search results revealing project structure
✓ Build errors or test failures with context
✓ Code revealing architecture or design decisions
✓ Git diffs with significant changes
✓ Command outputs showing system state
✓ Bug fixes (e.g., "fixed race condition in auth middleware by adding mutex")
✓ New features (e.g., "implemented JWT refresh token flow")
✓ Refactorings (e.g., "extracted validation logic into separate service")
✓ Discoveries (e.g., "found that API rate limit is 100 req/min")
✗ NOT routine operations (reading files, listing directories)
✗ NOT work-in-progress (only completed work)
✗ NOT obvious facts (e.g., "TypeScript file has types")
WHAT TO SKIP
------------
Skip these:
✗ Simple status checks (git status with no changes)
✗ Trivial edits (one-line config changes)
✗ Repeated operations
✗ Anything without semantic value
HOW TO STORE OBSERVATIONS
--------------------------
@@ -73,50 +86,62 @@ The SDK worker will parse all <observation> blocks from your response using rege
You can include your reasoning before or after the observation block, or just output the observation by itself.
EXAMPLE
-------
Bad: "Read src/auth.ts file"
Good: "Implemented JWT token refresh flow with 7-day expiry"
Wait for tool observations. Acknowledge this message briefly.`;
Ready to process tool responses.`;
}
/**
* Build prompt to send tool observation to SDK agent
*/
export function buildObservationPrompt(obs: Observation): string {
// Safely parse tool_input and tool_output - they're already JSON strings
let toolInput: any;
let toolOutput: any;
try {
toolInput = typeof obs.tool_input === 'string' ? JSON.parse(obs.tool_input) : obs.tool_input;
} catch {
toolInput = obs.tool_input; // If parse fails, use raw value
}
try {
toolOutput = typeof obs.tool_output === 'string' ? JSON.parse(obs.tool_output) : obs.tool_output;
} catch {
toolOutput = obs.tool_output; // If parse fails, use raw value
}
return `TOOL OBSERVATION
================
Tool: ${obs.tool_name}
Time: ${new Date(obs.created_at_epoch).toISOString()}
Input:
${JSON.stringify(JSON.parse(obs.tool_input), null, 2)}
${JSON.stringify(toolInput, null, 2)}
Output:
${JSON.stringify(JSON.parse(obs.tool_output), null, 2)}
${JSON.stringify(toolOutput, null, 2)}
ANALYSIS TASK
-------------
1. Does this observation contain something worth remembering?
2. If YES: Output the observation in this EXACT XML format:
ANALYZE this tool response and DECIDE: Does it contain something worth storing?
\`\`\`xml
<observation>
<type>feature</type>
<text>Your concise observation here</text>
</observation>
\`\`\`
Most Read, Edit, Grep, Bash, and Write operations contain meaningful content.
Requirements:
- Use one of these types: decision, bugfix, feature, refactor, discovery
- Keep text concise (one sentence preferred)
- No markdown formatting inside <text>
- No additional XML fields
If this contains something worth remembering, output the observation in this EXACT XML format:
3. If NO: Just acknowledge and wait for next observation
\`\`\`xml
<observation>
<type>feature</type>
<text>Your concise observation here</text>
</observation>
\`\`\`
Remember: Quality over quantity. Only store meaningful insights.`;
Requirements:
- Use one of these types: decision, bugfix, feature, refactor, discovery
- Keep text concise (one sentence preferred)
- No markdown formatting inside <text>
- No additional XML fields
If this is truly routine (e.g., empty git status), you can skip it. Otherwise, PROCESS and STORE it.`;
}
/**
+136 -27
View File
@@ -1,4 +1,4 @@
import { Database } from 'bun:sqlite';
import Database from 'better-sqlite3';
import { DATA_DIR, DB_PATH, ensureDir } from '../../shared/paths.js';
/**
@@ -11,12 +11,33 @@ export class HooksDatabase {
constructor() {
ensureDir(DATA_DIR);
this.db = new Database(DB_PATH, { create: true, readwrite: true });
this.db = new Database(DB_PATH);
// Ensure optimized settings
this.db.run('PRAGMA journal_mode = WAL');
this.db.run('PRAGMA synchronous = NORMAL');
this.db.run('PRAGMA foreign_keys = ON');
this.db.pragma('journal_mode = WAL');
this.db.pragma('synchronous = NORMAL');
this.db.pragma('foreign_keys = ON');
// Run migration to add worker_port column if it doesn't exist
this.ensureWorkerPortColumn();
}
/**
* Ensure worker_port column exists (migration)
*/
private ensureWorkerPortColumn(): void {
try {
// 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');
if (!hasWorkerPort) {
this.db.exec('ALTER TABLE sdk_sessions ADD COLUMN worker_port INTEGER');
console.error('[HooksDatabase] Added worker_port column to sdk_sessions table');
}
} catch (error: any) {
console.error('[HooksDatabase] Migration error:', error.message);
}
}
/**
@@ -33,7 +54,7 @@ export class HooksDatabase {
notes: string | null;
created_at: string;
}> {
const query = this.db.query(`
const stmt = this.db.prepare(`
SELECT
request, investigated, learned, completed, next_steps,
files_read, files_edited, notes, created_at
@@ -43,7 +64,26 @@ export class HooksDatabase {
LIMIT ?
`);
return query.all(project, limit) as any[];
return stmt.all(project, limit) as any[];
}
/**
* Get recent observations for a project
*/
getRecentObservations(project: string, limit: number = 20): Array<{
type: string;
text: string;
created_at: string;
}> {
const stmt = this.db.prepare(`
SELECT type, text, created_at
FROM observations
WHERE project = ?
ORDER BY created_at_epoch DESC
LIMIT ?
`);
return stmt.all(project, limit) as any[];
}
/**
@@ -53,15 +93,43 @@ export class HooksDatabase {
id: number;
sdk_session_id: string | null;
project: string;
worker_port: number | null;
} | null {
const query = this.db.query(`
SELECT id, sdk_session_id, project
const stmt = this.db.prepare(`
SELECT id, sdk_session_id, project, worker_port
FROM sdk_sessions
WHERE claude_session_id = ? AND status = 'active'
LIMIT 1
`);
return query.get(claudeSessionId) as any || null;
return stmt.get(claudeSessionId) as any || null;
}
/**
* Find any SDK session for a Claude session (active, failed, or completed)
*/
findAnySDKSession(claudeSessionId: string): { id: number } | null {
const stmt = this.db.prepare(`
SELECT id
FROM sdk_sessions
WHERE claude_session_id = ?
LIMIT 1
`);
return stmt.get(claudeSessionId) as any || null;
}
/**
* Reactivate an existing session
*/
reactivateSession(id: number, userPrompt: string): void {
const stmt = this.db.prepare(`
UPDATE sdk_sessions
SET status = 'active', user_prompt = ?, worker_port = NULL
WHERE id = ?
`);
stmt.run(userPrompt, id);
}
/**
@@ -71,31 +139,55 @@ export class HooksDatabase {
const now = new Date();
const nowEpoch = now.getTime();
const query = this.db.query(`
const stmt = this.db.prepare(`
INSERT INTO sdk_sessions
(claude_session_id, project, user_prompt, started_at, started_at_epoch, status)
VALUES (?, ?, ?, ?, ?, 'active')
`);
query.run(claudeSessionId, project, userPrompt, now.toISOString(), nowEpoch);
// Get the last inserted ID
const lastIdQuery = this.db.query('SELECT last_insert_rowid() as id');
const result = lastIdQuery.get() as { id: number };
return result.id;
const result = stmt.run(claudeSessionId, project, userPrompt, now.toISOString(), nowEpoch);
return result.lastInsertRowid as number;
}
/**
* Update SDK session ID (captured from init message)
*/
updateSDKSessionId(id: number, sdkSessionId: string): void {
const query = this.db.query(`
const stmt = this.db.prepare(`
UPDATE sdk_sessions
SET sdk_session_id = ?
WHERE id = ?
`);
query.run(sdkSessionId, id);
stmt.run(sdkSessionId, id);
}
/**
* Set worker port for a session
*/
setWorkerPort(id: number, port: number): void {
const stmt = this.db.prepare(`
UPDATE sdk_sessions
SET worker_port = ?
WHERE id = ?
`);
stmt.run(port, id);
}
/**
* Get worker port for a session
*/
getWorkerPort(id: number): number | null {
const stmt = this.db.prepare(`
SELECT worker_port
FROM sdk_sessions
WHERE id = ?
LIMIT 1
`);
const result = stmt.get(id) as { worker_port: number | null } | undefined;
return result?.worker_port || null;
}
/**
@@ -110,13 +202,13 @@ export class HooksDatabase {
const now = new Date();
const nowEpoch = now.getTime();
const query = this.db.query(`
const stmt = this.db.prepare(`
INSERT INTO observations
(sdk_session_id, project, text, type, created_at, created_at_epoch)
VALUES (?, ?, ?, ?, ?, ?)
`);
query.run(sdkSessionId, project, text, type, now.toISOString(), nowEpoch);
stmt.run(sdkSessionId, project, text, type, now.toISOString(), nowEpoch);
}
/**
@@ -139,14 +231,14 @@ export class HooksDatabase {
const now = new Date();
const nowEpoch = now.getTime();
const query = this.db.query(`
const stmt = this.db.prepare(`
INSERT INTO session_summaries
(sdk_session_id, project, request, investigated, learned, completed,
next_steps, files_read, files_edited, notes, created_at, created_at_epoch)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`);
query.run(
stmt.run(
sdkSessionId,
project,
summary.request || null,
@@ -169,13 +261,13 @@ export class HooksDatabase {
const now = new Date();
const nowEpoch = now.getTime();
const query = this.db.query(`
const stmt = this.db.prepare(`
UPDATE sdk_sessions
SET status = 'completed', completed_at = ?, completed_at_epoch = ?
WHERE id = ?
`);
query.run(now.toISOString(), nowEpoch, id);
stmt.run(now.toISOString(), nowEpoch, id);
}
/**
@@ -185,13 +277,30 @@ export class HooksDatabase {
const now = new Date();
const nowEpoch = now.getTime();
const query = this.db.query(`
const stmt = this.db.prepare(`
UPDATE sdk_sessions
SET status = 'failed', completed_at = ?, completed_at_epoch = ?
WHERE id = ?
`);
query.run(now.toISOString(), nowEpoch, id);
stmt.run(now.toISOString(), nowEpoch, id);
}
/**
* Clean up orphaned active sessions (called on worker startup)
*/
cleanupOrphanedSessions(): number {
const now = new Date();
const nowEpoch = now.getTime();
const stmt = this.db.prepare(`
UPDATE sdk_sessions
SET status = 'failed', completed_at = ?, completed_at_epoch = ?
WHERE status = 'active'
`);
const result = stmt.run(now.toISOString(), nowEpoch);
return result.changes;
}
/**
+487
View File
@@ -0,0 +1,487 @@
/**
* Worker Service - Long-running HTTP service managed by PM2
* Replaces detached Bun worker processes with single persistent Node service
*/
import express, { Request, Response } from 'express';
import { query } from '@anthropic-ai/claude-agent-sdk';
import type { SDKUserMessage, SDKSystemMessage } from '@anthropic-ai/claude-agent-sdk';
import { HooksDatabase } from './sqlite/HooksDatabase.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';
const MODEL = 'claude-sonnet-4-5';
const DISALLOWED_TOOLS = ['Glob', 'Grep', 'ListMcpResourcesTool', 'WebSearch'];
interface ObservationMessage {
type: 'observation';
tool_name: string;
tool_input: string;
tool_output: string;
}
interface FinalizeMessage {
type: 'finalize';
}
type WorkerMessage = ObservationMessage | FinalizeMessage;
/**
* Active session state
*/
interface ActiveSession {
sessionDbId: number;
sdkSessionId: string | null;
project: string;
userPrompt: string;
isFinalized: boolean;
pendingMessages: WorkerMessage[];
abortController: AbortController;
generatorPromise: Promise<void> | null;
}
class WorkerService {
private app: express.Application;
private port: number | null = null;
private sessions: Map<number, ActiveSession> = new Map();
constructor() {
this.app = express();
this.app.use(express.json({ limit: '50mb' }));
// Health check
this.app.get('/health', this.handleHealth.bind(this));
// Session endpoints
this.app.post('/sessions/:sessionDbId/init', this.handleInit.bind(this));
this.app.post('/sessions/:sessionDbId/observations', this.handleObservation.bind(this));
this.app.post('/sessions/:sessionDbId/finalize', this.handleFinalize.bind(this));
this.app.get('/sessions/:sessionDbId/status', this.handleStatus.bind(this));
this.app.delete('/sessions/:sessionDbId', this.handleDelete.bind(this));
}
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;
// Clean up orphaned sessions from previous worker instances
const db = new HooksDatabase();
const cleanedCount = db.cleanupOrphanedSessions();
db.close();
if (cleanedCount > 0) {
console.error(`[WorkerService] Cleaned up ${cleanedCount} orphaned sessions`);
}
return new Promise((resolve, reject) => {
this.app.listen(port, '127.0.0.1', () => {
console.error(`[WorkerService] Started on http://127.0.0.1:${port}`);
console.error(`[WorkerService] PID: ${process.pid}`);
console.error(`[WorkerService] Active sessions: ${this.sessions.size}`);
// Write port to file for hooks to discover
const { writeFileSync } = require('fs');
const { join } = require('path');
const { homedir } = require('os');
const portFile = join(homedir(), '.claude-mem', 'worker.port');
writeFileSync(portFile, port.toString(), 'utf8');
resolve();
}).on('error', reject);
});
}
/**
* GET /health
*/
private handleHealth(req: Request, res: Response): void {
res.json({
status: 'ok',
port: this.port,
pid: process.pid,
activeSessions: this.sessions.size,
uptime: process.uptime(),
memory: process.memoryUsage()
});
}
/**
* POST /sessions/:sessionDbId/init
* Body: { project, userPrompt }
*/
private async handleInit(req: Request, res: Response): Promise<void> {
const sessionDbId = parseInt(req.params.sessionDbId, 10);
const { project, userPrompt } = req.body;
console.error(`[WorkerService] Initializing session ${sessionDbId}`, { project });
if (this.sessions.has(sessionDbId)) {
res.status(409).json({ error: 'Session already exists' });
return;
}
// Create session state
const session: ActiveSession = {
sessionDbId,
sdkSessionId: null,
project,
userPrompt,
isFinalized: false,
pendingMessages: [],
abortController: new AbortController(),
generatorPromise: null
};
this.sessions.set(sessionDbId, session);
// Update port in database
const db = new HooksDatabase();
db.setWorkerPort(sessionDbId, this.port!);
db.close();
// Start SDK agent in background
session.generatorPromise = this.runSDKAgent(session).catch(err => {
console.error(`[WorkerService] SDK agent error for session ${sessionDbId}:`, err);
const db = new HooksDatabase();
db.markSessionFailed(sessionDbId);
db.close();
this.sessions.delete(sessionDbId);
});
res.json({
status: 'initialized',
sessionDbId,
port: this.port
});
}
/**
* POST /sessions/:sessionDbId/observations
* Body: { tool_name, tool_input, tool_output }
*/
private handleObservation(req: Request, res: Response): void {
const sessionDbId = parseInt(req.params.sessionDbId, 10);
const { tool_name, tool_input, tool_output } = req.body;
const session = this.sessions.get(sessionDbId);
if (!session) {
res.status(404).json({ error: 'Session not found' });
return;
}
if (session.isFinalized) {
res.status(400).json({ error: 'Session already finalized' });
return;
}
console.error(`[WorkerService] Queueing observation for session ${sessionDbId}:`, tool_name);
session.pendingMessages.push({
type: 'observation',
tool_name,
tool_input,
tool_output
});
res.json({ status: 'queued', queueLength: session.pendingMessages.length });
}
/**
* POST /sessions/:sessionDbId/finalize
*/
private handleFinalize(req: Request, res: Response): void {
const sessionDbId = parseInt(req.params.sessionDbId, 10);
const session = this.sessions.get(sessionDbId);
if (!session) {
res.status(404).json({ error: 'Session not found' });
return;
}
if (session.isFinalized) {
res.status(400).json({ error: 'Session already finalized' });
return;
}
console.error(`[WorkerService] Finalizing session ${sessionDbId}`);
session.pendingMessages.push({ type: 'finalize' });
res.json({ status: 'finalizing' });
}
/**
* GET /sessions/:sessionDbId/status
*/
private handleStatus(req: Request, res: Response): void {
const sessionDbId = parseInt(req.params.sessionDbId, 10);
const session = this.sessions.get(sessionDbId);
if (!session) {
res.status(404).json({ error: 'Session not found' });
return;
}
res.json({
sessionDbId,
sdkSessionId: session.sdkSessionId,
project: session.project,
isFinalized: session.isFinalized,
pendingMessages: session.pendingMessages.length
});
}
/**
* DELETE /sessions/:sessionDbId
*/
private async handleDelete(req: Request, res: Response): Promise<void> {
const sessionDbId = parseInt(req.params.sessionDbId, 10);
const session = this.sessions.get(sessionDbId);
if (!session) {
res.status(404).json({ error: 'Session not found' });
return;
}
console.error(`[WorkerService] Deleting session ${sessionDbId}`);
// Abort SDK agent
session.abortController.abort();
// Wait for generator to finish (with timeout)
if (session.generatorPromise) {
await Promise.race([
session.generatorPromise,
new Promise(resolve => setTimeout(resolve, 5000))
]);
}
// Mark as failed if not completed
if (!session.isFinalized) {
const db = new HooksDatabase();
db.markSessionFailed(sessionDbId);
db.close();
}
this.sessions.delete(sessionDbId);
res.json({ status: 'deleted' });
}
/**
* Run SDK agent for a session
*/
private async runSDKAgent(session: ActiveSession): Promise<void> {
console.error(`[WorkerService] Starting SDK agent for session ${session.sessionDbId}`);
const claudePath = process.env.CLAUDE_CODE_PATH || '/Users/alexnewman/.nvm/versions/node/v24.5.0/bin/claude';
try {
const queryResult = query({
prompt: this.createMessageGenerator(session),
options: {
model: MODEL,
disallowedTools: DISALLOWED_TOOLS,
abortController: session.abortController,
pathToClaudeCodeExecutable: claudePath
}
});
for await (const message of queryResult) {
// Handle system init message
if (message.type === 'system' && message.subtype === 'init') {
const systemMsg = message as SDKSystemMessage;
if (systemMsg.session_id) {
console.error(`[WorkerService] SDK session initialized:`, systemMsg.session_id);
session.sdkSessionId = systemMsg.session_id;
// Update in database
const db = new HooksDatabase();
db.updateSDKSessionId(session.sessionDbId, systemMsg.session_id);
db.close();
}
}
// Handle assistant messages
else if (message.type === 'assistant') {
const content = message.message.content;
const textContent = Array.isArray(content)
? content.filter((c: any) => c.type === 'text').map((c: any) => c.text).join('\n')
: typeof content === 'string' ? content : '';
console.error(`[WorkerService] SDK response (${textContent.length} chars)`);
// Parse and store
this.handleAgentMessage(session, textContent);
}
}
// Mark completed
console.error(`[WorkerService] SDK agent completed for session ${session.sessionDbId}`);
const db = new HooksDatabase();
db.markSessionCompleted(session.sessionDbId);
db.close();
this.sessions.delete(session.sessionDbId);
} catch (error: any) {
if (error.name === 'AbortError') {
console.error(`[WorkerService] SDK agent aborted for session ${session.sessionDbId}`);
} else {
console.error(`[WorkerService] SDK agent error for session ${session.sessionDbId}:`, error);
}
throw error;
}
}
/**
* Create async message generator for SDK streaming
*/
private async* createMessageGenerator(session: ActiveSession): AsyncIterable<SDKUserMessage> {
const claudeSessionId = `session-${session.sessionDbId}`;
const initPrompt = buildInitPrompt(session.project, claudeSessionId, session.userPrompt);
console.error(`[WorkerService] Yielding init prompt (${initPrompt.length} chars)`);
yield {
type: 'user',
session_id: session.sdkSessionId || claudeSessionId,
parent_tool_use_id: null,
message: {
role: 'user',
content: initPrompt
}
};
// Process messages as they arrive
while (!session.isFinalized) {
if (session.pendingMessages.length === 0) {
await new Promise(resolve => setTimeout(resolve, 100));
continue;
}
while (session.pendingMessages.length > 0) {
const message = session.pendingMessages.shift()!;
if (message.type === 'finalize') {
console.error(`[WorkerService] Processing FINALIZE for session ${session.sessionDbId}`);
session.isFinalized = true;
const db = new HooksDatabase();
const dbSession = db.db.prepare(`
SELECT id, sdk_session_id, project, user_prompt
FROM sdk_sessions
WHERE id = ?
`).get(session.sessionDbId) as SDKSession | undefined;
db.close();
if (dbSession) {
const finalizePrompt = buildFinalizePrompt(dbSession);
console.error(`[WorkerService] Yielding finalize prompt (${finalizePrompt.length} chars)`);
yield {
type: 'user',
session_id: session.sdkSessionId || claudeSessionId,
parent_tool_use_id: null,
message: {
role: 'user',
content: finalizePrompt
}
};
}
break;
}
if (message.type === 'observation') {
const observationPrompt = buildObservationPrompt({
id: 0,
tool_name: message.tool_name,
tool_input: message.tool_input,
tool_output: message.tool_output,
created_at_epoch: Date.now()
});
console.error(`[WorkerService] Yielding observation: ${message.tool_name}`);
yield {
type: 'user',
session_id: session.sdkSessionId || claudeSessionId,
parent_tool_use_id: null,
message: {
role: 'user',
content: observationPrompt
}
};
}
}
}
}
/**
* Handle agent message - parse and store observations/summaries
*/
private handleAgentMessage(session: ActiveSession, content: string): void {
// Parse observations
const observations = parseObservations(content);
console.error(`[WorkerService] Parsed ${observations.length} observations`);
const db = new HooksDatabase();
for (const obs of observations) {
if (session.sdkSessionId) {
db.storeObservation(session.sdkSessionId, session.project, obs.type, obs.text);
}
}
// Parse summary
const summary = parseSummary(content);
if (summary && session.sdkSessionId) {
console.error(`[WorkerService] Parsed summary for session ${session.sessionDbId}`);
const summaryWithArrays = {
request: summary.request,
investigated: summary.investigated,
learned: summary.learned,
completed: summary.completed,
next_steps: summary.next_steps,
files_read: JSON.stringify(summary.files_read),
files_edited: JSON.stringify(summary.files_edited),
notes: summary.notes
};
db.storeSummary(session.sdkSessionId, session.project, summaryWithArrays);
}
db.close();
}
}
// Main entry point
async function main() {
const service = new WorkerService();
await service.start();
// Graceful shutdown
process.on('SIGINT', () => {
console.error('[WorkerService] Shutting down gracefully...');
process.exit(0);
});
process.on('SIGTERM', () => {
console.error('[WorkerService] Shutting down gracefully...');
process.exit(0);
});
}
// Auto-start when run directly (not when imported)
main().catch(err => {
console.error('[WorkerService] Fatal error:', err);
process.exit(1);
});
export { WorkerService };
-2
View File
@@ -18,7 +18,6 @@ export const ARCHIVES_DIR = join(DATA_DIR, 'archives');
export const LOGS_DIR = join(DATA_DIR, 'logs');
export const TRASH_DIR = join(DATA_DIR, 'trash');
export const BACKUPS_DIR = join(DATA_DIR, 'backups');
export const CHROMA_DIR = join(DATA_DIR, 'chroma');
export const USER_SETTINGS_PATH = join(DATA_DIR, 'settings.json');
export const DB_PATH = join(DATA_DIR, 'claude-mem.db');
@@ -57,7 +56,6 @@ export function ensureAllDataDirs(): void {
ensureDir(LOGS_DIR);
ensureDir(TRASH_DIR);
ensureDir(BACKUPS_DIR);
ensureDir(CHROMA_DIR);
}
/**
+63
View File
@@ -0,0 +1,63 @@
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);
}