-- router.lua — task classifier: meta / shell / AI / model-routing. -- See docs/PHASE0.md §5 and docs/PHASE5.md §4 for Phase 5 additions. -- -- M.classify(line, config) → (kind, payload) for input dispatch (Phase 0). -- M.classify_model(text, cfg) → name | nil for per-request model routing -- (Phase 5; pure-Lua heuristics, no IO). local M = {} local function trim(s) return (s:gsub("^%s+", ""):gsub("%s+$", "")) end local function first_word(s) return s:match("^(%S+)") or "" end local function known_commands_set(config) local set = {} local list = config and config.shell and config.shell.known_commands or {} for _, c in ipairs(list) do set[c] = true end return set end -- §5.1 path-like: ./foo, ../foo, /usr/bin/foo, ~/foo, bare ~. Quoted / -- escaped paths are intentionally out of scope in Phase 0. ~ is included -- for symmetry with executor.maybe_chdir, which expands ~ on `cd ~/foo`. local function path_like(token) return token == "~" or token:sub(1, 1) == "/" or token:sub(1, 2) == "./" or token:sub(1, 2) == "~/" or token:sub(1, 3) == "../" end function M.classify(line, config) line = trim(line or "") if line == "" then return "ai", "" end -- meta: ":" prefix if line:sub(1, 1) == ":" then return "meta", line:sub(2) end -- shell explicit override: "$" prefix if line:sub(1, 1) == "$" then return "shell", trim(line:sub(2)) end local first = first_word(line) local known = known_commands_set(config) -- known-command allowlist if known[first] then return "shell", line end -- path-like first token if path_like(first) then return "shell", line end -- everything else -> AI return "ai", line end -- ---------------------------------------------------------------- classify_model -- Phase 5: per-request model routing heuristic. Pure-Lua, no IO. -- Returns the NAME of a model preset (string) to switch to for this -- request, or nil to keep the active model unchanged. -- -- The mapping from class to model name lives in `cfg.routing.classes`. -- A class with value `nil` means "keep current" — even though the -- heuristic fires, no override happens (used by default for the -- `reasoning` class per R-N2 cost-safety policy). -- -- This function ALWAYS evaluates the heuristic regardless of -- `cfg.routing.auto` — the caller (repl.ask_ai) gates on the flag. -- This separation lets `:route check ` introspect the heuristic -- even when routing is disabled (N1). local function classify_class(text) if not text or text == "" then return "default" end -- ── Code class — looks like a paste or contains code markers if text:find("```", 1, true) then return "code" end local lower = text:lower() if lower:find("traceback", 1, true) or lower:find("stacktrace", 1, true) or lower:find("stack trace", 1, true) then return "code" end -- exception/error markers near beginning (first 60 chars) if lower:sub(1, 60):find("error:", 1, true) or lower:sub(1, 60):find("exception:", 1, true) then return "code" end -- path with code-extension token if text:match("[%./~][%w%-_/.]+%.([%w]+)") then local ext = text:match("[%./~][%w%-_/.]+%.([%w]+)") if ext == "py" or ext == "lua" or ext == "c" or ext == "js" or ext == "go" or ext == "rs" or ext == "cpp" or ext == "h" or ext == "ts" then return "code" end end -- multi-line + indented (looks like a code paste) local nlines = 0 for _ in (text .. "\n"):gmatch("[^\n]*\n") do nlines = nlines + 1 end if nlines > 4 and text:find("\n%s+%S") then return "code" end -- ── Reasoning class -- Min length 15 — catches "how does X work" but excludes bare "why" / "explain" if #text >= 15 then if lower:find("explain", 1, true) or lower:find("why ", 1, true) -- trailing space (not "whyever") or lower:find("how does", 1, true) or lower:find("compare", 1, true) then return "reasoning" end end if text:find("?", 1, true) and #text > 100 then return "reasoning" end return "default" end -- Public API. function M.classify_model(text, cfg) local class = classify_class(text) local classes = (cfg and cfg.routing and cfg.routing.classes) or {} local target = classes[class] -- nil target = keep current (this is the R-N2 default for "reasoning") if target == nil then return nil, class end -- Caller may want the class label for the status line; return both. return target, class end -- Exposed for `:route check` introspection (N1). M._classify_class = classify_class return M