diff --git a/context.lua b/context.lua index 1e0441a..00222a1 100644 --- a/context.lua +++ b/context.lua @@ -136,8 +136,40 @@ end -- model (user → assistant → user → assistant), no strict-template breakage. -- -- The system prompt is NOT stored in self.turns per §6. +-- Phase 3: NORRIS MODE suffix appended to the system prompt when +-- self.norris_active. Carries self.norris_goal so eviction of the +-- user's "[norris] goal: ..." turn doesn't lose the anchor. +local NORRIS_SUFFIX_TEMPLATE = [[ + + +[NORRIS MODE] You are operating autonomously toward the following goal: + + %s + +Plan and execute step by step using CMD: lines (for shell) or tool_calls +(when MCP tools are available). After each action, you will see its +result in the next turn. Re-plan based on what you observe. + +When the goal is achieved, emit a single line: + GOAL: complete +on its own line, optionally followed by a brief summary. + +If the goal is unreachable or you need user input, emit: + GOAL: blocked +with a one-line reason. + +Avoid destructive operations unless the goal explicitly requires them. +The user will be prompted to confirm destructive actions; expect their +verdict in the next turn as a synthesized "[aish] ... skipped by user" +message if they declined.]] + function Context:to_messages() - local msgs = { { role = "system", content = self.system_prompt } } + local sys_content = self.system_prompt + if self.norris_active and self.norris_goal then + sys_content = sys_content + .. string.format(NORRIS_SUFFIX_TEMPLATE, self.norris_goal) + end + local msgs = { { role = "system", content = sys_content } } if self.use_tool_role then for _, t in ipairs(self.turns) do diff --git a/ffi/readline.lua b/ffi/readline.lua index 682203a..0a15dfb 100644 --- a/ffi/readline.lua +++ b/ffi/readline.lua @@ -1,7 +1,10 @@ -- ffi/readline.lua — GNU readline binding. -- Phase 0: readline + add_history + EOF handling. -- Phase 1: custom key bindings via rl_bind_keyseq. --- See docs/PHASE0.md §9 and docs/PHASE1.md §7. +-- Phase 3: rl_insert_text + rl_redisplay so bound key handlers can +-- stuff text into the in-progress line buffer (used by \C-n +-- to insert ":norris " in repl.lua). +-- See docs/PHASE0.md §9 and docs/PHASE1.md §7 and docs/PHASE3.md §3. local ffi = require("ffi") @@ -12,6 +15,8 @@ void free(void *ptr); typedef int (*rl_command_func_t)(int, int); int rl_bind_keyseq(const char *keyseq, rl_command_func_t function); +int rl_insert_text(const char *text); +int rl_redisplay(void); ]] -- libreadline-dev (which ships the unversioned `libreadline.so` symlink) is @@ -53,15 +58,21 @@ end -- Bind `seq` (e.g. "\\C-n") to a Lua function that runs when the user types -- that key sequence at the readline prompt. The Lua fn takes no arguments --- (readline passes count + key, but Phase 1 consumers don't need them). --- Callback trampolines are pinned in module-local state so they outlive the --- M.bind call — readline retains the function pointer indefinitely. -local _bound = {} +-- (readline passes count + key, but consumers don't need them). +-- Callback trampolines are pinned in module-local state for process +-- lifetime. We do NOT free the previous binding on rebind: readline +-- retains the function pointer in its keymap, and the window between +-- :free() and the new rl_bind_keyseq is a potential use-after-free. +-- Memory cost is bounded — one closure per bound key sequence. +-- (Phase 3 R-C4 fold-in.) +-- `_pinned` keeps every callback ever cast alive for process lifetime +-- (so readline's keymap pointers never dangle even after a re-bind). +-- `_bound` indexes by seq for "what's currently bound here" lookup but +-- both old and new closures stay reachable via _pinned. +local _bound = {} +local _pinned = {} function M.bind(seq, fn) - if _bound[seq] then - _bound[seq]:free() - end local cb = ffi.cast("rl_command_func_t", function(_count, _key) local ok, err = pcall(fn) if not ok then @@ -69,8 +80,25 @@ function M.bind(seq, fn) end return 0 end) + _pinned[#_pinned + 1] = cb -- never freed; bounded by N rebinds + local rc = rl.rl_bind_keyseq(seq, cb) _bound[seq] = cb - return rl.rl_bind_keyseq(seq, cb) == 0 + return rc == 0 +end + +-- Insert `text` at the cursor in the in-progress readline buffer. +-- Used by bound key handlers to stuff e.g. ":norris " into the line. +-- Caller typically follows with M.redisplay() to refresh the display. +function M.insert_text(text) + if text and text ~= "" then + rl.rl_insert_text(text) + end +end + +-- Force readline to redraw the current line. Call after insert_text or +-- any other buffer mutation from inside a bound handler. +function M.redisplay() + rl.rl_redisplay() end return M diff --git a/repl.lua b/repl.lua index c1c3cd9..23492fb 100644 --- a/repl.lua +++ b/repl.lua @@ -33,6 +33,10 @@ Meta commands: :mcp tool show one tool's inputSchema :mcp connect [a] open an MCP session at runtime :mcp disconnect drop an MCP session + :norris launch Chuck Norris autonomous mode on + :norris off exit Norris mode (rare — usually 'abort' at halt) + :safety patterns list active destructive-op patterns + :safety check probe is_destructive against without running :help this message ]] @@ -186,14 +190,18 @@ function M.run(config) end local function prompt() + if ctx.norris_active then + return ("[aish:%s \xE2\x9A\xA1]> "):format(active_name) + end return ("[aish:%s]> "):format(active_name) end - -- Phase 1 reserved-key wiring (PHASE1.md §7). The mechanism is real; the - -- handlers are placeholders that emit a status. Phase 3 (Norris) is the - -- first consumer that replaces the body with real work. + -- 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() - renderer.status("Norris mode not yet implemented (Phase 3)") + rl.insert_text(":norris ") + rl.redisplay() end) local function status_evictions(n) @@ -357,6 +365,113 @@ function M.run(config) if session then session:close(); session = nil end end + -- ---------------------------------------------------------------- Norris driver + -- The Phase 3 autonomous mode driver. Sets ctx.norris_active + + -- ctx.norris_goal so context.to_messages() composes the NORRIS MODE + -- system-prompt suffix on each broker call. Loops calling + -- safety.norris_step until the planner returns a terminal status. + local max_norris_steps = + (config.safety and config.safety.max_norris_steps) or 8 + + -- The HALT prompt — proceed / skip / abort. Returns one of those + -- three verdict strings. Used by safety.norris_step via the helpers + -- table. \C-x\C-c also aborts (PHASE1.md §7 reserved key). + local function norris_halt(step_n, max_n, reason, action) + renderer.norris_halt(step_n, max_n, reason, action) + local ans = rl.readline("[N] proceed / skip / abort? ") or "" + local first = ans:lower():sub(1, 1) + if first == "p" then return "proceed" end + if first == "s" then return "skip" end + return "abort" -- empty input or anything else → abort (safe default) + end + + -- Dispatch an MCP tool by name. Returns (content_string, is_error). + -- Mirrors what the Phase 2 ask_ai tool path does, but factored so + -- safety.norris_step can call it via helpers. + local function dispatch_tool(name, args) + local alias, tool_name = name:match("^(.-)__(.+)$") + if not alias or alias == "" then + return ("[aish] tool name has no alias prefix: %s"):format(name), true + end + local sess = mcp_sessions[alias] + if not sess then + return ("[aish] no MCP server connected for alias '%s'") + :format(alias), true + end + local result, kind, err = sess:call_tool(tool_name, args) + if not result then + if kind == "rpc_error" then + local msg = (type(err) == "table" and err.message) or tostring(err) + return ("[aish] tool dispatch failed: %s"):format(msg), true + else + return ("[aish] tool transport error: %s"):format(tostring(err)), true + end + end + local parts = {} + for _, b in ipairs(result.content or {}) do + if b.type == "text" then parts[#parts + 1] = b.text or "" end + end + return table.concat(parts, "\n"), (kind == "handler_error") + end + + -- Exec a shell command for Norris (mirrors run_shell minus the cd + -- intercept which is interactive-only). Returns (output, exit_code). + local function norris_exec(cmd) + local chd, _ = executor.maybe_chdir(cmd) + if chd ~= nil then + -- cd in autonomous mode just changes our cwd silently + return chd and "" or "[aish] cd failed", 0 + end + return executor.exec(cmd) + end + + local function run_norris(goal) + ctx.norris_active = true + ctx.norris_goal = goal + ctx.norris_consecutive_skips = 0 + ctx:append_user(("[norris] %s"):format(goal)) + log_turn(ctx.turns[#ctx.turns]) + + renderer.norris_begin(goal) + + local helpers = { + tools_schema = tools_schema, + exec_cmd = norris_exec, + dispatch_tool = dispatch_tool, + extract_cmd_lines = executor.extract_cmd_lines, + halt = norris_halt, + render_step = renderer.norris_step, + render_tool_begin = renderer.tool_call_begin, + render_tool_end = renderer.tool_call_end, + render_exec_begin = renderer.exec_begin, + render_exec_end = renderer.exec_end, + render_assistant_delta = renderer.assistant_delta, + render_assistant_flush = renderer.assistant_flush, + log_turn = log_turn, + } + + local step_n = 1 + local final_status, final_reason + while true do + local result = safety.norris_step(ctx, active_cfg, helpers, { + step_n = step_n, + max_steps = max_norris_steps, + cfg = config, + }) + if result.status == "continue" then + step_n = step_n + 1 + else + final_status, final_reason = result.status, result.reason + break + end + end + + ctx.norris_active = false + ctx.norris_goal = nil + renderer.norris_end(final_status, final_reason) + status_evictions(ctx:enforce_budget()) + end + -- Meta dispatch table. local meta = { quit = function() shutdown_session(); os.exit(0) end, @@ -540,6 +655,49 @@ function M.run(config) renderer.status("usage: :mcp {list|tools|tool|connect|disconnect}") end end, + norris = function(args) + local sub = args:match("^%s*(%S*)") + if sub == "off" then + if ctx.norris_active then + ctx.norris_active = false + ctx.norris_goal = nil + renderer.status("Norris mode off") + else + renderer.status("Norris mode is not active") + end + return + end + local goal = args:match("^%s*(.-)%s*$") + if not goal or goal == "" then + renderer.status("usage: :norris "); return + end + run_norris(goal) + end, + safety = function(args) + local sub, sub_args = args:match("^%s*(%S*)%s*(.*)$") + if sub == "patterns" then + for i, rule in ipairs(safety._patterns) do + local ci = rule.ci and " (ci)" or "" + io.write((" %2d. %-32s %s%s\n"):format( + i, rule.reason, rule.pat, ci)) + end + elseif sub == "check" then + local cmd = sub_args:match("^%s*(.-)%s*$") + if not cmd or cmd == "" then + renderer.status("usage: :safety check "); return + end + -- Pass cfg so the LLM probe runs; user can opt-out via + -- :safety check --no-llm if added in v2. + local hit, reason = safety.is_destructive(cmd, config) + if hit then + renderer.status(("DESTRUCTIVE — %s"):format(reason or "?")) + else + renderer.status("not destructive") + end + else + renderer.status("usage: :safety {patterns|check}") + end + end, help = function() io.write(HELP) end, }