8d166b47c1
This reverts commit bfc7de377a.
193 lines
8.3 KiB
TypeScript
193 lines
8.3 KiB
TypeScript
/**
|
|
* Session Init Handler - UserPromptSubmit
|
|
*
|
|
* Extracted from new-hook.ts - initializes session and starts SDK agent.
|
|
*/
|
|
|
|
import type { EventHandler, NormalizedHookInput, HookResult } from '../types.js';
|
|
import { ensureWorkerRunning, workerHttpRequest } from '../../shared/worker-utils.js';
|
|
import { getProjectContext } from '../../utils/project-name.js';
|
|
import { logger } from '../../utils/logger.js';
|
|
import { HOOK_EXIT_CODES } from '../../shared/hook-constants.js';
|
|
import { isProjectExcluded } from '../../utils/project-filter.js';
|
|
import { SettingsDefaultsManager } from '../../shared/SettingsDefaultsManager.js';
|
|
import { USER_SETTINGS_PATH } from '../../shared/paths.js';
|
|
import { normalizePlatformSource } from '../../shared/platform-source.js';
|
|
|
|
async function fetchSemanticContext(
|
|
prompt: string,
|
|
project: string,
|
|
limit: string,
|
|
sessionDbId: number
|
|
): Promise<string> {
|
|
const semanticRes = await workerHttpRequest('/api/context/semantic', {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ q: prompt, project, limit })
|
|
});
|
|
if (semanticRes.ok) {
|
|
const data = await semanticRes.json() as { context: string; count: number };
|
|
if (data.context) {
|
|
logger.debug('HOOK', `Semantic injection: ${data.count} observations for prompt`, { sessionId: sessionDbId, count: data.count });
|
|
return data.context;
|
|
}
|
|
}
|
|
return '';
|
|
}
|
|
|
|
export const sessionInitHandler: EventHandler = {
|
|
async execute(input: NormalizedHookInput): Promise<HookResult> {
|
|
// Ensure worker is running before any other logic
|
|
const workerReady = await ensureWorkerRunning();
|
|
if (!workerReady) {
|
|
// Worker not available - skip session init gracefully
|
|
return { continue: true, suppressOutput: true, exitCode: HOOK_EXIT_CODES.SUCCESS };
|
|
}
|
|
|
|
const { sessionId, prompt: rawPrompt } = input;
|
|
const cwd = input.cwd ?? process.cwd(); // Match context.ts fallback (#1918)
|
|
|
|
// Guard: Codex CLI and other platforms may not provide a session_id (#744)
|
|
if (!sessionId) {
|
|
logger.warn('HOOK', 'session-init: No sessionId provided, skipping (Codex CLI or unknown platform)');
|
|
return { continue: true, suppressOutput: true, exitCode: HOOK_EXIT_CODES.SUCCESS };
|
|
}
|
|
|
|
// Check if project is excluded from tracking
|
|
const settings = SettingsDefaultsManager.loadFromFile(USER_SETTINGS_PATH);
|
|
if (cwd && isProjectExcluded(cwd, settings.CLAUDE_MEM_EXCLUDED_PROJECTS)) {
|
|
logger.info('HOOK', 'Project excluded from tracking', { cwd });
|
|
return { continue: true, suppressOutput: true };
|
|
}
|
|
|
|
// Handle image-only prompts (where text prompt is empty/undefined)
|
|
// Use placeholder so sessions still get created and tracked for memory
|
|
const prompt = (!rawPrompt || !rawPrompt.trim()) ? '[media prompt]' : rawPrompt;
|
|
|
|
const project = getProjectContext(cwd).primary;
|
|
const platformSource = normalizePlatformSource(input.platform);
|
|
|
|
logger.debug('HOOK', 'session-init: Calling /api/sessions/init', { contentSessionId: sessionId, project });
|
|
|
|
// Initialize session via HTTP - handles DB operations and privacy checks
|
|
let initResponse: Response;
|
|
try {
|
|
initResponse = await workerHttpRequest('/api/sessions/init', {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({
|
|
contentSessionId: sessionId,
|
|
project,
|
|
prompt,
|
|
platformSource
|
|
})
|
|
});
|
|
} catch (err) {
|
|
// Worker unreachable — on Linux/WSL, hook may fire before worker is healthy (#1907)
|
|
logger.warn('HOOK', `session-init: worker request failed: ${err instanceof Error ? err.message : err}`);
|
|
return { continue: true, suppressOutput: true, exitCode: HOOK_EXIT_CODES.SUCCESS };
|
|
}
|
|
|
|
if (!initResponse.ok) {
|
|
// Log but don't throw - a worker 500 should not block the user's prompt
|
|
logger.failure('HOOK', `Session initialization failed: ${initResponse.status}`, { contentSessionId: sessionId, project });
|
|
return { continue: true, suppressOutput: true, exitCode: HOOK_EXIT_CODES.SUCCESS };
|
|
}
|
|
|
|
const initResult = await initResponse.json() as {
|
|
sessionDbId: number;
|
|
promptNumber: number;
|
|
skipped?: boolean;
|
|
reason?: string;
|
|
contextInjected?: boolean;
|
|
};
|
|
const sessionDbId = initResult.sessionDbId;
|
|
const promptNumber = initResult.promptNumber;
|
|
|
|
logger.debug('HOOK', 'session-init: Received from /api/sessions/init', { sessionDbId, promptNumber, skipped: initResult.skipped, contextInjected: initResult.contextInjected });
|
|
|
|
// Debug-level alignment log for detailed tracing
|
|
logger.debug('HOOK', `[ALIGNMENT] Hook Entry | contentSessionId=${sessionId} | prompt#=${promptNumber} | sessionDbId=${sessionDbId}`);
|
|
|
|
// Check if prompt was entirely private (worker performs privacy check)
|
|
if (initResult.skipped && initResult.reason === 'private') {
|
|
logger.info('HOOK', `INIT_COMPLETE | sessionDbId=${sessionDbId} | promptNumber=${promptNumber} | skipped=true | reason=private`, {
|
|
sessionId: sessionDbId
|
|
});
|
|
return { continue: true, suppressOutput: true };
|
|
}
|
|
|
|
// Skip SDK agent re-initialization if context was already injected for this session (#1079)
|
|
// The prompt was already saved to the database by /api/sessions/init above —
|
|
// no need to re-start the SDK agent on every turn.
|
|
// Note: we do NOT return here — semantic injection below must run on every prompt.
|
|
const skipAgentInit = Boolean(initResult.contextInjected);
|
|
if (skipAgentInit) {
|
|
logger.info('HOOK', `INIT_COMPLETE | sessionDbId=${sessionDbId} | promptNumber=${promptNumber} | skipped_agent_init=true | reason=context_already_injected`, {
|
|
sessionId: sessionDbId
|
|
});
|
|
}
|
|
|
|
// Only initialize SDK agent for Claude Code (not Cursor)
|
|
// Cursor doesn't use the SDK agent - it only needs session/observation storage
|
|
if (!skipAgentInit && input.platform !== 'cursor' && sessionDbId) {
|
|
// Strip leading slash from commands for memory agent
|
|
// /review 101 -> review 101 (more semantic for observations)
|
|
const cleanedPrompt = prompt.startsWith('/') ? prompt.substring(1) : prompt;
|
|
|
|
logger.debug('HOOK', 'session-init: Calling /sessions/{sessionDbId}/init', { sessionDbId, promptNumber });
|
|
|
|
// Initialize SDK agent session via HTTP (starts the agent!)
|
|
const response = await workerHttpRequest(`/sessions/${sessionDbId}/init`, {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ userPrompt: cleanedPrompt, promptNumber })
|
|
});
|
|
|
|
if (!response.ok) {
|
|
// Log but don't throw - SDK agent failure should not block the user's prompt
|
|
logger.failure('HOOK', `SDK agent start failed: ${response.status}`, { sessionDbId, promptNumber });
|
|
}
|
|
} else if (!skipAgentInit && input.platform === 'cursor') {
|
|
logger.debug('HOOK', 'session-init: Skipping SDK agent init for Cursor platform', { sessionDbId, promptNumber });
|
|
}
|
|
|
|
// Semantic context injection: query Chroma for relevant past observations
|
|
// and inject as additionalContext so Claude receives relevant memory each prompt.
|
|
// Controlled by CLAUDE_MEM_SEMANTIC_INJECT setting (default: true).
|
|
const semanticInject =
|
|
String(settings.CLAUDE_MEM_SEMANTIC_INJECT).toLowerCase() === 'true';
|
|
let additionalContext = '';
|
|
|
|
if (semanticInject && prompt && prompt.length >= 20 && prompt !== '[media prompt]') {
|
|
const limit = settings.CLAUDE_MEM_SEMANTIC_INJECT_LIMIT || '5';
|
|
try {
|
|
additionalContext = await fetchSemanticContext(prompt, project, limit, sessionDbId);
|
|
} catch (e) {
|
|
// Graceful degradation — semantic injection is optional
|
|
logger.debug('HOOK', 'Semantic injection unavailable', {
|
|
error: e instanceof Error ? e.message : String(e)
|
|
});
|
|
}
|
|
}
|
|
|
|
logger.info('HOOK', `INIT_COMPLETE | sessionDbId=${sessionDbId} | promptNumber=${promptNumber} | project=${project}`, {
|
|
sessionId: sessionDbId
|
|
});
|
|
|
|
// Return with semantic context if available
|
|
if (additionalContext) {
|
|
return {
|
|
continue: true,
|
|
suppressOutput: true,
|
|
hookSpecificOutput: {
|
|
hookEventName: 'UserPromptSubmit',
|
|
additionalContext
|
|
}
|
|
};
|
|
}
|
|
|
|
return { continue: true, suppressOutput: true };
|
|
}
|
|
};
|