/** * router-intents.ts — LLM-based intent classification for the router. * * Flow (zero added latency): * 1. User sends prompt → router picks model with heuristics → turn starts IMMEDIATELY * 2. In background: haiku classifies the prompt intent ("debugging", "architecture"…) * 3. If user overrode the router, record: intent X → user prefers model Y * 4. After 3 consistent observations → intent X gets a "learned preference" * 5. Next time: classifyPromptForRouter checks learned preferences BEFORE heuristics * * Only runs when config.router.learn = true AND anthropic is authenticated. * Storage: ~/.squeezr-code/router-intents.json */ import fs from 'node:fs' import path from 'node:path' import os from 'node:os' import type { SqConfig } from '../config.js' import type { AuthManager } from '../auth/manager.js' const INTENTS_FILE = path.join(os.homedir(), '.squeezr-code', 'router-intents.json') const PROMOTE_THRESHOLD = 3 // consistent observations to create a preference export const INTENT_CATEGORIES = [ 'debugging', // fix bugs, errors, crashes 'code-review', // review, audit, check quality 'architecture', // design, system, scalability 'refactoring', // improve, clean, reorganize 'explanation', // explain, teach, describe 'translation', // translate between languages 'creative', // write, generate, brainstorm 'factual', // quick facts, definitions 'testing', // write tests, coverage 'documentation', // docs, comments, README 'other', ] as const export type IntentCategory = typeof INTENT_CATEGORIES[number] interface Observation { intent: IntentCategory modelUsed: string // model that actually ran the turn wasOverride: boolean // true if user corrected the router ts: number } interface IntentStore { version: 1 /** Learned intent → model preferences (after PROMOTE_THRESHOLD observations). */ preferences: Record /** Raw observations used to build preferences. */ observations: Observation[] } // ── Storage ──────────────────────────────────────────────────────────────────── function load(): IntentStore { try { return JSON.parse(fs.readFileSync(INTENTS_FILE, 'utf-8')) as IntentStore } catch { return { version: 1, preferences: {}, observations: [] } } } function save(store: IntentStore): void { try { fs.mkdirSync(path.dirname(INTENTS_FILE), { recursive: true }) fs.writeFileSync(INTENTS_FILE, JSON.stringify(store, null, 2), 'utf-8') } catch { /* best-effort */ } } // ── Classification ───────────────────────────────────────────────────────────── const CLASSIFY_PROMPT = `Classify the following user prompt into EXACTLY ONE category. Reply with ONLY the category name, nothing else. Categories: debugging, code-review, architecture, refactoring, explanation, translation, creative, factual, testing, documentation, other Examples: "fix the null pointer in auth.ts" → debugging "review this function for edge cases" → code-review "design a microservices architecture" → architecture "translate this to French" → translation "what is a binary tree?" → factual "write unit tests for UserService" → testing User prompt: ` /** * Classifies the intent of a prompt using haiku. * Runs in background — does NOT block the main turn. */ export async function classifyPromptIntentAsync( prompt: string, auth: AuthManager, ): Promise { try { const { SqAgent } = await import('../agent/agent.js') const ag = new SqAgent(auth, { defaultModel: 'claude-haiku-4-5-20251001', permissions: 'bypass', transplant: { warnThreshold: 99, autoThreshold: 100 }, }) let result = '' for await (const ev of ag.send( CLASSIFY_PROMPT + `"${prompt.slice(0, 300)}"`, { cwd: process.cwd() }, )) { if (ev.type === 'text' && ev.text) result += ev.text if (ev.type === 'done') break } ag.shutdown() const cat = result.trim().toLowerCase() as IntentCategory return INTENT_CATEGORIES.includes(cat) ? cat : 'other' } catch { return 'other' } } // ── Learning ─────────────────────────────────────────────────────────────────── /** * Record an observation after a turn completes (background, fire-and-forget). * Then re-compute preferences from all observations. */ export function recordIntentObservation( intent: IntentCategory, modelUsed: string, wasOverride: boolean, ): void { const store = load() // Normalise model to family alias const model = modelUsed.includes('opus') ? 'opus' : modelUsed.includes('haiku') ? 'haiku' : modelUsed.includes('sonnet') ? 'sonnet' : modelUsed.startsWith('gpt') || modelUsed.startsWith('o3') || modelUsed.startsWith('o4') ? 'gpt' : modelUsed.startsWith('gemini') ? 'gemini' : 'sonnet' store.observations.push({ intent, modelUsed: model, wasOverride, ts: Date.now() }) // Keep last 200 observations if (store.observations.length > 200) store.observations = store.observations.slice(-200) // Re-compute preferences from observations for this intent recomputePreferences(store) save(store) } function recomputePreferences(store: IntentStore): void { const intentObs = new Map>() // intent → model → count for (const obs of store.observations) { if (!intentObs.has(obs.intent)) intentObs.set(obs.intent, new Map()) const modelCounts = intentObs.get(obs.intent)! // Overrides count double (stronger signal) const weight = obs.wasOverride ? 2 : 1 modelCounts.set(obs.modelUsed, (modelCounts.get(obs.modelUsed) ?? 0) + weight) } for (const [intent, modelCounts] of intentObs) { const total = Array.from(modelCounts.values()).reduce((a, b) => a + b, 0) const best = Array.from(modelCounts.entries()).sort((a, b) => b[1] - a[1])[0] if (!best) continue const [model, count] = best const dominance = count / total // how consistent is the preference? // Promote if: count ≥ threshold AND model is clearly preferred (≥ 60% of obs) if (count >= PROMOTE_THRESHOLD && dominance >= 0.6) { store.preferences[intent] = model } else if (count < PROMOTE_THRESHOLD) { // Not enough data yet — remove any existing preference for this intent // so we don't keep stale preferences delete store.preferences[intent] } } } // ── Query ────────────────────────────────────────────────────────────────────── /** * Returns the learned model preference for the given prompt, or null. * Reads the intent store synchronously — called from classifyPromptForRouter. */ export function getLearnedIntentPreference(prompt: string): string | null { // Quick intent detection from the prompt text (no LLM needed for lookup) const p = prompt.toLowerCase() const intentHint = detectIntentHeuristic(p) if (!intentHint) return null try { const store = load() return store.preferences[intentHint] ?? null } catch { return null } } /** Fast heuristic intent detection (no LLM) for lookup purposes. */ function detectIntentHeuristic(p: string): IntentCategory | null { if (/\b(fix|bug|error|crash|exception|null|undefined|broken|fails?|not working)\b/.test(p)) return 'debugging' if (/\b(review|audit|check|quality|smell|issues? in|look at)\b/.test(p)) return 'code-review' if (/\b(design|architect|system|scalab|microservice|distribut|pattern)\b/.test(p)) return 'architecture' if (/\b(refactor|clean|improve|restructure|reorgani[sz]e|simplif)\b/.test(p)) return 'refactoring' if (/\b(explain|what is|how does|describe|teach|help me understand)\b/.test(p)) return 'explanation' if (/\b(translate|in (spanish|french|german|chinese|japanese|portuguese))\b/.test(p)) return 'translation' if (/\b(write a (story|poem|email|message)|create|brainstorm|generate)\b/.test(p)) return 'creative' if (/\b(test|spec|unit test|coverage|jest|vitest|mocha)\b/.test(p)) return 'testing' if (/\b(document|docs|readme|comment|jsdoc|add docs)\b/.test(p)) return 'documentation' return null } /** Human-readable summary of learned intent preferences. */ export function learnedIntentsSummary(): string { try { const store = load() const entries = Object.entries(store.preferences) if (entries.length === 0) return ' No learned preferences yet.' return entries .map(([intent, model]) => ` ${intent.padEnd(16)} → ${model}`) .join('\n') } catch { return ' No learned preferences yet.' } }