pmacs/tests/fixtures/pmacs-mcp-ai/init.lua

462 lines
18 KiB
Lua

-- pmacs-mcp-ai/init.lua --- T M9.8 AI-assistance example package.
--
-- Public API:
--
-- local ai = require("pmacs-mcp-ai")
-- ai.configure {
-- server_label = "claude-mcp", -- the label of an MCP server already
-- -- spawned via pmacs.mcp.spawn
-- prompts = {
-- fn = "review_function", -- which advertised prompt handles
-- -- the function-context flow
-- project = "review_project", -- ... project-context flow
-- ask = "ask_freeform", -- ... freeform-question flow
-- },
-- }
-- ai.unconfigure() -- drop commands; no server change
--
-- The package's three commands (defined on first configure):
--
-- ai.ask-about-function -- selects the enclosing function via tree-sitter
-- and sends as code-context
-- ai.ask-about-project -- collects all file-backed buffers and sends
-- as a structured `files: [{path, content}, ...]`
-- array (Q5: explicit JSON beats separator
-- encoding)
-- ai.ask -- prompts for a question, sends with no buffer
-- context
--
-- Architectural commitment (the M9.8 ship gate):
--
-- * Zero direct calls into the Rust core. Everything reaches the
-- Rust side through the public Lua surface (`pmacs.mcp.*`,
-- `pmacs.parse.*`, `pmacs.command.*`, etc.).
-- * Zero model-specific code. The package speaks MCP; the model
-- behind the configured server is interchangeable. The
-- `m9_8_server_pluggability_*` tests pin this by configure'ing
-- two distinct fake servers and verifying the same command
-- routes to whichever is currently configured.
--
-- Composition story:
--
-- * Rendering: composes with `pmacs-mcp-prompts.render(label,
-- prompt_name, response)` (promoted from internal to public on
-- M9.8's request as the second consumer). Result buffers land
-- in the M9.7 `*mcp:<label>:<prompt>*` namespace, so re-invoking
-- the underlying prompt from either path (M9.8's
-- `ai.ask-about-X` or M9.7's auto-registered
-- `<label>-<prompt>`) lands in the same buffer.
-- * Notifications: M9.8 doesn't subscribe directly. The user is
-- expected to also `require("pmacs-mcp-prompts")` and call
-- `register(server)` on their AI server if they want the auto-
-- registered prompt-commands surface. Either layer functions
-- standalone; the AI commands work without M9.7 registration.
--
-- Context selection (v0.1):
--
-- * Function context: walk the buffer's tree-sitter parse view to
-- find the deepest function-shaped node enclosing the cursor.
-- The "function-shaped" mapping is per-language with a generic
-- fallback for grammars without a hand-coded entry. v0.1 ships
-- with rust + lua mappings (matches the M4 builtin grammars).
-- * Project context: all open buffers whose name does NOT start
-- with `*`. The exclusion rule is intentional — anything in
-- `*name*` is a special buffer (REPL, *help*, *mcp:* result
-- buffers, *scratch*, etc.) by convention. Power users wanting
-- custom collection should call `pmacs.mcp.get_prompt` directly.
-- * Freeform: minibuffer-prompted question; no buffer context.
--
-- M9.6+M9.7 audit-finding carry-forward:
--
-- * Server-gone teardown (M9.6 finding 5): `dispatch` detects
-- "unknown server" / "not ready for requests" on get_prompt
-- failure and clears `_config` so subsequent invocations
-- surface the configure-needed message rather than the same
-- dead-server error. The user re-configures (or re-spawns the
-- server with the same label) to recover.
-- * Cross-source collision (M9.6 finding 6): the three command
-- names are namespaced under `ai.*` to minimize collision risk
-- with builtins. `pmacs.command.exists` is checked before
-- defining; on hit, the package warns and skips the colliding
-- command — the rest still register cleanly.
-- * `notify()` helper (M9.6 finding 10): warnings hit both
-- `set_status` and `pmacs.error` so they survive past the
-- next set_status overwrite.
-- * Notification subscription refcount (M9.6 finding 3): n/a —
-- M9.8 doesn't subscribe to notifications directly. M9.7's
-- package handles its own subscription lifecycle if the user
-- also registers it.
local mcp_prompts = require("pmacs-mcp-prompts")
local M = {}
-- Single global config. Re-configure replaces; unconfigure clears.
-- Shape: { server_label, prompts = { fn, project, ask } }
local _config = nil
-- Tracks whether the three commands are currently defined. Re-
-- configure does NOT redefine — the existing command bodies read
-- `_config` lazily, so flipping `server_label` between configure
-- calls reroutes invocations without touching the registry.
local _commands_defined = false
-- ---------------------------------------------------------------------------
-- Helpers
-- ---------------------------------------------------------------------------
local function notify(msg)
pmacs.editor.set_status(msg)
if pmacs.error then
pmacs.error("pmacs-mcp-ai: " .. msg)
end
end
local function looks_like_server_gone(err)
local s = type(err) == "table" and tostring(err.message or "") or tostring(err)
return s:find("unknown server", 1, true) ~= nil
or s:find("not ready for requests", 1, true) ~= nil
end
-- Resolve the configured server label to a live McpServerIdLua, or
-- return nil + a friendly message. Done at *invocation time*, not
-- configure time, so re-configure is observable on the very next
-- invocation without recomputing anything cached.
local function resolve_server()
if _config == nil then
return nil, "ai: not configured (call ai.configure first)"
end
for _, row in ipairs(pmacs.mcp.list()) do
if row.label == _config.server_label then
return row.id, nil
end
end
return nil, string.format(
"ai: no MCP server with label %q (spawn first, then configure)",
_config.server_label)
end
-- ---------------------------------------------------------------------------
-- Tree-sitter context selection
-- ---------------------------------------------------------------------------
-- Per-language mapping of "function-shaped node types". Adding a new
-- language is a one-line addition; languages without a mapping fall
-- through to a generic set that covers most C-family / dynamic-
-- language grammars.
local _FUNCTION_NODE_TYPES = {
rust = { "function_item" },
lua = { "function_declaration", "local_function", "function_definition" },
}
local _GENERIC_FUNCTION_NODE_TYPES = {
"function_declaration",
"function_definition",
"function_item",
"method_declaration",
"method_definition",
}
local function function_types_set(language)
local list = _FUNCTION_NODE_TYPES[language] or _GENERIC_FUNCTION_NODE_TYPES
local set = {}
for _, t in ipairs(list) do set[t] = true end
return set
end
-- Find the deepest node of any type in `type_set` whose byte range
-- contains `byte_pos`. Returns the node or nil. Walks the parse tree
-- depth-first; deeper matches take precedence so a method inside a
-- struct returns the method (not the struct).
--
-- Boundary: tree-sitter `end_byte` is exclusive (`end_byte` is the
-- position just past the node's last byte). The check `byte_pos > eb`
-- — strictly greater than — therefore *includes* `byte_pos == eb`
-- as enclosing. This is deliberate and inclusive at the right edge:
-- a cursor that has just stepped past the closing brace of a function
-- still gets that function as context, which matches the way users
-- think about "I'm working on this function." The trade is that a
-- cursor on the very first byte of a sibling function will return
-- the previous function, since it's `eb` of the previous one *and*
-- `sb` of the next, and depth-first ordering visits the previous
-- one first. Pinned by `m9_8_find_enclosing_at_end_byte_includes_node`.
local function find_enclosing(node, byte_pos, type_set)
if node == nil then return nil end
local sb = node:start_byte()
local eb = node:end_byte()
if sb == nil or eb == nil then return nil end
if byte_pos < sb or byte_pos > eb then return nil end
local children = node:children()
if type(children) == "table" then
for _, child in ipairs(children) do
local found = find_enclosing(child, byte_pos, type_set)
if found ~= nil then return found end
end
end
if type_set[node:type()] then return node end
return nil
end
-- Test seam (unstable): returns the function-shaped node enclosing
-- byte_pos in `buf`, plus the language string and a failure-kind
-- string (or nil on success). The third return distinguishes the
-- two failure modes the body callers care about:
--
-- * `"no_tree"` — buffer has no parse view yet (common for
-- `pmacs.buffer.from_bytes` / `pmacs.buffer.create` buffers, or
-- in the brief async-parse window right after a file-open).
-- * `"no_enclosing"` — there is a tree, but no function-shaped
-- node contains the cursor (cursor in a comment, top-level
-- scope, etc.).
--
-- The seam exists so the M9.8 enclosing-walk test can pin the lookup
-- without driving a full M-x → minibuffer → render flow.
function M._find_enclosing_function(buf, byte_pos)
local tree = pmacs.parse.tree(buf)
if tree == nil then return nil, nil, "no_tree" end
local language = tree:language()
local type_set = function_types_set(language)
local node = find_enclosing(tree:root(), byte_pos, type_set)
if node == nil then return nil, language, "no_enclosing" end
return node, language, nil
end
-- ---------------------------------------------------------------------------
-- Project context selection
-- ---------------------------------------------------------------------------
-- Collect all "user-content" buffers — file-backed or otherwise, but
-- excluding `*<anything>*` star-buffers (REPL, *help*, *mcp:*
-- result buffers, *scratch*, etc.). The shape returned to the wire is
-- `{path, content}` per Q5; `path` is the buffer name (which is the
-- file path for file-backed buffers).
--
-- Soft size guardrail: if the projected payload (sum of paths +
-- contents) exceeds `_PROJECT_PAYLOAD_WARN_BYTES`, surface a notify
-- so the user knows they're about to send (and pay for) a large
-- request. The collection still proceeds — the warning is
-- informational, not a hard cap. Power users wanting a hard limit
-- should call `pmacs.mcp.get_prompt` directly with their own
-- selection.
M._PROJECT_PAYLOAD_WARN_BYTES = 500 * 1024
function M._collect_project_files()
local out = {}
local total = 0
for _, id in ipairs(pmacs.buffer.list()) do
local d = pmacs.describe.buffer(id)
if d ~= nil and type(d.name) == "string" and not d.name:match("^%*") then
local content = id:slice(0, id:len())
total = total + #content + #d.name
out[#out + 1] = { path = d.name, content = content }
end
end
if total > M._PROJECT_PAYLOAD_WARN_BYTES then
notify(string.format(
"project context is %d bytes (>%d KB warning threshold); proceeding",
total, math.floor(M._PROJECT_PAYLOAD_WARN_BYTES / 1024)))
end
return out
end
-- ---------------------------------------------------------------------------
-- Dispatch
-- ---------------------------------------------------------------------------
local function dispatch(server, server_label, prompt_name, args)
pmacs.async(function()
local ok, response_or_err = pcall(function()
return pmacs.mcp.get_prompt(server, prompt_name, args):await()
end)
if ok then
mcp_prompts.render(server_label, prompt_name, response_or_err)
else
local msg
if type(response_or_err) == "table" and type(response_or_err.message) == "string" then
msg = response_or_err.message
else
msg = tostring(response_or_err)
end
pmacs.editor.set_status("ai " .. prompt_name .. " error: " .. msg)
if looks_like_server_gone(response_or_err) then
-- The configured server vanished. Clear `_config` so the next
-- ai.* invocation surfaces the configure-needed message
-- rather than the same dead-server error on every retry. The
-- user re-configures (or re-spawns the server with the same
-- label) to recover. Mirrors M9.6 finding 5 — but since this
-- package's commands are stable across configure cycles, we
-- clear the *config* rather than unregistering the commands.
_config = nil
end
end
end)
end
-- ---------------------------------------------------------------------------
-- Command bodies
-- ---------------------------------------------------------------------------
local function ask_about_function_body()
local server, err = resolve_server()
if server == nil then
pmacs.editor.set_status(err)
return
end
local prompt_name = (_config.prompts or {}).fn
if type(prompt_name) ~= "string" or prompt_name == "" then
pmacs.editor.set_status("ai: no `prompts.fn` configured for ask-about-function")
return
end
local buf = pmacs.window.buffer()
if buf == nil then
pmacs.editor.set_status("ai: no active buffer")
return
end
local cursor = pmacs.editor.cursor()
local node, language, fail_kind = M._find_enclosing_function(buf, cursor)
if fail_kind == "no_tree" then
pmacs.editor.set_status(
"ai: buffer not parsed yet (open as a file, or wait for the parse to settle)")
return
end
if node == nil then
pmacs.editor.set_status("ai: no enclosing function at cursor (place cursor inside a function)")
return
end
local source = node:text()
local file_path = (pmacs.describe.buffer(buf) or {}).name or "<unnamed>"
dispatch(server, _config.server_label, prompt_name, {
language = language or "text",
file_path = file_path,
source = source,
})
end
local function ask_about_project_body()
local server, err = resolve_server()
if server == nil then
pmacs.editor.set_status(err)
return
end
local prompt_name = (_config.prompts or {}).project
if type(prompt_name) ~= "string" or prompt_name == "" then
pmacs.editor.set_status("ai: no `prompts.project` configured for ask-about-project")
return
end
local files = M._collect_project_files()
if #files == 0 then
pmacs.editor.set_status("ai: no file-backed buffers to send as project context")
return
end
dispatch(server, _config.server_label, prompt_name, { files = files })
end
local function ask_body()
local server, err = resolve_server()
if server == nil then
pmacs.editor.set_status(err)
return
end
local prompt_name = (_config.prompts or {}).ask
if type(prompt_name) ~= "string" or prompt_name == "" then
pmacs.editor.set_status("ai: no `prompts.ask` configured for ask")
return
end
pmacs.minibuffer.read {
prompt = "Ask: ",
on_accept = function(question)
if question == nil or question == "" then return end
dispatch(server, _config.server_label, prompt_name, { question = question })
end,
on_cancel = function()
pmacs.editor.set_status("ai: cancelled")
end,
}
end
-- ---------------------------------------------------------------------------
-- Command lifecycle
-- ---------------------------------------------------------------------------
-- Define-once + cross-source-collision skip (M9.6 finding 6
-- carry-forward). Each (name, body) pair is gated on
-- `pmacs.command.exists` so a builtin or user command already owning
-- the slot doesn't abort the whole register. Returns the count of
-- commands actually defined.
local _COMMAND_DEFS = {
{ name = "ai.ask-about-function",
description = "Send the enclosing function as context to the configured AI server.",
fn = ask_about_function_body },
{ name = "ai.ask-about-project",
description = "Send all file-backed buffers as project context to the configured AI server.",
fn = ask_about_project_body },
{ name = "ai.ask",
description = "Prompt for a freeform question and send to the configured AI server.",
fn = ask_body },
}
local function define_commands()
local defined = 0
for _, spec in ipairs(_COMMAND_DEFS) do
if pmacs.command.exists(spec.name) then
notify(string.format(
"command %q already defined (skipping)", spec.name))
else
pmacs.command.define(spec)
defined = defined + 1
end
end
return defined
end
local function undefine_commands()
for _, spec in ipairs(_COMMAND_DEFS) do
if pmacs.command.exists(spec.name) then
pmacs.command.unregister(spec.name)
end
end
end
-- ---------------------------------------------------------------------------
-- Public API
-- ---------------------------------------------------------------------------
function M.configure(opts)
if type(opts) ~= "table" then
error("pmacs-mcp-ai.configure: opts must be a table")
end
if type(opts.server_label) ~= "string" or opts.server_label == "" then
error("pmacs-mcp-ai.configure: opts.server_label must be a non-empty string")
end
local prompts = opts.prompts
if prompts ~= nil and type(prompts) ~= "table" then
error("pmacs-mcp-ai.configure: opts.prompts must be a table or nil")
end
_config = {
server_label = opts.server_label,
prompts = prompts or {},
}
if not _commands_defined then
define_commands()
_commands_defined = true
end
end
function M.unconfigure()
_config = nil
if _commands_defined then
undefine_commands()
_commands_defined = false
end
end
-- Test seam (unstable): returns the current config table or nil.
-- The seam exists so configure / re-configure / unconfigure tests
-- can pin the state transitions without scraping commands_for or
-- pmacs.command.list.
function M._config()
return _config
end
return M