refactor: Convert all hooks to HTTP clients (remove all SQL)

Architecture transformation: Hooks → HTTP → Worker → Database

**context-hook.ts** (843 → 104 lines, 88% reduction)
- Remove all database imports and raw SQL queries
- HTTP GET to /api/context/inject
- Returns both formatted (stderr) and unformatted (stdout) context
- Dual output: colored display for users, plain text for model

**user-message-hook.ts** (updated, 113 lines)
- HTTP GET to /api/context/inject with colors=true
- Displays formatted context to users via stderr
- No database dependencies

**save-hook.ts** (418 → 99 lines, 76% reduction)
- Remove all SessionStore database methods
- HTTP POST to /api/sessions/observations
- Worker handles privacy checks and observation creation
- Fire-and-forget pattern with 2s timeout

**summary-hook.ts** (435 → 200 lines, 54% reduction)
- Remove all SessionStore database methods
- Keep local transcript parsing (hook has file access)
- HTTP POST to /api/sessions/summarize
- Worker handles privacy checks and summary generation

**cleanup-hook.ts** (414 → 90 lines, 78% reduction)
- Remove all SessionStore database methods
- HTTP POST to /api/sessions/complete
- Worker handles session completion and DB cleanup
- Non-fatal if worker unavailable

**Benefits:**
- Zero native module dependencies in hooks (Node.js or Bun compatible)
- Hooks can run in any runtime without recompilation
- All database operations centralized in worker service
- Simpler, more maintainable hook code
- Complete separation of concerns: I/O vs business logic
This commit is contained in:
Alex Newman
2025-12-05 19:10:51 -05:00
parent d3aaef926b
commit 8e0b1ee4e1
5 changed files with 128 additions and 968 deletions
+15 -64
View File
@@ -1,15 +1,19 @@
/**
* Summary Hook - Stop
* Consolidated entry point + logic
*
* Pure HTTP client - sends data to worker, worker handles all database operations
* including privacy checks. This allows the hook to run under any runtime
* (Node.js or Bun) since it has no native module dependencies.
*
* Transcript parsing stays in the hook because only the hook has access to
* the transcript file path.
*/
import { stdin } from 'process';
import { readFileSync, existsSync } from 'fs';
import { SessionStore } from '../services/sqlite/SessionStore.js';
import { createHookResponse } from './hook-response.js';
import { logger } from '../utils/logger.js';
import { ensureWorkerRunning, getWorkerPort } from '../shared/worker-utils.js';
import { silentDebug } from '../utils/silent-debug.js';
export interface StopInput {
session_id: string;
@@ -123,7 +127,7 @@ function extractLastAssistantMessage(transcriptPath: string): string {
}
/**
* Summary Hook Main Logic
* Summary Hook Main Logic - Fire-and-forget HTTP client
*/
async function summaryHook(input?: StopInput): Promise<void> {
if (!input) {
@@ -135,77 +139,25 @@ async function summaryHook(input?: StopInput): Promise<void> {
// Ensure worker is running
await ensureWorkerRunning();
const db = new SessionStore();
// Get or create session
const sessionDbId = db.createSDKSession(session_id, '', '');
const promptNumber = db.getPromptCounter(sessionDbId);
// Skip summary if user prompt was entirely private
// This respects the user's intent: if they marked the entire prompt as <private>,
// they don't want ANY memory operations including summaries
const userPrompt = db.getUserPrompt(session_id, promptNumber);
if (!userPrompt || userPrompt.trim() === '') {
silentDebug('[summary-hook] Skipping summary - user prompt was entirely private', {
session_id,
promptNumber
});
db.close();
console.log(createHookResponse('Stop', true));
return;
}
// DIAGNOSTIC: Check session and observations
const sessionInfo = db.db.prepare(`
SELECT id, claude_session_id, sdk_session_id, project
FROM sdk_sessions WHERE id = ?
`).get(sessionDbId) as any;
const obsCount = db.db.prepare(`
SELECT COUNT(*) as count
FROM observations
WHERE sdk_session_id = ?
`).get(sessionInfo?.sdk_session_id) as { count: number };
silentDebug('[summary-hook] Session diagnostics', {
claudeSessionId: session_id,
sessionDbId,
sdkSessionId: sessionInfo?.sdk_session_id,
project: sessionInfo?.project,
promptNumber,
observationCount: obsCount?.count || 0,
transcriptPath: input.transcript_path
});
db.close();
const port = getWorkerPort();
// Extract last user AND assistant messages from transcript
const lastUserMessage = extractLastUserMessage(input.transcript_path || '');
const lastAssistantMessage = extractLastAssistantMessage(input.transcript_path || '');
silentDebug('[summary-hook] Extracted messages', {
hasLastUserMessage: !!lastUserMessage,
hasLastAssistantMessage: !!lastAssistantMessage,
lastAssistantPreview: lastAssistantMessage.substring(0, 200),
lastAssistantLength: lastAssistantMessage.length
});
logger.dataIn('HOOK', 'Stop: Requesting summary', {
sessionId: sessionDbId,
workerPort: port,
promptNumber,
hasLastUserMessage: !!lastUserMessage,
hasLastAssistantMessage: !!lastAssistantMessage
});
try {
const response = await fetch(`http://127.0.0.1:${port}/sessions/${sessionDbId}/summarize`, {
// Send to worker - worker handles privacy check and database operations
const response = await fetch(`http://127.0.0.1:${port}/api/sessions/summarize`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
prompt_number: promptNumber,
claudeSessionId: session_id,
last_user_message: lastUserMessage,
last_assistant_message: lastAssistantMessage
}),
@@ -215,26 +167,25 @@ async function summaryHook(input?: StopInput): Promise<void> {
if (!response.ok) {
const errorText = await response.text();
logger.failure('HOOK', 'Failed to generate summary', {
sessionId: sessionDbId,
status: response.status
}, errorText);
throw new Error(`Failed to request summary from worker: ${response.status} ${errorText}`);
}
logger.debug('HOOK', 'Summary request sent successfully', { sessionId: sessionDbId });
logger.debug('HOOK', 'Summary request sent successfully');
} catch (error: any) {
// Only show restart message for connection errors, not HTTP errors
if (error.cause?.code === 'ECONNREFUSED' || error.name === 'TimeoutError' || error.message.includes('fetch failed')) {
throw new Error("There's a problem with the worker. If you just updated, type `pm2 restart claude-mem-worker` in your terminal to continue");
}
// Re-throw HTTP errors and other errors as-is
throw error;
} finally {
await fetch(`http://127.0.0.1:${port}/api/processing`, {
// Notify worker to stop spinner (fire-and-forget)
fetch(`http://127.0.0.1:${port}/api/processing`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ isProcessing: false })
});
}).catch(() => {});
}
console.log(createHookResponse('Stop', true));