Files
aish/repl.lua
T
marfrit 8e0e735e15 repl: fallback patterns — add 'Could not connect to server' (CURLE_COULDNT_CONNECT)
Surfaced by autonomous run of TC #48: pointing models.fast at
http://localhost:9999 (port closed, host resolves) emits
"transport: Could not connect to server" — CURLE_COULDNT_CONNECT
(7) which the Phase 5 fallback pattern set didn't include.

Added "Could not connect to server" to FALLBACK_PATTERNS in repl.lua.
Now fallback fires for the full set of common libcurl/HTTP transport
failure shapes:

  HTTP 5xx              server-side
  HTTP 404 model_not_found
  HTTP 408              gateway request timeout
  Couldn't resolve host CURLE_COULDNT_RESOLVE_HOST
  Could not connect to server   CURLE_COULDNT_CONNECT  (← added)
  Connection refused
  Timeout was reached   CURLE_OPERATION_TIMEDOUT (variant A)
  Operation timed out   CURLE_OPERATION_TIMEDOUT (variant B)

Re-tested #48 end-to-end:
  fast pointed at dead port → fast fails → status fires →
  cloud (anthropic/claude-haiku-4.5 via openrouter) responds normally

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

1123 lines
49 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")
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
: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)
: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
-- 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
return true, #sess:list_tools()
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
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
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
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
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,
})
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)
status_evictions(ctx:enforce_budget())
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,
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(args)
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))
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"
ask_ai(payload)
end
end
end
end
-- Phase 0 module export. Meta-command list shown above lives in HELP and
-- is implemented inline in run().
return M