/** * `IntentClassifier`. * * Decision flow: * 1. Empty / whitespace-only → `chitchat` (zero-cost). * 2. Heuristic rule fires with conf ≥ 0.85 → use it. * 3. Heuristic rule fires with lower conf → keep as fallback, try LLM. * 4. No rule fires → try LLM. * 5. LLM unavailable / fails → use heuristic fallback * or `unknown` (full retrieval). * * The classifier is pure-ish: it only depends on an injected LlmClient, so * tests can stub it. When `kind=meta`, retrieval is skipped regardless. */ import type { EpisodeId } from "../../agent-contract/dto.js"; import { ERROR_CODES, MemosError } from "../../agent-contract/errors.js"; import type { LlmClient } from "../llm/index.js"; import { rootLogger } from "../logger/index.js"; import { sanitizeDerivedText } from "../safety/content.js"; import { HEURISTIC_RULES, matchFirst, retrievalFor } from "./heuristics.js"; import type { IntentDecision, IntentKind } from "./types.js"; const STRONG_HEURISTIC = 0.85; export interface IntentClassifierOptions { /** Optional LLM client. When absent, we only use heuristics. */ llm?: LlmClient; /** Budget per classification. Default 6000 ms. */ timeoutMs?: number; /** When true, skip LLM entirely (e.g. provider=local_only). Default derived from llm presence. */ disableLlm?: boolean; } /** * Optional context the caller can supply to `classify` so the LLM * audit trail (`system_model_status` rows) can be correlated back to * a specific episode in the Logs viewer. * * Callers that have already minted the target episode id (the standard * `sessionManager.startEpisode` flow does this — it pre-allocates the * id before calling the classifier so the classifier's LLM call carries * it) should pass it here. Anything else can omit it, in which case * the resulting log row will be a stand-alone entry — same as today. */ export interface IntentClassifyOptions { /** Episode id this classification is being run for, when known. */ episodeId?: EpisodeId; /** Foreground request cancellation propagated to the provider call. */ signal?: AbortSignal; } export interface IntentClassifier { classify( firstUserMessage: string, options?: IntentClassifyOptions, ): Promise; } export function createIntentClassifier(opts: IntentClassifierOptions = {}): IntentClassifier { const log = rootLogger.child({ channel: "core.session.intent" }); const llmDisabled = opts.disableLlm ?? !opts.llm; const llm = opts.llm; const timeoutMs = opts.timeoutMs ?? 6_000; return { async classify( firstUserMessage: string, options?: IntentClassifyOptions, ): Promise { const text = (firstUserMessage ?? "").trim(); if (text.length === 0) { return decisionFrom("chitchat", 0.9, "empty message", ["empty"]); } // Step 1: heuristics. const heuristic = matchFirst(text); if (heuristic && heuristic.confidence >= STRONG_HEURISTIC) { log.debug("heuristic.strong", { ruleId: heuristic.rule.id, kind: heuristic.kind, confidence: heuristic.confidence, }); return decisionFrom( heuristic.kind, heuristic.confidence, heuristic.rule.label, [heuristic.rule.id], ); } // Step 2: LLM tiebreaker when available. if (!llmDisabled && llm) { try { const result = await withTimeout( callLlm(llm, text, options?.episodeId, timeoutMs, options?.signal), timeoutMs, "intent.llm.timeout", ); log.debug("llm.ok", { kind: result.kind, confidence: result.confidence, modelServedBy: result.servedBy, }); const signals = ["llm"]; if (heuristic) signals.push(`heuristic:${heuristic.rule.id}(weak)`); const llmModel = result.servedBy; return { kind: result.kind, confidence: clamp01(result.confidence), reason: result.reason.slice(0, 120), retrieval: retrievalFor(result.kind), llmModel, signals, }; } catch (err) { log.warn("llm.failed", { err: summarizeErr(err) }); // Fall through — use heuristic fallback or `unknown`. } } // Step 3: heuristic fallback. if (heuristic) { log.debug("heuristic.fallback", { ruleId: heuristic.rule.id, kind: heuristic.kind, confidence: heuristic.confidence, }); return decisionFrom( heuristic.kind, heuristic.confidence, `${heuristic.rule.label} (fallback)`, [heuristic.rule.id, "llm_skipped"], ); } // Step 4: no signal at all — default to full retrieval. return decisionFrom( "unknown", 0.4, "no classifier signal; defaulting to full retrieval", ["default_unknown"], ); }, }; } function decisionFrom( kind: IntentKind, confidence: number, reason: string, signals: string[], ): IntentDecision { return { kind, confidence: clamp01(confidence), reason: reason.slice(0, 120), retrieval: retrievalFor(kind), signals, }; } function clamp01(n: number): number { if (!Number.isFinite(n)) return 0; if (n < 0) return 0; if (n > 1) return 1; return n; } function summarizeErr(err: unknown): Record { if (err instanceof MemosError) return { code: err.code, message: err.message }; if (err instanceof Error) return { name: err.name, message: err.message }; return { value: String(err) }; } // ─── LLM path ─────────────────────────────────────────────────────────────── const ALLOWED_KINDS: IntentKind[] = ["task", "memory_probe", "chitchat", "meta", "unknown"]; interface LlmIntentAnswer { kind: IntentKind; confidence: number; reason: string; servedBy: string; } const INTENT_SYSTEM = `You are a fast intent classifier for a memory/tool-using agent. Classify the user's message into ONE of: - "task" — user wants the agent to do work (build / fix / analyze / explain / run …). - "memory_probe" — user is asking about past conversation context. - "chitchat" — small talk, thanks, greetings with no actionable content. - "meta" — command to the plugin itself (starts with "/memos" / "/memory"). - "unknown" — truly ambiguous. Return JSON with exactly these keys: { "kind": one of the five labels above, "confidence": number in [0, 1], "reason": short English justification (≤ 80 chars, no quotes) } Rules: - Never invent a new label. - If unsure, pick "unknown" with confidence ≤ 0.5. - "task" is the safe default for imperative requests in any language.`; async function callLlm( llm: LlmClient, text: string, episodeId?: EpisodeId, timeoutMs?: number, signal?: AbortSignal, ): Promise { const rsp = await llm.completeJson<{ kind: unknown; confidence: unknown; reason: unknown }>( [ { role: "system", content: INTENT_SYSTEM }, { role: "user", content: text.slice(0, 2000) }, ], { op: "session.intent.classify", phase: "session", episodeId, timeoutMs, signal, schemaHint: `{"kind":"task"|"memory_probe"|"chitchat"|"meta"|"unknown","confidence":0..1,"reason":"..."}`, validate: (v) => { const o = v as Record; if (typeof o.kind !== "string" || !ALLOWED_KINDS.includes(o.kind as IntentKind)) { throw new MemosError(ERROR_CODES.LLM_OUTPUT_MALFORMED, "intent.kind out of vocabulary", { got: o.kind, }); } if (typeof o.confidence !== "number") { throw new MemosError( ERROR_CODES.LLM_OUTPUT_MALFORMED, "intent.confidence must be a number", { got: o.confidence }, ); } if (typeof o.reason !== "string") { throw new MemosError( ERROR_CODES.LLM_OUTPUT_MALFORMED, "intent.reason must be a string", { got: o.reason }, ); } }, malformedRetries: 1, temperature: 0, }, ); return { kind: rsp.value.kind as IntentKind, confidence: rsp.value.confidence as number, reason: sanitizeDerivedText(rsp.value.reason), servedBy: rsp.servedBy, }; } async function withTimeout(p: Promise, ms: number, label: string): Promise { let timer: ReturnType | null = null; const timeout = new Promise((_, reject) => { timer = setTimeout( () => reject(new MemosError(ERROR_CODES.LLM_TIMEOUT, label, { timeoutMs: ms })), ms, ); }); try { return await Promise.race([p, timeout]); } finally { if (timer) clearTimeout(timer); } } /** Expose rule metadata for the frontend / audit. */ export function listHeuristicRules(): ReadonlyArray<{ id: string; kind: IntentKind; label: string }> { return HEURISTIC_RULES.map((r) => ({ id: r.id, kind: r.kind, label: r.label })); }