Files
aish/repl.lua
T
marfrit 10d2501cff repl: peel trailing punctuation from @path mentions (#7 follow-up)
Natural-language prose like "look at @README.md, then..." or
"@foo.lua." at sentence end previously failed to expand because the
trailing comma/period was included in the path.

Now: if the raw token doesn't resolve, peel trailing chars from
[.,;:?!)] one at a time until the path resolves or no more peels are
possible. On success, the peeled chars are emitted verbatim AFTER the
closing fence so the original punctuation is preserved.

Surfaced during higgs smoke test (TC: "say the first line of
@README.md, then stop" — the trailing comma broke resolution).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-16 21:11:22 +00:00

1309 lines
58 KiB
Lua

-- 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
local function expand_mentions(line, on_status)
-- Walk the line; for each "@<path>" 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)
if content then
if on_status then
on_status(("@%s expanded (%d bytes%s)"):format(
path, #content, truncated and ", truncated" or ""))
end
out[#out + 1] = ("```%s path=%s\n%s\n```%s"):format(
_lang_of(path), 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 <name> switch active model
:models list configured models (* = active)
:history show conversation turns
:exec <cmd> force shell execution
:ask <text> force AI query (supports @path expansion)
:sessions list session log files
:save <name> rename current session log to <name>.jsonl
:resume <name> load <name>.jsonl turns into the in-memory context
:mcp list show connected MCP servers
:mcp tools list tools across all sessions
:mcp tool <alias__name> show one tool's inputSchema
:mcp connect <url> [a] open an MCP session at runtime
:mcp disconnect <alias> drop an MCP session
:norris <goal> launch Chuck Norris autonomous mode on <goal>
: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 <cmd> probe is_destructive against <cmd> without running
:remember <text> shortcut: :memory add fact <text>
:memory list show active memory items (id, ts, kind, content)
:memory add <kind> <t> add a memory item (kind: fact | pref | context)
:memory forget <id> append a tombstone for <id>
: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 <text> report which class <text> would route to (debug)
:fallback on/off toggle cloud retry when local transport fails
: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
-- 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 reply, err = broker.chat(sum_cfg, {
{ role = "system", content =
"Output exactly one short summary paragraph. "
.. "No commentary, no markdown, no bullet lists." },
{ role = "user", content = body },
}, { max_tokens = 300, timeout_ms = 30000 })
if not reply then
renderer.status("context summarize failed: " .. tostring(err))
return nil
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
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 <alias>__<tool>, 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
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
local function prompt()
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
-- 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).
local function call_broker(model_cfg, model_name, msgs, on_delta, opts)
local any_delta = false
local wrapped = function(kind, payload)
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.
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
renderer.exec_begin()
local out, code = executor.exec(cmd)
renderer.exec_end(code)
if config.shell and config.shell.capture_output then
ctx:append_exec_output(out)
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
if config.routing and config.routing.auto then
local routed, class = router.classify_model(text, config)
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
local depth = 0
local final_resp = ""
local first_iteration = true
while true do
local text_parts = {}
local tool_calls_seen = {}
local ok, err = call_broker(req_cfg, req_name, ctx:to_messages(),
function(kind, payload)
if kind == "text" then
text_parts[#text_parts + 1] = payload
renderer.assistant_delta(payload)
elseif kind == "tool_call" then
tool_calls_seen[#tool_calls_seen + 1] = payload
end
end,
{ tools = tools_schema() })
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
local doit
if config.shell and config.shell.confirm_cmd then
local ans = rl.readline(("execute '%s'? [y/N] "):format(cmd)) or ""
doit = (ans:lower():sub(1, 1) == "y")
else
doit = true
end
if doit then run_shell(cmd) 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,
}
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 <name>; 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 <cmd>"); return end
run_shell(args)
end,
ask = function(args)
args = (args or ""):match("^%s*(.-)%s*$")
if args == "" then renderer.status("usage: :ask <text>"); 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 <name>"); 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 <name>"); 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 <alias__name>"); 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 <url> [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 <alias>"); 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 <goal text>"); 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 <text>"); 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 <fact|pref|context> <text>"); 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 <id>"); 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 reply, err = broker.chat(sum_cfg, {
{ 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 },
}, { max_tokens = 1024, timeout_ms = 90000 })
if not reply then
renderer.status("summarize failed: " .. tostring(err))
return
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 <cmd>"); return
end
-- Pass cfg so the LLM probe runs; user can opt-out via
-- :safety check --no-llm <cmd> if added in v2.
local hit, reason = safety.is_destructive(cmd, config)
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,
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 <text>"); 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,
}
-- Main loop.
while true do
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