94b7d86926
Activates Phase 8 pillars 2+3+5 end-to-end and adds the R3-revised
:cost detail trailing line.
Changes:
- When cfg.tokenize.use_endpoint is true, ctx_opts.tokenize_fn is
set to `function(text) return broker.token_count(active_cfg, text) end`
before Context.new fires. R4: the closure body references
active_cfg DIRECTLY (upvalue) — Lua resolves upvalues at call
time, so subsequent :model switches re-route to the new model's
tokenizer automatically (verified by E2E: :model cloud after the
fast call still produces clean estimate row).
- :cost detail gains a trailing line per R3:
estimated session ctx: <N> tokens; token_budget=<M> (X.Y% used)
N comes from ctx:estimate_tokens() (current in-memory snapshot,
NOT a comparison against the accumulator sum above which is
cumulative across calls + evicted turns). Gives at-a-glance
budget utilization.
E2E verified against live broker:
- fast model call -> 168 tokens estimated (real BPE via /tokenize)
- :model cloud + cloud call -> 178 tokens estimated (closure
follows :model switch correctly per R4)
- 21% / 22.3% budget utilization shown
- Accumulator sums and estimate are intentionally different
(sums are cumulative, estimate is current snapshot) — R3-
correctly displayed as separate lines
Regression: test_safety 87/87, test_router_model 31/31, repl loads.
With this commit landed, Phase 8 is functionally complete; commit
#5 is config example + status bump.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2455 lines
112 KiB
Lua
2455 lines
112 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
|
|
|
|
-- ---------------------------------------------------------------- shared shell helpers
|
|
-- Lifted from M.run closure scope so expand_mentions (module-scope) can also
|
|
-- use them for the @<r1>..<r2> diff-retry path. Same single source of truth
|
|
-- for the B1 git invocation prefix; commits #3 and #4 both call _git_clean_cmd.
|
|
local function _shq(s) return "'" .. (s or ""):gsub("'", [['\'']]) .. "'" end
|
|
local function _git_clean_cmd(subcmd_and_args)
|
|
return "git --no-pager -c color.ui=never " .. subcmd_and_args
|
|
end
|
|
|
|
local function expand_mentions(line, on_status)
|
|
-- Walk the line; for each "@<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)
|
|
local lang_override = nil
|
|
-- Phase 6 / A6: tiered resolution — if path lookup
|
|
-- failed AND token contains "..", try as a git diff
|
|
-- ref-range. `@HEAD~1..HEAD` and `@origin/main..feature`
|
|
-- both fall through to this branch when no such file
|
|
-- exists. `@../sibling.txt` resolves as path first
|
|
-- and never reaches this retry.
|
|
if not content and path:find("..", 1, true) then
|
|
local r1, r2 = path:match("^(.-)%.%.(.+)$")
|
|
if r1 and r2 and r1 ~= "" and r2 ~= "" then
|
|
local out_diff, code = executor.exec(
|
|
_git_clean_cmd(("diff %s..%s 2>/dev/null")
|
|
:format(_shq(r1), _shq(r2))))
|
|
if code == 0 and out_diff and out_diff:match("%S") then
|
|
content = out_diff
|
|
lang_override = "diff"
|
|
end
|
|
end
|
|
end
|
|
if content then
|
|
local lang = lang_override or _lang_of(path)
|
|
if on_status then
|
|
on_status(("@%s expanded (%d bytes%s)"):format(
|
|
path, #content,
|
|
truncated and ", truncated"
|
|
or (lang_override == "diff" and ", diff" or "")))
|
|
end
|
|
out[#out + 1] = ("```%s path=%s\n%s\n```%s"):format(
|
|
lang, path, content, trail)
|
|
i = path_end
|
|
else
|
|
if on_status then
|
|
on_status(("@%s: not found"):format(raw))
|
|
end
|
|
out[#out + 1] = line:sub(i, path_end - 1)
|
|
i = path_end
|
|
end
|
|
else
|
|
out[#out + 1] = "@"
|
|
i = i + 1
|
|
end
|
|
else
|
|
out[#out + 1] = line:sub(i, i)
|
|
i = i + 1
|
|
end
|
|
end
|
|
return table.concat(out)
|
|
end
|
|
|
|
local M = {}
|
|
|
|
local HELP = [[
|
|
Meta commands:
|
|
:quit / :q exit aish (session flushed and closed)
|
|
:clear clear screen (history kept)
|
|
:reset clear in-memory conversation history
|
|
:model <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
|
|
:perms list show configured permission rules (allow/confirm/deny)
|
|
:perms check <cmd> report which permission verdict <cmd> would receive
|
|
: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
|
|
:skills list user-defined skills loaded from ~/.config/aish/skills/
|
|
:secrets [status] show vault state, active broker redact mode (never prints values)
|
|
:secrets check <text> show what the active broker's scrub would do to <text>
|
|
:every <i> <prompt> schedule a recurring prompt (i: 30s | 5m | 2h)
|
|
:every list show scheduled recurring prompts
|
|
:every cancel <id> remove a scheduled prompt
|
|
:bg-spawn <cmd> start a background job directly (no AI needed)
|
|
:bg-list list background jobs (issued via CMD&: or :bg-spawn)
|
|
:bg-output <id> dump the log of a background job
|
|
:bg-kill <id> SIGTERM a background job
|
|
:tree [<depth>] scan cwd file-tree, inject as [project] block in system prompt
|
|
:tree refresh re-scan with last opts (or config defaults)
|
|
:tree off clear the [project] block
|
|
:diff [<git-args>] git diff <args> -> inject as [diff ...] exec_output
|
|
examples: :diff :diff --cached :diff main..feature
|
|
:cost summary of session token/cost usage
|
|
:cost detail per-model + per-category breakdown
|
|
:cost reset zero the cost meter (also clears warn flags)
|
|
:highlight [on|off|status]
|
|
toggle tree-sitter syntax highlighting on assistant
|
|
code fences (requires external tree-sitter CLI +
|
|
built grammars; off by default)
|
|
:delegate <p> <prompt> one-shot sub-broker call to preset <p>; prints reply
|
|
:help this message
|
|
]]
|
|
|
|
function M.run(config)
|
|
assert(config and config.models, "repl.run: config.models required")
|
|
local active_name = config.default_model or next(config.models)
|
|
local active_cfg = config.models[active_name]
|
|
if not active_cfg then
|
|
error("aish: default_model '" .. tostring(active_name)
|
|
.. "' not found in config.models")
|
|
end
|
|
|
|
-- Plan mode (issue #5): when true, CMD: lines are NOT executed; they
|
|
-- are echoed as "PLAN:" and fed back to the next-turn context as
|
|
-- would-have-run notes so the model can iterate without side effects.
|
|
-- Off by default; toggle with :plan / :plan on / :plan off. Orthogonal
|
|
-- to Norris mode (Norris has its own halt protocol).
|
|
local plan_mode = false
|
|
|
|
-- Forward decl (issue #8): the bg spawn closure is defined deeper in
|
|
-- M.run alongside the meta dispatch, but ask_ai needs to call it when
|
|
-- routing CMD&: lines. Lua looks up names at call time; the closure
|
|
-- has to exist as a local in scope BEFORE ask_ai is declared.
|
|
local _bg_spawn
|
|
|
|
-- Phase 7 forward decl: _record_usage is the central chokepoint
|
|
-- for ctx:add_usage + warn-threshold check. Defined alongside
|
|
-- call_broker below, but needs to be in lexical scope of the
|
|
-- summarize-on-evict closure (which is built up earlier in
|
|
-- make_summarize_fn). Same forward-declaration pattern as
|
|
-- _bg_spawn — assign below, reference both early and late.
|
|
local _record_usage
|
|
|
|
-- Issue #13: secret redaction. Load vault if configured, create a
|
|
-- session for this conversation. ctx stores PLAIN; we scrub just
|
|
-- before broker.chat_stream and rehydrate the streamed reply for
|
|
-- display. Tool args dispatched to MCP get rehydrated so the server
|
|
-- sees the real values. Default mode resolution: per-broker
|
|
-- `redact` field on the model preset → `config.secrets.default`
|
|
-- → "vault+autodetect" if vault loaded → "off".
|
|
local secrets = require("secrets")
|
|
local secrets_session
|
|
do
|
|
local vpath = config.secrets and config.secrets.vault
|
|
if vpath then
|
|
-- Tilde expansion: ~ → $HOME for a common form.
|
|
if vpath:sub(1, 2) == "~/" then
|
|
vpath = (os.getenv("HOME") or "") .. vpath:sub(2)
|
|
end
|
|
local v, err = secrets.load(vpath)
|
|
if v then
|
|
secrets_session = secrets.make_session(v)
|
|
renderer.status(("secrets vault loaded (%d entries)")
|
|
:format(#v.entries))
|
|
else
|
|
renderer.status(err or "secrets: load failed")
|
|
end
|
|
end
|
|
end
|
|
local function secrets_mode_for(model_cfg)
|
|
if not secrets_session then return "off" end
|
|
local m = (model_cfg and model_cfg.redact)
|
|
or (config.secrets and config.secrets.default)
|
|
if m then return m end
|
|
return secrets_session:has_vault() and "vault+autodetect" or "off"
|
|
end
|
|
-- Walk an OpenAI-shape messages array, scrub all string content per
|
|
-- the model's redact policy. Tool-call arguments are JSON strings —
|
|
-- scrub them too (they may carry secrets if the model put a placeholder
|
|
-- in a tool arg and was rendered through here on a re-iteration).
|
|
local function scrub_messages(messages, mode)
|
|
if mode == "off" or not secrets_session then return messages end
|
|
for _, m in ipairs(messages) do
|
|
if type(m.content) == "string" then
|
|
m.content = secrets_session:scrub(m.content, mode)
|
|
end
|
|
if m.tool_calls then
|
|
for _, tc in ipairs(m.tool_calls) do
|
|
if tc["function"] and tc["function"].arguments then
|
|
tc["function"].arguments = secrets_session:scrub(
|
|
tc["function"].arguments, mode)
|
|
end
|
|
end
|
|
end
|
|
end
|
|
return messages
|
|
end
|
|
-- Rehydrate a tool-call args table (recursive). Used at MCP dispatch
|
|
-- so the server sees the real values when the model emitted placeholders.
|
|
local function rehydrate_args(t)
|
|
if not secrets_session then return t end
|
|
if type(t) == "string" then
|
|
return secrets_session:rehydrate(t)
|
|
elseif type(t) == "table" then
|
|
for k, v in pairs(t) do t[k] = rehydrate_args(v) end
|
|
end
|
|
return t
|
|
end
|
|
|
|
-- Phase 5: render the evicted turns into a compact transcript for
|
|
-- the summarizer prompt. Same shape as :memory summarize uses.
|
|
local function render_evicted(turns)
|
|
local parts = {}
|
|
for _, t in ipairs(turns or {}) do
|
|
parts[#parts + 1] = ("%s: %s"):format(
|
|
t.role, (t.content or ""):gsub("\n", " "):sub(1, 600))
|
|
end
|
|
return table.concat(parts, "\n")
|
|
end
|
|
|
|
-- Phase 5: summarize_fn factory. Returns a closure that maps
|
|
-- (prior_summary, evicted_turns) onto a broker.chat call against
|
|
-- the configured summarizer model. Returns nil on any failure so
|
|
-- Context falls back to silent eviction (Phase 0 behavior).
|
|
local function make_summarize_fn()
|
|
local sum_name = (config.context and config.context.summarizer_model)
|
|
or "fast"
|
|
local sum_cfg = config.models[sum_name]
|
|
if not sum_cfg then return nil end
|
|
return function(prior, evicted)
|
|
local body
|
|
if evicted == nil then
|
|
body = "Compress this prior summary into 2-3 sentences. "
|
|
.. "Keep names, facts, decisions; drop chatter.\n\n"
|
|
.. "Prior summary:\n" .. (prior or "")
|
|
elseif prior and prior ~= "" then
|
|
body = "Extend this prior summary with the new turns. "
|
|
.. "Keep it 2-4 sentences. Preserve names, facts, decisions.\n\n"
|
|
.. "Prior summary:\n" .. prior
|
|
.. "\n\nNew turns:\n" .. render_evicted(evicted)
|
|
else
|
|
body = "Summarize the following conversation turns in "
|
|
.. "2-3 sentences. Preserve names, facts, decisions.\n\n"
|
|
.. render_evicted(evicted)
|
|
end
|
|
local sum_msgs = scrub_messages({
|
|
{ role = "system", content =
|
|
"Output exactly one short summary paragraph. "
|
|
.. "No commentary, no markdown, no bullet lists." },
|
|
{ role = "user", content = body },
|
|
}, secrets_mode_for(sum_cfg))
|
|
-- Phase 7: broker.chat returns (text, usage) on success or
|
|
-- (nil, errmsg) on failure. Capture as (text, second); branch
|
|
-- on text nil-ness to interpret second.
|
|
local reply, second = broker.chat(sum_cfg, sum_msgs,
|
|
{ max_tokens = 300, timeout_ms = 30000,
|
|
category = "summarize" })
|
|
if not reply then
|
|
renderer.status("context summarize failed: " .. tostring(second))
|
|
return nil
|
|
end
|
|
if second then -- usage payload
|
|
_record_usage(second.model, second.category, second)
|
|
end
|
|
if secrets_session then
|
|
reply = secrets_session:rehydrate(reply)
|
|
end
|
|
return reply:gsub("^%s+", ""):gsub("%s+$", "")
|
|
end
|
|
end
|
|
|
|
-- Build Context with optional summarize_fn (gated by cfg flag).
|
|
local ctx_opts = {}
|
|
if config.context then
|
|
for k, v in pairs(config.context) do ctx_opts[k] = v end
|
|
end
|
|
if config.context and config.context.summarize_on_evict then
|
|
ctx_opts.summarize_fn = make_summarize_fn()
|
|
end
|
|
-- Phase 8 (docs/PHASE8.md): when cfg.tokenize.use_endpoint is true,
|
|
-- wire a tokenize_fn so Context:estimate_tokens uses real counts
|
|
-- from <endpoint>/tokenize (broker.token_count handles per-endpoint
|
|
-- capability cache + char/4 fallback). R4: the closure body MUST
|
|
-- reference `active_cfg` directly as an upvalue (NOT capture by
|
|
-- value) so :model switches naturally re-route to the new model's
|
|
-- tokenizer. A5 verified Lua upvalue semantics resolve at call time.
|
|
if config.tokenize and config.tokenize.use_endpoint then
|
|
ctx_opts.tokenize_fn = function(text)
|
|
return broker.token_count(active_cfg, text)
|
|
end
|
|
end
|
|
local ctx = Context.new(ctx_opts)
|
|
|
|
-- Phase 2: MCP sessions. Populated from config.mcp.servers at startup
|
|
-- (best-effort — failures are status-logged once, session absent from
|
|
-- mcp_sessions until manual :mcp connect; no auto-retry per PHASE2.md
|
|
-- §4 Lifecycle). Tools cached per-session for the session lifetime
|
|
-- (lmcp announces capabilities.tools.listChanged = false).
|
|
local mcp_sessions = {} -- { [alias] = session }
|
|
local function connect_mcp(alias, server_cfg)
|
|
local sess = mcp.connect(server_cfg.url, {
|
|
alias = alias,
|
|
auth_token = server_cfg.auth_token,
|
|
auth_env = server_cfg.auth_env,
|
|
})
|
|
local ok, kind, err = sess:initialize()
|
|
if not ok then
|
|
renderer.status(("mcp %s: %s (%s)")
|
|
:format(alias, tostring(err), kind))
|
|
return false
|
|
end
|
|
mcp_sessions[alias] = sess
|
|
if sess.version_warning then
|
|
renderer.status("mcp " .. alias .. ": " .. sess.version_warning)
|
|
end
|
|
-- Tool-name validation (issue #32): Anthropic via Bedrock enforces
|
|
-- ^[a-zA-Z0-9_-]{1,128}$. We use "__" as the alias separator, so the
|
|
-- emitted name is alias__tool. Warn at startup; emit anyway so local
|
|
-- llama.cpp users aren't penalized for lenient downstreams.
|
|
if alias:find("__", 1, true) then
|
|
renderer.status(("mcp %s: alias contains '__' (used as separator); "
|
|
.. "tool dispatch will misparse"):format(alias))
|
|
end
|
|
for _, t in ipairs(sess:list_tools()) do
|
|
local full = alias .. "__" .. (t.name or "")
|
|
if #full > 128 or full:find("[^%w_-]") then
|
|
renderer.status(("mcp %s: tool name '%s' violates "
|
|
.. "^[a-zA-Z0-9_-]{1,128}$ (will fail with strict providers "
|
|
.. "e.g. anthropic via Bedrock)"):format(alias, full))
|
|
end
|
|
end
|
|
return true, #sess:list_tools()
|
|
end
|
|
|
|
-- Walk config.mcp.auto_approve and warn about keys that match no live
|
|
-- tool / no live alias (issue #33). Stale entries silently failed to
|
|
-- auto-approve, leaving the user with unexpected confirm prompts.
|
|
-- Called at startup AND after :mcp connect so newly-arrived sessions
|
|
-- retroactively validate any keys that referenced them.
|
|
local function validate_auto_approve()
|
|
local policy = config.mcp and config.mcp.auto_approve
|
|
if not policy then return end
|
|
for key, _ in pairs(policy) do
|
|
local alias_glob = key:match("^(.-)__%*$")
|
|
if alias_glob then
|
|
if not mcp_sessions[alias_glob] then
|
|
renderer.status(("auto_approve key '%s': no MCP server "
|
|
.. "connected for alias '%s'"):format(key, alias_glob))
|
|
end
|
|
else
|
|
local alias, tname = key:match("^(.-)__(.+)$")
|
|
if not alias or alias == "" or not tname then
|
|
renderer.status(("auto_approve key '%s': not in "
|
|
.. "'alias__tool' or 'alias__*' form"):format(key))
|
|
else
|
|
local sess = mcp_sessions[alias]
|
|
if not sess then
|
|
renderer.status(("auto_approve key '%s': no MCP "
|
|
.. "server connected for alias '%s'")
|
|
:format(key, alias))
|
|
else
|
|
local found = false
|
|
for _, t in ipairs(sess:list_tools()) do
|
|
if t.name == tname then found = true; break end
|
|
end
|
|
if not found then
|
|
renderer.status(("auto_approve key '%s': "
|
|
.. "alias '%s' has no tool named '%s'")
|
|
:format(key, alias, tname))
|
|
end
|
|
end
|
|
end
|
|
end
|
|
end
|
|
end
|
|
|
|
if config.mcp and config.mcp.servers then
|
|
for alias, server_cfg in pairs(config.mcp.servers) do
|
|
local ok, n = connect_mcp(alias, server_cfg)
|
|
if ok then
|
|
renderer.status(("mcp %s: %d tools"):format(alias, n))
|
|
end
|
|
end
|
|
validate_auto_approve()
|
|
end
|
|
|
|
-- Assemble OpenAI-shape `tools` array across all live sessions, with
|
|
-- "alias__name" namespacing. Originally PHASE2 used "." as the separator,
|
|
-- but Anthropic via Bedrock validates tool names against
|
|
-- ^[a-zA-Z0-9_-]{1,128}$ and rejects dots — amended to "__" 2026-05-12.
|
|
-- Empty array → broker omits the field entirely (§12 risk row 1).
|
|
-- Aliases must not themselves contain "__" so the parse stays unambiguous.
|
|
local function tools_schema()
|
|
local out = {}
|
|
for alias, sess in pairs(mcp_sessions) do
|
|
for _, t in ipairs(sess:list_tools()) do
|
|
out[#out + 1] = {
|
|
type = "function",
|
|
["function"] = {
|
|
name = alias .. "__" .. t.name,
|
|
description = t.description or "",
|
|
parameters = t.inputSchema
|
|
or { type = "object", properties = {} },
|
|
},
|
|
}
|
|
end
|
|
end
|
|
return out
|
|
end
|
|
|
|
-- §4 "Content flattening": tool results may carry multiple blocks; v1
|
|
-- concatenates text and ignores non-text with a one-shot status.
|
|
local non_text_warned = false
|
|
local function flatten_content(content)
|
|
local parts = {}
|
|
local saw_non_text = false
|
|
for _, b in ipairs(content or {}) do
|
|
if b.type == "text" then
|
|
parts[#parts + 1] = b.text or ""
|
|
else
|
|
saw_non_text = true
|
|
end
|
|
end
|
|
if saw_non_text and not non_text_warned then
|
|
non_text_warned = true
|
|
renderer.status("tool returned non-text content blocks "
|
|
.. "(image/resource ignored in v1)")
|
|
end
|
|
return table.concat(parts, "\n")
|
|
end
|
|
|
|
-- Split <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
|
|
-- Issue #13: when secrets are configured, the model sees placeholders
|
|
-- in its context and consequently emits placeholder-bearing tool args.
|
|
-- The MCP server is treated as trusted local — rehydrate args before
|
|
-- dispatch so the tool gets the real values.
|
|
args = rehydrate_args(args)
|
|
local result, kind, err = sess:call_tool(tool_name, args)
|
|
if not result then
|
|
if kind == "rpc_error" then
|
|
local msg = (type(err) == "table" and err.message)
|
|
or tostring(err)
|
|
return ("[aish] tool dispatch failed: %s"):format(msg), true
|
|
else
|
|
return ("[aish] tool transport error: %s")
|
|
:format(tostring(err)), true
|
|
end
|
|
end
|
|
-- result has content[] and possibly isError=true. flatten_content
|
|
-- handles the text-blocks-only flattening. We pass through the
|
|
-- content body regardless of isError (per PHASE2-baseline.md §3:
|
|
-- some tools set isError=false on actual failures, content text
|
|
-- is authoritative).
|
|
return flatten_content(result.content), (kind == "handler_error")
|
|
end
|
|
|
|
-- Session log (PHASE1.md §6). Always open one on startup; auto-write
|
|
-- every user/assistant turn; close on :quit. If history.dir is set but
|
|
-- unwritable, log a status and continue without persistence.
|
|
local history_dir = (config.history and config.history.dir) or nil
|
|
local sessions_dir = history_dir and (history_dir .. "/sessions") or nil
|
|
local session_path = sessions_dir
|
|
and (sessions_dir .. "/" .. os.date("!%Y-%m-%dT%H-%M-%SZ") .. ".jsonl")
|
|
local session
|
|
if session_path then
|
|
local sess, serr = history.open(session_path, {
|
|
started = os.date("!%Y-%m-%dT%H:%M:%SZ"),
|
|
model = active_name,
|
|
aish_version = "phase1",
|
|
})
|
|
if sess then
|
|
session = sess
|
|
else
|
|
renderer.status("session log disabled: " .. tostring(serr))
|
|
end
|
|
end
|
|
-- Phase 4: memory.jsonl handle. Sibling of sessions/ in the history dir.
|
|
-- Single-writer enforced via flock; if held by another aish process,
|
|
-- status-log once and run without memory (Phase 3 behavior).
|
|
local memory_path = history_dir and (history_dir .. "/memory.jsonl") or nil
|
|
local memory -- handle or nil
|
|
local inject_max_chars =
|
|
(config.memory and config.memory.inject_max_chars) or 2000
|
|
|
|
-- Inject the top-N items into ctx.memory_items, capped by char budget.
|
|
local function inject_memory()
|
|
if not memory_path then ctx.memory_items = nil; return end
|
|
local items = history.load_memory(memory_path)
|
|
if #items == 0 then ctx.memory_items = nil; return end
|
|
local picked, total = {}, 0
|
|
for _, it in ipairs(items) do -- already sorted by ts desc
|
|
local cost = #(it.content or "") + 16 -- rough overhead per line
|
|
if total + cost > inject_max_chars then break end
|
|
picked[#picked + 1] = it
|
|
total = total + cost
|
|
end
|
|
ctx.memory_items = picked
|
|
end
|
|
|
|
if memory_path then
|
|
local m, merr = history.open_memory(memory_path)
|
|
if m then
|
|
memory = m
|
|
inject_memory()
|
|
if ctx.memory_items and #ctx.memory_items > 0 then
|
|
renderer.status(("memory: %d items injected"):format(
|
|
#ctx.memory_items))
|
|
end
|
|
else
|
|
renderer.status("memory disabled: " .. tostring(merr))
|
|
end
|
|
end
|
|
|
|
local function log_turn(turn)
|
|
if session then session:append(turn) end
|
|
end
|
|
|
|
-- Issue #10: configurable prompt template. When config.shell.prompt is
|
|
-- set, substitute {model}/{ctx_used}/{ctx_max}/{turn}/{cwd}/{cwd_short}
|
|
-- /{last_status}/{mode}. Otherwise fall back to the default with the
|
|
-- norris ⚡ + plan markers.
|
|
local libc = require("ffi.libc")
|
|
local last_exec_code = nil
|
|
local function _cwd_short()
|
|
local c = libc.getcwd() or os.getenv("PWD") or "?"
|
|
local home = os.getenv("HOME")
|
|
if home and c:sub(1, #home) == home then
|
|
c = "~" .. c:sub(#home + 1)
|
|
end
|
|
return c
|
|
end
|
|
local function _mode()
|
|
if ctx.norris_active then return "norris" end
|
|
if plan_mode then return "plan" end
|
|
return "normal"
|
|
end
|
|
local function prompt()
|
|
local tmpl = config.shell and config.shell.prompt
|
|
if tmpl then
|
|
local vars = {
|
|
model = active_name,
|
|
ctx_used = tostring(ctx:estimate_tokens()),
|
|
ctx_max = tostring(ctx.token_budget),
|
|
turn = tostring(#ctx.turns),
|
|
cwd = libc.getcwd() or "?",
|
|
cwd_short = _cwd_short(),
|
|
last_status = last_exec_code and tostring(last_exec_code) or "",
|
|
mode = _mode(),
|
|
}
|
|
return (tmpl:gsub("{([%w_]+)}", function(k) return vars[k] or "" end))
|
|
end
|
|
if ctx.norris_active then
|
|
return ("[aish:%s \xE2\x9A\xA1]> "):format(active_name)
|
|
end
|
|
if plan_mode then
|
|
return ("[aish:%s plan]> "):format(active_name)
|
|
end
|
|
return ("[aish:%s]> "):format(active_name)
|
|
end
|
|
|
|
-- Phase 3: \C-n inserts ":norris " at the cursor so the user can type
|
|
-- their goal and press Enter — routes through the meta dispatch
|
|
-- normally. The :norris handler is implemented in `meta` below.
|
|
rl.bind("\\C-n", function()
|
|
rl.insert_text(":norris ")
|
|
rl.redisplay()
|
|
end)
|
|
|
|
local function status_evictions(n)
|
|
if n and n > 0 then
|
|
renderer.status(("oldest %d turns evicted"):format(n))
|
|
end
|
|
end
|
|
|
|
-- ── Phase 5: fallback eligibility per PHASE5.md §5 ──────────────────
|
|
-- All transport-failure patterns must match against the err string
|
|
-- as broker.lua emits it (with "transport: " prefix). The matcher
|
|
-- strips the prefix before testing.
|
|
local FALLBACK_PATTERNS = {
|
|
"^HTTP 5%d%d",
|
|
"^HTTP 404.*model_not_found",
|
|
"^HTTP 408",
|
|
"Couldn'?t resolve host",
|
|
"Could not connect to server", -- CURLE_COULDNT_CONNECT (port closed, host resolved)
|
|
"Connection refused",
|
|
"Timeout was reached",
|
|
"Operation timed out",
|
|
}
|
|
local function fallback_reason(err)
|
|
if type(err) ~= "string" then return "unknown error" end
|
|
local stripped = err:gsub("^transport:%s*", "")
|
|
for _, pat in ipairs(FALLBACK_PATTERNS) do
|
|
if stripped:match(pat) then return (stripped:match(pat)) end
|
|
end
|
|
return nil
|
|
end
|
|
|
|
local function should_fallback(err)
|
|
return config.routing and config.routing.fallback
|
|
and fallback_reason(err) ~= nil
|
|
end
|
|
|
|
-- Phase 7 (R5): central chokepoint for usage recording. Wraps
|
|
-- ctx:add_usage AND does the warn-threshold check. All callers
|
|
-- (this file + safety.lua via helpers.on_usage / opts.on_usage)
|
|
-- route through here so the warn check fires exactly once per
|
|
-- accumulator update. Keeps context.lua decoupled from renderer.
|
|
-- R2: caller passes the model name that should be CREDITED — for
|
|
-- normal calls that's the active model; for fallback retries the
|
|
-- broker's payload.model (which IS the fallback's model_cfg.model
|
|
-- per broker emission) handles it correctly.
|
|
_record_usage = function(model, category, usage)
|
|
if not usage then return end
|
|
ctx:add_usage(model, category, usage)
|
|
if not (config.cost) then return end
|
|
local cw = ctx.cost_warn_state
|
|
if config.cost.warn_at_dollars and not cw.dollars then
|
|
local cost = ctx:total_cost()
|
|
if cost >= config.cost.warn_at_dollars then
|
|
renderer.status(("session cost $%.6f has crossed warn_at_dollars=$%.6f")
|
|
:format(cost, config.cost.warn_at_dollars))
|
|
cw.dollars = true
|
|
end
|
|
end
|
|
if config.cost.warn_at_tokens and not cw.tokens then
|
|
local p, c = ctx:total_tokens()
|
|
if (p + c) >= config.cost.warn_at_tokens then
|
|
renderer.status(("session tokens %d has crossed warn_at_tokens=%d")
|
|
:format(p + c, config.cost.warn_at_tokens))
|
|
cw.tokens = true
|
|
end
|
|
end
|
|
end
|
|
|
|
-- Wrap broker.chat_stream with the Phase 5 fallback-retry path.
|
|
-- Retries ONCE against cfg.routing.fallback_model (default "cloud")
|
|
-- when (a) cfg.routing.fallback is true, (b) err matches a
|
|
-- fallback-eligible pattern, AND (c) no deltas have arrived yet
|
|
-- (mid-stream failures aren't retried — partial text would be
|
|
-- duplicated).
|
|
--
|
|
-- Phase 7 (R2): wrapped on_delta keys usage by payload.model
|
|
-- (set inside broker.lua from model_cfg.model — the
|
|
-- CALLER-INTENDED model name). When fallback fires, the broker
|
|
-- is called with fb_cfg, so payload.model is naturally the
|
|
-- fallback's model name — wrapper doesn't need to track
|
|
-- primary-vs-fallback itself.
|
|
local function call_broker(model_cfg, model_name, msgs, on_delta, opts)
|
|
local any_delta = false
|
|
local wrapped = function(kind, payload)
|
|
if kind == "usage" then
|
|
_record_usage(payload.model, payload.category, payload)
|
|
return -- usage isn't forwarded to the underlying on_delta
|
|
end
|
|
any_delta = true
|
|
return on_delta(kind, payload)
|
|
end
|
|
local ok, err = broker.chat_stream(model_cfg, msgs, wrapped, opts)
|
|
if ok then return ok end
|
|
if any_delta then return ok, err end -- mid-stream — don't retry
|
|
if not should_fallback(err) then return ok, err end
|
|
local fb_name = (config.routing and config.routing.fallback_model)
|
|
or "cloud"
|
|
local fb_cfg = config.models[fb_name]
|
|
if not fb_cfg then return ok, err end
|
|
renderer.status(("local %s failed (%s); retrying via %s")
|
|
:format(model_name, fallback_reason(err), fb_name))
|
|
return broker.chat_stream(fb_cfg, msgs, wrapped, opts)
|
|
end
|
|
|
|
-- Run a shell command, framing output and (per config.shell.capture_output)
|
|
-- buffering it for the NEXT user turn — context.append_exec_output keeps
|
|
-- a [exec output] block pending until ask_ai flushes it via append_user.
|
|
-- Direct user-role injection violated chat-template alternation (mistral-
|
|
-- nemo's Jinja rejects user/user back-to-back); see PHASE0.md §6.
|
|
--
|
|
-- Issue #3: pre_cmd / post_cmd hooks fire around exec. Each hook
|
|
-- receives the command on stdin and AISH_CMD/AISH_TURN/AISH_CWD as
|
|
-- env vars. Non-zero exit on pre_cmd aborts. post_cmd exit is
|
|
-- ignored; its stdout is logged via renderer.status.
|
|
-- _shq lifted to module scope (above expand_mentions) so the
|
|
-- @-mention diff retry can share the same quoter.
|
|
local function _run_hook(script, cmd, want_output)
|
|
local cwd = (require("ffi.libc").getcwd()) or os.getenv("PWD") or "?"
|
|
local pipeline = string.format(
|
|
"printf '%%s' %s | AISH_CMD=%s AISH_TURN=%d AISH_CWD=%s %s 2>&1",
|
|
_shq(cmd), _shq(cmd), #ctx.turns, _shq(cwd), _shq(script))
|
|
if want_output then
|
|
local out, code = executor.exec(pipeline)
|
|
return code, out
|
|
else
|
|
local out, code = executor.exec(pipeline)
|
|
-- Even when we don't *want* output, surface it if the hook
|
|
-- aborts so the user sees why.
|
|
return code, out
|
|
end
|
|
end
|
|
|
|
-- _git_clean_cmd lifted to module scope (above expand_mentions);
|
|
-- shared with the @<r1>..<r2> @-mention diff retry. Same B1
|
|
-- invariant: every git invocation that flows back into context
|
|
-- runs with `--no-pager -c color.ui=never`.
|
|
|
|
-- Phase 6 highlighter (commit #5): tree-sitter CLI detection +
|
|
-- per-language extension map + path-based dispatch.
|
|
--
|
|
-- R4 resolution: the upstream `tree-sitter highlight` CLI takes a
|
|
-- PATH (no --lang flag); language is inferred from the file
|
|
-- extension. Empty `--scope source.X` is also unreliable
|
|
-- without configured grammars. So we name the tmpfile with the
|
|
-- canonical extension for `lang` and let the CLI dispatch.
|
|
--
|
|
-- Additional B4-followup: even with the CLI installed, highlighting
|
|
-- requires parser-directories configured AND grammars cloned + built.
|
|
-- Without those, every highlight call emits a warning to stderr and
|
|
-- returns empty stdout. We treat empty/error as pass-through (body
|
|
-- returned as-is).
|
|
local LANG_EXTENSION = {
|
|
lua = ".lua", python = ".py", javascript = ".js", typescript = ".ts",
|
|
bash = ".sh", c = ".c", cpp = ".cpp", rust = ".rs", go = ".go",
|
|
java = ".java", ruby = ".rb", markdown = ".md", json = ".json",
|
|
yaml = ".yaml", toml = ".toml", html = ".html", css = ".css",
|
|
sql = ".sql", xml = ".xml",
|
|
}
|
|
-- Map lang-tag (as it appears in ```<tag>) to canonical lang. Mirrors
|
|
-- expand_mentions LANG_BY_EXT but indexed by tag (e.g., "py" -> "python").
|
|
local LANG_TAG = {
|
|
py = "python", python = "python",
|
|
lua = "lua",
|
|
js = "javascript", javascript = "javascript",
|
|
ts = "typescript", typescript = "typescript",
|
|
sh = "bash", bash = "bash",
|
|
c = "c", cpp = "cpp", cc = "cpp",
|
|
rs = "rust", go = "go", java = "java", rb = "ruby", ruby = "ruby",
|
|
md = "markdown", markdown = "markdown",
|
|
json = "json", yaml = "yaml", yml = "yaml", toml = "toml",
|
|
html = "html", css = "css", sql = "sql", xml = "xml",
|
|
}
|
|
local function _detect_treesitter()
|
|
local pipe = io.popen("command -v tree-sitter 2>/dev/null && tree-sitter --version 2>/dev/null")
|
|
-- N2 / B3: pipe:close() returns true on LuaJIT regardless of exit
|
|
-- code; we don't use it for the verdict. Presence of an output
|
|
-- line from --version is the actual signal.
|
|
local ok = pipe and pipe:read("*l") and pipe:close()
|
|
return ok and true or false
|
|
end
|
|
local highlight_enabled = false
|
|
local highlight_detected = _detect_treesitter()
|
|
|
|
-- highlighted(body, lang_tag) — R2-placed in repl.lua so it has
|
|
-- access to _shq + executor. Returns the rendered body (with ANSI)
|
|
-- or `body` unchanged on any failure (silent pass-through so the
|
|
-- user never sees a broken highlighter swallow their code block).
|
|
local function highlighted(body, lang_tag)
|
|
if not highlight_enabled then return body end
|
|
local lang = LANG_TAG[(lang_tag or ""):lower()]
|
|
local ext = lang and LANG_EXTENSION[lang]
|
|
if not ext then return body end
|
|
-- B3: io.popen close doesn't expose exit code; route via
|
|
-- executor.exec (pty.spawn + waitpid) for reliable (out, code).
|
|
local tmp = os.tmpname() .. ext
|
|
local f = io.open(tmp, "wb")
|
|
if not f then return body end
|
|
f:write(body); f:close()
|
|
local out, code = executor.exec(
|
|
("tree-sitter highlight %s 2>/dev/null"):format(_shq(tmp)))
|
|
os.remove(tmp)
|
|
if code ~= 0 or not out or out == "" then return body end
|
|
return out
|
|
end
|
|
|
|
-- Wire the filter into renderer (off by default; user opts in via
|
|
-- :highlight on). Even when off, we set the callback so a later
|
|
-- toggle works without reinitialization.
|
|
renderer.set_highlight(highlight_enabled, highlight_detected, highlighted)
|
|
|
|
-- Phase 6 (§6 + N4): project file-tree scanner. Prefers
|
|
-- `git -C <dir> ls-files --cached --others --exclude-standard`
|
|
-- when <dir> is inside a git repo (free .gitignore honor);
|
|
-- falls back to `find ... -not -path '*/.<wildcard>'` for non-repo
|
|
-- cwds. opts: { depth = N, max_chars = N }; defaults via cfg.project.
|
|
-- Returns (body, info) where info = { file_count, truncated }.
|
|
local function _scan_project_tree(dir, opts)
|
|
opts = opts or {}
|
|
local p_cfg = config.project or {}
|
|
local depth = opts.depth or p_cfg.tree_depth or 3
|
|
local max_chars = opts.max_chars or p_cfg.tree_max_chars or 4096
|
|
|
|
-- N4: `git -C <dir>` skips the subshell vs `cd && git ...`.
|
|
local in_git = os.execute(
|
|
("git -C %s rev-parse --git-dir >/dev/null 2>&1"):format(_shq(dir))
|
|
) == 0
|
|
local listcmd
|
|
if in_git then
|
|
listcmd = ("git -C %s ls-files --cached --others --exclude-standard")
|
|
:format(_shq(dir))
|
|
else
|
|
-- find honors -maxdepth from the start path; we count the
|
|
-- depth in terms of nested subdirectories beneath <dir>.
|
|
listcmd = ("find %s -mindepth 1 -maxdepth %d -type f -not -path '*/.*' 2>/dev/null")
|
|
:format(_shq(dir), depth + 1)
|
|
end
|
|
|
|
local pipe = io.popen(listcmd)
|
|
if not pipe then return nil, "scan failed (popen)" end
|
|
|
|
local files = {}
|
|
for line in pipe:lines() do
|
|
-- Depth filter is a no-op for the git case (ls-files emits
|
|
-- full repo-relative paths); for find we already capped via
|
|
-- -maxdepth. Keep the slash count here as a defensive bound.
|
|
local _, slashes = line:gsub("/", "")
|
|
if slashes <= depth then files[#files + 1] = line end
|
|
end
|
|
pipe:close()
|
|
|
|
table.sort(files)
|
|
|
|
local body = table.concat(files, "\n")
|
|
local truncated = false
|
|
if #body > max_chars then
|
|
body = body:sub(1, max_chars) .. "\n... (truncated)"
|
|
truncated = true
|
|
end
|
|
return body, { file_count = #files, truncated = truncated, in_git = in_git }
|
|
end
|
|
|
|
local function run_shell(cmd)
|
|
local chd, err = executor.maybe_chdir(cmd)
|
|
if chd ~= nil then
|
|
if chd then
|
|
local pwd = io.popen("pwd"):read("*l") or "?"
|
|
renderer.status("cwd -> " .. pwd)
|
|
else
|
|
renderer.status("cd: " .. tostring(err))
|
|
end
|
|
return
|
|
end
|
|
local hooks = config.hooks or {}
|
|
if hooks.pre_cmd then
|
|
local rc = _run_hook(hooks.pre_cmd, cmd, false)
|
|
if rc ~= 0 then
|
|
renderer.status(("pre_cmd hook aborted (exit %d): %s")
|
|
:format(rc, cmd))
|
|
last_exec_code = rc
|
|
return
|
|
end
|
|
end
|
|
renderer.exec_begin()
|
|
local out, code = executor.exec(cmd)
|
|
last_exec_code = code
|
|
renderer.exec_end(code)
|
|
if config.shell and config.shell.capture_output then
|
|
ctx:append_exec_output(out)
|
|
end
|
|
if hooks.post_cmd then
|
|
_run_hook(hooks.post_cmd, cmd, true)
|
|
end
|
|
end
|
|
|
|
-- Send user text to the active model and process the response. If MCP
|
|
-- tools are connected and the model emits tool_calls, dispatch each
|
|
-- call (with safety confirm gate), append role:"tool" turns, and
|
|
-- re-call the broker — looping until the model returns pure text or
|
|
-- max_tool_depth is hit. CMD: extraction runs ONCE on the final
|
|
-- pure-text response (the §6 substrate invariant is unchanged).
|
|
local max_tool_depth = (config.mcp and config.mcp.max_tool_depth) or 8
|
|
|
|
local function ask_ai(text)
|
|
local prev_pending = ctx.pending_exec_output
|
|
ctx:append_user(text)
|
|
log_turn(ctx.turns[#ctx.turns])
|
|
|
|
-- Phase 5 R-C2: routing decision taken ONCE on entry to ask_ai.
|
|
-- req_name/req_cfg are used for every iteration of the
|
|
-- tool-sub-loop; active_name/active_cfg are NOT mutated so the
|
|
-- user's :model selection survives the request.
|
|
local req_name, req_cfg = active_name, active_cfg
|
|
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 redact_mode = secrets_mode_for(req_cfg)
|
|
local scrubbed_msgs = scrub_messages(ctx:to_messages(), redact_mode)
|
|
-- Streaming rehydrator wraps the on_delta so the user sees real
|
|
-- values; text_parts accumulates the REHYDRATED chunks so
|
|
-- final_resp (used for CMD: / DELEGATE: extraction) is plain.
|
|
local rehydrator = secrets_session
|
|
and secrets.streaming_rehydrator(secrets_session)
|
|
or nil
|
|
local ok, err = call_broker(req_cfg, req_name, scrubbed_msgs,
|
|
function(kind, payload)
|
|
if kind == "text" then
|
|
local emit = rehydrator and rehydrator:push(payload)
|
|
or payload
|
|
if emit ~= "" then
|
|
text_parts[#text_parts + 1] = emit
|
|
renderer.assistant_delta(emit)
|
|
end
|
|
elseif kind == "tool_call" then
|
|
tool_calls_seen[#tool_calls_seen + 1] = payload
|
|
end
|
|
end,
|
|
{ tools = tools_schema(), category = "main" })
|
|
if rehydrator then
|
|
local tail = rehydrator:flush()
|
|
if tail ~= "" then
|
|
text_parts[#text_parts + 1] = tail
|
|
renderer.assistant_delta(tail)
|
|
end
|
|
end
|
|
renderer.assistant_flush()
|
|
|
|
if not ok then
|
|
renderer.status("broker error: " .. tostring(err))
|
|
if first_iteration then
|
|
-- Back out the user turn so :resume / retry is clean.
|
|
table.remove(ctx.turns)
|
|
ctx.pending_exec_output = prev_pending
|
|
end
|
|
return
|
|
end
|
|
first_iteration = false
|
|
|
|
local resp_text = table.concat(text_parts)
|
|
|
|
if #tool_calls_seen == 0 then
|
|
-- Pure text response — end of this AI turn.
|
|
ctx:append({ role = "assistant", content = resp_text })
|
|
log_turn(ctx.turns[#ctx.turns])
|
|
final_resp = resp_text
|
|
break
|
|
end
|
|
|
|
-- Record the assistant turn with text AND tool_calls. Content
|
|
-- may be "" (C3: model often emits no prose before a call).
|
|
ctx:append({
|
|
role = "assistant",
|
|
content = resp_text,
|
|
tool_calls = tool_calls_seen,
|
|
})
|
|
log_turn(ctx.turns[#ctx.turns])
|
|
|
|
-- Process each tool_call. Every iteration appends EXACTLY one
|
|
-- role:"tool" turn per call (keeps alternation legal even on
|
|
-- decline/error per C5/C7).
|
|
for _, call in ipairs(tool_calls_seen) do
|
|
local args_table, args_err
|
|
if call.arguments and call.arguments ~= "" then
|
|
args_table, _, args_err = json.decode(call.arguments)
|
|
else
|
|
args_table = {}
|
|
end
|
|
|
|
local tool_content, is_error
|
|
if args_err then
|
|
tool_content = ("[aish] tool arguments not parseable as "
|
|
.. "JSON: %s"):format(tostring(args_err))
|
|
is_error = true
|
|
renderer.tool_call_begin(call.name, call.arguments)
|
|
renderer.tool_call_end(tool_content, true)
|
|
elseif not safety.confirm_tool_call(call.name, args_table,
|
|
config) then
|
|
tool_content = "[aish] tool call declined by user"
|
|
is_error = true
|
|
renderer.status(tool_content)
|
|
else
|
|
renderer.tool_call_begin(call.name, call.arguments)
|
|
local content, errflag = dispatch_tool_call(call.name,
|
|
args_table)
|
|
tool_content = content
|
|
is_error = errflag
|
|
renderer.tool_call_end(content, errflag)
|
|
end
|
|
|
|
ctx:append({
|
|
role = "tool",
|
|
tool_call_id = call.id,
|
|
content = tool_content,
|
|
})
|
|
log_turn(ctx.turns[#ctx.turns])
|
|
end
|
|
|
|
depth = depth + 1
|
|
if depth >= max_tool_depth then
|
|
renderer.status(("tool-call depth limit reached (%d); "
|
|
.. "stopping sub-loop"):format(max_tool_depth))
|
|
final_resp = resp_text
|
|
break
|
|
end
|
|
-- loop body re-runs broker.chat_stream with the now-extended ctx
|
|
end
|
|
|
|
status_evictions(ctx:enforce_budget())
|
|
|
|
-- CMD: extraction on the final pure-text response only.
|
|
for _, cmd in ipairs(executor.extract_cmd_lines(final_resp)) do
|
|
if plan_mode then
|
|
-- Issue #5: print PLAN: and feed back as a would-have-run
|
|
-- note. Same context flow as a real exec output so the
|
|
-- model can iterate on the plan turn by turn.
|
|
renderer.status(("PLAN: %s"):format(cmd))
|
|
ctx:append_exec_output(("[plan] would run: %s"):format(cmd))
|
|
else
|
|
-- Issue #9: permission policy DSL — verdict drives the gate.
|
|
-- Falls back to shell.confirm_cmd boolean when config.permissions
|
|
-- is unset (backward compat).
|
|
local verdict, rule = safety.classify_command(cmd, config)
|
|
local doit = false
|
|
if verdict == "allow" then
|
|
doit = true
|
|
elseif verdict == "deny" then
|
|
renderer.status(("denied by policy [%s]: %s")
|
|
:format(rule or "default", cmd))
|
|
else -- "confirm"
|
|
local ans = rl.readline(("execute '%s'? [y/N] "):format(cmd)) or ""
|
|
doit = (ans:lower():sub(1, 1) == "y")
|
|
end
|
|
if doit then run_shell(cmd) end
|
|
end
|
|
end
|
|
|
|
-- Issue #8: CMD&: extraction — spawn each as a background job.
|
|
-- No confirm gate in v1 (the model issuing CMD&: is opting into the
|
|
-- async path; permission policy is still bypassed there. Revisit
|
|
-- once #9 is generalized beyond the synchronous CMD: gate).
|
|
for _, cmd in ipairs(executor.extract_cmd_bg_lines(final_resp)) do
|
|
if plan_mode then
|
|
renderer.status(("PLAN: & %s"):format(cmd))
|
|
ctx:append_exec_output(("[plan] would bg-run: %s"):format(cmd))
|
|
else
|
|
local job, err = _bg_spawn(cmd)
|
|
if not job then
|
|
renderer.status(("bg spawn failed: %s"):format(tostring(err)))
|
|
ctx:append_exec_output(("[bg failed to start]: %s"):format(cmd))
|
|
else
|
|
local note = ("[bg:%d started pid=%d]: %s")
|
|
:format(job.id, job.pid, cmd)
|
|
renderer.status(note)
|
|
ctx:append_exec_output(note)
|
|
end
|
|
end
|
|
end
|
|
|
|
-- Issue #6: DELEGATE: <preset> "<prompt>" — sub-broker call against
|
|
-- a different model preset. Result is fed back as exec-output so the
|
|
-- model sees it on the next turn. Synchronous (blocks the current
|
|
-- ask_ai return until each delegate resolves). Cost note: a DELEGATE
|
|
-- to a paid cloud preset spends API tokens silently — the user has
|
|
-- already opted in by configuring the preset.
|
|
for _, d in ipairs(executor.extract_delegate_lines(final_resp)) do
|
|
local sub_cfg = config.models[d.preset]
|
|
if plan_mode then
|
|
renderer.status(("PLAN: DELEGATE %s \"%s\""):format(d.preset, d.prompt))
|
|
ctx:append_exec_output(
|
|
("[plan] would delegate to %s: %s"):format(d.preset, d.prompt))
|
|
elseif not sub_cfg then
|
|
renderer.status(("DELEGATE: unknown preset '%s'"):format(d.preset))
|
|
ctx:append_exec_output(
|
|
("[delegate %s failed: unknown preset]"):format(d.preset))
|
|
else
|
|
renderer.status(("DELEGATE -> %s: %s"):format(d.preset, d.prompt))
|
|
local sub_msgs = scrub_messages(
|
|
{ { role = "user", content = d.prompt } },
|
|
secrets_mode_for(sub_cfg))
|
|
-- Phase 7: capture (text, usage) — second is err on failure.
|
|
local sub_text, second = broker.chat(sub_cfg, sub_msgs,
|
|
{ category = "delegate" })
|
|
if not sub_text then
|
|
renderer.status(("delegate %s failed: %s"):format(d.preset, tostring(second)))
|
|
ctx:append_exec_output(
|
|
("[delegate %s failed: %s]"):format(d.preset, tostring(second)))
|
|
else
|
|
if second then -- usage payload
|
|
_record_usage(second.model, second.category, second)
|
|
end
|
|
-- Rehydrate the reply so the model sees its own
|
|
-- secrets restored when this gets re-serialized
|
|
-- on the next ask_ai turn.
|
|
if secrets_session then
|
|
sub_text = secrets_session:rehydrate(sub_text)
|
|
end
|
|
ctx:append_exec_output(
|
|
("[delegate %s]: %s"):format(d.preset, sub_text))
|
|
end
|
|
end
|
|
end
|
|
end
|
|
|
|
local function shutdown_session()
|
|
if session then session:close(); session = nil end
|
|
if memory then memory:close(); memory = nil end
|
|
end
|
|
|
|
-- ---------------------------------------------------------------- Norris driver
|
|
-- The Phase 3 autonomous mode driver. Sets ctx.norris_active +
|
|
-- ctx.norris_goal so context.to_messages() composes the NORRIS MODE
|
|
-- system-prompt suffix on each broker call. Loops calling
|
|
-- safety.norris_step until the planner returns a terminal status.
|
|
local max_norris_steps =
|
|
(config.safety and config.safety.max_norris_steps) or 8
|
|
|
|
-- The HALT prompt — proceed / skip / abort. Returns one of those
|
|
-- three verdict strings. Used by safety.norris_step via the helpers
|
|
-- table. \C-x\C-c also aborts (PHASE1.md §7 reserved key).
|
|
local function norris_halt(step_n, max_n, reason, action)
|
|
renderer.norris_halt(step_n, max_n, reason, action)
|
|
local ans = rl.readline("[N] proceed / skip / abort? ") or ""
|
|
local first = ans:lower():sub(1, 1)
|
|
if first == "p" then return "proceed" end
|
|
if first == "s" then return "skip" end
|
|
return "abort" -- empty input or anything else → abort (safe default)
|
|
end
|
|
|
|
-- Dispatch an MCP tool by name. Returns (content_string, is_error).
|
|
-- Mirrors what the Phase 2 ask_ai tool path does, but factored so
|
|
-- safety.norris_step can call it via helpers.
|
|
local function dispatch_tool(name, args)
|
|
local alias, tool_name = name:match("^(.-)__(.+)$")
|
|
if not alias or alias == "" then
|
|
return ("[aish] tool name has no alias prefix: %s"):format(name), true
|
|
end
|
|
local sess = mcp_sessions[alias]
|
|
if not sess then
|
|
return ("[aish] no MCP server connected for alias '%s'")
|
|
:format(alias), true
|
|
end
|
|
local result, kind, err = sess:call_tool(tool_name, args)
|
|
if not result then
|
|
if kind == "rpc_error" then
|
|
local msg = (type(err) == "table" and err.message) or tostring(err)
|
|
return ("[aish] tool dispatch failed: %s"):format(msg), true
|
|
else
|
|
return ("[aish] tool transport error: %s"):format(tostring(err)), true
|
|
end
|
|
end
|
|
local parts = {}
|
|
for _, b in ipairs(result.content or {}) do
|
|
if b.type == "text" then parts[#parts + 1] = b.text or "" end
|
|
end
|
|
return table.concat(parts, "\n"), (kind == "handler_error")
|
|
end
|
|
|
|
-- Exec a shell command for Norris (mirrors run_shell minus the cd
|
|
-- intercept which is interactive-only). Returns (output, exit_code).
|
|
local function norris_exec(cmd)
|
|
local chd, _ = executor.maybe_chdir(cmd)
|
|
if chd ~= nil then
|
|
-- cd in autonomous mode just changes our cwd silently
|
|
return chd and "" or "[aish] cd failed", 0
|
|
end
|
|
return executor.exec(cmd)
|
|
end
|
|
|
|
local function run_norris(goal)
|
|
ctx.norris_active = true
|
|
ctx.norris_goal = goal
|
|
ctx.norris_consecutive_skips = 0
|
|
ctx:append_user(("[norris] %s"):format(goal))
|
|
log_turn(ctx.turns[#ctx.turns])
|
|
|
|
renderer.norris_begin(goal)
|
|
|
|
local helpers = {
|
|
tools_schema = tools_schema,
|
|
exec_cmd = norris_exec,
|
|
dispatch_tool = dispatch_tool,
|
|
extract_cmd_lines = executor.extract_cmd_lines,
|
|
halt = norris_halt,
|
|
render_step = renderer.norris_step,
|
|
render_tool_begin = renderer.tool_call_begin,
|
|
render_tool_end = renderer.tool_call_end,
|
|
render_exec_begin = renderer.exec_begin,
|
|
render_exec_end = renderer.exec_end,
|
|
render_assistant_delta = renderer.assistant_delta,
|
|
render_assistant_flush = renderer.assistant_flush,
|
|
log_turn = log_turn,
|
|
-- Issue #52: pass secrets-aware callbacks so safety.lua
|
|
-- can scrub outbound Norris broker messages + LLM probe
|
|
-- inputs + rehydrate streamed replies. All three are nil-
|
|
-- safe; safety.lua only wires them in when present.
|
|
scrub_msgs = function(msgs, mode_cfg)
|
|
return scrub_messages(msgs, secrets_mode_for(mode_cfg or active_cfg))
|
|
end,
|
|
rehydrate = function(text)
|
|
return secrets_session and secrets_session:rehydrate(text) or text
|
|
end,
|
|
streaming_rehydrator = function()
|
|
return secrets_session
|
|
and secrets.streaming_rehydrator(secrets_session)
|
|
or nil
|
|
end,
|
|
-- Phase 7: hand the central usage chokepoint to safety.lua.
|
|
-- safety.norris_step routes the Norris main broker's usage
|
|
-- here under category="norris"; safety.is_destructive's LLM
|
|
-- probe routes via opts.on_usage under category="probe".
|
|
on_usage = _record_usage,
|
|
}
|
|
|
|
local step_n = 1
|
|
local final_status, final_reason
|
|
while true do
|
|
local result = safety.norris_step(ctx, active_cfg, helpers, {
|
|
step_n = step_n,
|
|
max_steps = max_norris_steps,
|
|
cfg = config,
|
|
})
|
|
-- Issue #51: enforce budget after every step (was post-loop only).
|
|
-- PHASE3.md §2 specifies sliding-window eviction mid-Norris-session
|
|
-- when the loop runs long; this is what makes R-C3 (NORRIS suffix
|
|
-- goal anchor surviving eviction) observable end-to-end.
|
|
status_evictions(ctx:enforce_budget())
|
|
if result.status == "continue" then
|
|
step_n = step_n + 1
|
|
else
|
|
final_status, final_reason = result.status, result.reason
|
|
break
|
|
end
|
|
end
|
|
|
|
ctx.norris_active = false
|
|
ctx.norris_goal = nil
|
|
renderer.norris_end(final_status, final_reason)
|
|
end
|
|
|
|
-- Meta dispatch table.
|
|
local meta = {
|
|
quit = function() shutdown_session(); os.exit(0) end,
|
|
q = function() shutdown_session(); os.exit(0) end,
|
|
clear = function() io.write("\27[H\27[2J"); io.flush() end,
|
|
reset = function()
|
|
ctx:reset(); renderer.status("context reset")
|
|
end,
|
|
model = function(args)
|
|
local name = args:match("^%s*(%S+)")
|
|
if not name or not config.models[name] then
|
|
renderer.status("usage: :model <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 sum_msgs = scrub_messages({
|
|
{ role = "system", content =
|
|
"Read the following conversation transcript. Extract "
|
|
.. "facts, preferences, or context worth remembering "
|
|
.. "across future sessions. Output ONE candidate per "
|
|
.. "line, prefixed with the kind: \"fact: ...\", "
|
|
.. "\"pref: ...\", or \"context: ...\". Maximum 10 "
|
|
.. "candidates. No commentary outside candidate lines."
|
|
},
|
|
{ role = "user", content = transcript },
|
|
}, secrets_mode_for(sum_cfg))
|
|
-- Phase 7: capture (text, usage); second is err on failure.
|
|
local reply, second = broker.chat(sum_cfg, sum_msgs,
|
|
{ max_tokens = 1024, timeout_ms = 90000,
|
|
category = "memory_summarize" })
|
|
|
|
if not reply then
|
|
renderer.status("summarize failed: " .. tostring(second))
|
|
return
|
|
end
|
|
if second then -- usage payload
|
|
_record_usage(second.model, second.category, second)
|
|
end
|
|
if secrets_session then
|
|
reply = secrets_session:rehydrate(reply)
|
|
end
|
|
|
|
-- Persist the summarize-tagged assistant turn so future
|
|
-- :memory summarize filters it out (R-C2).
|
|
log_turn({ role = "assistant", content = reply, meta = "summarize" })
|
|
|
|
-- Parse candidates: tolerate bullets and bold markup.
|
|
local candidates = {}
|
|
for line in (reply .. "\n"):gmatch("([^\n]*)\n") do
|
|
local kind, body = line:match("^%s*[-*]?%s*[*_]*(%a+)[*_]*%s*:%s*(.+)$")
|
|
if kind then
|
|
kind = kind:lower()
|
|
if kind == "fact" or kind == "pref" or kind == "context" then
|
|
candidates[#candidates + 1] = { kind = kind,
|
|
content = body:gsub("%s+$", "") }
|
|
end
|
|
end
|
|
end
|
|
|
|
if #candidates == 0 then
|
|
renderer.status("no candidates parsed from response"); return
|
|
end
|
|
|
|
local added = 0
|
|
for _, cand in ipairs(candidates) do
|
|
io.write(("\n[memory] candidate (%s): %s\n")
|
|
:format(cand.kind, cand.content))
|
|
local ans = rl.readline("keep? [y/N/edit] ") or ""
|
|
local first = ans:lower():sub(1, 1)
|
|
if first == "y" then
|
|
memory:add(cand.kind, cand.content)
|
|
added = added + 1
|
|
elseif first == "e" then
|
|
local edited = rl.readline("edit: ") or ""
|
|
edited = edited:gsub("^%s+", ""):gsub("%s+$", "")
|
|
if edited ~= "" then
|
|
memory:add(cand.kind, edited)
|
|
added = added + 1
|
|
end
|
|
end
|
|
end
|
|
inject_memory()
|
|
renderer.status(("summarize: added %d / %d candidates")
|
|
:format(added, #candidates))
|
|
else
|
|
renderer.status("usage: :memory {list|add|forget|clear|inject|summarize}")
|
|
end
|
|
end,
|
|
safety = function(args)
|
|
local sub, sub_args = args:match("^%s*(%S*)%s*(.*)$")
|
|
if sub == "patterns" then
|
|
for i, rule in ipairs(safety._patterns) do
|
|
local ci = rule.ci and " (ci)" or ""
|
|
io.write((" %2d. %-32s %s%s\n"):format(
|
|
i, rule.reason, rule.pat, ci))
|
|
end
|
|
elseif sub == "check" then
|
|
local cmd = sub_args:match("^%s*(.-)%s*$")
|
|
if not cmd or cmd == "" then
|
|
renderer.status("usage: :safety check <cmd>"); return
|
|
end
|
|
-- Pass cfg so the LLM probe runs; user can opt-out via
|
|
-- :safety check --no-llm <cmd> if added in v2.
|
|
-- Issue #52: thread secrets scrub/rehydrate so the probe
|
|
-- model sees placeholders for any secrets in `cmd`.
|
|
-- Phase 7: also thread on_usage so the probe's cost
|
|
-- lands in the accumulator under category="probe".
|
|
local probe_opts = { on_usage = _record_usage }
|
|
if secrets_session then
|
|
probe_opts.scrub_msgs = function(msgs, mode_cfg)
|
|
return scrub_messages(msgs,
|
|
secrets_mode_for(mode_cfg or active_cfg))
|
|
end
|
|
probe_opts.rehydrate = function(t)
|
|
return secrets_session:rehydrate(t)
|
|
end
|
|
end
|
|
local hit, reason = safety.is_destructive(cmd, config, probe_opts)
|
|
if hit then
|
|
renderer.status(("DESTRUCTIVE — %s"):format(reason or "?"))
|
|
else
|
|
renderer.status("not destructive")
|
|
end
|
|
else
|
|
renderer.status("usage: :safety {patterns|check}")
|
|
end
|
|
end,
|
|
perms = function(args)
|
|
local sub, sub_args = args:match("^%s*(%S*)%s*(.*)$")
|
|
if sub == "list" or sub == "" then
|
|
local p = config.permissions
|
|
if not p then
|
|
renderer.status(("(no permissions set; fallback: confirm_cmd=%s)")
|
|
:format(tostring(config.shell and config.shell.confirm_cmd or false)))
|
|
return
|
|
end
|
|
local function dump(label, rules)
|
|
if not rules or #rules == 0 then return end
|
|
io.write((" %s:\n"):format(label))
|
|
for i, r in ipairs(rules) do
|
|
io.write((" %2d. %s\n"):format(i, r))
|
|
end
|
|
end
|
|
renderer.status("permissions (deny > confirm > allow; default verdict: confirm):")
|
|
dump("deny", p.deny)
|
|
dump("confirm", p.confirm)
|
|
dump("allow", p.allow)
|
|
elseif sub == "check" then
|
|
local cmd = sub_args:match("^%s*(.-)%s*$")
|
|
if not cmd or cmd == "" then
|
|
renderer.status("usage: :perms check <cmd>"); return
|
|
end
|
|
local v, rule = safety.classify_command(cmd, config)
|
|
renderer.status(("verdict=%s rule=%s"):format(v, rule or "(default)"))
|
|
else
|
|
renderer.status("usage: :perms {list|check}")
|
|
end
|
|
end,
|
|
route = function(args)
|
|
local sub, sub_args = args:match("^%s*(%S*)%s*(.*)$")
|
|
config.routing = config.routing or {}
|
|
if sub == "on" then
|
|
config.routing.auto = true
|
|
renderer.status("auto-routing on")
|
|
elseif sub == "off" then
|
|
config.routing.auto = false
|
|
renderer.status("auto-routing off")
|
|
elseif sub == "classes" then
|
|
local classes = config.routing.classes or {}
|
|
if next(classes) == nil then
|
|
renderer.status("(no classes configured)"); return
|
|
end
|
|
for k, v in pairs(classes) do
|
|
io.write((" %-10s → %s\n"):format(k, tostring(v)))
|
|
end
|
|
elseif sub == "check" then
|
|
local text = sub_args:match("^%s*(.-)%s*$")
|
|
if not text or text == "" then
|
|
renderer.status("usage: :route check <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,
|
|
}
|
|
|
|
-- Issue #2: user-defined skills loader. Scan ~/.config/aish/skills/
|
|
-- (or $AISH_SKILLS_DIR) for *.lua modules. Each module returns
|
|
-- { name = "<meta-cmd-name>", description = "...", run = function(args, h) end }
|
|
-- and gets registered as a meta command :<name>. Helpers passed to run():
|
|
-- h.ask(text) -- send text as an ai-kind prompt (same path as :ask)
|
|
-- h.status(s) -- emit a [aish] status line
|
|
-- h.exec(cmd) -- run a shell command (subject to plan/hooks)
|
|
-- h.model() -- current active model name
|
|
-- h.ctx -- raw context object (advanced)
|
|
-- h.config -- raw config table
|
|
local skills = {} -- { [name] = {description=, run=} }
|
|
local function load_skills()
|
|
local dir = os.getenv("AISH_SKILLS_DIR")
|
|
or ((os.getenv("HOME") or ".") .. "/.config/aish/skills")
|
|
local pipe = io.popen(("ls -1 %q/*.lua 2>/dev/null"):format(dir))
|
|
if not pipe then return end
|
|
for path in pipe:lines() do
|
|
local ok, mod = pcall(dofile, path)
|
|
if not ok then
|
|
renderer.status(("skill load failed: %s: %s")
|
|
:format(path, tostring(mod)))
|
|
elseif type(mod) ~= "table"
|
|
or type(mod.name) ~= "string"
|
|
or type(mod.run) ~= "function"
|
|
or not mod.name:match("^[%w_-]+$")
|
|
then
|
|
renderer.status(("skill %s: invalid module (need {name, run})")
|
|
:format(path))
|
|
elseif meta[mod.name] or skills[mod.name] then
|
|
renderer.status(("skill %s: name '%s' already in use")
|
|
:format(path, mod.name))
|
|
else
|
|
skills[mod.name] = {
|
|
description = mod.description or "",
|
|
run = mod.run,
|
|
}
|
|
local helpers = {
|
|
ask = function(t) ask_ai(expand_mentions(t or "", renderer.status)) end,
|
|
status = renderer.status,
|
|
exec = run_shell,
|
|
model = function() return active_name end,
|
|
ctx = ctx,
|
|
config = config,
|
|
}
|
|
meta[mod.name] = function(args)
|
|
local sk_ok, sk_err = pcall(mod.run, args or "", helpers)
|
|
if not sk_ok then
|
|
renderer.status(("skill %s failed: %s")
|
|
:format(mod.name, tostring(sk_err)))
|
|
end
|
|
end
|
|
end
|
|
end
|
|
pipe:close()
|
|
end
|
|
meta.skills = function()
|
|
local names = {}
|
|
for n, _ in pairs(skills) do names[#names + 1] = n end
|
|
table.sort(names)
|
|
if #names == 0 then renderer.status("(no skills loaded)"); return end
|
|
renderer.status(("skills (%d):"):format(#names))
|
|
for _, n in ipairs(names) do
|
|
io.write((" :%-16s %s\n"):format(n, skills[n].description))
|
|
end
|
|
end
|
|
|
|
-- Issue #13: :secrets meta — vault status, current mode per active
|
|
-- broker, mapping size. Never prints actual values (the vault file
|
|
-- is itself a secret, gotcha 7).
|
|
meta.secrets = function(args)
|
|
local sub = args:match("^%s*(%S*)") or ""
|
|
if sub == "" or sub == "status" then
|
|
if not secrets_session then
|
|
renderer.status("(no vault loaded; configure config.secrets.vault)")
|
|
return
|
|
end
|
|
renderer.status(("vault: %d entries; %d placeholders allocated this session")
|
|
:format(#secrets_session.entries, secrets_session:mapping_size()))
|
|
renderer.status(("active broker mode: %s"):format(secrets_mode_for(active_cfg)))
|
|
local names = secrets_session:vault_names()
|
|
if #names > 0 then
|
|
io.write(" entry names: " .. table.concat(names, ", ") .. "\n")
|
|
end
|
|
elseif sub == "check" then
|
|
-- Run a scrub against the given text and report what would change.
|
|
local text = args:match("^%s*check%s+(.+)$") or ""
|
|
if text == "" then renderer.status("usage: :secrets check <text>"); return end
|
|
if not secrets_session then renderer.status("(no vault loaded)"); return end
|
|
local mode = secrets_mode_for(active_cfg)
|
|
local scrubbed = secrets_session:scrub(text, mode)
|
|
if scrubbed == text then
|
|
renderer.status(("no matches (mode=%s)"):format(mode))
|
|
else
|
|
renderer.status(("scrubbed (mode=%s):"):format(mode))
|
|
io.write(" " .. scrubbed .. "\n")
|
|
end
|
|
else
|
|
renderer.status("usage: :secrets [status|check <text>]")
|
|
end
|
|
end
|
|
|
|
load_skills()
|
|
|
|
-- Issue #11: in-session recurring prompts (:every). Pre-prompt due-check
|
|
-- model: timers fire between user inputs, not during readline waits or
|
|
-- broker calls. This is the minimum viable approach without rewriting
|
|
-- ffi/readline to callback-mode. Suppressed during Norris.
|
|
local every_jobs = {} -- { {id, interval_s, next_fire, prompt, model_name}, ... }
|
|
local next_every_id = 1
|
|
local function _parse_interval(s)
|
|
s = (s or ""):gsub("%s+", "")
|
|
local num, unit = s:match("^(%d+)([smh]?)$")
|
|
if not num then return nil end
|
|
local mult = ({ s = 1, m = 60, h = 3600, [""] = 1 })[unit]
|
|
return tonumber(num) * mult
|
|
end
|
|
local function _every_fire(job)
|
|
renderer.status(("[every #%d tick: %s]")
|
|
:format(job.id, job.prompt))
|
|
-- Temporarily swap to the job's chosen model so the recurring prompt
|
|
-- hits the preset selected at :every time (defaulted to "fast").
|
|
local saved_name, saved_cfg = active_name, active_cfg
|
|
if config.models[job.model_name] then
|
|
active_name, active_cfg = job.model_name, config.models[job.model_name]
|
|
end
|
|
local ok, err = pcall(ask_ai, job.prompt)
|
|
active_name, active_cfg = saved_name, saved_cfg
|
|
if not ok then
|
|
renderer.status(("[every #%d failed: %s]"):format(job.id, tostring(err)))
|
|
end
|
|
end
|
|
local function check_every_due()
|
|
if ctx.norris_active then return end
|
|
local now = os.time()
|
|
-- Snapshot the due jobs so a long-running tick doesn't compound.
|
|
local due = {}
|
|
for _, j in ipairs(every_jobs) do
|
|
if now >= j.next_fire then due[#due + 1] = j end
|
|
end
|
|
for _, j in ipairs(due) do
|
|
j.next_fire = os.time() + j.interval_s
|
|
_every_fire(j)
|
|
end
|
|
end
|
|
-- Phase 6: :tree meta — scan + inject project file-tree as the
|
|
-- [project] block in the system prompt. Variants per §6:
|
|
-- :tree scan with config defaults; resets _project_opts
|
|
-- :tree <N> scan with depth=N; cached as _project_opts
|
|
-- :tree refresh re-scan with cached opts; else config defaults
|
|
-- :tree off clear ctx.project AND ctx._project_opts
|
|
-- Phase 6: :highlight meta — toggle tree-sitter highlighter.
|
|
-- :highlight flip current setting
|
|
-- :highlight on enable (status warns if CLI absent
|
|
-- AND/OR parsers may not be installed)
|
|
-- :highlight off disable; renderer passes through
|
|
-- :highlight status report toggle + CLI detection state
|
|
meta.highlight = function(args)
|
|
local sub = ((args or ""):match("^%s*(%S*)") or ""):lower()
|
|
if sub == "status" then
|
|
renderer.status(("highlight: %s (tree-sitter CLI %s)"):format(
|
|
highlight_enabled and "on" or "off",
|
|
highlight_detected and "detected" or "absent"))
|
|
return
|
|
end
|
|
if sub == "" then
|
|
highlight_enabled = not highlight_enabled
|
|
elseif sub == "on" then
|
|
highlight_enabled = true
|
|
elseif sub == "off" then
|
|
highlight_enabled = false
|
|
else
|
|
renderer.status("usage: :highlight [on|off|status]")
|
|
return
|
|
end
|
|
renderer.set_highlight(highlight_enabled, highlight_detected, highlighted)
|
|
if highlight_enabled and not highlight_detected then
|
|
-- B4: install hint when toggled on but CLI absent. Also note
|
|
-- the parser-directory + grammar-clone requirement that
|
|
-- catches users who installed only the CLI.
|
|
renderer.status("highlight on but tree-sitter CLI not found.")
|
|
renderer.status("install: `apt install tree-sitter-cli` OR `cargo install tree-sitter-cli`")
|
|
renderer.status("then: `tree-sitter init-config` AND clone the relevant")
|
|
renderer.status("`tree-sitter-<lang>` grammars into a parser directory.")
|
|
elseif highlight_enabled then
|
|
renderer.status("highlight on (note: needs parser-directories with built tree-sitter-<lang> grammars)")
|
|
else
|
|
renderer.status("highlight off")
|
|
end
|
|
end
|
|
-- Phase 6: :diff meta — `git diff <args>` (B1-clean), appends as
|
|
-- [diff <args>]\n<output> exec_output. Reads cwd at invocation
|
|
-- time (R6: differs from :tree's scan-time cwd capture). Empty
|
|
-- Phase 7: :cost meta — read-only reporter of ctx.usage_totals.
|
|
-- :cost summary line
|
|
-- :cost detail per-model + per-category breakdown
|
|
-- :cost reset zero the meter (also clears warn flags)
|
|
-- R7 sort: (cost desc, model asc, category asc) — table.sort is
|
|
-- unstable, so the 3-level key ensures deterministic output.
|
|
-- R10: $%.6f for sub-cent precision (cloud costs can be ~3e-05).
|
|
-- R6: annotation uses the per-slot is_local sticky flag rather
|
|
-- than a fragile cost==0 heuristic.
|
|
meta.cost = function(args)
|
|
local sub = ((args or ""):match("^%s*(%S*)") or ""):lower()
|
|
if sub == "reset" then
|
|
ctx:reset_usage()
|
|
renderer.status("cost meter reset")
|
|
return
|
|
end
|
|
local total_cost = ctx:total_cost()
|
|
local total_p, total_c = ctx:total_tokens()
|
|
local has_local, has_cloud = false, false
|
|
for _, m in pairs(ctx.usage_totals or {}) do
|
|
for _, c in pairs(m) do
|
|
if c.is_local then has_local = true end
|
|
if c.cost > 0 then has_cloud = true end
|
|
end
|
|
end
|
|
local label
|
|
if has_local and has_cloud then
|
|
label = "(cloud only; local: tokens but no cost field)"
|
|
elseif has_local and not has_cloud then
|
|
label = "(local only; no cost field)"
|
|
else
|
|
label = ""
|
|
end
|
|
if sub == "" then
|
|
local calls = 0
|
|
for _, m in pairs(ctx.usage_totals or {}) do
|
|
for _, c in pairs(m) do calls = calls + c.calls end
|
|
end
|
|
renderer.status(("session usage: %d calls, prompt=%d / completion=%d tokens, cost=$%.6f %s"):format(
|
|
calls, total_p, total_c, total_cost, label))
|
|
return
|
|
end
|
|
if sub == "detail" then
|
|
local rows = {}
|
|
for model, cats in pairs(ctx.usage_totals or {}) do
|
|
for category, c in pairs(cats) do
|
|
rows[#rows + 1] = {
|
|
model = model, category = category,
|
|
prompt = c.prompt, completion = c.completion,
|
|
calls = c.calls, cost = c.cost,
|
|
is_local = c.is_local,
|
|
}
|
|
end
|
|
end
|
|
if #rows == 0 then renderer.status("(no usage recorded)"); return end
|
|
-- R7: 3-level deterministic sort
|
|
table.sort(rows, function(a, b)
|
|
if a.cost ~= b.cost then return a.cost > b.cost end
|
|
if a.model ~= b.model then return a.model < b.model end
|
|
return a.category < b.category
|
|
end)
|
|
renderer.status(("session usage detail (total=$%.6f, %d/%d tokens):"):format(
|
|
total_cost, total_p, total_c))
|
|
for _, r in ipairs(rows) do
|
|
io.write((" %-26s %-18s %3d calls, %6d / %6d tokens, $%.6f%s\n"):format(
|
|
r.model, r.category, r.calls, r.prompt, r.completion, r.cost,
|
|
r.is_local and " (local)" or ""))
|
|
end
|
|
-- Phase 8 R3: trailing summary line — current ctx snapshot
|
|
-- (NOT a comparison against the accumulator sums above; the
|
|
-- accumulator carries cumulative across all calls including
|
|
-- evicted turns, while estimate_tokens is current-in-memory
|
|
-- only). Shows budget utilization at-a-glance.
|
|
local est = ctx:estimate_tokens()
|
|
local budget = ctx.token_budget or 0
|
|
local pct = (budget > 0) and (est * 100 / budget) or 0
|
|
renderer.status(("estimated session ctx: %d tokens; token_budget=%d (%.1f%% used)"):format(
|
|
est, budget, pct))
|
|
return
|
|
end
|
|
renderer.status("usage: :cost [detail|reset]")
|
|
end
|
|
-- diff or git failure emits status and skips — never pollutes
|
|
-- context with empty or error noise.
|
|
meta.diff = function(args)
|
|
args = (args or ""):gsub("^%s+", ""):gsub("%s+$", "")
|
|
local cmd = _git_clean_cmd("diff " .. args)
|
|
local out, code = executor.exec(cmd)
|
|
if code ~= 0 then
|
|
renderer.status(("diff failed (exit %d): %s")
|
|
:format(code, args == "" and "(working tree)" or args))
|
|
return
|
|
end
|
|
if not out or out:gsub("%s", "") == "" then
|
|
renderer.status(("(no diff): %s"):format(
|
|
args == "" and "(working tree)" or args))
|
|
return
|
|
end
|
|
local label = args == "" and "(working tree)" or args
|
|
ctx:append_exec_output(("[diff %s]\n%s"):format(label, out))
|
|
renderer.status(("diff injected: %s (%d bytes)"):format(label, #out))
|
|
end
|
|
meta.tree = function(args)
|
|
local sub = (args or ""):match("^%s*(%S*)") or ""
|
|
if sub == "off" then
|
|
ctx.project = nil
|
|
ctx._project_opts = nil
|
|
renderer.status("project tree cleared")
|
|
return
|
|
end
|
|
local opts
|
|
if sub == "refresh" then
|
|
opts = ctx._project_opts or {}
|
|
elseif sub == "" then
|
|
opts = {}
|
|
ctx._project_opts = nil
|
|
else
|
|
local n = tonumber(sub)
|
|
if not n or n < 1 then
|
|
renderer.status("usage: :tree [<depth>|refresh|off]"); return
|
|
end
|
|
opts = { depth = n }
|
|
ctx._project_opts = opts
|
|
end
|
|
local dir = libc.getcwd() or "."
|
|
local body, info = _scan_project_tree(dir, opts)
|
|
if not body then
|
|
renderer.status("tree scan failed: " .. tostring(info))
|
|
return
|
|
end
|
|
ctx.project = body
|
|
renderer.status(("project tree: %d files%s (%s)"):format(
|
|
info.file_count,
|
|
info.truncated and " (truncated)" or "",
|
|
info.in_git and "git ls-files" or "find fallback"))
|
|
end
|
|
meta.every = function(args)
|
|
local sub = args:match("^%s*(%S*)") or ""
|
|
if sub == "list" or sub == "" and args:match("^%s*$") then
|
|
if #every_jobs == 0 then
|
|
renderer.status("(no recurring prompts)"); return
|
|
end
|
|
local now = os.time()
|
|
renderer.status(("recurring prompts (%d):"):format(#every_jobs))
|
|
for _, j in ipairs(every_jobs) do
|
|
io.write((" #%d every %ds (next in %ds, model=%s) %s\n")
|
|
:format(j.id, j.interval_s, j.next_fire - now, j.model_name, j.prompt))
|
|
end
|
|
return
|
|
end
|
|
if sub == "cancel" then
|
|
local id = tonumber(args:match("cancel%s+(%d+)"))
|
|
if not id then renderer.status("usage: :every cancel <id>"); return end
|
|
for i, j in ipairs(every_jobs) do
|
|
if j.id == id then
|
|
table.remove(every_jobs, i)
|
|
renderer.status(("cancelled #%d"):format(id)); return
|
|
end
|
|
end
|
|
renderer.status(("no such job: #%d"):format(id)); return
|
|
end
|
|
-- :every <interval> <prompt...> (prompt may be quoted; quotes stripped)
|
|
local interval_s, rest = args:match("^%s*(%S+)%s+(.+)$")
|
|
local secs = _parse_interval(interval_s)
|
|
if not secs or secs < 1 then
|
|
renderer.status("usage: :every <interval> <prompt> (interval: 30s | 5m | 2h | bare int)")
|
|
return
|
|
end
|
|
local p = rest:gsub("^%s+", ""):gsub("%s+$", "")
|
|
p = p:match("^\"(.*)\"$") or p:match("^'(.*)'$") or p
|
|
if p == "" then renderer.status("usage: :every <interval> <prompt>"); return end
|
|
local job_model = (config.models and config.models.fast) and "fast" or active_name
|
|
local id = next_every_id; next_every_id = next_every_id + 1
|
|
every_jobs[#every_jobs + 1] = {
|
|
id = id,
|
|
interval_s = secs,
|
|
next_fire = os.time() + secs,
|
|
prompt = p,
|
|
model_name = job_model,
|
|
}
|
|
renderer.status(("scheduled #%d every %ds (model=%s): %s")
|
|
:format(id, secs, job_model, p))
|
|
end
|
|
|
|
-- Issue #8: background CMD (CMD&: marker). Spawn via a shell wrapper
|
|
-- that captures stdout+stderr to <history.dir>/bg/<id>.log and the
|
|
-- exit code to <id>.status. We poll with kill -0; on completion read
|
|
-- the .status sidecar. No fork()/execv() FFI required — relies on POSIX
|
|
-- shell semantics. Reparented child is owned by init; we treat it as
|
|
-- "managed" via the PID and the status file only.
|
|
local bg_jobs = {} -- { {id, pid, cmd, started_at, log_path, status_path, exited} }
|
|
local next_bg_id = 1
|
|
local bg_dir = history_dir and (history_dir .. "/bg") or nil
|
|
if bg_dir then os.execute(("mkdir -p %q 2>/dev/null"):format(bg_dir)) end
|
|
|
|
local function _bg_shq(s) return "'" .. (s or ""):gsub("'", [['\'']]) .. "'" end
|
|
_bg_spawn = function(cmd)
|
|
if not bg_dir then
|
|
return nil, "background CMD requires history.dir to be configured"
|
|
end
|
|
local id = next_bg_id; next_bg_id = next_bg_id + 1
|
|
local log_path = ("%s/%d.log"):format(bg_dir, id)
|
|
local status_path = ("%s/%d.status"):format(bg_dir, id)
|
|
-- Wrapper: redirect, capture exit, write status. nohup + </dev/null
|
|
-- so the child survives our exit and doesn't compete for stdin.
|
|
-- Use `(...) &` so the subshell that wraps the exit-capture is
|
|
-- itself backgrounded; we echo $! to capture its PID.
|
|
local wrapper = ("nohup sh -c %s </dev/null >/dev/null 2>&1 & echo $!"):format(
|
|
_bg_shq(("(%s) > %s 2>&1; echo $? > %s"):format(
|
|
cmd, _bg_shq(log_path), _bg_shq(status_path))))
|
|
local pipe = io.popen(wrapper)
|
|
local pid_str = pipe and pipe:read("*l")
|
|
if pipe then pipe:close() end
|
|
local pid = tonumber(pid_str)
|
|
if not pid then
|
|
return nil, "failed to spawn (no PID returned)"
|
|
end
|
|
local job = {
|
|
id = id,
|
|
pid = pid,
|
|
cmd = cmd,
|
|
started_at = os.time(),
|
|
log_path = log_path,
|
|
status_path = status_path,
|
|
exited = false,
|
|
}
|
|
bg_jobs[#bg_jobs + 1] = job
|
|
return job
|
|
end
|
|
local function _bg_status_check(job)
|
|
if job.exited then return end
|
|
-- Read status file: presence means the wrapper finished writing
|
|
-- the exit code. If absent and PID is still alive, job is running.
|
|
local f = io.open(job.status_path, "rb")
|
|
if f then
|
|
local s = f:read("*l") or ""
|
|
f:close()
|
|
job.exit_code = tonumber(s) or -1
|
|
job.exited = true
|
|
job.exited_at = os.time()
|
|
local lf = io.open(job.log_path, "rb")
|
|
job.log_bytes = 0
|
|
if lf then
|
|
lf:seek("end"); job.log_bytes = lf:seek(); lf:close()
|
|
end
|
|
end
|
|
end
|
|
local function _fmt_bytes(n)
|
|
if n < 1024 then return ("%dB"):format(n) end
|
|
if n < 1024*1024 then return ("%.1fKB"):format(n/1024) end
|
|
return ("%.1fMB"):format(n/(1024*1024))
|
|
end
|
|
local function check_bg_done()
|
|
for _, job in ipairs(bg_jobs) do
|
|
if not job.exited then
|
|
_bg_status_check(job)
|
|
if job.exited then
|
|
local wall = (job.exited_at or os.time()) - job.started_at
|
|
local summary = ("[bg:%d exited %d, %s, %ds wall] %s")
|
|
:format(job.id, job.exit_code,
|
|
_fmt_bytes(job.log_bytes or 0), wall, job.cmd)
|
|
renderer.status(summary)
|
|
-- Feed back into context so the model sees completion
|
|
-- on the next ai turn — same channel as foreground exec.
|
|
ctx:append_exec_output(summary)
|
|
end
|
|
end
|
|
end
|
|
end
|
|
|
|
meta.delegate = function(args)
|
|
local preset, prompt = args:match("^%s*(%S+)%s+(.+)$")
|
|
if not preset then
|
|
renderer.status("usage: :delegate <preset> <prompt>"); return
|
|
end
|
|
prompt = prompt:gsub("^%s+", ""):gsub("%s+$", "")
|
|
prompt = prompt:match([[^"(.*)"$]]) or prompt:match([[^'(.*)'$]]) or prompt
|
|
if prompt == "" then renderer.status("usage: :delegate <preset> <prompt>"); return end
|
|
local sub_cfg = config.models[preset]
|
|
if not sub_cfg then
|
|
renderer.status(("unknown preset: %s"):format(preset)); return
|
|
end
|
|
renderer.status(("DELEGATE -> %s: %s"):format(preset, prompt))
|
|
local sub_msgs = scrub_messages(
|
|
{ { role = "user", content = prompt } },
|
|
secrets_mode_for(sub_cfg))
|
|
-- Phase 7: capture (text, usage); second is err on failure.
|
|
local sub_text, second = broker.chat(sub_cfg, sub_msgs,
|
|
{ category = "delegate" })
|
|
if not sub_text then
|
|
renderer.status(("delegate %s failed: %s"):format(preset, tostring(second)))
|
|
else
|
|
if second then -- usage payload
|
|
_record_usage(second.model, second.category, second)
|
|
end
|
|
if secrets_session then
|
|
sub_text = secrets_session:rehydrate(sub_text)
|
|
end
|
|
io.write(sub_text)
|
|
if not sub_text:match("\n$") then io.write("\n") end
|
|
ctx:append_exec_output(("[delegate %s]: %s"):format(preset, sub_text))
|
|
end
|
|
end
|
|
meta["bg-spawn"] = function(args)
|
|
local cmd = (args or ""):match("^%s*(.-)%s*$")
|
|
if cmd == "" then renderer.status("usage: :bg-spawn <cmd>"); return end
|
|
local job, err = _bg_spawn(cmd)
|
|
if not job then
|
|
renderer.status("bg spawn failed: " .. tostring(err))
|
|
else
|
|
renderer.status(("started #%d pid=%d: %s")
|
|
:format(job.id, job.pid, cmd))
|
|
end
|
|
end
|
|
meta["bg-list"] = function()
|
|
if #bg_jobs == 0 then renderer.status("(no bg jobs)"); return end
|
|
check_bg_done()
|
|
renderer.status(("bg jobs (%d):"):format(#bg_jobs))
|
|
for _, j in ipairs(bg_jobs) do
|
|
local state
|
|
if j.exited then
|
|
state = ("exit=%d %ds"):format(j.exit_code,
|
|
(j.exited_at - j.started_at))
|
|
else
|
|
local age = os.time() - j.started_at
|
|
state = ("running pid=%d %ds"):format(j.pid, age)
|
|
end
|
|
io.write((" #%-3d %s %s\n"):format(j.id, state, j.cmd))
|
|
end
|
|
end
|
|
meta["bg-output"] = function(args)
|
|
local id = tonumber(args:match("^%s*(%d+)"))
|
|
if not id then renderer.status("usage: :bg-output <id>"); return end
|
|
local job
|
|
for _, j in ipairs(bg_jobs) do if j.id == id then job = j; break end end
|
|
if not job then renderer.status("no such bg job: #" .. id); return end
|
|
local f = io.open(job.log_path, "rb")
|
|
if not f then renderer.status("(no log file yet)"); return end
|
|
io.write(f:read("*a") or ""); f:close()
|
|
if not job.log_path:match("\n$") then io.write("\n") end
|
|
end
|
|
meta["bg-kill"] = function(args)
|
|
local id = tonumber(args:match("^%s*(%d+)"))
|
|
if not id then renderer.status("usage: :bg-kill <id>"); return end
|
|
for _, j in ipairs(bg_jobs) do
|
|
if j.id == id then
|
|
if j.exited then
|
|
renderer.status(("#%d already exited"):format(id))
|
|
else
|
|
os.execute(("kill %d 2>/dev/null"):format(j.pid))
|
|
renderer.status(("sent SIGTERM to #%d (pid %d)"):format(id, j.pid))
|
|
end
|
|
return
|
|
end
|
|
end
|
|
renderer.status("no such bg job: #" .. id)
|
|
end
|
|
|
|
-- Phase 6: cfg.project.auto_tree startup hook. Runs once before the
|
|
-- main loop opens; opts.dir = cwd at startup. Failures are status-
|
|
-- logged once and skipped — the rest of the REPL works fine.
|
|
-- :tree refresh later picks up cwd changes (cd intercept doesn't
|
|
-- auto-refresh per A8 — v2 polish).
|
|
if config.project and config.project.auto_tree then
|
|
local dir = libc.getcwd() or "."
|
|
local body, info = _scan_project_tree(dir, {})
|
|
if body then
|
|
ctx.project = body
|
|
renderer.status(("project tree auto-injected: %d files%s (%s)")
|
|
:format(info.file_count,
|
|
info.truncated and " (truncated)" or "",
|
|
info.in_git and "git ls-files" or "find fallback"))
|
|
else
|
|
renderer.status("project tree auto-inject failed: " .. tostring(info))
|
|
end
|
|
end
|
|
|
|
-- Main loop.
|
|
while true do
|
|
check_every_due()
|
|
check_bg_done()
|
|
local line = rl.readline(prompt())
|
|
if line == nil then -- EOF (Ctrl-D on empty line)
|
|
io.write("\n")
|
|
shutdown_session()
|
|
break
|
|
end
|
|
if line:gsub("%s", "") == "" then
|
|
-- empty / whitespace-only: skip silently
|
|
else
|
|
rl.add_history(line)
|
|
local kind, payload = router.classify(line, config)
|
|
if kind == "meta" then
|
|
local name, rest = payload:match("^(%S+)%s*(.*)$")
|
|
local handler = name and meta[name]
|
|
if handler then
|
|
handler(rest or "")
|
|
else
|
|
renderer.status("unknown meta command: :" .. tostring(name))
|
|
end
|
|
elseif kind == "shell" then
|
|
run_shell(payload)
|
|
else -- "ai"
|
|
local expanded = expand_mentions(payload, renderer.status)
|
|
ask_ai(expanded)
|
|
end
|
|
end
|
|
end
|
|
end
|
|
|
|
-- Phase 0 module export. Meta-command list shown above lives in HELP and
|
|
-- is implemented inline in run().
|
|
return M
|