Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion src/domain/interfaces/IHookConfig.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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. */
Copy link

Copilot AI Feb 15, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Missing documentation constraint. Following the pattern established for enrichTimeout (line 58 in IHookConfig.ts: "Must be under hook timeout (10s)"), this field should include the same constraint documentation since it's also used within the hook's 10-second hard timeout.

Suggested change
/** Timeout in ms for intent extraction LLM call. Default: 3000. */
/** Timeout in ms for intent extraction LLM call. Default: 3000. Must be under hook timeout (10s). */

Copilot uses AI. Check for mistakes.
readonly intentTimeout: number;
/** Minimum word count to trigger intent extraction. Default: 5. */
readonly minWords: number;
Expand Down
59 changes: 19 additions & 40 deletions src/domain/interfaces/IIntentExtractor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
17 changes: 17 additions & 0 deletions src/infrastructure/di/container.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand Down Expand Up @@ -110,6 +111,22 @@ export function createContainer(options?: IContainerOptions): AwilixContainer<IC
return createLLMClient() ?? null;
}).singleton(),

intentExtractor: asFunction(() => {
// 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(),
Comment on lines +114 to +128
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Check for usages of intentTimeout/minWords/extractIntent across the codebase
rg -n --type=ts '\bintentTimeout\b|\bminWords\b|\bextractIntent\b' | head -100

Repository: TonyCasey/git-mem

Length of output: 1197


🏁 Script executed:

#!/bin/bash
# Also search for IntentExtractor class definition and usage
rg -n --type=ts 'class IntentExtractor|IntentExtractor(' | head -50

Repository: TonyCasey/git-mem

Length of output: 187


🏁 Script executed:

#!/bin/bash
# Search for promptSubmit config references
rg -n --type=ts 'promptSubmit' | head -50

Repository: TonyCasey/git-mem

Length of output: 3445


🏁 Script executed:

#!/bin/bash
# Check the IntentExtractor class definition to see what parameters it accepts
cat -n src/infrastructure/llm/IntentExtractor.ts | head -60

Repository: TonyCasey/git-mem

Length of output: 2777


🏁 Script executed:

#!/bin/bash
# Check where IntentExtractor is instantiated - look for "new IntentExtractor"
rg -n --type=ts 'new IntentExtractor' -A5

Repository: TonyCasey/git-mem

Length of output: 441


🏁 Script executed:

#!/bin/bash
# Check the container.ts file around the intentExtractor registration
cat -n src/infrastructure/di/container.ts | sed -n '100,150p'

Repository: TonyCasey/git-mem

Length of output: 2305


🏁 Script executed:

#!/bin/bash
# Check how intentExtractor is used in the hooks layer
rg -n --type=ts 'intentExtractor|extractIntent' src/hooks --context=3

Repository: TonyCasey/git-mem

Length of output: 436


🏁 Script executed:

#!/bin/bash
# Check if the hook config is passed to the DI container
rg -n --type=ts 'hookConfig|HookConfig' src/infrastructure/di --context=2

Repository: TonyCasey/git-mem

Length of output: 2030


🏁 Script executed:

#!/bin/bash
# Check the entire container.ts file to see what options are passed in
cat -n src/infrastructure/di/container.ts | head -50

Repository: TonyCasey/git-mem

Length of output: 2798


🏁 Script executed:

#!/bin/bash
# Check where intentExtractor is actually used (dependencies)
rg -n --type=ts 'intentExtractor|IIntentExtractor' src/application --context=3

Repository: TonyCasey/git-mem

Length of output: 43


🏁 Script executed:

#!/bin/bash
# Check PromptSubmitHandler to see how it uses intentExtractor
cat -n src/application/handlers/PromptSubmitHandler.ts | head -100

Repository: TonyCasey/git-mem

Length of output: 2447


🏁 Script executed:

#!/bin/bash
# Check if hookConfig is somehow passed to PromptSubmitHandler
rg -n --type=ts 'PromptSubmitHandler' src --context=5

Repository: TonyCasey/git-mem

Length of output: 7080


🏁 Script executed:

#!/bin/bash
# Check if intentExtractor is actually used/injected anywhere in the codebase
rg -n --type=ts 'intentExtractor' src --context=2 | grep -v 'asFunction\|singleton\|test'

Repository: TonyCasey/git-mem

Length of output: 639


🏁 Script executed:

#!/bin/bash
# Check if the config values are actually read and used anywhere
rg -n --type=ts 'intentTimeout|minWords' src/infrastructure/di --context=3

Repository: TonyCasey/git-mem

Length of output: 43


🏁 Script executed:

#!/bin/bash
# Check all services that depend on intentExtractor in the DI container
cat -n src/infrastructure/di/container.ts | sed -n '50,150p'

Repository: TonyCasey/git-mem

Length of output: 4454


🏁 Script executed:

#!/bin/bash
# Search for any service that has intentExtractor as a constructor dependency
rg -n --type=ts 'intentExtractor|IIntentExtractor' src/application src/infrastructure/services --context=3

Repository: TonyCasey/git-mem

Length of output: 43


🏁 Script executed:

#!/bin/bash
# Check if intentExtractor is ever retrieved from the container after registration
rg -n --type=ts 'container.cradle.intentExtractor' src

Repository: TonyCasey/git-mem

Length of output: 43


Wire intentTimeout and minWords from hook config to IntentExtractor, or remove unused registration.

promptSubmit.intentTimeout and promptSubmit.minWords are defined in config defaults (lines 50-51 in src/hooks/utils/config.ts), and IntentExtractor accepts these as timeout and minWords options. However, the DI container instantiation (lines 121-124 in src/infrastructure/di/container.ts) passes only apiKey and logger, ignoring the config values. Additionally, intentExtractor is registered in the container but appears unused—no service depends on it or retrieves it via container.cradle.intentExtractor. Either thread the config values through the container and wire them into services that use intentExtractor, or remove the registration if it's not needed.

🤖 Prompt for AI Agents
In `@src/infrastructure/di/container.ts` around lines 114 - 128, The DI
registration for intentExtractor either needs to accept and pass the
promptSubmit config values into IntentExtractor or be removed if unused: update
the intentExtractor asFunction factory to read promptSubmit.intentTimeout and
promptSubmit.minWords from your config (e.g., the same source that defines
promptSubmit defaults) and pass them as timeout and minWords into new
IntentExtractor({ apiKey, logger: container.cradle.logger, timeout:
<intentTimeout>, minWords: <minWords> }), and ensure any service that relies on
intent extraction obtains container.cradle.intentExtractor; alternatively, if no
service consumes container.cradle.intentExtractor, delete the intentExtractor
registration to avoid dead code.


// ── Application services ─────────────────────────────────────

// GitTriageService constructor uses `git` not `gitClient`, so
Expand Down
2 changes: 2 additions & 0 deletions src/infrastructure/di/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -40,6 +41,7 @@ export interface ICradle {
eventBus: IEventBus;
agentResolver: IAgentResolver;
hookConfigLoader: IHookConfigLoader;
intentExtractor: IIntentExtractor | null;

// Application — core services
memoryService: IMemoryService;
Expand Down
143 changes: 143 additions & 0 deletions src/infrastructure/llm/IntentExtractor.ts
Original file line number Diff line number Diff line change
@@ -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');
}
Comment on lines +48 to +50
Copy link

Copilot AI Feb 15, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The constructor throws a generic Error instead of a domain-specific error. The existing AnthropicLLMClient uses LLMError (imported from '../../domain/errors/LLMError') when the API key is missing. For consistency, IntentExtractor should either use LLMError or create a similar domain error class.

Copilot uses AI. Check for mistakes.

this.client = new Anthropic({ apiKey });
this.timeout = options.timeout ?? 3000;
this.minWords = options.minWords ?? 5;
this.logger = options.logger;
Comment on lines +46 to +55
Copy link

Copilot AI Feb 15, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Design mismatch: IntentExtractor accepts timeout and minWords in the constructor (lines 20-23, 52-53), but the DI container creates it without config (line 121-124 doesn't pass these options). Unlike llmClient which accepts config per-call, IntentExtractor's config is fixed at construction. Since hook config is only available at runtime in handlers (see CommitMsgHandler line 48-49 pattern), the configured values (intentTimeout, minWords from IPromptSubmitConfig) cannot be used. Either: (1) Add timeout/minWords parameters to the extract() method, or (2) Make intentExtractor a factory function that accepts config.

Copilot uses AI. Check for mistakes.
}

async extract(input: IIntentExtractorInput): Promise<IIntentExtractorResult> {
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<string | null> {
let timeoutId: ReturnType<typeof setTimeout> | undefined;

const timeoutPromise = new Promise<never>((_, 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<string | null> {
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;
}
}
Comment on lines +40 to +143
Copy link

Copilot AI Feb 15, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

No unit tests exist for IntentExtractor, despite the presence of comprehensive test coverage for AnthropicLLMClient (258 lines in tests/unit/infrastructure/llm/AnthropicLLMClient.test.ts). Tests should cover: constructor validation (API key handling), minWords threshold logic, confirmation pattern matching, timeout behavior, LLM skip response handling, and error cases.

Copilot uses AI. Check for mistakes.