Files
claude-mem/src/hooks/new-hook.ts
T
Alex Newman a2f7a4dc5a Refactor new-hook to initialize sessions via HTTP and improve privacy handling
- Removed direct database operations in new-hook.ts and replaced them with an HTTP call to initialize sessions.
- Added error handling for HTTP requests and improved logging for session initialization.
- Updated SessionRoutes to handle new session initialization and privacy checks.
- Enhanced privacy tag stripping logic to prevent saving fully private prompts.
- Improved overall error handling and debugging messages throughout the session management process.
2025-12-09 13:43:11 -05:00

162 lines
5.6 KiB
TypeScript

/**
* New Hook - UserPromptSubmit
*
* DUAL PURPOSE HOOK: Handles BOTH session initialization AND continuation
* ==========================================================================
*
* CRITICAL ARCHITECTURE FACTS (NEVER FORGET):
*
* 1. SESSION ID THREADING - The Single Source of Truth
* - Claude Code assigns ONE session_id per conversation
* - ALL hooks in that conversation receive the SAME session_id
* - We ALWAYS use this session_id - NEVER generate our own
* - This is how NEW hook, SAVE hook, and SUMMARY hook stay connected
*
* 2. NO EXISTENCE CHECKS NEEDED
* - createSDKSession is idempotent (INSERT OR IGNORE)
* - Prompt #1: Creates new database row, returns new ID
* - Prompt #2+: Row exists, returns existing ID
* - We NEVER need to check "does session exist?" - just use the session_id
*
* 3. CONTINUATION LOGIC LOCATION
* - This hook does NOT contain continuation prompt logic
* - That lives in SDKAgent.ts (lines 125-127)
* - SDKAgent checks promptNumber to choose init vs continuation prompt
* - BOTH prompts receive the SAME session_id from this hook
*
* 4. UNIFIED WITH SAVE HOOK
* - SAVE hook uses: db.createSDKSession(session_id, '', '')
* - NEW hook uses: db.createSDKSession(session_id, project, prompt)
* - Both use session_id from hook context - this keeps everything connected
*
* This is KISS in action: Use the session_id we're given, trust idempotent
* database operations, and let SDKAgent handle init vs continuation logic.
*/
import path from 'path';
import { stdin } from 'process';
import { createHookResponse } from './hook-response.js';
import { ensureWorkerRunning, getWorkerPort } from '../shared/worker-utils.js';
import { silentDebug } from '../utils/silent-debug.js';
export interface UserPromptSubmitInput {
session_id: string;
cwd: string;
prompt: string;
[key: string]: any;
}
/**
* New Hook Main Logic
*/
async function newHook(input?: UserPromptSubmitInput): Promise<void> {
// Ensure worker is running before any other logic
await ensureWorkerRunning();
if (!input) {
throw new Error('newHook requires input');
}
const { session_id, cwd, prompt } = input;
// Debug: Log what we received
silentDebug('[new-hook] Input received', {
session_id,
cwd,
cwd_type: typeof cwd,
cwd_length: cwd?.length,
has_cwd: !!cwd,
prompt_length: prompt?.length
});
const project = path.basename(cwd);
silentDebug('[new-hook] Project extracted', {
project,
project_type: typeof project,
project_length: project?.length,
is_empty: project === '',
cwd_was: cwd
});
const port = getWorkerPort();
// Initialize session via HTTP - handles DB operations and privacy checks
let sessionDbId: number;
let promptNumber: number;
try {
const initResponse = await fetch(`http://127.0.0.1:${port}/api/sessions/init`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
claudeSessionId: session_id,
project,
prompt
}),
signal: AbortSignal.timeout(5000)
});
if (!initResponse.ok) {
const errorText = await initResponse.text();
throw new Error(`Failed to initialize session: ${initResponse.status} ${errorText}`);
}
const initResult = await initResponse.json();
sessionDbId = initResult.sessionDbId;
promptNumber = initResult.promptNumber;
// Check if prompt was entirely private (worker performs privacy check)
if (initResult.skipped && initResult.reason === 'private') {
console.error(`[new-hook] Session ${sessionDbId}, prompt #${promptNumber} (fully private - skipped)`);
console.log(createHookResponse('UserPromptSubmit', true));
return;
}
console.error(`[new-hook] Session ${sessionDbId}, prompt #${promptNumber}`);
} 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");
}
throw error;
}
// Strip leading slash from commands for memory agent
// /review 101 → review 101 (more semantic for observations)
const cleanedPrompt = prompt.startsWith('/') ? prompt.substring(1) : prompt;
try {
// 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: cleanedPrompt, promptNumber }),
signal: AbortSignal.timeout(5000)
});
if (!response.ok) {
const errorText = await response.text();
throw new Error(`Failed to initialize session: ${response.status} ${errorText}`);
}
} 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;
}
console.log(createHookResponse('UserPromptSubmit', true));
}
// Entry Point
let input = '';
stdin.on('data', (chunk) => input += chunk);
stdin.on('end', async () => {
const parsed = input ? JSON.parse(input) : undefined;
await newHook(parsed);
});