diff --git a/repl.lua b/repl.lua index 455f03e..0d28a4b 100644 --- a/repl.lua +++ b/repl.lua @@ -13,6 +13,77 @@ local mcp = require("mcp") local safety = require("safety") local json = require("dkjson") +-- ---------------------------------------------------------------- @-mentions (issue #7) +-- Triggered when "@" follows start-of-string or whitespace (avoids +-- false positives on email addresses like user@example.com). Path +-- runs until next whitespace. The mention is replaced by a fenced +-- code block carrying the file contents, language-tagged by extension. +-- Files over MENTION_MAX_BYTES are truncated head+tail with a marker. +local MENTION_MAX_BYTES = 32 * 1024 +local MENTION_HEAD = 16 * 1024 +local MENTION_TAIL = 8 * 1024 +local LANG_BY_EXT = { + lua = "lua", py = "python", js = "javascript", ts = "typescript", + sh = "bash", c = "c", h = "c", cc = "cpp", cpp = "cpp", hpp = "cpp", + rs = "rust", go = "go", java = "java", rb = "ruby", md = "markdown", + json = "json", yaml = "yaml", yml = "yaml", toml = "toml", + html = "html", css = "css", sql = "sql", xml = "xml", +} +local function _lang_of(path) + local ext = path:match("%.([%w]+)$") + return ext and LANG_BY_EXT[ext:lower()] or "" +end +local function _read_truncated(path) + local f = io.open(path, "rb") + if not f then return nil end + local content = f:read("*a") or "" + f:close() + if #content <= MENTION_MAX_BYTES then return content, false end + local head = content:sub(1, MENTION_HEAD) + local tail = content:sub(#content - MENTION_TAIL + 1) + return head + .. ("\n... [%d bytes elided] ...\n"):format(#content - MENTION_HEAD - MENTION_TAIL) + .. tail, true +end +local function expand_mentions(line, on_status) + -- Walk the line; for each "@" 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 path = line:sub(i + 1, path_end - 1) + if path ~= "" then + local content, truncated = _read_truncated(path) + if content then + if on_status then + on_status(("@%s expanded (%d bytes%s)"):format( + path, #content, truncated and ", truncated" or "")) + end + out[#out + 1] = ("```%s path=%s\n%s\n```"):format( + _lang_of(path), path, content) + i = path_end + else + if on_status then + on_status(("@%s: not found"):format(path)) + 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 = [[ @@ -24,7 +95,7 @@ Meta commands: :models list configured models (* = active) :history show conversation turns :exec force shell execution - :ask force AI query + :ask force AI query (supports @path expansion) :sessions list session log files :save rename current session log to .jsonl :resume load .jsonl turns into the in-memory context @@ -783,7 +854,7 @@ function M.run(config) ask = function(args) args = (args or ""):match("^%s*(.-)%s*$") if args == "" then renderer.status("usage: :ask "); return end - ask_ai(args) + ask_ai(expand_mentions(args, renderer.status)) end, sessions = function() if not sessions_dir then renderer.status("(no history.dir configured)"); return end @@ -1210,7 +1281,8 @@ function M.run(config) elseif kind == "shell" then run_shell(payload) else -- "ai" - ask_ai(payload) + local expanded = expand_mentions(payload, renderer.status) + ask_ai(expanded) end end end