From 2fa7fe1eb55d3b9688caf05608e1110c69afb11f Mon Sep 17 00:00:00 2001 From: Morris Date: Wed, 16 Sep 2026 00:40:18 +0800 Subject: [PATCH 1/6] Add local skill paths and system prompt files --- README.md | 65 ++++++++++++++++- lua/ai/config.lua | 1 + lua/ai/pi/command.lua | 32 ++++++--- lua/ai/prompt.lua | 62 ++++++++++++++++ tests/config_e2e.lua | 3 + tests/pi_command_e2e.lua | 65 ++++++++++++++--- tests/prompts_e2e.lua | 148 +++++++++++++++++++++++++++++++++++++++ 7 files changed, 357 insertions(+), 19 deletions(-) create mode 100644 lua/ai/prompt.lua create mode 100644 tests/prompts_e2e.lua diff --git a/README.md b/README.md index 0fd504c..c6120a4 100644 --- a/README.md +++ b/README.md @@ -95,6 +95,7 @@ The expanded lazy.nvim configuration below shows the plugin's actual defaults. I binary = "pi", extensions = true, skills = false, + skill_paths = {}, thinking = "off", reload = true, auto_close = true, @@ -174,12 +175,74 @@ The expanded lazy.nvim configuration below shows the plugin's actual defaults. I Without lazy.nvim, pass the contents of `opts` above to `require("ai").setup()`. -Pi runs in RPC mode with session persistence disabled. Extensions are enabled by default, while skills are disabled unless explicitly enabled. +Pi runs in RPC mode with session persistence disabled. Extensions are enabled by default. After each completed AI turn, `reload = true` reloads every loaded file buffer whose file changed on disk during that turn. This keeps Neovim synchronized with agent edits, including replacing unsaved buffer contents when the agent changed the same file. Set `reload = false` to disable this behavior. Set `chat.show_hints = false` to hide the contextual hint bar. The `chat.hints.input` and `chat.hints.display` lists are rendered exactly in their configured order. Set `chat.keys = false` to disable all chat-specific keymaps, or set `chat.keys.input` or `chat.keys.display` to `false` to disable one group. +### Skills + +No skills are bundled or enabled by default. + +- `skills = true` enables Pi's skill discovery, including `~/.agents/skills/`. +- `skill_paths = { ... }` loads local `SKILL.md` files or directories, even with `skills = false`. + +Paths support `~`; relative paths use the agent's working directory. + +Pi loads skill instructions when needed. To invoke a skill explicitly, send `/skill:name` followed by your request. + +### System prompt + +Append text, file contents, or both: + +```lua +require("ai").setup({ + append_system_prompt = "Keep responses concise.", + append_system_prompt_filepath = "~/.config/nvim/ai/writing.md", +}) +``` + +Both options are optional and accept a string or list of strings. Text comes first, then file contents, in list order with blank lines between entries. + +To append multiple prompts: + +```lua +require("ai").setup({ + append_system_prompt = { + "Keep responses concise.", + "Explain your changes.", + }, + append_system_prompt_filepath = { + "~/.config/nvim/ai/writing.md", + "~/.config/nvim/ai/project.md", + }, +}) +``` + +Files are read when opening a new session, including with Ctrl-N. Missing or unreadable files stop startup with an error notification. Paths support `~/`; relative paths use the agent's working directory. + +### Recommended upstream skill + +[Unslop](https://github.com/cursor/plugins/blob/main/pstack/skills/unslop/SKILL.md) by [Lauren Tan](https://github.com/poteto) removes common AI writing patterns. It is [MIT licensed](https://github.com/cursor/plugins/blob/main/pstack/LICENSE). + +With Unslop installed in `~/.agents/skills/unslop/`: + +```lua +require("ai").setup({ + skill_paths = { + "~/.agents/skills/unslop/SKILL.md", + }, + append_system_prompt_filepath = "~/.config/nvim/ai/unslop.md", +}) +``` + +Unslop disables automatic model invocation. Use `/skill:unslop`, or create `~/.config/nvim/ai/unslop.md` with: + +```markdown +Before writing or editing prose, read `~/.agents/skills/unslop/SKILL.md` and follow its Unslop instructions. +``` + ## Tests ```sh diff --git a/lua/ai/config.lua b/lua/ai/config.lua index 756fa02..6b0dcaa 100644 --- a/lua/ai/config.lua +++ b/lua/ai/config.lua @@ -5,6 +5,7 @@ M.defaults = { binary = "pi", extensions = true, skills = false, + skill_paths = {}, thinking = "off", reload = true, auto_close = true, diff --git a/lua/ai/pi/command.lua b/lua/ai/pi/command.lua index 1c1ab07..cd41e36 100644 --- a/lua/ai/pi/command.lua +++ b/lua/ai/pi/command.lua @@ -1,15 +1,18 @@ local M = {} +local Prompt = require("ai.prompt") ---@class ai.PiCommandOpts ---@field cmd? string[] ---@field binary? string ---@field extensions? boolean ----@field skills? boolean +---@field skills? boolean Enable Pi's skill discovery. +---@field skill_paths? string[] Local skill files or directories, loaded regardless of discovery. ---@field provider? string ---@field model? string ---@field thinking? string ---@field system_prompt? string ----@field append_system_prompt? string|string[] +---@field append_system_prompt? string|string[] Text to append. +---@field append_system_prompt_filepath? string|string[] Readable local files to append after text. ---@class ai.PiOpts: ai.PiCommandOpts ---@field agent_spawn_dir string @@ -52,19 +55,30 @@ function M.build(opts) if not opts.skills then command[#command + 1] = "--no-skills" end + if opts.skill_paths ~= nil then + assert( + type(opts.skill_paths) == "table" and vim.islist(opts.skill_paths), + "skill_paths must be a list of paths" + ) + for _, path in ipairs(opts.skill_paths) do + assert( + type(path) == "string", + "skill_paths entries must be strings" + ) + assert( + not path:match("^%a[%w+.-]*://"), + "skill_paths entries must be local paths: " .. path + ) + add_option(command, "--skill", vim.fn.expand(path)) + end + end add_option(command, "--provider", opts.provider) add_option(command, "--model", opts.model) add_option(command, "--thinking", opts.thinking) add_option(command, "--system-prompt", opts.system_prompt) - local prompts = opts.append_system_prompt - if type(prompts) == "string" then - prompts = { prompts } - end - for _, prompt in ipairs(prompts or {}) do - add_option(command, "--append-system-prompt", prompt) - end + add_option(command, "--append-system-prompt", Prompt.build(opts)) return command end diff --git a/lua/ai/prompt.lua b/lua/ai/prompt.lua new file mode 100644 index 0000000..c6e19b4 --- /dev/null +++ b/lua/ai/prompt.lua @@ -0,0 +1,62 @@ +local M = {} + +local function strings(value, option, allow_empty) + if value == nil then + return {} + end + if type(value) == "string" then + value = { value } + end + assert( + type(value) == "table" and vim.islist(value), + option .. " must be a string or a list of strings" + ) + for _, entry in ipairs(value) do + assert( + type(entry) == "string" and (allow_empty or entry ~= ""), + option .. " entries must be " .. (allow_empty and "strings" or "non-empty strings") + ) + end + return value +end + +local function read_prompt(path, cwd) + assert( + not path:match("^%a[%w+.-]*://"), + "append_system_prompt_filepath must be a local path: " .. path + ) + if path:sub(1, 2) == "~/" then + path = vim.fn.expand("~") .. path:sub(2) + end + if not path:match("^/") and not path:match("^%a:[/\\]") then + path = cwd .. "/" .. path + end + local stat = vim.uv.fs_stat(path) + assert( + stat and stat.type == "file", + "append_system_prompt_filepath: expected a file: " .. path + ) + return table.concat(vim.fn.readfile(path, "b"), "\n") +end + +---Combines configured text and file contents when starting a session. +---@param opts table +---@return string +function M.build(opts) + local parts = {} + local function append(text) + if text ~= "" then + parts[#parts + 1] = text + end + end + for _, text in ipairs(strings(opts.append_system_prompt, "append_system_prompt", true)) do + append(text) + end + local paths = strings(opts.append_system_prompt_filepath, "append_system_prompt_filepath", false) + for _, path in ipairs(paths) do + append(read_prompt(path, opts.agent_spawn_dir or vim.fn.getcwd())) + end + return table.concat(parts, "\n\n") +end + +return M diff --git a/tests/config_e2e.lua b/tests/config_e2e.lua index b169ba0..5d1a09a 100644 --- a/tests/config_e2e.lua +++ b/tests/config_e2e.lua @@ -16,6 +16,7 @@ end local config = Config.resolve({ model = "test-model", + skill_paths = { "custom-skills/SKILL.md" }, custom_backend_option = "preserved", chat = { keys = false }, }) @@ -25,6 +26,7 @@ assert_equal(config.binary, "pi", "default binary") assert_equal(config.thinking, "off", "default thinking level") assert_equal(config.extensions, true, "default extensions") assert_equal(config.skills, false, "default skills") +assert_equal(Config.defaults.skill_paths, {}, "default skill paths") assert_equal(config.reload, true, "default buffer reload") assert_equal(config.auto_close, true, "default auto close") assert_equal(config.model, "test-model", "overridden model") @@ -126,6 +128,7 @@ assert_equal( local backend = Config.backend(config) assert_equal(backend.backend, "pi", "backend name") assert_equal(backend.model, "test-model", "backend model") +assert_equal(backend.skill_paths, { "custom-skills/SKILL.md" }, "backend skill paths") assert_equal(backend.reload, true, "AI buffer reload") assert_equal( backend.custom_backend_option, diff --git a/tests/pi_command_e2e.lua b/tests/pi_command_e2e.lua index 39c9a02..87625e9 100644 --- a/tests/pi_command_e2e.lua +++ b/tests/pi_command_e2e.lua @@ -2,6 +2,7 @@ local repo = vim.fn.getcwd() vim.opt.runtimepath:prepend(repo) local Command = require("ai.pi.command") +local Config = require("ai.config") local function assert_equal(actual, expected, message) assert( @@ -14,22 +15,37 @@ local function assert_equal(actual, expected, message) ) end -assert_equal(Command.build({ +assert_equal(Command.build(Config.backend()), { + "pi", + "--mode", + "rpc", + "--no-session", + "--no-skills", + "--thinking", + "off", +}, "defaults select no skills and append no custom prompt") + +assert_equal(Command.build(Config.backend({ binary = "~/bin/pi", extensions = false, skills = false, + skill_paths = { "~/skills/writing/SKILL.md", "project skills" }, provider = "test-provider", model = "test-model", thinking = "high", system_prompt = "system prompt", - append_system_prompt = { "first append", "second append" }, -}), { + append_system_prompt = { "Keep $HOME and {{name}} literal", "~/prompts/style.md" }, +})), { vim.fn.expand("~/bin/pi"), "--mode", "rpc", "--no-session", "--no-extensions", "--no-skills", + "--skill", + vim.fn.expand("~/skills/writing/SKILL.md"), + "--skill", + "project skills", "--provider", "test-provider", "--model", @@ -39,26 +55,57 @@ assert_equal(Command.build({ "--system-prompt", "system prompt", "--append-system-prompt", - "first append", - "--append-system-prompt", - "second append", -}, "Pi command") + "Keep $HOME and {{name}} literal\n\n~/prompts/style.md", +}, "Pi command uses configured skills and prompts") assert_equal(Command.build({ binary = "pi", extensions = true, skills = true, - append_system_prompt = "single append", + skill_paths = { "extra-skills" }, + append_system_prompt = "/tmp/writing instructions.md", }), { "pi", "--mode", "rpc", "--no-session", + "--skill", + "extra-skills", "--append-system-prompt", - "single append", + "/tmp/writing instructions.md", }, "Pi command with optional features") +for _, paths in ipairs({ "skills", false, { named = "skills" }, { [2] = "skills" } }) do + local ok, err = pcall(Command.build, { binary = "pi", skill_paths = paths }) + assert_equal(ok, false, "invalid skill_paths validation") + assert( + tostring(err):find("skill_paths must be a list of paths", 1, true), + "invalid skill_paths error" + ) +end + +assert_equal( + Command.build(Config.backend({ skill_paths = { "", "local-skills", "" } })), + Command.build(Config.backend({ skill_paths = { "local-skills" } })), + "empty skill paths are ignored" +) + +for _, path in ipairs({ false, 42 }) do + local ok, err = pcall(Command.build, { binary = "pi", skill_paths = { path } }) + assert_equal(ok, false, "invalid skill path validation") + assert( + tostring(err):find("skill_paths entries must be strings", 1, true), + "invalid skill path error" + ) +end + local command_override = { "custom-pi", "--custom" } +for _, url in ipairs({ "https://example.com/SKILL.md", "http://example.com/SKILL.md" }) do + local ok, err = pcall(Command.build, { binary = "pi", skill_paths = { url } }) + assert_equal(ok, false, "skill URL validation") + assert(tostring(err):find("skill_paths entries must be local paths", 1, true), "skill URL error") +end + local overridden_command = Command.build({ cmd = command_override }) assert_equal(overridden_command, command_override, "Pi command override") assert(overridden_command ~= command_override, "Pi command override was not copied") diff --git a/tests/prompts_e2e.lua b/tests/prompts_e2e.lua new file mode 100644 index 0000000..51b0d77 --- /dev/null +++ b/tests/prompts_e2e.lua @@ -0,0 +1,148 @@ +local repo = vim.fn.getcwd() +local snacks = vim.env.SNACKS_NVIM + or (vim.fn.stdpath("data") .. "/lazy/snacks.nvim") +vim.opt.runtimepath:prepend(snacks) +vim.opt.runtimepath:prepend(repo) +vim.cmd.runtime("plugin/ai.nvim.lua") + +local Plugin = require("ai") +local Session = require("ai.session") +local root = vim.fn.tempname() .. " prompts" +vim.fn.mkdir(root, "p") +local missing = root .. "/missing.md" +local instructions = root .. "/instructions.md" +vim.fn.writefile({ "File instructions with $HOME, {{name}}, and Unicode: 台灣." }, instructions) + +local function read(path) + return table.concat(vim.fn.readfile(path, "b"), "\n") +end + +-- Capture the text passed directly to Pi. +local binary = root .. "/pi" +vim.fn.writefile(vim.split([=[#!/bin/sh +: > "$AI_PROMPTS_OUTPUT" +while [ "$#" -gt 0 ]; do + if [ "$1" = --append-system-prompt ]; then + printf '%s' "$2" >> "$AI_PROMPTS_OUTPUT" + shift + fi + shift +done +printf ready > "$AI_PROMPTS_READY" +IFS= read -r release +exit 0 +]=], "\n", { plain = true }), binary) +assert(vim.uv.fs_chmod(binary, 493)) +vim.env.AI_PROMPTS_OUTPUT = root .. "/output" +vim.env.AI_PROMPTS_READY = root .. "/ready" + +local function launch(opts, expected) + vim.fn.delete(vim.env.AI_PROMPTS_READY) + Plugin.setup(vim.tbl_extend("force", { binary = binary }, opts)) + local session = assert(Session.new()) + assert(vim.wait(2000, function() + return vim.fn.filereadable(vim.env.AI_PROMPTS_READY) == 1 + end), "backend did not read its prompt") + assert(read(vim.env.AI_PROMPTS_OUTPUT) == expected, "backend received incorrect instructions") + session.ai:finish() + assert(vim.wait(2000, function() return session.ai.result ~= nil end), "backend did not exit") + assert(Session.stop_current()) +end + +local notifications = {} +local notify = vim.notify +vim.notify = function(message) notifications[#notifications + 1] = message end + +-- Configuration is stored at setup; startup failures use the existing notification. +local function startup_error(opts, expected) + Plugin.setup(vim.tbl_extend("force", { binary = binary }, opts)) + local before = #vim.api.nvim_list_bufs() + local count = #notifications + vim.cmd("AI") + assert(Session.get_current() == nil, "invalid prompt configuration opened a session") + assert(#vim.api.nvim_list_bufs() == before, "failed startup leaked scratch buffers") + assert(#notifications == count + 1, "startup did not report one error") + assert(notifications[#notifications]:find("Failed to start AI backend:", 1, true), "startup notification was bypassed") + assert(notifications[#notifications]:find(expected, 1, true), "startup notification omitted the error") +end + +startup_error({ append_system_prompt_filepath = missing }, missing) +assert(notifications[#notifications]:find("append_system_prompt_filepath", 1, true), "error omitted the option name") + +for _, value in ipairs({ root, "", false, 42, { instructions, false }, { named = instructions }, { [2] = instructions } }) do + startup_error({ append_system_prompt_filepath = value }, "append_system_prompt_filepath") +end +for _, value in ipairs({ false, 42, { "valid", false }, { named = "invalid" } }) do + startup_error({ append_system_prompt = value }, "append_system_prompt") +end + +for _, url in ipairs({ "https://example.com/instructions.md", "http://example.com/instructions.md" }) do + startup_error({ append_system_prompt_filepath = url }, "must be a local path") +end + +if vim.uv.getuid and vim.uv.getuid() ~= 0 then + assert(vim.uv.fs_chmod(instructions, 0)) + startup_error({ append_system_prompt_filepath = instructions }, instructions) + assert(vim.uv.fs_chmod(instructions, 384)) +end + +launch({ append_system_prompt = "Keep responses concise." }, "Keep responses concise.") +launch({ append_system_prompt = "~/keep-this-literal.md" }, "~/keep-this-literal.md") +launch({ append_system_prompt = "https://example.com/literal.md" }, "https://example.com/literal.md") +launch({ append_system_prompt_filepath = instructions }, read(instructions)) + +local other = root .. "/other.md" +vim.fn.writefile({ instructions }, other, "b") +launch({ + append_system_prompt = { "First instruction.", "Second instruction." }, + append_system_prompt_filepath = { instructions, other }, +}, "First instruction.\n\nSecond instruction.\n\n" .. read(instructions) .. "\n\n" .. instructions) + +local empty = root .. "/empty.md" +vim.fn.writefile({}, empty) +launch({ append_system_prompt = "", append_system_prompt_filepath = empty }, "") + +-- Setup can precede a directory change; relative paths use the session directory. +Plugin.setup({ binary = binary, append_system_prompt_filepath = "instructions.md" }) +vim.api.nvim_set_current_dir(root) +vim.fn.delete(vim.env.AI_PROMPTS_READY) +vim.cmd("AI") +local session = assert(Session.get_current()) +assert(vim.wait(2000, function() + return vim.fn.filereadable(vim.env.AI_PROMPTS_READY) == 1 +end), "relative-path backend did not start") +assert(read(vim.env.AI_PROMPTS_OUTPUT) == read(instructions), "relative path used the setup directory") + +local function new_session_key() + Session.get_current().chat.input:focus() + vim.cmd("startinsert") + vim.api.nvim_feedkeys(vim.keycode(""), "xt", false) +end + +-- Ctrl-N reports missing files and preserves the current session on failure. +vim.fn.delete(instructions) +local before = #vim.api.nvim_list_bufs() +local count = #notifications +new_session_key() +assert(vim.wait(2000, function() return #notifications > count end), "Ctrl-N did not report the missing file") +assert(notifications[#notifications]:find(instructions, 1, true), "Ctrl-N notification omitted the missing file") +assert(Session.get_current() == session, "failed Ctrl-N replaced the current session") +assert(#vim.api.nvim_list_bufs() == before, "failed startup leaked scratch buffers") + +-- A subsequent Ctrl-N reads the updated file without another setup() call. +vim.fn.writefile({ "Updated instructions." }, instructions) +vim.fn.delete(vim.env.AI_PROMPTS_READY) +new_session_key() +assert(vim.wait(2000, function() + return Session.get_current() ~= session and vim.fn.filereadable(vim.env.AI_PROMPTS_READY) == 1 +end), "Ctrl-N did not start a new session") +assert(read(vim.env.AI_PROMPTS_OUTPUT) == read(instructions), "Ctrl-N did not reread the prompt file") +assert(Session.stop_current()) + +startup_error({ append_system_prompt_filepath = "missing.md" }, missing) +vim.notify = notify + +vim.api.nvim_set_current_dir(repo) +vim.fn.delete(root, "rf") +print("Prompt configuration E2E checks passed") +vim.cmd("qa!") From 2bc49f5492c5cdeb24d31db9fbef257d799c4bee Mon Sep 17 00:00:00 2001 From: Morris Date: Wed, 16 Sep 2026 00:56:58 +0800 Subject: [PATCH 2/6] Explain oversized system prompt startup errors --- lua/ai/pi/init.lua | 4 ++++ tests/prompts_e2e.lua | 21 ++++++++++++++++++++- 2 files changed, 24 insertions(+), 1 deletion(-) diff --git a/lua/ai/pi/init.lua b/lua/ai/pi/init.lua index f0584dc..97981a4 100644 --- a/lua/ai/pi/init.lua +++ b/lua/ai/pi/init.lua @@ -346,6 +346,10 @@ function Pi.start(opts, dispatch) end)) if not ok then + if not opts.cmd and tostring(process):find("E2BIG:", 1, true) then + return nil, "The system prompt is too large to start Pi. " + .. "Shorten your prompt text or prompt files, then try again." + end return nil, process end diff --git a/tests/prompts_e2e.lua b/tests/prompts_e2e.lua index 51b0d77..b7ecbef 100644 --- a/tests/prompts_e2e.lua +++ b/tests/prompts_e2e.lua @@ -51,7 +51,11 @@ end local notifications = {} local notify = vim.notify -vim.notify = function(message) notifications[#notifications + 1] = message end +local notification_levels = {} +vim.notify = function(message, level) + notifications[#notifications + 1] = message + notification_levels[#notification_levels + 1] = level +end -- Configuration is stored at setup; startup failures use the existing notification. local function startup_error(opts, expected) @@ -62,10 +66,25 @@ local function startup_error(opts, expected) assert(Session.get_current() == nil, "invalid prompt configuration opened a session") assert(#vim.api.nvim_list_bufs() == before, "failed startup leaked scratch buffers") assert(#notifications == count + 1, "startup did not report one error") + assert(notification_levels[#notification_levels] == vim.log.levels.ERROR, "startup notification was not an error") assert(notifications[#notifications]:find("Failed to start AI backend:", 1, true), "startup notification was bypassed") assert(notifications[#notifications]:find(expected, 1, true), "startup notification omitted the error") end +-- Exercise the real OS argument limit through :AI, for both text and files. +local large_prompt = string.rep("Keep responses concise.\n", 50000) +local large_file = root .. "/large.md" +vim.fn.writefile({ large_prompt }, large_file, "b") +local size_error = "The system prompt is too large to start Pi. " + .. "Shorten your prompt text or prompt files, then try again." +startup_error({ append_system_prompt = { large_prompt, large_prompt } }, size_error) +startup_error({ append_system_prompt_filepath = large_file }, size_error) +startup_error({ system_prompt = large_prompt }, size_error) + +-- Preserve the original diagnostics for custom commands and unrelated failures. +startup_error({ cmd = { binary, large_prompt } }, "E2BIG:") +startup_error({ binary = missing }, "ENOENT:") + startup_error({ append_system_prompt_filepath = missing }, missing) assert(notifications[#notifications]:find("append_system_prompt_filepath", 1, true), "error omitted the option name") From 0a9f4aeecaeca99f8048d3267bb12a1c3cff0d04 Mon Sep 17 00:00:00 2001 From: Morris Date: Wed, 16 Sep 2026 01:10:35 +0800 Subject: [PATCH 3/6] no-mistakes(review): Preserve raw E2BIG and literal skill paths --- lua/ai/pi/command.lua | 30 ++++++++++++++---- lua/ai/pi/init.lua | 67 +++++++++++++++++++++++----------------- tests/pi_command_e2e.lua | 29 +++++++++++++++++ tests/prompts_e2e.lua | 6 ++-- 4 files changed, 95 insertions(+), 37 deletions(-) diff --git a/lua/ai/pi/command.lua b/lua/ai/pi/command.lua index cd41e36..2445ec2 100644 --- a/lua/ai/pi/command.lua +++ b/lua/ai/pi/command.lua @@ -28,14 +28,18 @@ local function add_option(command, flag, value) end end ----Builds the command used to launch the Pi RPC process. ----@param opts ai.PiCommandOpts ----@return string[] -function M.build(opts) +local function expand_home(path) + if path:sub(1, 2) == "~/" then + return vim.fn.expand("~") .. path:sub(2) + end + return path +end + +local function build(opts) assert(type(opts) == "table", "Pi options are required") if opts.cmd then - return vim.deepcopy(opts.cmd) + return vim.deepcopy(opts.cmd), false end assert( @@ -69,18 +73,32 @@ function M.build(opts) not path:match("^%a[%w+.-]*://"), "skill_paths entries must be local paths: " .. path ) - add_option(command, "--skill", vim.fn.expand(path)) + add_option(command, "--skill", expand_home(path)) end end add_option(command, "--provider", opts.provider) add_option(command, "--model", opts.model) add_option(command, "--thinking", opts.thinking) + + local prompt_start = #command add_option(command, "--system-prompt", opts.system_prompt) add_option(command, "--append-system-prompt", Prompt.build(opts)) + return command, #command > prompt_start +end + +---Builds the command used to launch the Pi RPC process. +---@param opts ai.PiCommandOpts +---@return string[] +function M.build(opts) + local command = build(opts) return command end +function M.build_for_start(opts) + return build(opts) +end + return M diff --git a/lua/ai/pi/init.lua b/lua/ai/pi/init.lua index 97981a4..dfed088 100644 --- a/lua/ai/pi/init.lua +++ b/lua/ai/pi/init.lua @@ -316,37 +316,46 @@ function Pi.start(opts, dispatch) result = nil, }, Pi) - local ok, process = pcall(vim.system, Command.build(opts), { - cwd = opts.agent_spawn_dir, - text = true, - stdin = true, - -- Feeds scheduled stdout chunks into the Pi line buffer. - stdout = vim.schedule_wrap(function(err, data) - if err then - pi.dispatch({ - type = EventType.ERROR, - message = tostring(err), - source = "stdout", - }) - return - end - if not pi.cancelled then - pi:feed_stdout(data) - end - end), - }, vim.schedule_wrap(function(result) - -- Flushes buffered output and reports process completion. - pi:flush_stdout() - pi.pending_messages = {} - pi.result = result - pi.dispatch({ - type = EventType.EXIT, - result = pi.result, - }) - end)) + local has_prompt + local ok, process = pcall(function() + local command + command, has_prompt = Command.build_for_start(opts) + return vim.system(command, { + cwd = opts.agent_spawn_dir, + text = true, + stdin = true, + -- Feeds scheduled stdout chunks into the Pi line buffer. + stdout = vim.schedule_wrap(function(err, data) + if err then + pi.dispatch({ + type = EventType.ERROR, + message = tostring(err), + source = "stdout", + }) + return + end + if not pi.cancelled then + pi:feed_stdout(data) + end + end), + }, vim.schedule_wrap(function(result) + -- Flushes buffered output and reports process completion. + pi:flush_stdout() + pi.pending_messages = {} + pi.result = result + pi.dispatch({ + type = EventType.EXIT, + result = pi.result, + }) + end)) + end) if not ok then - if not opts.cmd and tostring(process):find("E2BIG:", 1, true) then + if + not opts.cmd + and has_prompt + and tostring(process):find("E2BIG:", 1, true) + then return nil, "The system prompt is too large to start Pi. " .. "Shorten your prompt text or prompt files, then try again." end diff --git a/tests/pi_command_e2e.lua b/tests/pi_command_e2e.lua index 87625e9..78bdc17 100644 --- a/tests/pi_command_e2e.lua +++ b/tests/pi_command_e2e.lua @@ -75,6 +75,35 @@ assert_equal(Command.build({ "/tmp/writing instructions.md", }, "Pi command with optional features") +local literal_skill_paths = { + "#notes/SKILL.md", + "%notes/SKILL.md", + "skills/$HOME/SKILL.md", + "lua/ai/*.lua", + "lua/ai/[cp]*.lua", +} +assert_equal(Command.build({ + binary = "pi", + extensions = true, + skills = true, + skill_paths = literal_skill_paths, +}), { + "pi", + "--mode", + "rpc", + "--no-session", + "--skill", + literal_skill_paths[1], + "--skill", + literal_skill_paths[2], + "--skill", + literal_skill_paths[3], + "--skill", + literal_skill_paths[4], + "--skill", + literal_skill_paths[5], +}, "Pi command preserves literal skill paths") + for _, paths in ipairs({ "skills", false, { named = "skills" }, { [2] = "skills" } }) do local ok, err = pcall(Command.build, { binary = "pi", skill_paths = paths }) assert_equal(ok, false, "invalid skill_paths validation") diff --git a/tests/prompts_e2e.lua b/tests/prompts_e2e.lua index b7ecbef..8dc45f3 100644 --- a/tests/prompts_e2e.lua +++ b/tests/prompts_e2e.lua @@ -12,6 +12,8 @@ vim.fn.mkdir(root, "p") local missing = root .. "/missing.md" local instructions = root .. "/instructions.md" vim.fn.writefile({ "File instructions with $HOME, {{name}}, and Unicode: 台灣." }, instructions) +local empty = root .. "/empty.md" +vim.fn.writefile({}, empty) local function read(path) return table.concat(vim.fn.readfile(path, "b"), "\n") @@ -83,6 +85,8 @@ startup_error({ system_prompt = large_prompt }, size_error) -- Preserve the original diagnostics for custom commands and unrelated failures. startup_error({ cmd = { binary, large_prompt } }, "E2BIG:") +startup_error({ model = large_prompt }, "E2BIG:") +startup_error({ model = large_prompt, append_system_prompt_filepath = empty }, "E2BIG:") startup_error({ binary = missing }, "ENOENT:") startup_error({ append_system_prompt_filepath = missing }, missing) @@ -117,8 +121,6 @@ launch({ append_system_prompt_filepath = { instructions, other }, }, "First instruction.\n\nSecond instruction.\n\n" .. read(instructions) .. "\n\n" .. instructions) -local empty = root .. "/empty.md" -vim.fn.writefile({}, empty) launch({ append_system_prompt = "", append_system_prompt_filepath = empty }, "") -- Setup can precede a directory change; relative paths use the session directory. From 4265d91ee2c5efa584276caa22dc5098f927ed59 Mon Sep 17 00:00:00 2001 From: Morris Date: Wed, 16 Sep 2026 01:17:47 +0800 Subject: [PATCH 4/6] no-mistakes(review): Clarify E2BIG guidance while retaining raw diagnostics --- lua/ai/pi/init.lua | 6 ++++-- tests/prompts_e2e.lua | 20 +++++++++++++------- 2 files changed, 17 insertions(+), 9 deletions(-) diff --git a/lua/ai/pi/init.lua b/lua/ai/pi/init.lua index dfed088..33d7a59 100644 --- a/lua/ai/pi/init.lua +++ b/lua/ai/pi/init.lua @@ -356,8 +356,10 @@ function Pi.start(opts, dispatch) and has_prompt and tostring(process):find("E2BIG:", 1, true) then - return nil, "The system prompt is too large to start Pi. " - .. "Shorten your prompt text or prompt files, then try again." + return nil, "Pi could not start because its inputs exceed the system size limit. " + .. "Shorten your system prompt text or prompt files, " + .. "or check other startup options.\n" + .. tostring(process) end return nil, process end diff --git a/tests/prompts_e2e.lua b/tests/prompts_e2e.lua index 8dc45f3..f79096c 100644 --- a/tests/prompts_e2e.lua +++ b/tests/prompts_e2e.lua @@ -54,6 +54,7 @@ end local notifications = {} local notify = vim.notify local notification_levels = {} +local startup_prefix = "Failed to start AI backend: " vim.notify = function(message, level) notifications[#notifications + 1] = message notification_levels[#notification_levels + 1] = level @@ -69,24 +70,29 @@ local function startup_error(opts, expected) assert(#vim.api.nvim_list_bufs() == before, "failed startup leaked scratch buffers") assert(#notifications == count + 1, "startup did not report one error") assert(notification_levels[#notification_levels] == vim.log.levels.ERROR, "startup notification was not an error") - assert(notifications[#notifications]:find("Failed to start AI backend:", 1, true), "startup notification was bypassed") - assert(notifications[#notifications]:find(expected, 1, true), "startup notification omitted the error") + local message = notifications[#notifications] + assert(message:sub(1, #startup_prefix) == startup_prefix, "startup notification was bypassed") + assert(message:find(expected, #startup_prefix + 1, true), "startup notification omitted the error") + return message:sub(#startup_prefix + 1) end -- Exercise the real OS argument limit through :AI, for both text and files. local large_prompt = string.rep("Keep responses concise.\n", 50000) local large_file = root .. "/large.md" vim.fn.writefile({ large_prompt }, large_file, "b") -local size_error = "The system prompt is too large to start Pi. " - .. "Shorten your prompt text or prompt files, then try again." +local raw_e2big = startup_error({ model = large_prompt }, "E2BIG:") +local size_error = "Pi could not start because its inputs exceed the system size limit. " + .. "Shorten your system prompt text or prompt files, " + .. "or check other startup options.\n" + .. raw_e2big startup_error({ append_system_prompt = { large_prompt, large_prompt } }, size_error) startup_error({ append_system_prompt_filepath = large_file }, size_error) startup_error({ system_prompt = large_prompt }, size_error) +startup_error({ model = large_prompt, append_system_prompt = "short" }, size_error) -- Preserve the original diagnostics for custom commands and unrelated failures. -startup_error({ cmd = { binary, large_prompt } }, "E2BIG:") -startup_error({ model = large_prompt }, "E2BIG:") -startup_error({ model = large_prompt, append_system_prompt_filepath = empty }, "E2BIG:") +startup_error({ cmd = { binary, large_prompt } }, raw_e2big) +startup_error({ model = large_prompt, append_system_prompt_filepath = empty }, raw_e2big) startup_error({ binary = missing }, "ENOENT:") startup_error({ append_system_prompt_filepath = missing }, missing) From b9bdfa9d4a320d4349fff9f47764a9e4311994e0 Mon Sep 17 00:00:00 2001 From: Morris Date: Wed, 16 Sep 2026 01:28:31 +0800 Subject: [PATCH 5/6] Pass session prompts through private temporary files --- README.md | 2 + lua/ai/pi/command.lua | 36 ++++++------ lua/ai/pi/init.lua | 39 ++++++++----- lua/ai/prompt.lua | 49 +++++++++++++++- tests/prompts_e2e.lua | 132 +++++++++++++++++++++++++++++++++++++----- 5 files changed, 209 insertions(+), 49 deletions(-) diff --git a/README.md b/README.md index c6120a4..521daf9 100644 --- a/README.md +++ b/README.md @@ -222,6 +222,8 @@ require("ai").setup({ Files are read when opening a new session, including with Ctrl-N. Missing or unreadable files stop startup with an error notification. Paths support `~/`; relative paths use the agent's working directory. +The plugin passes the combined prompt through a private temporary file, so large prompts do not hit the operating system's command-line size limit. Text stays literal even when it matches an existing filename. The temporary file is removed when the backend exits, the session is stopped, or startup fails. Model context limits still apply. + ### Recommended upstream skill [Unslop](https://github.com/cursor/plugins/blob/main/pstack/skills/unslop/SKILL.md) by [Lauren Tan](https://github.com/poteto) removes common AI writing patterns. It is [MIT licensed](https://github.com/cursor/plugins/blob/main/pstack/LICENSE). diff --git a/lua/ai/pi/command.lua b/lua/ai/pi/command.lua index 2445ec2..d54bc01 100644 --- a/lua/ai/pi/command.lua +++ b/lua/ai/pi/command.lua @@ -35,11 +35,15 @@ local function expand_home(path) return path end -local function build(opts) +---Builds the command used to launch the Pi RPC process. +---@param opts ai.PiCommandOpts +---@param prepare_prompt? fun(text: string, flag: string): string +---@return string[] +function M.build(opts, prepare_prompt) assert(type(opts) == "table", "Pi options are required") if opts.cmd then - return vim.deepcopy(opts.cmd), false + return vim.deepcopy(opts.cmd) end assert( @@ -81,24 +85,18 @@ local function build(opts) add_option(command, "--model", opts.model) add_option(command, "--thinking", opts.thinking) - local prompt_start = #command - add_option(command, "--system-prompt", opts.system_prompt) - - add_option(command, "--append-system-prompt", Prompt.build(opts)) - - return command, #command > prompt_start -end - ----Builds the command used to launch the Pi RPC process. ----@param opts ai.PiCommandOpts ----@return string[] -function M.build(opts) - local command = build(opts) + local function add_prompt(flag, text) + if text ~= nil and text ~= "" then + assert(type(text) == "string", flag .. " must be a string") + if prepare_prompt then + text = prepare_prompt(text, flag) + end + add_option(command, flag, text) + end + end + add_prompt("--system-prompt", opts.system_prompt) + add_prompt("--append-system-prompt", Prompt.build(opts)) return command end -function M.build_for_start(opts) - return build(opts) -end - return M diff --git a/lua/ai/pi/init.lua b/lua/ai/pi/init.lua index 33d7a59..21f6f4c 100644 --- a/lua/ai/pi/init.lua +++ b/lua/ai/pi/init.lua @@ -6,16 +6,30 @@ ---@field request_id integer ---@field cancelled boolean ---@field stdin_closed boolean +---@field prompt_files string[] ---@field result? vim.SystemCompleted local Pi = {} Pi.__index = Pi local Command = require("ai.pi.command") local Events = require("ai.events") +local Prompt = require("ai.prompt") local AIAction = Events.AIAction local EventType = Events.Type +---Releases prompt files before user callbacks can interrupt teardown. +local function cleanup_prompt_files(pi) + local paths = pi.prompt_files + pi.prompt_files = {} + for _, path in ipairs(paths) do + local removed, err, code = vim.uv.fs_unlink(path) + if not removed and code ~= "ENOENT" then + pcall(vim.notify, "Could not delete temporary system prompt file: " .. tostring(err), vim.log.levels.WARN) + end + end +end + -- Decodes one JSON event line without raising parse errors. local function decode_event(line) local ok, decoded = pcall(vim.json.decode, line) @@ -287,6 +301,7 @@ function Pi:cancel() if not ok or not closing then pcall(self.process.kill, self.process, 15) end + cleanup_prompt_files(self) end ---Builds the command used to launch the Pi RPC process. @@ -314,12 +329,18 @@ function Pi.start(opts, dispatch) cancelled = false, stdin_closed = false, result = nil, + prompt_files = {}, }, Pi) - local has_prompt local ok, process = pcall(function() - local command - command, has_prompt = Command.build_for_start(opts) + local command = Command.build(opts, function(text, flag) + if flag == "--system-prompt" and Prompt.is_path(text, opts.agent_spawn_dir) then + return text + end + local path = Prompt.write_temp(text) + pi.prompt_files[#pi.prompt_files + 1] = path + return path + end) return vim.system(command, { cwd = opts.agent_spawn_dir, text = true, @@ -339,6 +360,7 @@ function Pi.start(opts, dispatch) end end), }, vim.schedule_wrap(function(result) + cleanup_prompt_files(pi) -- Flushes buffered output and reports process completion. pi:flush_stdout() pi.pending_messages = {} @@ -351,16 +373,7 @@ function Pi.start(opts, dispatch) end) if not ok then - if - not opts.cmd - and has_prompt - and tostring(process):find("E2BIG:", 1, true) - then - return nil, "Pi could not start because its inputs exceed the system size limit. " - .. "Shorten your system prompt text or prompt files, " - .. "or check other startup options.\n" - .. tostring(process) - end + cleanup_prompt_files(pi) return nil, process end diff --git a/lua/ai/prompt.lua b/lua/ai/prompt.lua index c6e19b4..18f991c 100644 --- a/lua/ai/prompt.lua +++ b/lua/ai/prompt.lua @@ -20,6 +20,13 @@ local function strings(value, option, allow_empty) return value end +local function absolute_path(path, cwd) + if not path:match("^/") and not path:match("^%a:[/\\]") then + return cwd .. "/" .. path + end + return path +end + local function read_prompt(path, cwd) assert( not path:match("^%a[%w+.-]*://"), @@ -28,9 +35,7 @@ local function read_prompt(path, cwd) if path:sub(1, 2) == "~/" then path = vim.fn.expand("~") .. path:sub(2) end - if not path:match("^/") and not path:match("^%a:[/\\]") then - path = cwd .. "/" .. path - end + path = absolute_path(path, cwd) local stat = vim.uv.fs_stat(path) assert( stat and stat.type == "file", @@ -39,6 +44,44 @@ local function read_prompt(path, cwd) return table.concat(vim.fn.readfile(path, "b"), "\n") end +---Checks the existing-path interpretation used by Pi's system_prompt option. +---@param text string +---@param cwd string +---@return boolean +function M.is_path(text, cwd) + return vim.uv.fs_stat(absolute_path(text, cwd)) ~= nil +end + +---Writes prompt text to an exclusively created, owner-only temporary file. +---@param text string +---@return string path The caller owns deletion of this file. +function M.write_temp(text) + assert(type(text) == "string", "system prompt must be a string") + local path = vim.fn.tempname() + local fd, open_err = vim.uv.fs_open(path, "wx", 384) + assert(fd, "Could not create temporary system prompt file: " .. tostring(open_err)) + + local ok, err = pcall(function() + local offset = 0 + while offset < #text do + local written, write_err = vim.uv.fs_write(fd, text:sub(offset + 1), offset) + assert(written and written > 0, "Could not write temporary system prompt file: " .. tostring(write_err)) + offset = offset + written + end + local closed, close_err = vim.uv.fs_close(fd) + assert(closed, "Could not close temporary system prompt file: " .. tostring(close_err)) + fd = nil + end) + if not ok then + if fd then + pcall(vim.uv.fs_close, fd) + end + pcall(vim.uv.fs_unlink, path) + error(err, 0) + end + return path +end + ---Combines configured text and file contents when starting a session. ---@param opts table ---@return string diff --git a/tests/prompts_e2e.lua b/tests/prompts_e2e.lua index f79096c..84ef330 100644 --- a/tests/prompts_e2e.lua +++ b/tests/prompts_e2e.lua @@ -19,13 +19,17 @@ local function read(path) return table.concat(vim.fn.readfile(path, "b"), "\n") end --- Capture the text passed directly to Pi. +-- Read prompt arguments once, matching Pi's file-input behavior. local binary = root .. "/pi" vim.fn.writefile(vim.split([=[#!/bin/sh : > "$AI_PROMPTS_OUTPUT" +: > "$AI_PROMPTS_SYSTEM" while [ "$#" -gt 0 ]; do if [ "$1" = --append-system-prompt ]; then - printf '%s' "$2" >> "$AI_PROMPTS_OUTPUT" + cat "$2" >> "$AI_PROMPTS_OUTPUT" || exit 1 + shift + elif [ "$1" = --system-prompt ]; then + cat "$2" >> "$AI_PROMPTS_SYSTEM" || exit 1 shift fi shift @@ -36,18 +40,50 @@ exit 0 ]=], "\n", { plain = true }), binary) assert(vim.uv.fs_chmod(binary, 493)) vim.env.AI_PROMPTS_OUTPUT = root .. "/output" +vim.env.AI_PROMPTS_SYSTEM = root .. "/system" vim.env.AI_PROMPTS_READY = root .. "/ready" -local function launch(opts, expected) +-- Observe paths while still using the real process launcher and filesystem. +local system = vim.system +local prompt_paths = {} +vim.system = function(command, ...) + prompt_paths = {} + for i, arg in ipairs(command) do + if arg == "--append-system-prompt" or arg == "--system-prompt" then + prompt_paths[#prompt_paths + 1] = command[i + 1] + end + end + return system(command, ...) +end + +local function assert_removed(paths) + for _, path in ipairs(paths) do + assert(not vim.uv.fs_stat(path), "temporary prompt file was not removed: " .. path) + end +end + +local function launch(opts, expected, expected_system) vim.fn.delete(vim.env.AI_PROMPTS_READY) Plugin.setup(vim.tbl_extend("force", { binary = binary }, opts)) - local session = assert(Session.new()) + vim.cmd("AI") + local session = assert(Session.get_current()) assert(vim.wait(2000, function() return vim.fn.filereadable(vim.env.AI_PROMPTS_READY) == 1 end), "backend did not read its prompt") assert(read(vim.env.AI_PROMPTS_OUTPUT) == expected, "backend received incorrect instructions") + assert(read(vim.env.AI_PROMPTS_SYSTEM) == (expected_system or ""), "backend received incorrect system prompt") + local owned_paths = {} + for _, path in ipairs(prompt_paths) do + if path ~= opts.system_prompt then + local stat = assert(vim.uv.fs_stat(path), "prompt file vanished while the backend was alive") + assert(bit.band(stat.mode, 511) == 384, "temporary prompt file was not owner-only") + assert(path:sub(1, 1) == "/", "temporary prompt path was not absolute") + owned_paths[#owned_paths + 1] = path + end + end session.ai:finish() assert(vim.wait(2000, function() return session.ai.result ~= nil end), "backend did not exit") + assert_removed(owned_paths) assert(Session.stop_current()) end @@ -65,6 +101,7 @@ local function startup_error(opts, expected) Plugin.setup(vim.tbl_extend("force", { binary = binary }, opts)) local before = #vim.api.nvim_list_bufs() local count = #notifications + prompt_paths = {} vim.cmd("AI") assert(Session.get_current() == nil, "invalid prompt configuration opened a session") assert(#vim.api.nvim_list_bufs() == before, "failed startup leaked scratch buffers") @@ -73,27 +110,83 @@ local function startup_error(opts, expected) local message = notifications[#notifications] assert(message:sub(1, #startup_prefix) == startup_prefix, "startup notification was bypassed") assert(message:find(expected, #startup_prefix + 1, true), "startup notification omitted the error") + for _, path in ipairs(prompt_paths) do + if path ~= opts.system_prompt then + assert_removed({ path }) + end + end return message:sub(#startup_prefix + 1) end --- Exercise the real OS argument limit through :AI, for both text and files. +-- Prompts larger than the real OS argument limit now start through :AI. local large_prompt = string.rep("Keep responses concise.\n", 50000) local large_file = root .. "/large.md" vim.fn.writefile({ large_prompt }, large_file, "b") -local raw_e2big = startup_error({ model = large_prompt }, "E2BIG:") -local size_error = "Pi could not start because its inputs exceed the system size limit. " - .. "Shorten your system prompt text or prompt files, " - .. "or check other startup options.\n" - .. raw_e2big -startup_error({ append_system_prompt = { large_prompt, large_prompt } }, size_error) -startup_error({ append_system_prompt_filepath = large_file }, size_error) -startup_error({ system_prompt = large_prompt }, size_error) -startup_error({ model = large_prompt, append_system_prompt = "short" }, size_error) +launch({ append_system_prompt = { large_prompt, large_prompt } }, large_prompt .. "\n\n" .. large_prompt) +launch({ append_system_prompt_filepath = large_file }, large_prompt) +launch({ system_prompt = large_prompt }, "", large_prompt) -- Preserve the original diagnostics for custom commands and unrelated failures. +local raw_e2big = startup_error({ model = large_prompt }, "E2BIG:") startup_error({ cmd = { binary, large_prompt } }, raw_e2big) startup_error({ model = large_prompt, append_system_prompt_filepath = empty }, raw_e2big) +startup_error({ model = large_prompt, append_system_prompt = "short" }, raw_e2big) startup_error({ binary = missing }, "ENOENT:") +startup_error({ binary = missing, system_prompt = "Base instructions.", append_system_prompt = "Extra instructions." }, "ENOENT:") +startup_error({ binary = missing, system_prompt = instructions, append_system_prompt = "Extra instructions." }, "ENOENT:") +assert(vim.fn.filereadable(instructions) == 1, "failed startup deleted the user's prompt file") + +-- Fail the second file operation after one prompt file has already been prepared. +for _, operation in ipairs({ "create", "write", "close" }) do + local fs_open, fs_write, fs_close = vim.uv.fs_open, vim.uv.fs_write, vim.uv.fs_close + local paths, second_fd = {}, nil + local attempts = 0 + vim.uv.fs_open = function(path, flags, mode) + if flags == "wx" then + attempts = attempts + 1 + if attempts == 2 and operation == "create" then + return nil, "EACCES: permission denied", "EACCES" + end + end + local fd, err, code = fs_open(path, flags, mode) + if flags == "wx" and fd then + paths[#paths + 1] = path + if attempts == 2 then second_fd = fd end + end + return fd, err, code + end + vim.uv.fs_write = function(fd, ...) + if fd == second_fd and operation == "write" then + return nil, "ENOSPC: no space left on device", "ENOSPC" + end + return fs_write(fd, ...) + end + vim.uv.fs_close = function(fd) + if fd == second_fd and operation == "close" then + second_fd = nil + fs_close(fd) + return nil, "EIO: input/output error", "EIO" + end + return fs_close(fd) + end + local ok, err = pcall(startup_error, { + system_prompt = "Base instructions.", + append_system_prompt = "Extra instructions.", + }, "Could not " .. operation .. " temporary system prompt file") + vim.uv.fs_open, vim.uv.fs_write, vim.uv.fs_close = fs_open, fs_write, fs_close + assert(ok, err) + assert_removed(paths) +end + +-- Short writes must preserve every byte, including multibyte characters. +local fs_write = vim.uv.fs_write +vim.uv.fs_write = function(fd, text, offset) + return fs_write(fd, text:sub(1, 7), offset) +end +local partial_text = "First line.\n台灣.\nFinal line." +local partial_ok, partial_err = pcall(launch, { append_system_prompt = partial_text }, partial_text) +vim.uv.fs_write = fs_write +assert(partial_ok, partial_err) startup_error({ append_system_prompt_filepath = missing }, missing) assert(notifications[#notifications]:find("append_system_prompt_filepath", 1, true), "error omitted the option name") @@ -119,9 +212,14 @@ launch({ append_system_prompt = "Keep responses concise." }, "Keep responses con launch({ append_system_prompt = "~/keep-this-literal.md" }, "~/keep-this-literal.md") launch({ append_system_prompt = "https://example.com/literal.md" }, "https://example.com/literal.md") launch({ append_system_prompt_filepath = instructions }, read(instructions)) +launch({ system_prompt = instructions }, "", read(instructions)) +assert(vim.fn.filereadable(instructions) == 1, "the user's system prompt file was deleted") +launch({ cmd = { binary }, append_system_prompt = large_prompt }, "") local other = root .. "/other.md" vim.fn.writefile({ instructions }, other, "b") +launch({ append_system_prompt = instructions }, instructions) +launch({ append_system_prompt_filepath = other }, instructions) launch({ append_system_prompt = { "First instruction.", "Second instruction." }, append_system_prompt_filepath = { instructions, other }, @@ -139,6 +237,7 @@ assert(vim.wait(2000, function() return vim.fn.filereadable(vim.env.AI_PROMPTS_READY) == 1 end), "relative-path backend did not start") assert(read(vim.env.AI_PROMPTS_OUTPUT) == read(instructions), "relative path used the setup directory") +local previous_prompt_paths = vim.deepcopy(prompt_paths) local function new_session_key() Session.get_current().chat.input:focus() @@ -155,6 +254,7 @@ assert(vim.wait(2000, function() return #notifications > count end), "Ctrl-N did assert(notifications[#notifications]:find(instructions, 1, true), "Ctrl-N notification omitted the missing file") assert(Session.get_current() == session, "failed Ctrl-N replaced the current session") assert(#vim.api.nvim_list_bufs() == before, "failed startup leaked scratch buffers") +assert(vim.fn.filereadable(previous_prompt_paths[1]) == 1, "failed replacement removed the active prompt file") -- A subsequent Ctrl-N reads the updated file without another setup() call. vim.fn.writefile({ "Updated instructions." }, instructions) @@ -164,10 +264,14 @@ assert(vim.wait(2000, function() return Session.get_current() ~= session and vim.fn.filereadable(vim.env.AI_PROMPTS_READY) == 1 end), "Ctrl-N did not start a new session") assert(read(vim.env.AI_PROMPTS_OUTPUT) == read(instructions), "Ctrl-N did not reread the prompt file") +assert_removed(previous_prompt_paths) +local current_prompt_paths = vim.deepcopy(prompt_paths) assert(Session.stop_current()) +assert_removed(current_prompt_paths) startup_error({ append_system_prompt_filepath = "missing.md" }, missing) vim.notify = notify +vim.system = system vim.api.nvim_set_current_dir(repo) vim.fn.delete(root, "rf") From 218619499c779f7bc9bf6cfa315e211d51c1ae07 Mon Sep 17 00:00:00 2001 From: Morris Date: Wed, 16 Sep 2026 01:42:56 +0800 Subject: [PATCH 6/6] no-mistakes(document): Clarify skill paths and temporary prompt handling --- README.md | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index 521daf9..e5c3944 100644 --- a/README.md +++ b/README.md @@ -188,7 +188,7 @@ No skills are bundled or enabled by default. - `skills = true` enables Pi's skill discovery, including `~/.agents/skills/`. - `skill_paths = { ... }` loads local `SKILL.md` files or directories, even with `skills = false`. -Paths support `~`; relative paths use the agent's working directory. +Only a leading `~/` is expanded. Other path text is passed to Pi unchanged, and relative paths use the agent's working directory. Pi loads skill instructions when needed. To invoke a skill explicitly, send `/skill:name` followed by your request. @@ -220,9 +220,11 @@ require("ai").setup({ }) ``` -Files are read when opening a new session, including with Ctrl-N. Missing or unreadable files stop startup with an error notification. Paths support `~/`; relative paths use the agent's working directory. +Files are read when opening a new session, including with Ctrl-N. Missing or unreadable files stop startup with an error notification. Only a leading `~/` is expanded; relative paths use the agent's working directory. -The plugin passes the combined prompt through a private temporary file, so large prompts do not hit the operating system's command-line size limit. Text stays literal even when it matches an existing filename. The temporary file is removed when the backend exits, the session is stopped, or startup fails. Model context limits still apply. +The plugin passes a nonempty combined prompt through an owner-only temporary file, so its size does not count against the operating system's command-line limit. `append_system_prompt` text stays literal even when it matches an existing filename. + +The separate `system_prompt` option replaces Pi's default prompt. When its value names an existing path, Pi keeps its file-input behavior and ai.nvim leaves that file in place. Other nonempty values, including large literal prompts, use an owner-only temporary file. The plugin removes its temporary files when the backend exits, the session is stopped, or startup fails. Model context limits still apply. ### Recommended upstream skill