/** * router-feedback.ts — Learns from user model overrides to improve routing. * * Every time the user types @opus / @sonnet / @haiku when the router chose * a different model, we record the prompt keywords → preferred model mapping. * After 2+ observations, the pattern is "promoted" to a learned rule and * classifyPromptForRouter uses it before the built-in heuristics. * * Storage: ~/.squeezr-code/router-feedback.json */ import fs from 'node:fs' import path from 'node:path' import os from 'node:os' const FEEDBACK_FILE = path.join(os.homedir(), '.squeezr-code', 'router-feedback.json') const PROMOTE_THRESHOLD = 2 // observations needed to become a learned rule export type RouterModel = 'opus' | 'sonnet' | 'haiku' interface FeedbackEntry { keywords: string[] // trigrams/tokens extracted from the prompt preferred: RouterModel routerSuggested: RouterModel count: number lastSeen: number // unix ms } interface FeedbackStore { version: 1 entries: FeedbackEntry[] } function load(): FeedbackStore { try { return JSON.parse(fs.readFileSync(FEEDBACK_FILE, 'utf-8')) as FeedbackStore } catch { return { version: 1, entries: [] } } } function save(store: FeedbackStore): void { try { fs.mkdirSync(path.dirname(FEEDBACK_FILE), { recursive: true }) fs.writeFileSync(FEEDBACK_FILE, JSON.stringify(store, null, 2), 'utf-8') } catch { /* best-effort */ } } /** Extract meaningful tokens from a prompt (lowercase, no stopwords, min 4 chars). */ function extractKeywords(prompt: string): string[] { const STOPWORDS = new Set(['this','that','the','and','for','with','from','have','will', 'would','could','should','what','when','where','please','make','need','want','also']) return prompt.toLowerCase() .split(/[\s,;:.!?()\[\]{}'"]+/) .filter(t => t.length >= 4 && !STOPWORDS.has(t) && /^[a-z]/.test(t)) .slice(0, 8) // keep top 8 tokens } /** * Record that the user preferred `chosen` when the router suggested `routerChoice`. * Only records if the user actually corrected the router (chose ≠ suggested). */ export function recordRouterFeedback( prompt: string, chosen: RouterModel, routerSuggested: RouterModel, ): void { if (chosen === routerSuggested) return // no correction, nothing to learn const keywords = extractKeywords(prompt) if (keywords.length === 0) return const store = load() // Find an existing entry that shares ≥ 2 keywords const match = store.entries.find(e => e.preferred === chosen && e.keywords.filter(k => keywords.includes(k)).length >= 2 ) if (match) { match.count++ match.lastSeen = Date.now() // Merge keywords (union, capped at 12) for (const k of keywords) { if (!match.keywords.includes(k) && match.keywords.length < 12) match.keywords.push(k) } } else { store.entries.push({ keywords, preferred: chosen, routerSuggested, count: 1, lastSeen: Date.now(), }) } // Prune old entries (>90 days, count = 1) const cutoff = Date.now() - 90 * 86400_000 store.entries = store.entries.filter(e => e.count > 1 || e.lastSeen > cutoff) save(store) } /** * Returns a learned routing decision for `prompt`, or null if no learned rule applies. * Only returns results for entries that have been seen ≥ PROMOTE_THRESHOLD times. */ export function learnedRoute(prompt: string): RouterModel | null { let store: FeedbackStore try { store = load() } catch { return null } const keywords = extractKeywords(prompt) if (keywords.length === 0) return null // Find the strongest matching learned rule let best: { model: RouterModel; score: number } | null = null for (const entry of store.entries) { if (entry.count < PROMOTE_THRESHOLD) continue const overlap = entry.keywords.filter(k => keywords.includes(k)).length if (overlap < 2) continue // need at least 2 matching keywords const score = overlap * entry.count if (!best || score > best.score) best = { model: entry.preferred, score } } return best?.model ?? null } /** Human-readable summary of learned rules (for /router show). */ export function learnedRulesSummary(): string { const store = load() const promoted = store.entries.filter(e => e.count >= PROMOTE_THRESHOLD) if (promoted.length === 0) return ' No learned rules yet.' return promoted .sort((a, b) => b.count - a.count) .slice(0, 10) .map(e => ` ${e.preferred.padEnd(8)} × ${e.count} [${e.keywords.slice(0, 4).join(', ')}]`) .join('\n') }