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