diff --git a/src/cli/handlers/session-init.ts b/src/cli/handlers/session-init.ts index eb687c38..1b704406 100644 --- a/src/cli/handlers/session-init.ts +++ b/src/cli/handlers/session-init.ts @@ -87,17 +87,18 @@ export const sessionInitHandler: EventHandler = { // 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 - if (initResult.contextInjected) { + // 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 }); - return { continue: true, suppressOutput: true }; } // Only initialize SDK agent for Claude Code (not Cursor) // Cursor doesn't use the SDK agent - it only needs session/observation storage - if (input.platform !== 'cursor' && sessionDbId) { + 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; @@ -115,14 +116,58 @@ export const sessionInitHandler: EventHandler = { // 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 (input.platform === 'cursor') { + } 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]') { + try { + const limit = settings.CLAUDE_MEM_SEMANTIC_INJECT_LIMIT || '5'; + 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) { + additionalContext = data.context; + logger.debug('HOOK', `Semantic injection: ${data.count} observations for prompt`, { + sessionId: sessionDbId, count: data.count + }); + } + } + } 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 }; } }; diff --git a/src/services/worker/http/routes/SearchRoutes.ts b/src/services/worker/http/routes/SearchRoutes.ts index 3cea0e24..650efbe9 100644 --- a/src/services/worker/http/routes/SearchRoutes.ts +++ b/src/services/worker/http/routes/SearchRoutes.ts @@ -38,6 +38,7 @@ export class SearchRoutes extends BaseRouteHandler { app.get('/api/context/timeline', this.handleGetContextTimeline.bind(this)); app.get('/api/context/preview', this.handleContextPreview.bind(this)); app.get('/api/context/inject', this.handleContextInject.bind(this)); + app.post('/api/context/semantic', this.handleSemanticContext.bind(this)); // Timeline and help endpoints app.get('/api/timeline/by-query', this.handleGetTimelineByQuery.bind(this)); @@ -246,6 +247,54 @@ export class SearchRoutes extends BaseRouteHandler { res.send(contextText); }); + /** + * Semantic context search for per-prompt injection + * GET /api/context/semantic?q=&project=&limit=5 + * + * Queries Chroma for observations semantically similar to the user's prompt. + * Returns compact markdown for injection as additionalContext. + */ + private handleSemanticContext = this.wrapHandler(async (req: Request, res: Response): Promise => { + const query = (req.body?.q || req.query.q) as string; + const project = (req.body?.project || req.query.project) as string; + const limit = parseInt(String(req.body?.limit || req.query.limit || '5'), 10); + + if (!query || query.length < 20) { + res.json({ context: '', count: 0 }); + return; + } + + try { + const result = await this.searchManager.search({ + query, + type: 'observations', + project, + limit: String(limit), + format: 'json' + }); + + const observations = (result as any)?.observations || []; + if (!observations.length) { + res.json({ context: '', count: 0 }); + return; + } + + // Format as compact markdown for context injection + const lines: string[] = ['## Relevant Past Work (semantic match)\n']; + for (const obs of observations.slice(0, limit)) { + const date = obs.created_at?.slice(0, 10) || ''; + lines.push(`### ${obs.title || 'Observation'} (${date})`); + if (obs.narrative) lines.push(obs.narrative); + lines.push(''); + } + + res.json({ context: lines.join('\n'), count: observations.length }); + } catch (error) { + logger.error('SEARCH', 'Semantic context query failed', {}, error as Error); + res.json({ context: '', count: 0 }); + } + }); + /** * Get timeline by query (search first, then get timeline around best match) * GET /api/timeline/by-query?query=...&mode=auto&depth_before=10&depth_after=10 diff --git a/src/shared/SettingsDefaultsManager.ts b/src/shared/SettingsDefaultsManager.ts index 923520ca..faa4d2ad 100644 --- a/src/shared/SettingsDefaultsManager.ts +++ b/src/shared/SettingsDefaultsManager.ts @@ -54,6 +54,9 @@ export interface SettingsDefaults { // Exclusion Settings CLAUDE_MEM_EXCLUDED_PROJECTS: string; // Comma-separated glob patterns for excluded project paths CLAUDE_MEM_FOLDER_MD_EXCLUDE: string; // JSON array of folder paths to exclude from CLAUDE.md generation + // Semantic Context Injection (per-prompt via Chroma) + CLAUDE_MEM_SEMANTIC_INJECT: string; // 'true' | 'false' - inject relevant observations on each prompt + CLAUDE_MEM_SEMANTIC_INJECT_LIMIT: string; // Max observations to inject per prompt // Chroma Vector Database Configuration CLAUDE_MEM_CHROMA_ENABLED: string; // 'true' | 'false' - set to 'false' for SQLite-only mode CLAUDE_MEM_CHROMA_MODE: string; // 'local' | 'remote' @@ -113,6 +116,9 @@ export class SettingsDefaultsManager { // Exclusion Settings CLAUDE_MEM_EXCLUDED_PROJECTS: '', // Comma-separated glob patterns for excluded project paths CLAUDE_MEM_FOLDER_MD_EXCLUDE: '[]', // JSON array of folder paths to exclude from CLAUDE.md generation + // Semantic Context Injection (per-prompt via Chroma vector search) + CLAUDE_MEM_SEMANTIC_INJECT: 'true', // Inject relevant past observations on every UserPromptSubmit + CLAUDE_MEM_SEMANTIC_INJECT_LIMIT: '5', // Top-N most relevant observations to inject per prompt // Chroma Vector Database Configuration CLAUDE_MEM_CHROMA_ENABLED: 'true', // Set to 'false' to disable Chroma and use SQLite-only search CLAUDE_MEM_CHROMA_MODE: 'local', // 'local' uses persistent chroma-mcp via uvx, 'remote' connects to existing server