-- repl.lua — readline loop, input dispatch, prompt rendering. -- Wires ffi/readline + router + executor + broker + context + renderer. -- See docs/PHASE0.md §5 (dispatch), §9 (prompt + readline). local rl = require("ffi.readline") local router = require("router") local executor = require("executor") local broker = require("broker") local renderer = require("renderer") local Context = require("context") local history = require("history") local mcp = require("mcp") local safety = require("safety") local json = require("dkjson") -- ---------------------------------------------------------------- @-mentions (issue #7) -- Triggered when "@" follows start-of-string or whitespace (avoids -- false positives on email addresses like user@example.com). Path -- runs until next whitespace. The mention is replaced by a fenced -- code block carrying the file contents, language-tagged by extension. -- Files over MENTION_MAX_BYTES are truncated head+tail with a marker. local MENTION_MAX_BYTES = 32 * 1024 local MENTION_HEAD = 16 * 1024 local MENTION_TAIL = 8 * 1024 local LANG_BY_EXT = { lua = "lua", py = "python", js = "javascript", ts = "typescript", sh = "bash", c = "c", h = "c", cc = "cpp", cpp = "cpp", hpp = "cpp", rs = "rust", go = "go", java = "java", rb = "ruby", md = "markdown", json = "json", yaml = "yaml", yml = "yaml", toml = "toml", html = "html", css = "css", sql = "sql", xml = "xml", } local function _lang_of(path) local ext = path:match("%.([%w]+)$") return ext and LANG_BY_EXT[ext:lower()] or "" end local function _read_truncated(path) local f = io.open(path, "rb") if not f then return nil end local content = f:read("*a") or "" f:close() if #content <= MENTION_MAX_BYTES then return content, false end local head = content:sub(1, MENTION_HEAD) local tail = content:sub(#content - MENTION_TAIL + 1) return head .. ("\n... [%d bytes elided] ...\n"):format(#content - MENTION_HEAD - MENTION_TAIL) .. tail, true end -- ---------------------------------------------------------------- shared shell helpers -- Lifted from M.run closure scope so expand_mentions (module-scope) can also -- use them for the @.. diff-retry path. Same single source of truth -- for the B1 git invocation prefix; commits #3 and #4 both call _git_clean_cmd. local function _shq(s) return "'" .. (s or ""):gsub("'", [['\'']]) .. "'" end local function _git_clean_cmd(subcmd_and_args) return "git --no-pager -c color.ui=never " .. subcmd_and_args end local function expand_mentions(line, on_status) -- Walk the line; for each "@" preceded by SOL or whitespace, -- attempt to read and substitute. Missing files leave the literal -- token in place + emit a status warning. local out, i = {}, 1 while i <= #line do local at_start = (i == 1) or line:sub(i - 1, i - 1):match("%s") ~= nil if at_start and line:sub(i, i) == "@" then local path_end = line:find("%s", i + 1) or (#line + 1) local raw = line:sub(i + 1, path_end - 1) -- Peel one or more trailing punctuation chars (,.;:?!) if the -- full path doesn't resolve — handles natural prose like -- "look at @README.md, then..." or "@foo.lua." at sentence end. local path, trail = raw, "" while #path > 0 do local f = io.open(path, "rb") if f then f:close(); break end local last = path:sub(-1) if last:match("[%.,;:?!)]") then trail = last .. trail path = path:sub(1, -2) else break end end if path ~= "" then local content, truncated = _read_truncated(path) local lang_override = nil -- Phase 6 / A6: tiered resolution — if path lookup -- failed AND token contains "..", try as a git diff -- ref-range. `@HEAD~1..HEAD` and `@origin/main..feature` -- both fall through to this branch when no such file -- exists. `@../sibling.txt` resolves as path first -- and never reaches this retry. if not content and path:find("..", 1, true) then local r1, r2 = path:match("^(.-)%.%.(.+)$") if r1 and r2 and r1 ~= "" and r2 ~= "" then local out_diff, code = executor.exec( _git_clean_cmd(("diff %s..%s 2>/dev/null") :format(_shq(r1), _shq(r2)))) if code == 0 and out_diff and out_diff:match("%S") then content = out_diff lang_override = "diff" end end end if content then local lang = lang_override or _lang_of(path) if on_status then on_status(("@%s expanded (%d bytes%s)"):format( path, #content, truncated and ", truncated" or (lang_override == "diff" and ", diff" or ""))) end out[#out + 1] = ("```%s path=%s\n%s\n```%s"):format( lang, path, content, trail) i = path_end else if on_status then on_status(("@%s: not found"):format(raw)) end out[#out + 1] = line:sub(i, path_end - 1) i = path_end end else out[#out + 1] = "@" i = i + 1 end else out[#out + 1] = line:sub(i, i) i = i + 1 end end return table.concat(out) end local M = {} local HELP = [[ Meta commands: :quit / :q exit aish (session flushed and closed) :clear clear screen (history kept) :reset clear in-memory conversation history :model switch active model :models list configured models (* = active) :history show conversation turns :exec force shell execution :ask force AI query (supports @path expansion) :sessions list session log files :save rename current session log to .jsonl :resume load .jsonl turns into the in-memory context :mcp list show connected MCP servers :mcp tools list tools across all sessions :mcp tool show one tool's inputSchema :mcp connect [a] open an MCP session at runtime :mcp disconnect drop an MCP session :norris launch Chuck Norris autonomous mode on :norris off exit Norris mode (rare — usually 'abort' at halt) :plan toggle plan mode (CMD: lines printed, NOT executed) :plan on / :plan off set plan mode explicitly :safety patterns list active destructive-op patterns :safety check probe is_destructive against without running :perms list show configured permission rules (allow/confirm/deny) :perms check report which permission verdict would receive :remember shortcut: :memory add fact :memory list show active memory items (id, ts, kind, content) :memory add add a memory item (kind: fact | pref | context) :memory forget append a tombstone for :memory clear forget all active items (confirms first) :memory inject reload memory.jsonl into ctx (after manual edits) :memory summarize LLM-extract candidate items from this session :route on/off toggle auto-routing per-request (heuristic in router.lua) :route classes show current class → model mapping :route check report which class would route to (debug) :fallback on/off toggle cloud retry when local transport fails :skills list user-defined skills loaded from ~/.config/aish/skills/ :secrets [status] show vault state, active broker redact mode (never prints values) :secrets check show what the active broker's scrub would do to :every schedule a recurring prompt (i: 30s | 5m | 2h) :every list show scheduled recurring prompts :every cancel remove a scheduled prompt :bg-spawn start a background job directly (no AI needed) :bg-list list background jobs (issued via CMD&: or :bg-spawn) :bg-output dump the log of a background job :bg-kill SIGTERM a background job :tree [] scan cwd file-tree, inject as [project] block in system prompt :tree refresh re-scan with last opts (or config defaults) :tree off clear the [project] block :diff [] git diff -> inject as [diff ...] exec_output examples: :diff :diff --cached :diff main..feature :cost summary of session token/cost usage :cost detail per-model + per-category breakdown :cost reset zero the cost meter (also clears warn flags) :config show [full] show config sources (user / project overlay) + effective config; sensitive keys masked as (set) :highlight [on|off|status] toggle tree-sitter syntax highlighting on assistant code fences (requires external tree-sitter CLI + built grammars; off by default) :delegate

one-shot sub-broker call to preset

; prints reply :help this message ]] function M.run(config) assert(config and config.models, "repl.run: config.models required") local active_name = config.default_model or next(config.models) local active_cfg = config.models[active_name] if not active_cfg then error("aish: default_model '" .. tostring(active_name) .. "' not found in config.models") end -- Plan mode (issue #5): when true, CMD: lines are NOT executed; they -- are echoed as "PLAN:" and fed back to the next-turn context as -- would-have-run notes so the model can iterate without side effects. -- Off by default; toggle with :plan / :plan on / :plan off. Orthogonal -- to Norris mode (Norris has its own halt protocol). local plan_mode = false -- Forward decl (issue #8): the bg spawn closure is defined deeper in -- M.run alongside the meta dispatch, but ask_ai needs to call it when -- routing CMD&: lines. Lua looks up names at call time; the closure -- has to exist as a local in scope BEFORE ask_ai is declared. local _bg_spawn -- Phase 7 forward decl: _record_usage is the central chokepoint -- for ctx:add_usage + warn-threshold check. Defined alongside -- call_broker below, but needs to be in lexical scope of the -- summarize-on-evict closure (which is built up earlier in -- make_summarize_fn). Same forward-declaration pattern as -- _bg_spawn — assign below, reference both early and late. local _record_usage -- Issue #13: secret redaction. Load vault if configured, create a -- session for this conversation. ctx stores PLAIN; we scrub just -- before broker.chat_stream and rehydrate the streamed reply for -- display. Tool args dispatched to MCP get rehydrated so the server -- sees the real values. Default mode resolution: per-broker -- `redact` field on the model preset → `config.secrets.default` -- → "vault+autodetect" if vault loaded → "off". local secrets = require("secrets") local secrets_session do local vpath = config.secrets and config.secrets.vault if vpath then -- Tilde expansion: ~ → $HOME for a common form. if vpath:sub(1, 2) == "~/" then vpath = (os.getenv("HOME") or "") .. vpath:sub(2) end local v, err = secrets.load(vpath) if v then secrets_session = secrets.make_session(v) renderer.status(("secrets vault loaded (%d entries)") :format(#v.entries)) else renderer.status(err or "secrets: load failed") end end end local function secrets_mode_for(model_cfg) if not secrets_session then return "off" end local m = (model_cfg and model_cfg.redact) or (config.secrets and config.secrets.default) if m then return m end return secrets_session:has_vault() and "vault+autodetect" or "off" end -- Walk an OpenAI-shape messages array, scrub all string content per -- the model's redact policy. Tool-call arguments are JSON strings — -- scrub them too (they may carry secrets if the model put a placeholder -- in a tool arg and was rendered through here on a re-iteration). local function scrub_messages(messages, mode) if mode == "off" or not secrets_session then return messages end for _, m in ipairs(messages) do if type(m.content) == "string" then m.content = secrets_session:scrub(m.content, mode) end if m.tool_calls then for _, tc in ipairs(m.tool_calls) do if tc["function"] and tc["function"].arguments then tc["function"].arguments = secrets_session:scrub( tc["function"].arguments, mode) end end end end return messages end -- Rehydrate a tool-call args table (recursive). Used at MCP dispatch -- so the server sees the real values when the model emitted placeholders. local function rehydrate_args(t) if not secrets_session then return t end if type(t) == "string" then return secrets_session:rehydrate(t) elseif type(t) == "table" then for k, v in pairs(t) do t[k] = rehydrate_args(v) end end return t end -- Phase 5: render the evicted turns into a compact transcript for -- the summarizer prompt. Same shape as :memory summarize uses. local function render_evicted(turns) local parts = {} for _, t in ipairs(turns or {}) do parts[#parts + 1] = ("%s: %s"):format( t.role, (t.content or ""):gsub("\n", " "):sub(1, 600)) end return table.concat(parts, "\n") end -- Phase 5: summarize_fn factory. Returns a closure that maps -- (prior_summary, evicted_turns) onto a broker.chat call against -- the configured summarizer model. Returns nil on any failure so -- Context falls back to silent eviction (Phase 0 behavior). local function make_summarize_fn() local sum_name = (config.context and config.context.summarizer_model) or "fast" local sum_cfg = config.models[sum_name] if not sum_cfg then return nil end return function(prior, evicted) local body if evicted == nil then body = "Compress this prior summary into 2-3 sentences. " .. "Keep names, facts, decisions; drop chatter.\n\n" .. "Prior summary:\n" .. (prior or "") elseif prior and prior ~= "" then body = "Extend this prior summary with the new turns. " .. "Keep it 2-4 sentences. Preserve names, facts, decisions.\n\n" .. "Prior summary:\n" .. prior .. "\n\nNew turns:\n" .. render_evicted(evicted) else body = "Summarize the following conversation turns in " .. "2-3 sentences. Preserve names, facts, decisions.\n\n" .. render_evicted(evicted) end local sum_msgs = scrub_messages({ { role = "system", content = "Output exactly one short summary paragraph. " .. "No commentary, no markdown, no bullet lists." }, { role = "user", content = body }, }, secrets_mode_for(sum_cfg)) -- Phase 7: broker.chat returns (text, usage) on success or -- (nil, errmsg) on failure. Capture as (text, second); branch -- on text nil-ness to interpret second. local reply, second = broker.chat(sum_cfg, sum_msgs, { max_tokens = 300, timeout_ms = 30000, category = "summarize" }) if not reply then renderer.status("context summarize failed: " .. tostring(second)) return nil end if second then -- usage payload _record_usage(second.model, second.category, second) end if secrets_session then reply = secrets_session:rehydrate(reply) end return reply:gsub("^%s+", ""):gsub("%s+$", "") end end -- Build Context with optional summarize_fn (gated by cfg flag). local ctx_opts = {} if config.context then for k, v in pairs(config.context) do ctx_opts[k] = v end end if config.context and config.context.summarize_on_evict then ctx_opts.summarize_fn = make_summarize_fn() end -- Phase 8 (docs/PHASE8.md): when cfg.tokenize.use_endpoint is true, -- wire a tokenize_fn so Context:estimate_tokens uses real counts -- from /tokenize (broker.token_count handles per-endpoint -- capability cache + char/4 fallback). R4: the closure body MUST -- reference `active_cfg` directly as an upvalue (NOT capture by -- value) so :model switches naturally re-route to the new model's -- tokenizer. A5 verified Lua upvalue semantics resolve at call time. if config.tokenize and config.tokenize.use_endpoint then ctx_opts.tokenize_fn = function(text) return broker.token_count(active_cfg, text) end end local ctx = Context.new(ctx_opts) -- Phase 2: MCP sessions. Populated from config.mcp.servers at startup -- (best-effort — failures are status-logged once, session absent from -- mcp_sessions until manual :mcp connect; no auto-retry per PHASE2.md -- §4 Lifecycle). Tools cached per-session for the session lifetime -- (lmcp announces capabilities.tools.listChanged = false). local mcp_sessions = {} -- { [alias] = session } local function connect_mcp(alias, server_cfg) local sess = mcp.connect(server_cfg.url, { alias = alias, auth_token = server_cfg.auth_token, auth_env = server_cfg.auth_env, }) local ok, kind, err = sess:initialize() if not ok then renderer.status(("mcp %s: %s (%s)") :format(alias, tostring(err), kind)) return false end mcp_sessions[alias] = sess if sess.version_warning then renderer.status("mcp " .. alias .. ": " .. sess.version_warning) end -- Tool-name validation (issue #32): Anthropic via Bedrock enforces -- ^[a-zA-Z0-9_-]{1,128}$. We use "__" as the alias separator, so the -- emitted name is alias__tool. Warn at startup; emit anyway so local -- llama.cpp users aren't penalized for lenient downstreams. if alias:find("__", 1, true) then renderer.status(("mcp %s: alias contains '__' (used as separator); " .. "tool dispatch will misparse"):format(alias)) end for _, t in ipairs(sess:list_tools()) do local full = alias .. "__" .. (t.name or "") if #full > 128 or full:find("[^%w_-]") then renderer.status(("mcp %s: tool name '%s' violates " .. "^[a-zA-Z0-9_-]{1,128}$ (will fail with strict providers " .. "e.g. anthropic via Bedrock)"):format(alias, full)) end end return true, #sess:list_tools() end -- Walk config.mcp.auto_approve and warn about keys that match no live -- tool / no live alias (issue #33). Stale entries silently failed to -- auto-approve, leaving the user with unexpected confirm prompts. -- Called at startup AND after :mcp connect so newly-arrived sessions -- retroactively validate any keys that referenced them. local function validate_auto_approve() local policy = config.mcp and config.mcp.auto_approve if not policy then return end for key, _ in pairs(policy) do local alias_glob = key:match("^(.-)__%*$") if alias_glob then if not mcp_sessions[alias_glob] then renderer.status(("auto_approve key '%s': no MCP server " .. "connected for alias '%s'"):format(key, alias_glob)) end else local alias, tname = key:match("^(.-)__(.+)$") if not alias or alias == "" or not tname then renderer.status(("auto_approve key '%s': not in " .. "'alias__tool' or 'alias__*' form"):format(key)) else local sess = mcp_sessions[alias] if not sess then renderer.status(("auto_approve key '%s': no MCP " .. "server connected for alias '%s'") :format(key, alias)) else local found = false for _, t in ipairs(sess:list_tools()) do if t.name == tname then found = true; break end end if not found then renderer.status(("auto_approve key '%s': " .. "alias '%s' has no tool named '%s'") :format(key, alias, tname)) end end end end end end if config.mcp and config.mcp.servers then for alias, server_cfg in pairs(config.mcp.servers) do local ok, n = connect_mcp(alias, server_cfg) if ok then renderer.status(("mcp %s: %d tools"):format(alias, n)) end end validate_auto_approve() end -- Assemble OpenAI-shape `tools` array across all live sessions, with -- "alias__name" namespacing. Originally PHASE2 used "." as the separator, -- but Anthropic via Bedrock validates tool names against -- ^[a-zA-Z0-9_-]{1,128}$ and rejects dots — amended to "__" 2026-05-12. -- Empty array → broker omits the field entirely (§12 risk row 1). -- Aliases must not themselves contain "__" so the parse stays unambiguous. local function tools_schema() local out = {} for alias, sess in pairs(mcp_sessions) do for _, t in ipairs(sess:list_tools()) do out[#out + 1] = { type = "function", ["function"] = { name = alias .. "__" .. t.name, description = t.description or "", parameters = t.inputSchema or { type = "object", properties = {} }, }, } end end return out end -- §4 "Content flattening": tool results may carry multiple blocks; v1 -- concatenates text and ignores non-text with a one-shot status. local non_text_warned = false local function flatten_content(content) local parts = {} local saw_non_text = false for _, b in ipairs(content or {}) do if b.type == "text" then parts[#parts + 1] = b.text or "" else saw_non_text = true end end if saw_non_text and not non_text_warned then non_text_warned = true renderer.status("tool returned non-text content blocks " .. "(image/resource ignored in v1)") end return table.concat(parts, "\n") end -- Split __, look up session, call. Returns (content_string, -- is_error). Errors of all flavors (rpc, transport, missing alias) -- yield a synthesized "[aish] tool ... failed: ..." string so the -- caller always has a body for the role:"tool" turn — the strict- -- template alternation rationale per PHASE0.md §6 and the C5/C7 fold -- in PHASE2.md §4. Non-greedy "(.-)__(.+)" splits at the leftmost "__". local function dispatch_tool_call(name, args) local alias, tool_name = name:match("^(.-)__(.+)$") if not alias then return ("[aish] tool name has no alias prefix: %s"):format(name), true end local sess = mcp_sessions[alias] if not sess then return ("[aish] no MCP server connected for alias '%s'") :format(alias), true end -- Issue #13: when secrets are configured, the model sees placeholders -- in its context and consequently emits placeholder-bearing tool args. -- The MCP server is treated as trusted local — rehydrate args before -- dispatch so the tool gets the real values. args = rehydrate_args(args) local result, kind, err = sess:call_tool(tool_name, args) if not result then if kind == "rpc_error" then local msg = (type(err) == "table" and err.message) or tostring(err) return ("[aish] tool dispatch failed: %s"):format(msg), true else return ("[aish] tool transport error: %s") :format(tostring(err)), true end end -- result has content[] and possibly isError=true. flatten_content -- handles the text-blocks-only flattening. We pass through the -- content body regardless of isError (per PHASE2-baseline.md §3: -- some tools set isError=false on actual failures, content text -- is authoritative). return flatten_content(result.content), (kind == "handler_error") end -- Session log (PHASE1.md §6). Always open one on startup; auto-write -- every user/assistant turn; close on :quit. If history.dir is set but -- unwritable, log a status and continue without persistence. local history_dir = (config.history and config.history.dir) or nil local sessions_dir = history_dir and (history_dir .. "/sessions") or nil local session_path = sessions_dir and (sessions_dir .. "/" .. os.date("!%Y-%m-%dT%H-%M-%SZ") .. ".jsonl") local session if session_path then local sess, serr = history.open(session_path, { started = os.date("!%Y-%m-%dT%H:%M:%SZ"), model = active_name, aish_version = "phase1", }) if sess then session = sess else renderer.status("session log disabled: " .. tostring(serr)) end end -- Phase 4: memory.jsonl handle. Sibling of sessions/ in the history dir. -- Single-writer enforced via flock; if held by another aish process, -- status-log once and run without memory (Phase 3 behavior). local memory_path = history_dir and (history_dir .. "/memory.jsonl") or nil local memory -- handle or nil local inject_max_chars = (config.memory and config.memory.inject_max_chars) or 2000 -- Inject the top-N items into ctx.memory_items, capped by char budget. local function inject_memory() if not memory_path then ctx.memory_items = nil; return end local items = history.load_memory(memory_path) if #items == 0 then ctx.memory_items = nil; return end local picked, total = {}, 0 for _, it in ipairs(items) do -- already sorted by ts desc local cost = #(it.content or "") + 16 -- rough overhead per line if total + cost > inject_max_chars then break end picked[#picked + 1] = it total = total + cost end ctx.memory_items = picked end if memory_path then local m, merr = history.open_memory(memory_path) if m then memory = m inject_memory() if ctx.memory_items and #ctx.memory_items > 0 then renderer.status(("memory: %d items injected"):format( #ctx.memory_items)) end else renderer.status("memory disabled: " .. tostring(merr)) end end local function log_turn(turn) if session then session:append(turn) end end -- Issue #10: configurable prompt template. When config.shell.prompt is -- set, substitute {model}/{ctx_used}/{ctx_max}/{turn}/{cwd}/{cwd_short} -- /{last_status}/{mode}. Otherwise fall back to the default with the -- norris ⚡ + plan markers. local libc = require("ffi.libc") local last_exec_code = nil local function _cwd_short() local c = libc.getcwd() or os.getenv("PWD") or "?" local home = os.getenv("HOME") if home and c:sub(1, #home) == home then c = "~" .. c:sub(#home + 1) end return c end local function _mode() if ctx.norris_active then return "norris" end if plan_mode then return "plan" end return "normal" end local function prompt() local tmpl = config.shell and config.shell.prompt if tmpl then local vars = { model = active_name, ctx_used = tostring(ctx:estimate_tokens()), ctx_max = tostring(ctx.token_budget), turn = tostring(#ctx.turns), cwd = libc.getcwd() or "?", cwd_short = _cwd_short(), last_status = last_exec_code and tostring(last_exec_code) or "", mode = _mode(), } return (tmpl:gsub("{([%w_]+)}", function(k) return vars[k] or "" end)) end if ctx.norris_active then return ("[aish:%s \xE2\x9A\xA1]> "):format(active_name) end if plan_mode then return ("[aish:%s plan]> "):format(active_name) end return ("[aish:%s]> "):format(active_name) end -- Phase 3: \C-n inserts ":norris " at the cursor so the user can type -- their goal and press Enter — routes through the meta dispatch -- normally. The :norris handler is implemented in `meta` below. rl.bind("\\C-n", function() rl.insert_text(":norris ") rl.redisplay() end) local function status_evictions(n) if n and n > 0 then renderer.status(("oldest %d turns evicted"):format(n)) end end -- ── Phase 5: fallback eligibility per PHASE5.md §5 ────────────────── -- All transport-failure patterns must match against the err string -- as broker.lua emits it (with "transport: " prefix). The matcher -- strips the prefix before testing. local FALLBACK_PATTERNS = { "^HTTP 5%d%d", "^HTTP 404.*model_not_found", "^HTTP 408", "Couldn'?t resolve host", "Could not connect to server", -- CURLE_COULDNT_CONNECT (port closed, host resolved) "Connection refused", "Timeout was reached", "Operation timed out", } local function fallback_reason(err) if type(err) ~= "string" then return "unknown error" end local stripped = err:gsub("^transport:%s*", "") for _, pat in ipairs(FALLBACK_PATTERNS) do if stripped:match(pat) then return (stripped:match(pat)) end end return nil end local function should_fallback(err) return config.routing and config.routing.fallback and fallback_reason(err) ~= nil end -- Phase 7 (R5): central chokepoint for usage recording. Wraps -- ctx:add_usage AND does the warn-threshold check. All callers -- (this file + safety.lua via helpers.on_usage / opts.on_usage) -- route through here so the warn check fires exactly once per -- accumulator update. Keeps context.lua decoupled from renderer. -- R2: caller passes the model name that should be CREDITED — for -- normal calls that's the active model; for fallback retries the -- broker's payload.model (which IS the fallback's model_cfg.model -- per broker emission) handles it correctly. _record_usage = function(model, category, usage) if not usage then return end ctx:add_usage(model, category, usage) if not (config.cost) then return end local cw = ctx.cost_warn_state if config.cost.warn_at_dollars and not cw.dollars then local cost = ctx:total_cost() if cost >= config.cost.warn_at_dollars then renderer.status(("session cost $%.6f has crossed warn_at_dollars=$%.6f") :format(cost, config.cost.warn_at_dollars)) cw.dollars = true end end if config.cost.warn_at_tokens and not cw.tokens then local p, c = ctx:total_tokens() if (p + c) >= config.cost.warn_at_tokens then renderer.status(("session tokens %d has crossed warn_at_tokens=%d") :format(p + c, config.cost.warn_at_tokens)) cw.tokens = true end end end -- Wrap broker.chat_stream with the Phase 5 fallback-retry path. -- Retries ONCE against cfg.routing.fallback_model (default "cloud") -- when (a) cfg.routing.fallback is true, (b) err matches a -- fallback-eligible pattern, AND (c) no deltas have arrived yet -- (mid-stream failures aren't retried — partial text would be -- duplicated). -- -- Phase 7 (R2): wrapped on_delta keys usage by payload.model -- (set inside broker.lua from model_cfg.model — the -- CALLER-INTENDED model name). When fallback fires, the broker -- is called with fb_cfg, so payload.model is naturally the -- fallback's model name — wrapper doesn't need to track -- primary-vs-fallback itself. local function call_broker(model_cfg, model_name, msgs, on_delta, opts) local any_delta = false local wrapped = function(kind, payload) if kind == "usage" then _record_usage(payload.model, payload.category, payload) return -- usage isn't forwarded to the underlying on_delta end any_delta = true return on_delta(kind, payload) end local ok, err = broker.chat_stream(model_cfg, msgs, wrapped, opts) if ok then return ok end if any_delta then return ok, err end -- mid-stream — don't retry if not should_fallback(err) then return ok, err end local fb_name = (config.routing and config.routing.fallback_model) or "cloud" local fb_cfg = config.models[fb_name] if not fb_cfg then return ok, err end renderer.status(("local %s failed (%s); retrying via %s") :format(model_name, fallback_reason(err), fb_name)) return broker.chat_stream(fb_cfg, msgs, wrapped, opts) end -- Run a shell command, framing output and (per config.shell.capture_output) -- buffering it for the NEXT user turn — context.append_exec_output keeps -- a [exec output] block pending until ask_ai flushes it via append_user. -- Direct user-role injection violated chat-template alternation (mistral- -- nemo's Jinja rejects user/user back-to-back); see PHASE0.md §6. -- -- Issue #3: pre_cmd / post_cmd hooks fire around exec. Each hook -- receives the command on stdin and AISH_CMD/AISH_TURN/AISH_CWD as -- env vars. Non-zero exit on pre_cmd aborts. post_cmd exit is -- ignored; its stdout is logged via renderer.status. -- _shq lifted to module scope (above expand_mentions) so the -- @-mention diff retry can share the same quoter. local function _run_hook(script, cmd, want_output) local cwd = (require("ffi.libc").getcwd()) or os.getenv("PWD") or "?" local pipeline = string.format( "printf '%%s' %s | AISH_CMD=%s AISH_TURN=%d AISH_CWD=%s %s 2>&1", _shq(cmd), _shq(cmd), #ctx.turns, _shq(cwd), _shq(script)) if want_output then local out, code = executor.exec(pipeline) return code, out else local out, code = executor.exec(pipeline) -- Even when we don't *want* output, surface it if the hook -- aborts so the user sees why. return code, out end end -- _git_clean_cmd lifted to module scope (above expand_mentions); -- shared with the @.. @-mention diff retry. Same B1 -- invariant: every git invocation that flows back into context -- runs with `--no-pager -c color.ui=never`. -- Phase 6 highlighter (commit #5): tree-sitter CLI detection + -- per-language extension map + path-based dispatch. -- -- R4 resolution: the upstream `tree-sitter highlight` CLI takes a -- PATH (no --lang flag); language is inferred from the file -- extension. Empty `--scope source.X` is also unreliable -- without configured grammars. So we name the tmpfile with the -- canonical extension for `lang` and let the CLI dispatch. -- -- Additional B4-followup: even with the CLI installed, highlighting -- requires parser-directories configured AND grammars cloned + built. -- Without those, every highlight call emits a warning to stderr and -- returns empty stdout. We treat empty/error as pass-through (body -- returned as-is). local LANG_EXTENSION = { lua = ".lua", python = ".py", javascript = ".js", typescript = ".ts", bash = ".sh", c = ".c", cpp = ".cpp", rust = ".rs", go = ".go", java = ".java", ruby = ".rb", markdown = ".md", json = ".json", yaml = ".yaml", toml = ".toml", html = ".html", css = ".css", sql = ".sql", xml = ".xml", } -- Map lang-tag (as it appears in ```) to canonical lang. Mirrors -- expand_mentions LANG_BY_EXT but indexed by tag (e.g., "py" -> "python"). local LANG_TAG = { py = "python", python = "python", lua = "lua", js = "javascript", javascript = "javascript", ts = "typescript", typescript = "typescript", sh = "bash", bash = "bash", c = "c", cpp = "cpp", cc = "cpp", rs = "rust", go = "go", java = "java", rb = "ruby", ruby = "ruby", md = "markdown", markdown = "markdown", json = "json", yaml = "yaml", yml = "yaml", toml = "toml", html = "html", css = "css", sql = "sql", xml = "xml", } local function _detect_treesitter() local pipe = io.popen("command -v tree-sitter 2>/dev/null && tree-sitter --version 2>/dev/null") -- N2 / B3: pipe:close() returns true on LuaJIT regardless of exit -- code; we don't use it for the verdict. Presence of an output -- line from --version is the actual signal. local ok = pipe and pipe:read("*l") and pipe:close() return ok and true or false end local highlight_enabled = false local highlight_detected = _detect_treesitter() -- highlighted(body, lang_tag) — R2-placed in repl.lua so it has -- access to _shq + executor. Returns the rendered body (with ANSI) -- or `body` unchanged on any failure (silent pass-through so the -- user never sees a broken highlighter swallow their code block). local function highlighted(body, lang_tag) if not highlight_enabled then return body end local lang = LANG_TAG[(lang_tag or ""):lower()] local ext = lang and LANG_EXTENSION[lang] if not ext then return body end -- B3: io.popen close doesn't expose exit code; route via -- executor.exec (pty.spawn + waitpid) for reliable (out, code). local tmp = os.tmpname() .. ext local f = io.open(tmp, "wb") if not f then return body end f:write(body); f:close() local out, code = executor.exec( ("tree-sitter highlight %s 2>/dev/null"):format(_shq(tmp))) os.remove(tmp) if code ~= 0 or not out or out == "" then return body end return out end -- Wire the filter into renderer (off by default; user opts in via -- :highlight on). Even when off, we set the callback so a later -- toggle works without reinitialization. renderer.set_highlight(highlight_enabled, highlight_detected, highlighted) -- Phase 6 (§6 + N4): project file-tree scanner. Prefers -- `git -C

ls-files --cached --others --exclude-standard` -- when is inside a git repo (free .gitignore honor); -- falls back to `find ... -not -path '*/.'` for non-repo -- cwds. opts: { depth = N, max_chars = N }; defaults via cfg.project. -- Returns (body, info) where info = { file_count, truncated }. local function _scan_project_tree(dir, opts) opts = opts or {} local p_cfg = config.project or {} local depth = opts.depth or p_cfg.tree_depth or 3 local max_chars = opts.max_chars or p_cfg.tree_max_chars or 4096 -- N4: `git -C ` skips the subshell vs `cd && git ...`. local in_git = os.execute( ("git -C %s rev-parse --git-dir >/dev/null 2>&1"):format(_shq(dir)) ) == 0 local listcmd if in_git then listcmd = ("git -C %s ls-files --cached --others --exclude-standard") :format(_shq(dir)) else -- find honors -maxdepth from the start path; we count the -- depth in terms of nested subdirectories beneath . listcmd = ("find %s -mindepth 1 -maxdepth %d -type f -not -path '*/.*' 2>/dev/null") :format(_shq(dir), depth + 1) end local pipe = io.popen(listcmd) if not pipe then return nil, "scan failed (popen)" end local files = {} for line in pipe:lines() do -- Depth filter is a no-op for the git case (ls-files emits -- full repo-relative paths); for find we already capped via -- -maxdepth. Keep the slash count here as a defensive bound. local _, slashes = line:gsub("/", "") if slashes <= depth then files[#files + 1] = line end end pipe:close() table.sort(files) local body = table.concat(files, "\n") local truncated = false if #body > max_chars then body = body:sub(1, max_chars) .. "\n... (truncated)" truncated = true end return body, { file_count = #files, truncated = truncated, in_git = in_git } end local function run_shell(cmd) local chd, err = executor.maybe_chdir(cmd) if chd ~= nil then if chd then local pwd = io.popen("pwd"):read("*l") or "?" renderer.status("cwd -> " .. pwd) else renderer.status("cd: " .. tostring(err)) end return end local hooks = config.hooks or {} if hooks.pre_cmd then local rc = _run_hook(hooks.pre_cmd, cmd, false) if rc ~= 0 then renderer.status(("pre_cmd hook aborted (exit %d): %s") :format(rc, cmd)) last_exec_code = rc return end end renderer.exec_begin() local out, code = executor.exec(cmd) last_exec_code = code renderer.exec_end(code) if config.shell and config.shell.capture_output then ctx:append_exec_output(out) end if hooks.post_cmd then _run_hook(hooks.post_cmd, cmd, true) end end -- Send user text to the active model and process the response. If MCP -- tools are connected and the model emits tool_calls, dispatch each -- call (with safety confirm gate), append role:"tool" turns, and -- re-call the broker — looping until the model returns pure text or -- max_tool_depth is hit. CMD: extraction runs ONCE on the final -- pure-text response (the §6 substrate invariant is unchanged). local max_tool_depth = (config.mcp and config.mcp.max_tool_depth) or 8 local function ask_ai(text) local prev_pending = ctx.pending_exec_output ctx:append_user(text) log_turn(ctx.turns[#ctx.turns]) -- Phase 5 R-C2: routing decision taken ONCE on entry to ask_ai. -- req_name/req_cfg are used for every iteration of the -- tool-sub-loop; active_name/active_cfg are NOT mutated so the -- user's :model selection survives the request. local req_name, req_cfg = active_name, active_cfg local req_class if config.routing and config.routing.auto then local routed, class = router.classify_model(text, config) req_class = class if routed and config.models[routed] and routed ~= active_name then renderer.status(("routed to %s (%s class)"):format(routed, class)) req_name, req_cfg = routed, config.models[routed] end end -- Phase 10 (#86): per-class system_prompt override. Tightens -- instruction adherence on small local models. Lookup is -- gated on auto-routing being on (no class -> no override). -- When the override is set, ctx:to_messages REPLACES the base -- system_prompt for THIS render only; dynamic blocks -- ([background], [project], [earlier summary], NORRIS suffix) -- still compose on top. Norris path is unaffected — it builds -- its own messages array inside safety.norris_step. local sys_override = config.routing and config.routing.system_prompts and req_class and config.routing.system_prompts[req_class] -- #88: per-class GBNF grammar passthrough. llama.cpp constrains -- the sampler to only emit tokens matching the grammar — kills -- format drift on small models. Cloud silently ignores the -- field (probed Anthropic/Bedrock returns normally). local grammar_override = config.routing and config.routing.grammars and req_class and config.routing.grammars[req_class] -- #87: route-aware context compression. When the routed model -- preset has `local_compress = true`, ctx:to_messages keeps only -- the last N turns and tail-truncates oversized content for -- THIS request. Cloud routes (model_cfg.local_compress nil/false) -- get the full context unchanged. Defaults from cfg.context.compress; -- per-model opt-in keeps the design surface predictable. local compress_opts if req_cfg and req_cfg.local_compress then local cc = (config.context and config.context.compress) or {} compress_opts = { keep_turns = cc.keep_turns or 2, max_turn_chars = cc.max_turn_chars or 800, } end local depth = 0 local final_resp = "" local first_iteration = true while true do local text_parts = {} local tool_calls_seen = {} local redact_mode = secrets_mode_for(req_cfg) local scrubbed_msgs = scrub_messages( ctx:to_messages({ system_prompt_override = sys_override, compress = compress_opts, }), redact_mode) -- Streaming rehydrator wraps the on_delta so the user sees real -- values; text_parts accumulates the REHYDRATED chunks so -- final_resp (used for CMD: / DELEGATE: extraction) is plain. local rehydrator = secrets_session and secrets.streaming_rehydrator(secrets_session) or nil local ok, err = call_broker(req_cfg, req_name, scrubbed_msgs, function(kind, payload) if kind == "text" then local emit = rehydrator and rehydrator:push(payload) or payload if emit ~= "" then text_parts[#text_parts + 1] = emit renderer.assistant_delta(emit) end elseif kind == "tool_call" then tool_calls_seen[#tool_calls_seen + 1] = payload end end, { tools = tools_schema(), category = "main", grammar = grammar_override }) if rehydrator then local tail = rehydrator:flush() if tail ~= "" then text_parts[#text_parts + 1] = tail renderer.assistant_delta(tail) end end renderer.assistant_flush() if not ok then renderer.status("broker error: " .. tostring(err)) if first_iteration then -- Back out the user turn so :resume / retry is clean. table.remove(ctx.turns) ctx.pending_exec_output = prev_pending end return end first_iteration = false local resp_text = table.concat(text_parts) if #tool_calls_seen == 0 then -- Pure text response — end of this AI turn. ctx:append({ role = "assistant", content = resp_text }) log_turn(ctx.turns[#ctx.turns]) final_resp = resp_text break end -- Record the assistant turn with text AND tool_calls. Content -- may be "" (C3: model often emits no prose before a call). ctx:append({ role = "assistant", content = resp_text, tool_calls = tool_calls_seen, }) log_turn(ctx.turns[#ctx.turns]) -- Process each tool_call. Every iteration appends EXACTLY one -- role:"tool" turn per call (keeps alternation legal even on -- decline/error per C5/C7). for _, call in ipairs(tool_calls_seen) do local args_table, args_err if call.arguments and call.arguments ~= "" then args_table, _, args_err = json.decode(call.arguments) else args_table = {} end local tool_content, is_error if args_err then tool_content = ("[aish] tool arguments not parseable as " .. "JSON: %s"):format(tostring(args_err)) is_error = true renderer.tool_call_begin(call.name, call.arguments) renderer.tool_call_end(tool_content, true) elseif not safety.confirm_tool_call(call.name, args_table, config) then tool_content = "[aish] tool call declined by user" is_error = true renderer.status(tool_content) else renderer.tool_call_begin(call.name, call.arguments) local content, errflag = dispatch_tool_call(call.name, args_table) tool_content = content is_error = errflag renderer.tool_call_end(content, errflag) end ctx:append({ role = "tool", tool_call_id = call.id, content = tool_content, }) log_turn(ctx.turns[#ctx.turns]) end depth = depth + 1 if depth >= max_tool_depth then renderer.status(("tool-call depth limit reached (%d); " .. "stopping sub-loop"):format(max_tool_depth)) final_resp = resp_text break end -- loop body re-runs broker.chat_stream with the now-extended ctx end status_evictions(ctx:enforce_budget()) -- CMD: extraction on the final pure-text response only. for _, cmd in ipairs(executor.extract_cmd_lines(final_resp)) do if plan_mode then -- Issue #5: print PLAN: and feed back as a would-have-run -- note. Same context flow as a real exec output so the -- model can iterate on the plan turn by turn. renderer.status(("PLAN: %s"):format(cmd)) ctx:append_exec_output(("[plan] would run: %s"):format(cmd)) else -- Issue #9: permission policy DSL — verdict drives the gate. -- Falls back to shell.confirm_cmd boolean when config.permissions -- is unset (backward compat). local verdict, rule = safety.classify_command(cmd, config) local doit = false if verdict == "allow" then doit = true elseif verdict == "deny" then renderer.status(("denied by policy [%s]: %s") :format(rule or "default", cmd)) else -- "confirm" local ans = rl.readline(("execute '%s'? [y/N] "):format(cmd)) or "" doit = (ans:lower():sub(1, 1) == "y") end if doit then run_shell(cmd) end end end -- Issue #8: CMD&: extraction — spawn each as a background job. -- No confirm gate in v1 (the model issuing CMD&: is opting into the -- async path; permission policy is still bypassed there. Revisit -- once #9 is generalized beyond the synchronous CMD: gate). for _, cmd in ipairs(executor.extract_cmd_bg_lines(final_resp)) do if plan_mode then renderer.status(("PLAN: & %s"):format(cmd)) ctx:append_exec_output(("[plan] would bg-run: %s"):format(cmd)) else local job, err = _bg_spawn(cmd) if not job then renderer.status(("bg spawn failed: %s"):format(tostring(err))) ctx:append_exec_output(("[bg failed to start]: %s"):format(cmd)) else local note = ("[bg:%d started pid=%d]: %s") :format(job.id, job.pid, cmd) renderer.status(note) ctx:append_exec_output(note) end end end -- Issue #6: DELEGATE: "" — sub-broker call against -- a different model preset. Result is fed back as exec-output so the -- model sees it on the next turn. Synchronous (blocks the current -- ask_ai return until each delegate resolves). Cost note: a DELEGATE -- to a paid cloud preset spends API tokens silently — the user has -- already opted in by configuring the preset. for _, d in ipairs(executor.extract_delegate_lines(final_resp)) do local sub_cfg = config.models[d.preset] if plan_mode then renderer.status(("PLAN: DELEGATE %s \"%s\""):format(d.preset, d.prompt)) ctx:append_exec_output( ("[plan] would delegate to %s: %s"):format(d.preset, d.prompt)) elseif not sub_cfg then renderer.status(("DELEGATE: unknown preset '%s'"):format(d.preset)) ctx:append_exec_output( ("[delegate %s failed: unknown preset]"):format(d.preset)) else renderer.status(("DELEGATE -> %s: %s"):format(d.preset, d.prompt)) local sub_msgs = scrub_messages( { { role = "user", content = d.prompt } }, secrets_mode_for(sub_cfg)) -- Phase 7: capture (text, usage) — second is err on failure. local sub_text, second = broker.chat(sub_cfg, sub_msgs, { category = "delegate" }) if not sub_text then renderer.status(("delegate %s failed: %s"):format(d.preset, tostring(second))) ctx:append_exec_output( ("[delegate %s failed: %s]"):format(d.preset, tostring(second))) else if second then -- usage payload _record_usage(second.model, second.category, second) end -- Rehydrate the reply so the model sees its own -- secrets restored when this gets re-serialized -- on the next ask_ai turn. if secrets_session then sub_text = secrets_session:rehydrate(sub_text) end ctx:append_exec_output( ("[delegate %s]: %s"):format(d.preset, sub_text)) end end end end local function shutdown_session() if session then session:close(); session = nil end if memory then memory:close(); memory = nil end end -- ---------------------------------------------------------------- Norris driver -- The Phase 3 autonomous mode driver. Sets ctx.norris_active + -- ctx.norris_goal so context.to_messages() composes the NORRIS MODE -- system-prompt suffix on each broker call. Loops calling -- safety.norris_step until the planner returns a terminal status. local max_norris_steps = (config.safety and config.safety.max_norris_steps) or 8 -- The HALT prompt — proceed / skip / abort. Returns one of those -- three verdict strings. Used by safety.norris_step via the helpers -- table. \C-x\C-c also aborts (PHASE1.md §7 reserved key). local function norris_halt(step_n, max_n, reason, action) renderer.norris_halt(step_n, max_n, reason, action) local ans = rl.readline("[N] proceed / skip / abort? ") or "" local first = ans:lower():sub(1, 1) if first == "p" then return "proceed" end if first == "s" then return "skip" end return "abort" -- empty input or anything else → abort (safe default) end -- Dispatch an MCP tool by name. Returns (content_string, is_error). -- Mirrors what the Phase 2 ask_ai tool path does, but factored so -- safety.norris_step can call it via helpers. local function dispatch_tool(name, args) local alias, tool_name = name:match("^(.-)__(.+)$") if not alias or alias == "" then return ("[aish] tool name has no alias prefix: %s"):format(name), true end local sess = mcp_sessions[alias] if not sess then return ("[aish] no MCP server connected for alias '%s'") :format(alias), true end local result, kind, err = sess:call_tool(tool_name, args) if not result then if kind == "rpc_error" then local msg = (type(err) == "table" and err.message) or tostring(err) return ("[aish] tool dispatch failed: %s"):format(msg), true else return ("[aish] tool transport error: %s"):format(tostring(err)), true end end local parts = {} for _, b in ipairs(result.content or {}) do if b.type == "text" then parts[#parts + 1] = b.text or "" end end return table.concat(parts, "\n"), (kind == "handler_error") end -- Exec a shell command for Norris (mirrors run_shell minus the cd -- intercept which is interactive-only). Returns (output, exit_code). local function norris_exec(cmd) local chd, _ = executor.maybe_chdir(cmd) if chd ~= nil then -- cd in autonomous mode just changes our cwd silently return chd and "" or "[aish] cd failed", 0 end return executor.exec(cmd) end local function run_norris(goal) ctx.norris_active = true ctx.norris_goal = goal ctx.norris_consecutive_skips = 0 ctx:append_user(("[norris] %s"):format(goal)) log_turn(ctx.turns[#ctx.turns]) renderer.norris_begin(goal) local helpers = { tools_schema = tools_schema, exec_cmd = norris_exec, dispatch_tool = dispatch_tool, extract_cmd_lines = executor.extract_cmd_lines, halt = norris_halt, render_step = renderer.norris_step, render_tool_begin = renderer.tool_call_begin, render_tool_end = renderer.tool_call_end, render_exec_begin = renderer.exec_begin, render_exec_end = renderer.exec_end, render_assistant_delta = renderer.assistant_delta, render_assistant_flush = renderer.assistant_flush, log_turn = log_turn, -- Issue #52: pass secrets-aware callbacks so safety.lua -- can scrub outbound Norris broker messages + LLM probe -- inputs + rehydrate streamed replies. All three are nil- -- safe; safety.lua only wires them in when present. scrub_msgs = function(msgs, mode_cfg) return scrub_messages(msgs, secrets_mode_for(mode_cfg or active_cfg)) end, rehydrate = function(text) return secrets_session and secrets_session:rehydrate(text) or text end, streaming_rehydrator = function() return secrets_session and secrets.streaming_rehydrator(secrets_session) or nil end, -- Phase 7: hand the central usage chokepoint to safety.lua. -- safety.norris_step routes the Norris main broker's usage -- here under category="norris"; safety.is_destructive's LLM -- probe routes via opts.on_usage under category="probe". on_usage = _record_usage, } local step_n = 1 local final_status, final_reason while true do local result = safety.norris_step(ctx, active_cfg, helpers, { step_n = step_n, max_steps = max_norris_steps, cfg = config, }) -- Issue #51: enforce budget after every step (was post-loop only). -- PHASE3.md §2 specifies sliding-window eviction mid-Norris-session -- when the loop runs long; this is what makes R-C3 (NORRIS suffix -- goal anchor surviving eviction) observable end-to-end. status_evictions(ctx:enforce_budget()) if result.status == "continue" then step_n = step_n + 1 else final_status, final_reason = result.status, result.reason break end end ctx.norris_active = false ctx.norris_goal = nil renderer.norris_end(final_status, final_reason) end -- Meta dispatch table. local meta = { quit = function() shutdown_session(); os.exit(0) end, q = function() shutdown_session(); os.exit(0) end, clear = function() io.write("\27[H\27[2J"); io.flush() end, reset = function() ctx:reset(); renderer.status("context reset") end, model = function(args) local name = args:match("^%s*(%S+)") if not name or not config.models[name] then renderer.status("usage: :model ; not found: " .. tostring(name)) return end active_name, active_cfg = name, config.models[name] renderer.status("model -> " .. name) end, plan = function(args) local sub = (args:match("^%s*(%S*)") or ""):lower() if sub == "" then plan_mode = not plan_mode elseif sub == "on" then plan_mode = true elseif sub == "off" then plan_mode = false else renderer.status("usage: :plan [on|off]"); return end renderer.status("plan mode " .. (plan_mode and "on" or "off")) end, models = function() renderer.status(("models (active: %s):"):format(active_name)) for name, cfg in pairs(config.models) do local mark = (name == active_name) and "*" or " " io.write((" %s %-8s %s @ %s\n"):format( mark, name, cfg.model or "?", cfg.endpoint or "?")) end end, history = function() if #ctx.turns == 0 then renderer.status("(empty)"); return end for i, t in ipairs(ctx.turns) do io.write(("[%d] %s: %s\n"):format( i, t.role, t.content:gsub("\n", " "))) end end, exec = function(args) args = (args or ""):match("^%s*(.-)%s*$") if args == "" then renderer.status("usage: :exec "); return end run_shell(args) end, ask = function(args) args = (args or ""):match("^%s*(.-)%s*$") if args == "" then renderer.status("usage: :ask "); return end ask_ai(expand_mentions(args, renderer.status)) end, sessions = function() if not sessions_dir then renderer.status("(no history.dir configured)"); return end local names = history.list_sessions(sessions_dir) if #names == 0 then renderer.status("(no sessions in " .. sessions_dir .. ")"); return end for _, n in ipairs(names) do local mark = (session_path and session_path:match("[^/]+$") == n) and "*" or " " io.write((" %s %s\n"):format(mark, n)) end end, save = function(args) local name = args:match("^%s*(%S+)") if not name then renderer.status("usage: :save "); return end if not (session and session_path and sessions_dir) then renderer.status("no active session to save") return end name = name:gsub("%.jsonl$", "") local new_path = sessions_dir .. "/" .. name .. ".jsonl" if new_path == session_path then renderer.status("already named " .. name) return end session:close() local ok, rerr = os.rename(session_path, new_path) if not ok then renderer.status("rename failed: " .. tostring(rerr)) -- best-effort reopen of original path so logging continues session = history.open(session_path) return end session_path = new_path session = history.open(session_path) -- reopen for continued append renderer.status("saved as " .. name .. ".jsonl") end, resume = function(args) local name = args:match("^%s*(%S+)") if not name then renderer.status("usage: :resume "); return end if not sessions_dir then renderer.status("(no history.dir configured)"); return end -- Refuse to silently clobber an active conversation; the user has -- to :reset first to express intent. The current session log on -- disk is unaffected by either choice. if #ctx.turns > 0 then renderer.status(("resume into non-empty ctx refused (%d turns); :reset first") :format(#ctx.turns)) return end name = name:gsub("%.jsonl$", "") local path = sessions_dir .. "/" .. name .. ".jsonl" local turns, _meta_hdr = history.load(path) if not turns then renderer.status("resume failed: cannot load " .. path) return end ctx:reset() for _, t in ipairs(turns) do ctx:append(t) end renderer.status(("resumed %d turns from %s"):format(#turns, name)) end, mcp = function(args) local sub, sub_args = args:match("^%s*(%S*)%s*(.*)$") if sub == "list" or sub == "" then if next(mcp_sessions) == nil then renderer.status("(no MCP sessions)"); return end for alias, sess in pairs(mcp_sessions) do io.write((" %s %s (%d tools)\n"):format( alias, sess.url, #sess:list_tools())) end elseif sub == "tools" then local any = false for alias, sess in pairs(mcp_sessions) do for _, t in ipairs(sess:list_tools()) do any = true local desc = (t.description or ""):gsub("\n", " ") io.write((" %s__%-16s %s\n"):format( alias, t.name, desc:sub(1, 60))) end end if not any then renderer.status("(no tools)") end elseif sub == "tool" then local name = sub_args:match("^%s*(%S+)") if not name then renderer.status("usage: :mcp tool "); return end local alias, tname = name:match("^(.-)__(.+)$") if not alias or alias == "" then renderer.status("tool name missing alias prefix: " .. name) return end local sess = mcp_sessions[alias] if not sess then renderer.status("unknown alias: " .. alias) return end local found for _, t in ipairs(sess:list_tools()) do if t.name == tname then found = t; break end end if not found then renderer.status("unknown tool: " .. name); return end io.write((" %s__%s\n"):format(alias, found.name)) io.write((" description: %s\n"):format(found.description or "(none)")) io.write(" inputSchema:\n ") io.write((json.encode(found.inputSchema or {}, {indent = true}) :gsub("\n", "\n "))) io.write("\n") elseif sub == "connect" then local url, alias = sub_args:match("^%s*(%S+)%s*(%S*)") if not url or url == "" then renderer.status("usage: :mcp connect [alias]"); return end if alias == "" then alias = url:match("https?://([^:/]+)") or url end if mcp_sessions[alias] then renderer.status("already connected: " .. alias); return end local ok, n = connect_mcp(alias, { url = url }) if ok then renderer.status(("mcp %s: connected (%d tools)") :format(alias, n)) -- Re-validate auto_approve so any stale keys that -- referenced this alias become live (issue #33 bonus). validate_auto_approve() end elseif sub == "disconnect" then local alias = sub_args:match("^%s*(%S+)") if not alias then renderer.status("usage: :mcp disconnect "); return end local sess = mcp_sessions[alias] if not sess then renderer.status("not connected: " .. alias); return end sess:close() mcp_sessions[alias] = nil renderer.status("disconnected " .. alias) else renderer.status("usage: :mcp {list|tools|tool|connect|disconnect}") end end, norris = function(args) local sub = args:match("^%s*(%S*)") if sub == "off" then if ctx.norris_active then ctx.norris_active = false ctx.norris_goal = nil renderer.status("Norris mode off") else renderer.status("Norris mode is not active") end return end local goal = args:match("^%s*(.-)%s*$") if not goal or goal == "" then renderer.status("usage: :norris "); return end run_norris(goal) end, remember = function(args) local text = args:match("^%s*(.-)%s*$") if not text or text == "" then renderer.status("usage: :remember "); return end if not memory then renderer.status("memory unavailable"); return end local id = memory:add("fact", text) inject_memory() -- refresh live ctx so the next AI turn sees it renderer.status(("remembered as id=%d (fact)"):format(id)) end, memory = function(args) local sub, sub_args = args:match("^%s*(%S*)%s*(.*)$") if sub == "" or sub == "list" then if not memory_path then renderer.status("memory unavailable (no history.dir)"); return end local items = history.load_memory(memory_path) if #items == 0 then renderer.status("(no memory items)"); return end for _, it in ipairs(items) do io.write((" %3d %s %-7s %s\n"):format( it.id, it.ts, it.kind, (it.content or ""):gsub("\n", " "):sub(1, 80))) end elseif sub == "add" then if not memory then renderer.status("memory unavailable"); return end local kind, body = sub_args:match("^%s*(%S+)%s+(.+)$") if not kind or not body then renderer.status("usage: :memory add "); return end if kind ~= "fact" and kind ~= "pref" and kind ~= "context" then renderer.status("kind must be fact, pref, or context"); return end local id = memory:add(kind, body:gsub("^%s+", ""):gsub("%s+$", "")) inject_memory() renderer.status(("added id=%d (%s)"):format(id, kind)) elseif sub == "forget" then if not memory then renderer.status("memory unavailable"); return end local id = tonumber(sub_args:match("^%s*(%d+)")) if not id then renderer.status("usage: :memory forget "); return end -- N1: check active set first; surface status if id isn't active local items = history.load_memory(memory_path) local found = false for _, it in ipairs(items) do if it.id == id then found = true; break end end if not found then renderer.status(("id %d not active (already forgotten or never existed)"):format(id)) return end memory:forget(id) inject_memory() renderer.status(("forgot id=%d"):format(id)) elseif sub == "clear" then if not memory then renderer.status("memory unavailable"); return end local items = history.load_memory(memory_path) if #items == 0 then renderer.status("(no items to clear)"); return end local ans = rl.readline( ("forget all %d active memory items? [y/N] "):format(#items)) or "" if ans:lower():sub(1,1) ~= "y" then renderer.status("clear cancelled"); return end for _, it in ipairs(items) do memory:forget(it.id) end inject_memory() renderer.status(("cleared %d items"):format(#items)) elseif sub == "inject" then inject_memory() renderer.status(("re-injected %d items"):format( (ctx.memory_items and #ctx.memory_items) or 0)) elseif sub == "summarize" then if not memory then renderer.status("memory unavailable"); return end if not session_path then renderer.status("no session log to summarize"); return end -- Source of truth is the session log file (R-C2). -- Exclude prior summarize exchanges to avoid drift. local turns, _meta = history.load(session_path) if not turns or #turns == 0 then renderer.status("session log empty; nothing to summarize"); return end local filtered = {} for _, t in ipairs(turns) do if t.meta ~= "summarize" then filtered[#filtered + 1] = ("%s: %s"):format(t.role, (t.content or ""):gsub("\n", " "):sub(1, 800)) end end local transcript = table.concat(filtered, "\n") if #transcript < 50 then renderer.status("session content too short to summarize"); return end -- Pick summarizer model. local sum_name = (config.memory and config.memory.summarizer_model) or active_name local sum_cfg = config.models[sum_name] if not sum_cfg then renderer.status("summarizer model not found: " .. sum_name); return end renderer.status(("summarizing via %s ..."):format(sum_name)) local sum_msgs = scrub_messages({ { role = "system", content = "Read the following conversation transcript. Extract " .. "facts, preferences, or context worth remembering " .. "across future sessions. Output ONE candidate per " .. "line, prefixed with the kind: \"fact: ...\", " .. "\"pref: ...\", or \"context: ...\". Maximum 10 " .. "candidates. No commentary outside candidate lines." }, { role = "user", content = transcript }, }, secrets_mode_for(sum_cfg)) -- Phase 7: capture (text, usage); second is err on failure. local reply, second = broker.chat(sum_cfg, sum_msgs, { max_tokens = 1024, timeout_ms = 90000, category = "memory_summarize" }) if not reply then renderer.status("summarize failed: " .. tostring(second)) return end if second then -- usage payload _record_usage(second.model, second.category, second) end if secrets_session then reply = secrets_session:rehydrate(reply) end -- Persist the summarize-tagged assistant turn so future -- :memory summarize filters it out (R-C2). log_turn({ role = "assistant", content = reply, meta = "summarize" }) -- Parse candidates: tolerate bullets and bold markup. local candidates = {} for line in (reply .. "\n"):gmatch("([^\n]*)\n") do local kind, body = line:match("^%s*[-*]?%s*[*_]*(%a+)[*_]*%s*:%s*(.+)$") if kind then kind = kind:lower() if kind == "fact" or kind == "pref" or kind == "context" then candidates[#candidates + 1] = { kind = kind, content = body:gsub("%s+$", "") } end end end if #candidates == 0 then renderer.status("no candidates parsed from response"); return end local added = 0 for _, cand in ipairs(candidates) do io.write(("\n[memory] candidate (%s): %s\n") :format(cand.kind, cand.content)) local ans = rl.readline("keep? [y/N/edit] ") or "" local first = ans:lower():sub(1, 1) if first == "y" then memory:add(cand.kind, cand.content) added = added + 1 elseif first == "e" then local edited = rl.readline("edit: ") or "" edited = edited:gsub("^%s+", ""):gsub("%s+$", "") if edited ~= "" then memory:add(cand.kind, edited) added = added + 1 end end end inject_memory() renderer.status(("summarize: added %d / %d candidates") :format(added, #candidates)) else renderer.status("usage: :memory {list|add|forget|clear|inject|summarize}") end end, safety = function(args) local sub, sub_args = args:match("^%s*(%S*)%s*(.*)$") if sub == "patterns" then for i, rule in ipairs(safety._patterns) do local ci = rule.ci and " (ci)" or "" io.write((" %2d. %-32s %s%s\n"):format( i, rule.reason, rule.pat, ci)) end elseif sub == "check" then local cmd = sub_args:match("^%s*(.-)%s*$") if not cmd or cmd == "" then renderer.status("usage: :safety check "); return end -- Pass cfg so the LLM probe runs; user can opt-out via -- :safety check --no-llm if added in v2. -- Issue #52: thread secrets scrub/rehydrate so the probe -- model sees placeholders for any secrets in `cmd`. -- Phase 7: also thread on_usage so the probe's cost -- lands in the accumulator under category="probe". local probe_opts = { on_usage = _record_usage } if secrets_session then probe_opts.scrub_msgs = function(msgs, mode_cfg) return scrub_messages(msgs, secrets_mode_for(mode_cfg or active_cfg)) end probe_opts.rehydrate = function(t) return secrets_session:rehydrate(t) end end local hit, reason = safety.is_destructive(cmd, config, probe_opts) if hit then renderer.status(("DESTRUCTIVE — %s"):format(reason or "?")) else renderer.status("not destructive") end else renderer.status("usage: :safety {patterns|check}") end end, perms = function(args) local sub, sub_args = args:match("^%s*(%S*)%s*(.*)$") if sub == "list" or sub == "" then local p = config.permissions if not p then renderer.status(("(no permissions set; fallback: confirm_cmd=%s)") :format(tostring(config.shell and config.shell.confirm_cmd or false))) return end local function dump(label, rules) if not rules or #rules == 0 then return end io.write((" %s:\n"):format(label)) for i, r in ipairs(rules) do io.write((" %2d. %s\n"):format(i, r)) end end renderer.status("permissions (deny > confirm > allow; default verdict: confirm):") dump("deny", p.deny) dump("confirm", p.confirm) dump("allow", p.allow) elseif sub == "check" then local cmd = sub_args:match("^%s*(.-)%s*$") if not cmd or cmd == "" then renderer.status("usage: :perms check "); return end local v, rule = safety.classify_command(cmd, config) renderer.status(("verdict=%s rule=%s"):format(v, rule or "(default)")) else renderer.status("usage: :perms {list|check}") end end, route = function(args) local sub, sub_args = args:match("^%s*(%S*)%s*(.*)$") config.routing = config.routing or {} if sub == "on" then config.routing.auto = true renderer.status("auto-routing on") elseif sub == "off" then config.routing.auto = false renderer.status("auto-routing off") elseif sub == "classes" then local classes = config.routing.classes or {} if next(classes) == nil then renderer.status("(no classes configured)"); return end for k, v in pairs(classes) do io.write((" %-10s → %s\n"):format(k, tostring(v))) end elseif sub == "check" then local text = sub_args:match("^%s*(.-)%s*$") if not text or text == "" then renderer.status("usage: :route check "); return end local m, class = router.classify_model(text, config) local extra = config.routing.auto and "" or " (routing currently disabled)" renderer.status(("class=%s model=%s%s"):format( class, tostring(m), extra)) else renderer.status("usage: :route {on|off|classes|check}") end end, fallback = function(args) local sub = args:match("^%s*(%S*)") config.routing = config.routing or {} if sub == "on" then config.routing.fallback = true renderer.status(("cloud fallback on (target: %s)"):format( config.routing.fallback_model or "cloud")) elseif sub == "off" then config.routing.fallback = false renderer.status("cloud fallback off") else renderer.status("usage: :fallback {on|off}") end end, help = function() io.write(HELP) end, } -- Issue #2: user-defined skills loader. Scan ~/.config/aish/skills/ -- (or $AISH_SKILLS_DIR) for *.lua modules. Each module returns -- { name = "", description = "...", run = function(args, h) end } -- and gets registered as a meta command :. Helpers passed to run(): -- h.ask(text) -- send text as an ai-kind prompt (same path as :ask) -- h.status(s) -- emit a [aish] status line -- h.exec(cmd) -- run a shell command (subject to plan/hooks) -- h.model() -- current active model name -- h.ctx -- raw context object (advanced) -- h.config -- raw config table local skills = {} -- { [name] = {description=, run=} } local function load_skills() local dir = os.getenv("AISH_SKILLS_DIR") or ((os.getenv("HOME") or ".") .. "/.config/aish/skills") local pipe = io.popen(("ls -1 %q/*.lua 2>/dev/null"):format(dir)) if not pipe then return end for path in pipe:lines() do local ok, mod = pcall(dofile, path) if not ok then renderer.status(("skill load failed: %s: %s") :format(path, tostring(mod))) elseif type(mod) ~= "table" or type(mod.name) ~= "string" or type(mod.run) ~= "function" or not mod.name:match("^[%w_-]+$") then renderer.status(("skill %s: invalid module (need {name, run})") :format(path)) elseif meta[mod.name] or skills[mod.name] then renderer.status(("skill %s: name '%s' already in use") :format(path, mod.name)) else skills[mod.name] = { description = mod.description or "", run = mod.run, } local helpers = { ask = function(t) ask_ai(expand_mentions(t or "", renderer.status)) end, status = renderer.status, exec = run_shell, model = function() return active_name end, ctx = ctx, config = config, } meta[mod.name] = function(args) local sk_ok, sk_err = pcall(mod.run, args or "", helpers) if not sk_ok then renderer.status(("skill %s failed: %s") :format(mod.name, tostring(sk_err))) end end end end pipe:close() end meta.skills = function() local names = {} for n, _ in pairs(skills) do names[#names + 1] = n end table.sort(names) if #names == 0 then renderer.status("(no skills loaded)"); return end renderer.status(("skills (%d):"):format(#names)) for _, n in ipairs(names) do io.write((" :%-16s %s\n"):format(n, skills[n].description)) end end -- Issue #13: :secrets meta — vault status, current mode per active -- broker, mapping size. Never prints actual values (the vault file -- is itself a secret, gotcha 7). meta.secrets = function(args) local sub = args:match("^%s*(%S*)") or "" if sub == "" or sub == "status" then if not secrets_session then renderer.status("(no vault loaded; configure config.secrets.vault)") return end renderer.status(("vault: %d entries; %d placeholders allocated this session") :format(#secrets_session.entries, secrets_session:mapping_size())) renderer.status(("active broker mode: %s"):format(secrets_mode_for(active_cfg))) local names = secrets_session:vault_names() if #names > 0 then io.write(" entry names: " .. table.concat(names, ", ") .. "\n") end elseif sub == "check" then -- Run a scrub against the given text and report what would change. local text = args:match("^%s*check%s+(.+)$") or "" if text == "" then renderer.status("usage: :secrets check "); return end if not secrets_session then renderer.status("(no vault loaded)"); return end local mode = secrets_mode_for(active_cfg) local scrubbed = secrets_session:scrub(text, mode) if scrubbed == text then renderer.status(("no matches (mode=%s)"):format(mode)) else renderer.status(("scrubbed (mode=%s):"):format(mode)) io.write(" " .. scrubbed .. "\n") end else renderer.status("usage: :secrets [status|check ]") end end load_skills() -- Issue #11: in-session recurring prompts (:every). Pre-prompt due-check -- model: timers fire between user inputs, not during readline waits or -- broker calls. This is the minimum viable approach without rewriting -- ffi/readline to callback-mode. Suppressed during Norris. local every_jobs = {} -- { {id, interval_s, next_fire, prompt, model_name}, ... } local next_every_id = 1 local function _parse_interval(s) s = (s or ""):gsub("%s+", "") local num, unit = s:match("^(%d+)([smh]?)$") if not num then return nil end local mult = ({ s = 1, m = 60, h = 3600, [""] = 1 })[unit] return tonumber(num) * mult end local function _every_fire(job) renderer.status(("[every #%d tick: %s]") :format(job.id, job.prompt)) -- Temporarily swap to the job's chosen model so the recurring prompt -- hits the preset selected at :every time (defaulted to "fast"). local saved_name, saved_cfg = active_name, active_cfg if config.models[job.model_name] then active_name, active_cfg = job.model_name, config.models[job.model_name] end local ok, err = pcall(ask_ai, job.prompt) active_name, active_cfg = saved_name, saved_cfg if not ok then renderer.status(("[every #%d failed: %s]"):format(job.id, tostring(err))) end end local function check_every_due() if ctx.norris_active then return end local now = os.time() -- Snapshot the due jobs so a long-running tick doesn't compound. local due = {} for _, j in ipairs(every_jobs) do if now >= j.next_fire then due[#due + 1] = j end end for _, j in ipairs(due) do j.next_fire = os.time() + j.interval_s _every_fire(j) end end -- Phase 6: :tree meta — scan + inject project file-tree as the -- [project] block in the system prompt. Variants per §6: -- :tree scan with config defaults; resets _project_opts -- :tree scan with depth=N; cached as _project_opts -- :tree refresh re-scan with cached opts; else config defaults -- :tree off clear ctx.project AND ctx._project_opts -- Phase 6: :highlight meta — toggle tree-sitter highlighter. -- :highlight flip current setting -- :highlight on enable (status warns if CLI absent -- AND/OR parsers may not be installed) -- :highlight off disable; renderer passes through -- :highlight status report toggle + CLI detection state meta.highlight = function(args) local sub = ((args or ""):match("^%s*(%S*)") or ""):lower() if sub == "status" then renderer.status(("highlight: %s (tree-sitter CLI %s)"):format( highlight_enabled and "on" or "off", highlight_detected and "detected" or "absent")) return end if sub == "" then highlight_enabled = not highlight_enabled elseif sub == "on" then highlight_enabled = true elseif sub == "off" then highlight_enabled = false else renderer.status("usage: :highlight [on|off|status]") return end renderer.set_highlight(highlight_enabled, highlight_detected, highlighted) if highlight_enabled and not highlight_detected then -- B4: install hint when toggled on but CLI absent. Also note -- the parser-directory + grammar-clone requirement that -- catches users who installed only the CLI. renderer.status("highlight on but tree-sitter CLI not found.") renderer.status("install: `apt install tree-sitter-cli` OR `cargo install tree-sitter-cli`") renderer.status("then: `tree-sitter init-config` AND clone the relevant") renderer.status("`tree-sitter-` grammars into a parser directory.") elseif highlight_enabled then renderer.status("highlight on (note: needs parser-directories with built tree-sitter- grammars)") else renderer.status("highlight off") end end -- Phase 6: :diff meta — `git diff ` (B1-clean), appends as -- [diff ]\n exec_output. Reads cwd at invocation -- time (R6: differs from :tree's scan-time cwd capture). Empty -- Phase 7: :cost meta — read-only reporter of ctx.usage_totals. -- :cost summary line -- :cost detail per-model + per-category breakdown -- :cost reset zero the meter (also clears warn flags) -- R7 sort: (cost desc, model asc, category asc) — table.sort is -- unstable, so the 3-level key ensures deterministic output. -- Phase 9: :config show meta. Reads config._sources (R3 cfg- -- embedded source map from main.lua's load_config_with_overlay) -- + the effective config. Sanitizes token-bearing fields per -- the masking heuristic (any key containing token/secret/auth/ -- key, case-insensitive -> "(set)" instead of the value). -- -- R6: `:config show` (default) shows top-level keys with nested -- tables collapsed to their inner-key list; `:config show full` -- recurses and applies the same masking heuristic at every level. -- N2 known false positive: key_env / auth_env hold env-var -- names (not secrets) but match the heuristic; future polish -- exempts `*_env` patterns. local _CFG_MASK_RE = "token|secret|auth|key" -- pipe-OR via gsub local function _is_sensitive_key(k) local lk = tostring(k):lower() return lk:find("token", 1, true) or lk:find("secret", 1, true) or lk:find("auth", 1, true) or lk:find("key", 1, true) end local function _fmt_value(v, full, depth) depth = depth or 0 if type(v) == "string" then return string.format("%q", v) end if type(v) == "number" or type(v) == "boolean" then return tostring(v) end if type(v) == "function" then return "" end if type(v) == "table" then local keys = {} for k, _ in pairs(v) do keys[#keys + 1] = tostring(k) end table.sort(keys) if not full then return "{" .. table.concat(keys, ", ") .. "}" end -- Full mode: recurse, masking sensitive keys. if depth >= 5 then return "{...}" end -- defensive depth cap local parts = {} for _, k in ipairs(keys) do local val if _is_sensitive_key(k) then val = "(set)" else val = _fmt_value(v[k], true, depth + 1) end parts[#parts + 1] = ("%s = %s"):format(k, val) end return "{" .. table.concat(parts, ", ") .. "}" end return "<" .. type(v) .. ">" end meta.config = function(args) local sub = ((args or ""):match("^%s*(%S*)") or ""):lower() if sub ~= "" and sub ~= "show" then renderer.status("usage: :config show [full]") return end local full = (args or ""):lower():match("show%s+full") ~= nil local sources = config._sources if sources then -- Group by source for the "config sources" listing. local user_keys, project_keys = {}, {} for k, src in pairs(sources) do if src == "project" then project_keys[#project_keys + 1] = k else user_keys[#user_keys + 1] = k end end table.sort(user_keys); table.sort(project_keys) renderer.status("config sources:") io.write((" user: %s keys: %s\n"):format( #user_keys, table.concat(user_keys, ", "))) if #project_keys > 0 then io.write((" project: %s keys: %s\n"):format( #project_keys, table.concat(project_keys, ", "))) end else renderer.status("config sources: (unknown — main didn't pass _sources)") end renderer.status(full and "effective config (full, sensitive masked):" or "effective config (top-level; ':config show full' for deep):") local top_keys = {} for k, _ in pairs(config) do if k ~= "_sources" then top_keys[#top_keys + 1] = k end end table.sort(top_keys) for _, k in ipairs(top_keys) do local source_tag = sources and sources[k] or "?" local val if _is_sensitive_key(k) then val = "(set)" else val = _fmt_value(config[k], full) end io.write((" %-20s = %s [%s]\n"):format(k, val, source_tag)) end end -- R10: $%.6f for sub-cent precision (cloud costs can be ~3e-05). -- R6: annotation uses the per-slot is_local sticky flag rather -- than a fragile cost==0 heuristic. meta.cost = function(args) local sub = ((args or ""):match("^%s*(%S*)") or ""):lower() if sub == "reset" then ctx:reset_usage() renderer.status("cost meter reset") return end local total_cost = ctx:total_cost() local total_p, total_c = ctx:total_tokens() local has_local, has_cloud = false, false for _, m in pairs(ctx.usage_totals or {}) do for _, c in pairs(m) do if c.is_local then has_local = true end if c.cost > 0 then has_cloud = true end end end local label if has_local and has_cloud then label = "(cloud only; local: tokens but no cost field)" elseif has_local and not has_cloud then label = "(local only; no cost field)" else label = "" end if sub == "" then local calls = 0 for _, m in pairs(ctx.usage_totals or {}) do for _, c in pairs(m) do calls = calls + c.calls end end renderer.status(("session usage: %d calls, prompt=%d / completion=%d tokens, cost=$%.6f %s"):format( calls, total_p, total_c, total_cost, label)) return end if sub == "detail" then local rows = {} for model, cats in pairs(ctx.usage_totals or {}) do for category, c in pairs(cats) do rows[#rows + 1] = { model = model, category = category, prompt = c.prompt, completion = c.completion, calls = c.calls, cost = c.cost, is_local = c.is_local, } end end if #rows == 0 then renderer.status("(no usage recorded)"); return end -- R7: 3-level deterministic sort table.sort(rows, function(a, b) if a.cost ~= b.cost then return a.cost > b.cost end if a.model ~= b.model then return a.model < b.model end return a.category < b.category end) renderer.status(("session usage detail (total=$%.6f, %d/%d tokens):"):format( total_cost, total_p, total_c)) for _, r in ipairs(rows) do io.write((" %-26s %-18s %3d calls, %6d / %6d tokens, $%.6f%s\n"):format( r.model, r.category, r.calls, r.prompt, r.completion, r.cost, r.is_local and " (local)" or "")) end -- Phase 8 R3: trailing summary line — current ctx snapshot -- (NOT a comparison against the accumulator sums above; the -- accumulator carries cumulative across all calls including -- evicted turns, while estimate_tokens is current-in-memory -- only). Shows budget utilization at-a-glance. local est = ctx:estimate_tokens() local budget = ctx.token_budget or 0 local pct = (budget > 0) and (est * 100 / budget) or 0 renderer.status(("estimated session ctx: %d tokens; token_budget=%d (%.1f%% used)"):format( est, budget, pct)) return end renderer.status("usage: :cost [detail|reset]") end -- diff or git failure emits status and skips — never pollutes -- context with empty or error noise. meta.diff = function(args) args = (args or ""):gsub("^%s+", ""):gsub("%s+$", "") local cmd = _git_clean_cmd("diff " .. args) local out, code = executor.exec(cmd) if code ~= 0 then renderer.status(("diff failed (exit %d): %s") :format(code, args == "" and "(working tree)" or args)) return end if not out or out:gsub("%s", "") == "" then renderer.status(("(no diff): %s"):format( args == "" and "(working tree)" or args)) return end local label = args == "" and "(working tree)" or args ctx:append_exec_output(("[diff %s]\n%s"):format(label, out)) renderer.status(("diff injected: %s (%d bytes)"):format(label, #out)) end meta.tree = function(args) local sub = (args or ""):match("^%s*(%S*)") or "" if sub == "off" then ctx.project = nil ctx._project_opts = nil renderer.status("project tree cleared") return end local opts if sub == "refresh" then opts = ctx._project_opts or {} elseif sub == "" then opts = {} ctx._project_opts = nil else local n = tonumber(sub) if not n or n < 1 then renderer.status("usage: :tree [|refresh|off]"); return end opts = { depth = n } ctx._project_opts = opts end local dir = libc.getcwd() or "." local body, info = _scan_project_tree(dir, opts) if not body then renderer.status("tree scan failed: " .. tostring(info)) return end ctx.project = body renderer.status(("project tree: %d files%s (%s)"):format( info.file_count, info.truncated and " (truncated)" or "", info.in_git and "git ls-files" or "find fallback")) end meta.every = function(args) local sub = args:match("^%s*(%S*)") or "" if sub == "list" or sub == "" and args:match("^%s*$") then if #every_jobs == 0 then renderer.status("(no recurring prompts)"); return end local now = os.time() renderer.status(("recurring prompts (%d):"):format(#every_jobs)) for _, j in ipairs(every_jobs) do io.write((" #%d every %ds (next in %ds, model=%s) %s\n") :format(j.id, j.interval_s, j.next_fire - now, j.model_name, j.prompt)) end return end if sub == "cancel" then local id = tonumber(args:match("cancel%s+(%d+)")) if not id then renderer.status("usage: :every cancel "); return end for i, j in ipairs(every_jobs) do if j.id == id then table.remove(every_jobs, i) renderer.status(("cancelled #%d"):format(id)); return end end renderer.status(("no such job: #%d"):format(id)); return end -- :every (prompt may be quoted; quotes stripped) local interval_s, rest = args:match("^%s*(%S+)%s+(.+)$") local secs = _parse_interval(interval_s) if not secs or secs < 1 then renderer.status("usage: :every (interval: 30s | 5m | 2h | bare int)") return end local p = rest:gsub("^%s+", ""):gsub("%s+$", "") p = p:match("^\"(.*)\"$") or p:match("^'(.*)'$") or p if p == "" then renderer.status("usage: :every "); return end local job_model = (config.models and config.models.fast) and "fast" or active_name local id = next_every_id; next_every_id = next_every_id + 1 every_jobs[#every_jobs + 1] = { id = id, interval_s = secs, next_fire = os.time() + secs, prompt = p, model_name = job_model, } renderer.status(("scheduled #%d every %ds (model=%s): %s") :format(id, secs, job_model, p)) end -- Issue #8: background CMD (CMD&: marker). Spawn via a shell wrapper -- that captures stdout+stderr to /bg/.log and the -- exit code to .status. We poll with kill -0; on completion read -- the .status sidecar. No fork()/execv() FFI required — relies on POSIX -- shell semantics. Reparented child is owned by init; we treat it as -- "managed" via the PID and the status file only. local bg_jobs = {} -- { {id, pid, cmd, started_at, log_path, status_path, exited} } local next_bg_id = 1 local bg_dir = history_dir and (history_dir .. "/bg") or nil if bg_dir then os.execute(("mkdir -p %q 2>/dev/null"):format(bg_dir)) end local function _bg_shq(s) return "'" .. (s or ""):gsub("'", [['\'']]) .. "'" end _bg_spawn = function(cmd) if not bg_dir then return nil, "background CMD requires history.dir to be configured" end local id = next_bg_id; next_bg_id = next_bg_id + 1 local log_path = ("%s/%d.log"):format(bg_dir, id) local status_path = ("%s/%d.status"):format(bg_dir, id) -- Wrapper: redirect, capture exit, write status. nohup + /dev/null 2>&1 & echo $!"):format( _bg_shq(("(%s) > %s 2>&1; echo $? > %s"):format( cmd, _bg_shq(log_path), _bg_shq(status_path)))) local pipe = io.popen(wrapper) local pid_str = pipe and pipe:read("*l") if pipe then pipe:close() end local pid = tonumber(pid_str) if not pid then return nil, "failed to spawn (no PID returned)" end local job = { id = id, pid = pid, cmd = cmd, started_at = os.time(), log_path = log_path, status_path = status_path, exited = false, } bg_jobs[#bg_jobs + 1] = job return job end local function _bg_status_check(job) if job.exited then return end -- Read status file: presence means the wrapper finished writing -- the exit code. If absent and PID is still alive, job is running. local f = io.open(job.status_path, "rb") if f then local s = f:read("*l") or "" f:close() job.exit_code = tonumber(s) or -1 job.exited = true job.exited_at = os.time() local lf = io.open(job.log_path, "rb") job.log_bytes = 0 if lf then lf:seek("end"); job.log_bytes = lf:seek(); lf:close() end end end local function _fmt_bytes(n) if n < 1024 then return ("%dB"):format(n) end if n < 1024*1024 then return ("%.1fKB"):format(n/1024) end return ("%.1fMB"):format(n/(1024*1024)) end local function check_bg_done() for _, job in ipairs(bg_jobs) do if not job.exited then _bg_status_check(job) if job.exited then local wall = (job.exited_at or os.time()) - job.started_at local summary = ("[bg:%d exited %d, %s, %ds wall] %s") :format(job.id, job.exit_code, _fmt_bytes(job.log_bytes or 0), wall, job.cmd) renderer.status(summary) -- Feed back into context so the model sees completion -- on the next ai turn — same channel as foreground exec. ctx:append_exec_output(summary) end end end end meta.delegate = function(args) local preset, prompt = args:match("^%s*(%S+)%s+(.+)$") if not preset then renderer.status("usage: :delegate "); return end prompt = prompt:gsub("^%s+", ""):gsub("%s+$", "") prompt = prompt:match([[^"(.*)"$]]) or prompt:match([[^'(.*)'$]]) or prompt if prompt == "" then renderer.status("usage: :delegate "); return end local sub_cfg = config.models[preset] if not sub_cfg then renderer.status(("unknown preset: %s"):format(preset)); return end renderer.status(("DELEGATE -> %s: %s"):format(preset, prompt)) local sub_msgs = scrub_messages( { { role = "user", content = prompt } }, secrets_mode_for(sub_cfg)) -- Phase 7: capture (text, usage); second is err on failure. local sub_text, second = broker.chat(sub_cfg, sub_msgs, { category = "delegate" }) if not sub_text then renderer.status(("delegate %s failed: %s"):format(preset, tostring(second))) else if second then -- usage payload _record_usage(second.model, second.category, second) end if secrets_session then sub_text = secrets_session:rehydrate(sub_text) end io.write(sub_text) if not sub_text:match("\n$") then io.write("\n") end ctx:append_exec_output(("[delegate %s]: %s"):format(preset, sub_text)) end end meta["bg-spawn"] = function(args) local cmd = (args or ""):match("^%s*(.-)%s*$") if cmd == "" then renderer.status("usage: :bg-spawn "); return end local job, err = _bg_spawn(cmd) if not job then renderer.status("bg spawn failed: " .. tostring(err)) else renderer.status(("started #%d pid=%d: %s") :format(job.id, job.pid, cmd)) end end meta["bg-list"] = function() if #bg_jobs == 0 then renderer.status("(no bg jobs)"); return end check_bg_done() renderer.status(("bg jobs (%d):"):format(#bg_jobs)) for _, j in ipairs(bg_jobs) do local state if j.exited then state = ("exit=%d %ds"):format(j.exit_code, (j.exited_at - j.started_at)) else local age = os.time() - j.started_at state = ("running pid=%d %ds"):format(j.pid, age) end io.write((" #%-3d %s %s\n"):format(j.id, state, j.cmd)) end end meta["bg-output"] = function(args) local id = tonumber(args:match("^%s*(%d+)")) if not id then renderer.status("usage: :bg-output "); return end local job for _, j in ipairs(bg_jobs) do if j.id == id then job = j; break end end if not job then renderer.status("no such bg job: #" .. id); return end local f = io.open(job.log_path, "rb") if not f then renderer.status("(no log file yet)"); return end io.write(f:read("*a") or ""); f:close() if not job.log_path:match("\n$") then io.write("\n") end end meta["bg-kill"] = function(args) local id = tonumber(args:match("^%s*(%d+)")) if not id then renderer.status("usage: :bg-kill "); return end for _, j in ipairs(bg_jobs) do if j.id == id then if j.exited then renderer.status(("#%d already exited"):format(id)) else os.execute(("kill %d 2>/dev/null"):format(j.pid)) renderer.status(("sent SIGTERM to #%d (pid %d)"):format(id, j.pid)) end return end end renderer.status("no such bg job: #" .. id) end -- Phase 6: cfg.project.auto_tree startup hook. Runs once before the -- main loop opens; opts.dir = cwd at startup. Failures are status- -- logged once and skipped — the rest of the REPL works fine. -- :tree refresh later picks up cwd changes (cd intercept doesn't -- auto-refresh per A8 — v2 polish). if config.project and config.project.auto_tree then local dir = libc.getcwd() or "." local body, info = _scan_project_tree(dir, {}) if body then ctx.project = body renderer.status(("project tree auto-injected: %d files%s (%s)") :format(info.file_count, info.truncated and " (truncated)" or "", info.in_git and "git ls-files" or "find fallback")) else renderer.status("project tree auto-inject failed: " .. tostring(info)) end end -- Main loop. while true do check_every_due() check_bg_done() local line = rl.readline(prompt()) if line == nil then -- EOF (Ctrl-D on empty line) io.write("\n") shutdown_session() break end if line:gsub("%s", "") == "" then -- empty / whitespace-only: skip silently else rl.add_history(line) local kind, payload = router.classify(line, config) if kind == "meta" then local name, rest = payload:match("^(%S+)%s*(.*)$") local handler = name and meta[name] if handler then handler(rest or "") else renderer.status("unknown meta command: :" .. tostring(name)) end elseif kind == "shell" then run_shell(payload) else -- "ai" local expanded = expand_mentions(payload, renderer.status) ask_ai(expanded) end end end end -- Phase 0 module export. Meta-command list shown above lives in HELP and -- is implemented inline in run(). return M