diff --git a/src/domain/interfaces/IHookConfig.ts b/src/domain/interfaces/IHookConfig.ts index 13665072..751862f5 100644 --- a/src/domain/interfaces/IHookConfig.ts +++ b/src/domain/interfaces/IHookConfig.ts @@ -31,7 +31,7 @@ export interface IPromptSubmitConfig { readonly surfaceContext: boolean; /** Enable LLM-based intent extraction for smarter memory retrieval. */ readonly extractIntent: boolean; - /** Timeout in ms for intent extraction LLM call. Default: 3000. Must be under hook timeout (10s). */ + /** Timeout in ms for intent extraction LLM call. Default: 3000. */ readonly intentTimeout: number; /** Minimum word count to trigger intent extraction. Default: 5. */ readonly minWords: number; diff --git a/src/domain/interfaces/IIntentExtractor.ts b/src/domain/interfaces/IIntentExtractor.ts index c583ce5f..e4d8d864 100644 --- a/src/domain/interfaces/IIntentExtractor.ts +++ b/src/domain/interfaces/IIntentExtractor.ts @@ -13,49 +13,28 @@ export interface IIntentExtractorInput { readonly prompt: string; } -/** - * Reason for skipping intent extraction. - */ -export type IntentSkipReason = - | 'too_short' - | 'confirmation' - | 'no_llm' - | 'llm_skip' - | 'timeout' - | 'error'; - /** * Result of intent extraction. - * - * This is a discriminated union keyed by `skipped`: - * - When `skipped` is `true`, `intent` is always `null` and `reason` is defined. - * - When `skipped` is `false`, `intent` may be a string, and `reason` is undefined. */ -export type IIntentExtractorResult = - | { - /** Whether extraction was skipped. */ - readonly skipped: true; - /** Always null when extraction is skipped. */ - readonly intent: null; - /** - * Reason for skipping. - * - 'too_short': Prompt has fewer words than minWords threshold. - * - 'confirmation': Prompt is a simple confirmation (yes/no/ok/etc). - * - 'no_llm': No LLM client available. - * - 'llm_skip': LLM returned SKIP (no extractable keywords). - * - 'timeout': LLM call timed out. - * - 'error': LLM call failed with error. - */ - readonly reason: IntentSkipReason; - } - | { - /** Whether extraction was skipped. */ - readonly skipped: false; - /** Extracted keywords for memory search. */ - readonly intent: string; - /** Reason is not present when extraction succeeded. */ - readonly reason?: undefined; - }; +export interface IIntentExtractorResult { + /** + * Extracted keywords for memory search. + * null if extraction was skipped or no keywords found. + */ + readonly intent: string | null; + /** Whether extraction was skipped. */ + readonly skipped: boolean; + /** + * Reason for skipping. + * - 'too_short': Prompt has fewer words than minWords threshold. + * - 'confirmation': Prompt is a simple confirmation (yes/no/ok/etc). + * - 'no_llm': No LLM client available. + * - 'llm_skip': LLM returned SKIP (no extractable keywords). + * - 'timeout': LLM call timed out. + * - 'error': LLM call failed with error. + */ + readonly reason?: 'too_short' | 'confirmation' | 'no_llm' | 'llm_skip' | 'timeout' | 'error'; +} /** * Service for extracting searchable keywords from user prompts. diff --git a/src/infrastructure/di/container.ts b/src/infrastructure/di/container.ts index 12ce9e30..76436753 100644 --- a/src/infrastructure/di/container.ts +++ b/src/infrastructure/di/container.ts @@ -25,6 +25,7 @@ import { TrailerService } from '../services/TrailerService'; import { EventBus } from '../events/EventBus'; import { createLogger } from '../logging/factory'; import { createLLMClient } from '../llm/LLMClientFactory'; +import { IntentExtractor } from '../llm/IntentExtractor'; import { AgentResolver } from '../services/AgentResolver'; import { HookConfigLoader } from '../services/HookConfigLoader'; @@ -110,6 +111,22 @@ export function createContainer(options?: IContainerOptions): AwilixContainer { + // Intent extraction requires an API key. Return null for graceful degradation. + const apiKey = process.env.ANTHROPIC_API_KEY; + if (!apiKey) { + return null; + } + try { + return new IntentExtractor({ + apiKey, + logger: container.cradle.logger, + }); + } catch { + return null; + } + }).singleton(), + // ── Application services ───────────────────────────────────── // GitTriageService constructor uses `git` not `gitClient`, so diff --git a/src/infrastructure/di/types.ts b/src/infrastructure/di/types.ts index 43913a2c..f16ab6ce 100644 --- a/src/infrastructure/di/types.ts +++ b/src/infrastructure/di/types.ts @@ -27,6 +27,7 @@ import type { ISessionCaptureService } from '../../domain/interfaces/ISessionCap import type { ITrailerService } from '../../domain/interfaces/ITrailerService'; import type { IAgentResolver } from '../../domain/interfaces/IAgentResolver'; import type { IHookConfigLoader } from '../../domain/interfaces/IHookConfigLoader'; +import type { IIntentExtractor } from '../../domain/interfaces/IIntentExtractor'; export interface ICradle { // Infrastructure @@ -40,6 +41,7 @@ export interface ICradle { eventBus: IEventBus; agentResolver: IAgentResolver; hookConfigLoader: IHookConfigLoader; + intentExtractor: IIntentExtractor | null; // Application — core services memoryService: IMemoryService; diff --git a/src/infrastructure/llm/IntentExtractor.ts b/src/infrastructure/llm/IntentExtractor.ts new file mode 100644 index 00000000..1ff2916d --- /dev/null +++ b/src/infrastructure/llm/IntentExtractor.ts @@ -0,0 +1,143 @@ +/** + * IntentExtractor + * + * Infrastructure implementation of IIntentExtractor using the Anthropic SDK. + * Extracts searchable keywords from user prompts for memory retrieval. + * Uses Claude Haiku for fast, cheap keyword extraction. + */ + +import Anthropic from '@anthropic-ai/sdk'; +import type { + IIntentExtractor, + IIntentExtractorInput, + IIntentExtractorResult, +} from '../../domain/interfaces/IIntentExtractor'; +import type { ILogger } from '../../domain/interfaces/ILogger'; +import { LLMError } from '../../domain/errors/LLMError'; + +export interface IIntentExtractorOptions { + /** Anthropic API key. Falls back to ANTHROPIC_API_KEY env var. */ + readonly apiKey?: string; + /** Timeout in ms for LLM call. Default: 3000. */ + readonly timeout?: number; + /** Minimum word count to trigger extraction. Default: 5. */ + readonly minWords?: number; + /** Logger for debug output. */ + readonly logger?: ILogger; +} + +const HAIKU_MODEL = 'claude-haiku-4-5-20251001'; +const MAX_TOKENS = 100; // Keywords are short + +const SYSTEM_PROMPT = `Extract searchable keywords from this user prompt. +Return ONLY a comma-separated list of: file names, class names, function names, issue IDs (e.g., GIT-95), and technical concepts. +No articles, no verbs, no summaries — just the nouns/identifiers that would appear in code or documentation. +If the prompt is a simple confirmation or has no extractable keywords, respond with "SKIP".`; + +/** Pattern for simple confirmations that should skip extraction. */ +const CONFIRMATION_PATTERN = /^(yes|no|ok|okay|go|sure|proceed|continue|done|y|n|yep|nope|thanks|thank you|\d+)$/i; + +export class IntentExtractor implements IIntentExtractor { + private readonly client: Anthropic; + private readonly timeout: number; + private readonly minWords: number; + private readonly logger?: ILogger; + + constructor(options: IIntentExtractorOptions) { + const apiKey = options.apiKey || process.env.ANTHROPIC_API_KEY; + if (!apiKey) { + throw new LLMError('Anthropic API key required for IntentExtractor'); + } + + this.client = new Anthropic({ apiKey }); + this.timeout = options.timeout ?? 3000; + this.minWords = options.minWords ?? 5; + this.logger = options.logger; + } + + async extract(input: IIntentExtractorInput): Promise { + const prompt = input.prompt.trim(); + + // Check minimum words + const words = prompt.split(/\s+/).filter(w => w.length > 0); + if (words.length < this.minWords) { + this.logger?.debug('Intent extraction skipped: too short', { wordCount: words.length }); + return { intent: null, skipped: true, reason: 'too_short' }; + } + + // Check confirmation patterns + if (CONFIRMATION_PATTERN.test(prompt)) { + this.logger?.debug('Intent extraction skipped: confirmation'); + return { intent: null, skipped: true, reason: 'confirmation' }; + } + + // Extract via LLM with timeout + try { + const intent = await this.extractWithTimeout(prompt); + + if (!intent || intent.toUpperCase() === 'SKIP') { + this.logger?.debug('Intent extraction: LLM returned SKIP'); + return { intent: null, skipped: true, reason: 'llm_skip' }; + } + + this.logger?.debug('Intent extracted', { intent }); + return { intent, skipped: false }; + } catch (error) { + const isTimeout = error instanceof Error && error.message.includes('timed out'); + this.logger?.warn('Intent extraction failed', { + error: error instanceof Error ? error.message : String(error), + isTimeout, + }); + return { + intent: null, + skipped: true, + reason: isTimeout ? 'timeout' : 'error', + }; + } + } + + /** + * Call LLM with timeout enforcement. + * Clears timeout after Promise.race to avoid lingering timers. + */ + private async extractWithTimeout(prompt: string): Promise { + let timeoutId: ReturnType | undefined; + + const timeoutPromise = new Promise((_, reject) => { + timeoutId = setTimeout( + () => reject(new LLMError('Intent extraction timed out')), + this.timeout, + ); + }); + + const extractPromise = this.callLLM(prompt); + + try { + return await Promise.race([extractPromise, timeoutPromise]); + } finally { + if (timeoutId !== undefined) { + clearTimeout(timeoutId); + } + } + } + + /** + * Make the actual LLM call. + */ + private async callLLM(prompt: string): Promise { + const response = await this.client.messages.create({ + model: HAIKU_MODEL, + max_tokens: MAX_TOKENS, + system: SYSTEM_PROMPT, + messages: [{ role: 'user', content: `User prompt: "${prompt}"` }], + }); + + const text = response.content + .filter((block): block is Anthropic.TextBlock => block.type === 'text') + .map(block => block.text) + .join('') + .trim(); + + return text || null; + } +}