10d2501cff
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>
1309 lines
58 KiB
Lua
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
|