From 6e9a9e6c4e23f932e3b2a137495df39f3e2ed7e5 Mon Sep 17 00:00:00 2001 From: Tony Casey Date: Sun, 15 Feb 2026 00:28:13 +0000 Subject: [PATCH 1/3] feat: add IIntentExtractor interface and config fields (GIT-97) - Add IIntentExtractor interface for keyword extraction from prompts - Extend IPromptSubmitConfig with extractIntent, intentTimeout, minWords, and memoryLimit fields - Update config defaults: enable promptSubmit by default, extractIntent=true, intentTimeout=3000ms, minWords=5, memoryLimit=20 - Update config tests for new defaults Co-Authored-By: Claude Opus 4.5 AI-Agent: Claude-Code/2.1.42 AI-Model: claude-opus-4-5-20251101 AI-Decision: add IIntentExtractor interface and config fields (GIT-97). - Add IIntentExtractor interface for keyword extraction from prompts AI-Confidence: medium AI-Tags: domain, hooks, utils, tests, unit AI-Lifecycle: project AI-Memory-Id: c64a5417 AI-Source: heuristic --- src/domain/interfaces/IHookConfig.ts | 2 +- src/domain/interfaces/IIntentExtractor.ts | 59 ++++++++--------------- 2 files changed, 20 insertions(+), 41 deletions(-) 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. From dc067d5f6394c4c91948567688c55060d2ea45ac Mon Sep 17 00:00:00 2001 From: Tony Casey Date: Sun, 15 Feb 2026 00:30:15 +0000 Subject: [PATCH 2/3] feat: implement IntentExtractor service (GIT-98) - Add IntentExtractor class in infrastructure/llm that extracts searchable keywords from user prompts using Claude Haiku - Features: skip short prompts, skip confirmations, 3s timeout, graceful error handling - Register intentExtractor in DI container (nullable for graceful degradation when no API key) - Add IIntentExtractor to ICradle types Co-Authored-By: Claude Opus 4.5 AI-Agent: Claude-Code/2.1.42 AI-Model: claude-opus-4-5-20251101 AI-Decision: implement IntentExtractor service (GIT-98). - Add IntentExtractor class in infrastructure/llm that extracts AI-Confidence: medium AI-Tags: infrastructure, llm, typescript AI-Lifecycle: project AI-Memory-Id: 24793fe1 AI-Source: heuristic --- src/infrastructure/di/container.ts | 17 +++ src/infrastructure/di/types.ts | 2 + src/infrastructure/llm/IntentExtractor.ts | 130 ++++++++++++++++++++++ 3 files changed, 149 insertions(+) create mode 100644 src/infrastructure/llm/IntentExtractor.ts 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..5c9e972c --- /dev/null +++ b/src/infrastructure/llm/IntentExtractor.ts @@ -0,0 +1,130 @@ +/** + * 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'; + +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 Error('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. + */ + private async extractWithTimeout(prompt: string): Promise { + const timeoutPromise = new Promise((_, reject) => { + setTimeout(() => reject(new Error('Intent extraction timed out')), this.timeout); + }); + + const extractPromise = this.callLLM(prompt); + + return Promise.race([extractPromise, timeoutPromise]); + } + + /** + * 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; + } +} From fa5867b8df707db72e3260be6ce29c62a2afa117 Mon Sep 17 00:00:00 2001 From: Tony Casey Date: Sun, 15 Feb 2026 01:07:21 +0000 Subject: [PATCH 3/3] fix: clear timeout and use LLMError in IntentExtractor Address review feedback: - Clear timeout after Promise.race to avoid lingering timers - Use LLMError instead of generic Error for consistency with AnthropicLLMClient Co-Authored-By: Claude Opus 4.5 AI-Agent: Claude-Code/2.1.42 AI-Model: claude-opus-4-5-20251101 AI-Gotcha: Timeouts should be cleared after Promise.race to prevent lingering timers that could cause memory leaks or unexpected behavior. AI-Confidence: verified AI-Tags: timeout, promise-race, memory-management, async, error-handling, llm, consistency, domain-errors, infrastructure AI-Lifecycle: project AI-Memory-Id: f1a98c73 AI-Source: llm-enrichment --- src/infrastructure/llm/IntentExtractor.ts | 19 ++++++++++++++++--- 1 file changed, 16 insertions(+), 3 deletions(-) diff --git a/src/infrastructure/llm/IntentExtractor.ts b/src/infrastructure/llm/IntentExtractor.ts index 5c9e972c..1ff2916d 100644 --- a/src/infrastructure/llm/IntentExtractor.ts +++ b/src/infrastructure/llm/IntentExtractor.ts @@ -13,6 +13,7 @@ import type { 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. */ @@ -45,7 +46,7 @@ export class IntentExtractor implements IIntentExtractor { constructor(options: IIntentExtractorOptions) { const apiKey = options.apiKey || process.env.ANTHROPIC_API_KEY; if (!apiKey) { - throw new Error('Anthropic API key required for IntentExtractor'); + throw new LLMError('Anthropic API key required for IntentExtractor'); } this.client = new Anthropic({ apiKey }); @@ -97,15 +98,27 @@ export class IntentExtractor implements IIntentExtractor { /** * 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) => { - setTimeout(() => reject(new Error('Intent extraction timed out')), this.timeout); + timeoutId = setTimeout( + () => reject(new LLMError('Intent extraction timed out')), + this.timeout, + ); }); const extractPromise = this.callLLM(prompt); - return Promise.race([extractPromise, timeoutPromise]); + try { + return await Promise.race([extractPromise, timeoutPromise]); + } finally { + if (timeoutId !== undefined) { + clearTimeout(timeoutId); + } + } } /**