/** * AI Enrichment for Observations and Session Summaries * * Uses pluggable AI providers (Claude CLI, OpenAI-compatible, Gemini) to * generate richer subtitle, narrative, facts, and concepts from tool * observations, and to enhance session summaries using transcript data. * Falls back to template-based extraction when the provider is not available. * * @module @agentkits/memory/hooks/ai-enrichment */ import { resolveAIProvider, type AIProviderConfig, type ResolvedProvider } from './ai-provider.js'; /** * Enriched observation data from AI extraction */ export interface EnrichedObservation { subtitle: string; narrative: string; facts: string[]; concepts: string[]; /** Confidence score 0.0-1.0 indicating extraction quality */ confidence: number; } /** * Environment variable to enable/disable AI enrichment. * Set AGENTKITS_AI_ENRICHMENT=true to enable, false to disable. * When not set, defaults to auto-detect (uses AI if provider available). */ const AI_ENRICHMENT_ENV_KEY = 'AGENTKITS_AI_ENRICHMENT'; /** Resolved provider (cached at module level, lazy-initialized) */ let _resolvedProvider: ResolvedProvider | null = null; /** Provider config from settings.json (set by service.ts) */ let _providerConfig: AIProviderConfig | undefined; /** Mock function for testing (replaces provider.run when set) */ let _mockRunClaudePrint: ((prompt: string, systemPrompt: string, timeoutMs: number) => string | null) | null = null; /** * Set AI provider config from settings.json. * Called by service.ts after loading settings. Forces re-resolution on next use. */ export function setAIProviderConfig(config?: AIProviderConfig): void { _providerConfig = config; _resolvedProvider = null; // force re-resolution } /** * Get the resolved AI provider (lazy-initialized, cached). */ function getProvider(): ResolvedProvider { if (!_resolvedProvider) { _resolvedProvider = resolveAIProvider(_providerConfig); } return _resolvedProvider; } /** * Check if AI enrichment is enabled via environment variable * - 'true' / '1' → force enable * - 'false' / '0' → force disable * - not set → auto-detect (try provider, fallback to template) */ function isEnvEnabled(): boolean | null { const value = process.env[AI_ENRICHMENT_ENV_KEY]; if (!value) return null; // auto-detect return value === 'true' || value === '1'; } /** * Check if the current AI provider is available. * Respects AGENTKITS_AI_ENRICHMENT env var override. */ function isProviderAvailable(): boolean { const envEnabled = isEnvEnabled(); if (envEnabled === false) return false; // When mock is set, provider is "available" if (_mockRunClaudePrint) return true; return getProvider().isAvailable(); } /** * Run a prompt through the current AI provider. * Uses mock if set (testing), otherwise delegates to the resolved provider. */ async function runProvider(prompt: string, systemPrompt: string, timeoutMs: number): Promise { if (_mockRunClaudePrint) { return _mockRunClaudePrint(prompt, systemPrompt, timeoutMs); } return getProvider().run(prompt, systemPrompt, timeoutMs); } /** * Synchronous check: is AI enrichment potentially enabled? * Used by observation hook to decide whether to spawn background process. * Does NOT check provider availability (that may be slow). Just checks env var. */ export function isAIEnrichmentEnabled(): boolean { const envEnabled = isEnvEnabled(); if (envEnabled === false) return false; // If explicitly enabled or auto-detect, optimistically return true. // The background process will handle provider availability check. return true; } /** * Build the extraction prompt for a tool observation */ export function buildExtractionPrompt( toolName: string, toolInput: string, toolResponse: string ): string { return `Analyze this Claude Code tool observation and extract structured insights. Tool: ${toolName} Input: ${toolInput.substring(0, 2000)} Response: ${toolResponse.substring(0, 2000)} Return ONLY a JSON object (no markdown, no code fences) with these fields: { "subtitle": "Brief context description (5-10 words, e.g. 'Examining authentication module')", "narrative": "One sentence explaining what happened and why (e.g. 'Read the authentication module to understand the login flow before making changes.')", "facts": ["Array of factual observations", "e.g. 'File auth.ts contains 150 lines'", "Max 5 facts"], "concepts": ["Array of technical concepts/topics involved", "e.g. 'authentication', 'typescript'", "Include 'intent:' tags for: bugfix, feature, refactor, testing, investigation, documentation, configuration, optimization", "Include 'fn:' for functions changed, 'class:' for classes, 'pattern:' for patterns used", "Max 8 concepts"], "confidence": 0.85 }`; } /** * Parse JSON from AI response, handling common formatting issues */ export function parseAIResponse(text: string): EnrichedObservation | null { try { // Strip markdown code fences if present let cleaned = text.trim(); if (cleaned.startsWith('```json')) { cleaned = cleaned.slice(7); } else if (cleaned.startsWith('```')) { cleaned = cleaned.slice(3); } if (cleaned.endsWith('```')) { cleaned = cleaned.slice(0, -3); } cleaned = cleaned.trim(); const parsed = JSON.parse(cleaned); // Validate structure if ( typeof parsed.subtitle !== 'string' || typeof parsed.narrative !== 'string' || !Array.isArray(parsed.facts) || !Array.isArray(parsed.concepts) ) { return null; } // Compute confidence: AI-reported value weighted with heuristic checks let confidence = typeof parsed.confidence === 'number' ? Math.max(0, Math.min(1, parsed.confidence)) : 0.5; // Penalize empty or very short fields if (parsed.subtitle.length < 5) confidence *= 0.7; if (parsed.narrative.length < 10) confidence *= 0.7; if (parsed.facts.length === 0) confidence *= 0.8; confidence = Math.round(confidence * 100) / 100; return { subtitle: parsed.subtitle.substring(0, 200), narrative: parsed.narrative.substring(0, 500), facts: parsed.facts.slice(0, 5).map((f: unknown) => String(f).substring(0, 200)), concepts: parsed.concepts.slice(0, 8).map((c: unknown) => String(c).substring(0, 50)), confidence, }; } catch { return null; } } /** * Enrich an observation using the configured AI provider. * * Returns enriched data if provider is available and succeeds, * or null to signal fallback to template-based extraction. */ export async function enrichWithAI( toolName: string, toolInput: string, toolResponse: string, timeoutMs: number = 15000 ): Promise { if (!isProviderAvailable()) return null; try { const prompt = buildExtractionPrompt(toolName, toolInput, toolResponse); const systemPrompt = 'You are a code observation analyzer. Extract structured insights from tool usage observations. Return only valid JSON.'; const resultText = await runProvider(prompt, systemPrompt, timeoutMs); if (!resultText) return null; return parseAIResponse(resultText); } catch { return null; } } /** * Check if AI enrichment is available (provider configured and reachable) */ export async function isAIEnrichmentAvailable(): Promise { return isProviderAvailable(); } /** * Reset cached provider and mock state (for testing) */ export function resetAIEnrichmentCache(): void { _resolvedProvider = null; _providerConfig = undefined; _mockRunClaudePrint = null; } /** * Override provider availability for testing (inject mock). * Sets the mock which makes isProviderAvailable() return true. */ export function _setCliAvailableForTesting(available: boolean): void { if (available) { // Set a pass-through mock that marks provider as available if (!_mockRunClaudePrint) { _mockRunClaudePrint = () => null; } } else { _mockRunClaudePrint = null; // Force provider to be unavailable by clearing cache _resolvedProvider = null; } } /** * Inject a mock for the AI provider run function (for testing). * The mock receives (prompt, systemPrompt, timeoutMs) and returns string | null. * Pass null to clear the mock. */ export function _setRunClaudePrintMockForTesting( fn: ((prompt: string, systemPrompt: string, timeoutMs: number) => string | null) | null ): void { _mockRunClaudePrint = fn; } // ===== Session Summary Enrichment ===== /** * Enriched session summary data from AI extraction */ export interface EnrichedSummary { completed: string; nextSteps: string; decisions: string[]; } /** * Build prompt for enriching a session summary using transcript context */ export function buildSummaryPrompt( templateSummary: string, lastAssistantMessage: string ): string { return `Analyze this Claude Code session and produce an enriched summary. ## Template Summary (from observations) ${templateSummary.substring(0, 3000)} ## Last Assistant Message (from transcript) ${lastAssistantMessage.substring(0, 3000)} Return ONLY a JSON object (no markdown, no code fences) with these fields: { "completed": "Concise paragraph describing what was actually completed (2-4 sentences). Merge info from both the template summary and the assistant's final message.", "nextSteps": "Concise list of remaining work or follow-up items, if any. Use 'None' if everything was completed.", "decisions": ["Array of key decision rationales — WHY specific changes were made, not just WHAT changed. E.g. 'Used mutex for token refresh to prevent race condition'. Max 5 decisions. Empty array if no clear decisions."] }`; } /** * Parse enriched summary from AI response */ export function parseSummaryResponse(text: string): EnrichedSummary | null { try { let cleaned = text.trim(); if (cleaned.startsWith('```json')) cleaned = cleaned.slice(7); else if (cleaned.startsWith('```')) cleaned = cleaned.slice(3); if (cleaned.endsWith('```')) cleaned = cleaned.slice(0, -3); cleaned = cleaned.trim(); const parsed = JSON.parse(cleaned); // Handle completed: must be string const completed = typeof parsed.completed === 'string' ? parsed.completed : null; if (!completed) return null; // Handle nextSteps: accept string or array (AI often returns arrays for "list") let nextSteps: string; if (typeof parsed.nextSteps === 'string') { nextSteps = parsed.nextSteps; } else if (Array.isArray(parsed.nextSteps)) { nextSteps = parsed.nextSteps.map((s: unknown) => String(s)).join('; '); } else { nextSteps = 'None'; } // Handle decisions: accept array or empty let decisions: string[] = []; if (Array.isArray(parsed.decisions)) { decisions = parsed.decisions .filter((d: unknown) => typeof d === 'string' && d.length > 0) .slice(0, 5) .map((d: unknown) => String(d).substring(0, 200)); } return { completed: completed.substring(0, 1000), nextSteps: nextSteps.substring(0, 500), decisions, }; } catch { return null; } } /** * Enrich a session summary using the configured AI provider. * * Takes template-based summary + last assistant message from transcript, * returns AI-enhanced completed/nextSteps fields. */ export async function enrichSummaryWithAI( templateSummary: string, lastAssistantMessage: string, timeoutMs: number = 20000 ): Promise { if (!isProviderAvailable()) return null; try { const prompt = buildSummaryPrompt(templateSummary, lastAssistantMessage); const systemPrompt = 'You are a session summary analyzer. Produce concise, accurate session summaries. Return only valid JSON.'; const resultText = await runProvider(prompt, systemPrompt, timeoutMs); if (!resultText) return null; return parseSummaryResponse(resultText); } catch { return null; } } // ===== Per-Observation Compression ===== /** * Compressed observation data */ export interface CompressedObservation { compressed_summary: string; } /** * Build prompt for compressing a single observation into a dense summary. * Uses existing subtitle/narrative as hints for faster, more accurate compression. */ export function buildCompressionPrompt( toolName: string, toolInput: string, toolResponse: string, subtitle?: string, narrative?: string ): string { const hints = [subtitle, narrative].filter(Boolean).join(' | '); return `Compress this tool observation into a single dense summary (50-150 chars). Tool: ${toolName} ${hints ? `Context: ${hints}\n` : ''}Input: ${toolInput.substring(0, 1000)} Response: ${toolResponse.substring(0, 1000)} Return ONLY a JSON object (no markdown, no code fences): {"compressed_summary": "dense summary here"}`; } /** * Parse compression response from AI */ export function parseCompressionResponse(text: string): CompressedObservation | null { try { let cleaned = text.trim(); if (cleaned.startsWith('```json')) cleaned = cleaned.slice(7); else if (cleaned.startsWith('```')) cleaned = cleaned.slice(3); if (cleaned.endsWith('```')) cleaned = cleaned.slice(0, -3); cleaned = cleaned.trim(); const parsed = JSON.parse(cleaned); if (typeof parsed.compressed_summary !== 'string' || !parsed.compressed_summary) return null; return { compressed_summary: parsed.compressed_summary.substring(0, 200), }; } catch { return null; } } /** * Compress a single observation using the configured AI provider. * Returns a dense 50-150 char summary suitable for context injection. */ export async function compressObservationWithAI( toolName: string, toolInput: string, toolResponse: string, subtitle?: string, narrative?: string, timeoutMs: number = 10000 ): Promise { if (!isProviderAvailable()) return null; try { const prompt = buildCompressionPrompt(toolName, toolInput, toolResponse, subtitle, narrative); const systemPrompt = 'You are a data compressor. Produce the shortest possible accurate summary. Return only valid JSON.'; const resultText = await runProvider(prompt, systemPrompt, timeoutMs); if (!resultText) return null; return parseCompressionResponse(resultText); } catch { return null; } } // ===== Session-Level Digest ===== /** * Session digest data from AI compression */ export interface SessionDigest { digest: string; } /** * Build prompt for generating a compressed session digest. * Takes the session's request, observation summaries, and completion info. */ export function buildSessionDigestPrompt( request: string, observationSummaries: string[], completed: string, filesModified: string[] ): string { const obsText = observationSummaries.slice(0, 30).join('\n- '); const filesText = filesModified.slice(0, 10).join(', '); return `Compress this session into a single dense digest (200-500 chars). Request: ${request.substring(0, 500)} Observations: - ${obsText} Completed: ${completed.substring(0, 300)} ${filesText ? `Files modified: ${filesText}\n` : ''} Return ONLY a JSON object (no markdown, no code fences): {"digest": "dense session digest here"}`; } /** * Parse session digest response from AI */ export function parseSessionDigestResponse(text: string): SessionDigest | null { try { let cleaned = text.trim(); if (cleaned.startsWith('```json')) cleaned = cleaned.slice(7); else if (cleaned.startsWith('```')) cleaned = cleaned.slice(3); if (cleaned.endsWith('```')) cleaned = cleaned.slice(0, -3); cleaned = cleaned.trim(); const parsed = JSON.parse(cleaned); if (typeof parsed.digest !== 'string' || !parsed.digest) return null; return { digest: parsed.digest.substring(0, 600), }; } catch { return null; } } /** * Generate a session-level digest using the configured AI provider. * Compresses an entire session into a 200-500 char digest. */ export async function generateSessionDigestWithAI( request: string, observationSummaries: string[], completed: string, filesModified: string[], timeoutMs: number = 15000 ): Promise { if (!isProviderAvailable()) return null; try { const prompt = buildSessionDigestPrompt(request, observationSummaries, completed, filesModified); const systemPrompt = 'You are a session compressor. Produce the shortest possible accurate digest of a coding session. Return only valid JSON.'; const resultText = await runProvider(prompt, systemPrompt, timeoutMs); if (!resultText) return null; return parseSessionDigestResponse(resultText); } catch { return null; } }