summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorStephanie Gredell <s.raide@gmail.com>2026-02-08 13:11:22 -0800
committerStephanie Gredell <s.raide@gmail.com>2026-02-08 17:22:07 -0800
commit17be2bff90a22d8bafc102e3ca1730bb05026841 (patch)
tree2ece5692956be9b03bc4453371fd8f105d8a10b4
parent489e132d4aec29970e0981ee12252d3925ad49c3 (diff)
downloada4-17be2bff90a22d8bafc102e3ca1730bb05026841.tar.xz
a4-17be2bff90a22d8bafc102e3ca1730bb05026841.zip
Add ability to reference files in prompt buffer
- type @ in the prompt to fuzzy-search and reference project files, content gets resolved and injected into LLMcontext - moved completion provider logic out of cmp.lua into agents and files domain modules - added completions registry to support multiple trigger characters (# rules, @ files) - added tests for file discovery, fuzzy matching, and the completions registry
-rw-r--r--README.md40
-rw-r--r--lua/99/extensions/agents/helpers.lua8
-rw-r--r--lua/99/extensions/agents/init.lua63
-rw-r--r--lua/99/extensions/cmp.lua123
-rw-r--r--lua/99/extensions/completions.lua83
-rw-r--r--lua/99/extensions/files/init.lua282
-rw-r--r--lua/99/extensions/init.lua8
-rw-r--r--lua/99/init.lua8
-rw-r--r--lua/99/ops/over-range.lua6
-rw-r--r--lua/99/request-context.lua11
-rw-r--r--lua/99/test/agents_spec.lua70
-rw-r--r--lua/99/test/completions_spec.lua162
-rw-r--r--lua/99/test/files_spec.lua186
-rw-r--r--scripts/ci/install_treesitter_parsers.lua107
-rw-r--r--syntax/99prompt.vim14
15 files changed, 1027 insertions, 144 deletions
diff --git a/README.md b/README.md
index 9f380b1..37b14fd 100644
--- a/README.md
+++ b/README.md
@@ -21,7 +21,7 @@ is for people who dont have "skill issues." This is meant to streamline the req
3. Still very alpha, could have severe problems
## How to use
-**you must have opencode installed and setup**
+**you must have a supported AI CLI installed (opencode, claude, or cursor-agent — see [Providers](#providers) below)**
Add the following configuration to your neovim config
@@ -38,15 +38,15 @@ I make the assumption you are using Lazy
local cwd = vim.uv.cwd()
local basename = vim.fs.basename(cwd)
_99.setup({
+ -- provider = _99.ClaudeCodeProvider, -- default: OpenCodeProvider
logger = {
level = _99.DEBUG,
path = "/tmp/" .. basename .. ".99.debug",
print_on_error = true,
},
- --- A new feature that is centered around tags
+ --- Completions: #rules and @files in the prompt buffer
completion = {
- --- Defaults to .cursor/rules
-- I am going to disable these until i understand the
-- problem better. Inside of cursor rules there is also
-- application rules, which means i need to apply these
@@ -69,6 +69,14 @@ I make the assumption you are using Lazy
"scratch/custom_rules/",
},
+ --- Configure @file completion (all fields optional, sensible defaults)
+ files = {
+ -- enabled = true,
+ -- max_file_size = 102400, -- bytes, skip files larger than this
+ -- max_files = 5000, -- cap on total discovered files
+ -- exclude = { ".env", ".env.*", "node_modules", ".git", ... },
+ },
+
--- What autocomplete do you use. We currently only
--- support cmp right now
source = "cmp",
@@ -107,10 +115,30 @@ I make the assumption you are using Lazy
},
```
-## Completion
-When prompting, if you have cmp installed as your autocomplete you can use an autocomplete for rule inclusion in your prompt.
+## Completions
+When prompting, you can reference rules and files to add context to your request.
+
+- `#` references rules — type `#` in the prompt to autocomplete rule files from your configured rule directories
+- `@` references files — type `@` to fuzzy-search project files
+
+Referenced content is automatically resolved and injected into the AI context. Requires cmp (`source = "cmp"` in your completion config).
-How skill completion and inclusion works is that you start by typing `@`.
+## Providers
+99 supports multiple AI CLI backends. Set `provider` in your setup to switch. If you don't set `model`, the provider's default is used.
+
+| Provider | CLI tool | Default model |
+|---|---|---|
+| `OpenCodeProvider` (default) | `opencode` | `opencode/claude-sonnet-4-5` |
+| `ClaudeCodeProvider` | `claude` | `claude-sonnet-4-5` |
+| `CursorAgentProvider` | `cursor-agent` | `sonnet-4.5` |
+
+```lua
+_99.setup({
+ provider = _99.ClaudeCodeProvider,
+ -- model is optional, overrides the provider's default
+ model = "claude-sonnet-4-5",
+})
+```
## API
You can see the full api at [99 API](./lua/99/init.lua)
diff --git a/lua/99/extensions/agents/helpers.lua b/lua/99/extensions/agents/helpers.lua
index 94df33b..8ac6f73 100644
--- a/lua/99/extensions/agents/helpers.lua
+++ b/lua/99/extensions/agents/helpers.lua
@@ -28,11 +28,17 @@ function M.ls(dir)
end
local rules = {}
+ local cwd = vim.uv.cwd()
for _, file in ipairs(files) do
local filename = vim.fn.fnamemodify(file, ":h:t")
+ local relative_path = file
+ if cwd and file:sub(1, #cwd) == cwd then
+ relative_path = file:sub(#cwd + 2) -- +2 to skip the trailing slash
+ end
table.insert(rules, {
name = filename,
- path = file,
+ path = relative_path,
+ absolute_path = file,
})
end
diff --git a/lua/99/extensions/agents/init.lua b/lua/99/extensions/agents/init.lua
index eb24e76..c53429e 100644
--- a/lua/99/extensions/agents/init.lua
+++ b/lua/99/extensions/agents/init.lua
@@ -5,6 +5,7 @@ local M = {}
--- @class _99.Agents.Rule
--- @field name string
--- @field path string
+--- @field absolute_path string?
--- @class _99.Agents.Rules
--- @field custom _99.Agents.Rule[]
@@ -24,8 +25,8 @@ local function add_rule_by_name(map, rules)
end
end
----@param _99 _99.State
----@return _99.Agents.Rules
+--- @param _99 _99.State
+--- @return _99.Agents.Rules
function M.rules(_99)
local custom = {}
for _, path in ipairs(_99.completion.custom_rules or {}) do
@@ -54,8 +55,8 @@ function M.rules_to_items(rules)
end
--- @param rules _99.Agents.Rules
----@param path string
----@return _99.Agents.Rule | nil
+--- @param path string
+--- @return _99.Agents.Rule | nil
function M.get_rule_by_path(rules, path)
for _, rule in ipairs(rules.custom or {}) do
if rule.path == path then
@@ -66,11 +67,11 @@ function M.get_rule_by_path(rules, path)
end
--- @param rules _99.Agents.Rules
----@param token string
----@return boolean
+--- @param token string
+--- @return boolean
function M.is_rule(rules, token)
for _, rule in ipairs(rules.custom or {}) do
- if rule.path == token then
+ if rule.path == token or rule.name == token then
return true
end
end
@@ -95,9 +96,9 @@ function M.find_rules(rules, haystack)
return out
end
----@param rules _99.Agents.Rules
----@param prompt string
----@return {names: string[], rules: _99.Agents.Rules[]}
+--- @param rules _99.Agents.Rules
+--- @param prompt string
+--- @return {names: string[], rules: _99.Agents.Rules[]}
function M.by_name(rules, prompt)
--- @type table<string, boolean>
local found = {}
@@ -127,4 +128,46 @@ function M.by_name(rules, prompt)
}
end
+--- @param _99 _99.State
+--- @return _99.CompletionProvider
+function M.completion_provider(_99)
+ return {
+ trigger = "#",
+ name = "rules",
+ get_items = function()
+ local agent_rules = M.rules_to_items(_99.rules)
+ local items = {}
+ for _, rule in ipairs(agent_rules) do
+ local docs = helpers.head(rule.absolute_path or rule.path)
+ table.insert(items, {
+ label = rule.name,
+ insertText = "#" .. rule.path,
+ filterText = "#" .. rule.name,
+ kind = 12, -- LSP CompletionItemKind.Value
+ documentation = { kind = "markdown", value = docs },
+ detail = "Rule: " .. rule.path,
+ })
+ end
+ return items
+ end,
+ is_valid = function(token)
+ return M.is_rule(_99.rules, token)
+ end,
+ resolve = function(token)
+ local rule = M.get_rule_by_path(_99.rules, token)
+ if not rule then
+ return nil
+ end
+ local file_path = rule.absolute_path or rule.path
+ local ok, file = pcall(io.open, file_path, "r")
+ if not ok or not file then
+ return nil
+ end
+ local content = file:read("*a")
+ file:close()
+ return string.format("<%s>\n%s\n</%s>", rule.name, content, rule.name)
+ end,
+ }
+end
+
return M
diff --git a/lua/99/extensions/cmp.lua b/lua/99/extensions/cmp.lua
index 3fd34f5..4279634 100644
--- a/lua/99/extensions/cmp.lua
+++ b/lua/99/extensions/cmp.lua
@@ -1,28 +1,10 @@
local Agents = require("99.extensions.agents")
-local Helpers = require("99.extensions.agents.helpers")
+local Files = require("99.extensions.files")
+local Completions = require("99.extensions.completions")
local SOURCE = "99"
---- @class _99.Extensions.CmpItem
---- @field rule _99.Agents.Rule
---- @field docs string
-
---- @param _99 _99.State
---- @return _99.Extensions.CmpItem[]
-local function rules(_99)
- local agent_rules = Agents.rules_to_items(_99.rules)
- local out = {}
- for _, rule in ipairs(agent_rules) do
- table.insert(out, {
- rule = rule,
- docs = Helpers.head(rule.path),
- })
- end
- return out
-end
-
--- @class CmpSource
--- @field _99 _99.State
---- @field items _99.Extensions.CmpItem[]
local CmpSource = {}
CmpSource.__index = CmpSource
@@ -30,7 +12,6 @@ CmpSource.__index = CmpSource
function CmpSource.new(_99)
return setmetatable({
_99 = _99,
- items = rules(_99),
}, CmpSource)
end
@@ -43,44 +24,41 @@ function CmpSource.get_debug_name()
end
function CmpSource.get_keyword_pattern()
- return [[@\k\+]]
+ return Completions.get_keyword_pattern()
end
function CmpSource.get_trigger_characters()
- return { "@" }
+ return Completions.get_trigger_characters()
end
---- @class CompletionItem
---- @field label string
---- @field kind number kind is optional but gives icons / categories
---- @field documentation string can be a string or markdown table
---- @field detail string detail shows a right-side hint
+function CmpSource.complete(_, params, callback)
+ local before = params.context.cursor_before_line or ""
---- @class Completion
---- @field items CompletionItem[]
---- @field isIncomplete boolean -
--- true: I might return more if user types more
--- false: this result set is complete
-function CmpSource:complete(_, callback)
- local items = {} --[[ @as CompletionItem[] ]]
- for _, item in ipairs(self.items) do
- table.insert(items, {
- label = item.rule.name,
- insertText = "@" .. item.rule.name,
- filterText = "@" .. item.rule.name,
- kind = 17, -- file
- documentation = {
- kind = "markdown",
- value = item.docs,
- },
- detail = item.rule.path,
- })
+ -- Find which trigger is active
+ local trigger = nil
+ for _, char in ipairs(Completions.get_trigger_characters()) do
+ local pattern = char:gsub("([%%%^%$%(%)%.%[%]%*%+%-%?])", "%%%1") .. "%S*$"
+ if before:match(pattern) then
+ trigger = char
+ break
+ end
end
- callback({
- items = items,
- isIncomplete = false,
- })
+ if not trigger then
+ callback({ items = {}, isIncomplete = false })
+ return
+ end
+
+ local items = Completions.get_completions(trigger)
+ callback({ items = items, isIncomplete = false })
+end
+
+function CmpSource.resolve(_, completion_item, callback)
+ callback(completion_item)
+end
+
+function CmpSource.execute(_, completion_item, callback)
+ callback(completion_item)
end
--- @type CmpSource | nil
@@ -88,29 +66,52 @@ local source = nil
--- @param _ _99.State
local function init_for_buffer(_)
+ local buf = vim.api.nvim_get_current_buf()
+
+ -- Set filetype for syntax highlighting
+ vim.bo[buf].filetype = "99prompt"
+
local cmp = require("cmp")
cmp.setup.buffer({
- sources = {
- { name = SOURCE },
- },
+ sources = { { name = SOURCE } },
window = {
- completion = {
- zindex = 1001,
- },
- documentation = {
- zindex = 1001,
- },
+ completion = { zindex = 1001 },
+ documentation = { zindex = 1001 },
},
})
end
--- @param _99 _99.State
+local function register_providers(_99)
+ Completions.register(Agents.completion_provider(_99))
+ Completions.register(Files.completion_provider())
+end
+
+--- @param _99 _99.State
local function init(_99)
assert(
source == nil,
"the source must be nil when calling init on an completer"
)
+ -- Collect rule directories to exclude from file search
+ local rule_dirs = {}
+ if _99.completion then
+ if _99.completion.custom_rules then
+ for _, dir in ipairs(_99.completion.custom_rules) do
+ table.insert(rule_dirs, dir)
+ end
+ end
+ end
+
+ if _99.completion and _99.completion.files then
+ Files.setup(_99.completion.files, rule_dirs)
+ else
+ Files.setup({ enabled = true }, rule_dirs)
+ end
+
+ register_providers(_99)
+
local cmp = require("cmp")
source = CmpSource.new(_99)
cmp.register_source(SOURCE, source)
@@ -121,7 +122,7 @@ local function refresh_state(_99)
if not source then
return
end
- source.items = rules(_99)
+ register_providers(_99)
end
--- @type _99.Extensions.Source
diff --git a/lua/99/extensions/completions.lua b/lua/99/extensions/completions.lua
new file mode 100644
index 0000000..1ba36ca
--- /dev/null
+++ b/lua/99/extensions/completions.lua
@@ -0,0 +1,83 @@
+--- A provider for completion tokens (#rules, @files) used in the prompt.
+--- @class _99.CompletionProvider
+--- @field trigger string
+--- @field name string
+--- @field get_items fun(): CompletionItem[]
+--- @field is_valid fun(token: string): boolean
+--- @field resolve fun(token: string): string|nil
+
+--- @class _99.Reference
+--- @field content string
+
+--- @type _99.CompletionProvider[]
+local providers = {}
+
+local M = {}
+
+--- @param provider _99.CompletionProvider
+function M.register(provider)
+ for i, p in ipairs(providers) do
+ if p.trigger == provider.trigger then
+ providers[i] = provider
+ return
+ end
+ end
+ table.insert(providers, provider)
+end
+
+--- @return string[]
+function M.get_trigger_characters()
+ local chars = {}
+ for _, p in ipairs(providers) do
+ table.insert(chars, p.trigger)
+ end
+ return chars
+end
+
+--- @return string
+function M.get_keyword_pattern()
+ local triggers = {}
+ for _, p in ipairs(providers) do
+ table.insert(triggers, p.trigger)
+ end
+ return "[" .. table.concat(triggers) .. "]\\k*"
+end
+
+--- @param prompt_text string
+--- @return _99.Reference[]
+function M.parse(prompt_text)
+ local refs = {}
+ for _, provider in ipairs(providers) do
+ local pattern = provider.trigger:gsub(
+ "([%%%^%$%(%)%.%[%]%*%+%-%?])",
+ "%%%1"
+ ) .. "%S+"
+ for word in prompt_text:gmatch(pattern) do
+ local token = word:sub(#provider.trigger + 1)
+ if provider.is_valid(token) then
+ local content = provider.resolve(token)
+ if content then
+ table.insert(refs, { content = content })
+ end
+ end
+ end
+ end
+ return refs
+end
+
+--- @param trigger_char string
+--- @return CompletionItem[]
+function M.get_completions(trigger_char)
+ for _, provider in ipairs(providers) do
+ if provider.trigger == trigger_char then
+ return provider.get_items()
+ end
+ end
+ return {}
+end
+
+function M._reset()
+ providers = {}
+end
+
+return M
diff --git a/lua/99/extensions/files/init.lua b/lua/99/extensions/files/init.lua
new file mode 100644
index 0000000..2b0ba57
--- /dev/null
+++ b/lua/99/extensions/files/init.lua
@@ -0,0 +1,282 @@
+local M = {}
+
+--- @class _99.Files.Config
+--- @field enabled boolean?
+--- @field max_file_size number?
+--- @field max_files number?
+--- @field exclude string[]?
+
+--- @class _99.Files.File
+--- @field path string -- Relative path from project root
+--- @field name string -- Filename
+--- @field absolute_path string -- Full absolute path
+
+local cache = {
+ files = {},
+ root = "",
+}
+
+local config = {
+ enabled = true,
+ max_file_size = 100 * 1024,
+ max_files = 5000,
+ exclude = {
+ ".env",
+ ".env.*",
+ "node_modules",
+ ".git",
+ "dist",
+ "build",
+ "*.log",
+ ".DS_Store",
+ "tmp",
+ ".cursor",
+ },
+}
+
+--- @param pattern string
+--- @return boolean
+local function matches_exclude_pattern(pattern)
+ for _, exclude_pattern in ipairs(config.exclude) do
+ local glob_pattern = exclude_pattern:gsub("%.", "%%."):gsub("%*", ".*")
+
+ if
+ pattern:match(glob_pattern .. "$")
+ or pattern:match("^" .. glob_pattern)
+ or pattern:match("/" .. glob_pattern .. "/")
+ then
+ return true
+ end
+ end
+ return false
+end
+
+--- @param path string
+--- @param root string
+--- @return string
+local function get_relative_path(path, root)
+ if path:sub(1, #root) == root then
+ local rel = path:sub(#root + 1)
+ if rel:sub(1, 1) == "/" then
+ rel = rel:sub(2)
+ end
+ return rel
+ end
+ return path
+end
+
+--- @param root string
+function M.set_project_root(root)
+ cache.root = root
+ cache.files = {}
+end
+
+--- @return string
+function M.get_project_root()
+ return cache.root
+end
+
+--- @return _99.Files.File[]
+function M.discover_files()
+ local root = cache.root
+ if root == "" then
+ return {}
+ end
+
+ local files = {}
+ local count = 0
+
+ local function scan_dir(dir)
+ if count >= config.max_files then
+ return
+ end
+
+ local handle = vim.uv.fs_scandir(dir)
+ if not handle then
+ return
+ end
+
+ while true do
+ local name, type = vim.uv.fs_scandir_next(handle)
+ if not name then
+ break
+ end
+
+ local full_path = dir .. "/" .. name
+ local rel_path = get_relative_path(full_path, root)
+
+ if matches_exclude_pattern(name) or matches_exclude_pattern(rel_path) then
+ goto continue
+ end
+
+ if type == "directory" then
+ scan_dir(full_path)
+ elseif type == "file" then
+ table.insert(files, {
+ path = rel_path,
+ name = name,
+ absolute_path = full_path,
+ })
+ count = count + 1
+
+ if count >= config.max_files then
+ break
+ end
+ end
+
+ ::continue::
+ end
+ end
+
+ scan_dir(root)
+
+ table.sort(files, function(a, b)
+ return a.path < b.path
+ end)
+
+ cache.files = files
+ return files
+end
+
+--- @return _99.Files.File[]
+function M.get_files()
+ if #cache.files == 0 and cache.root ~= "" then
+ return M.discover_files()
+ end
+ return cache.files
+end
+
+--- @param query string
+--- @return _99.Files.File[]
+function M.find_matches(query)
+ local files = M.get_files()
+ if not query or query == "" then
+ return files
+ end
+
+ query = query:lower()
+ local matches = {}
+
+ for _, file in ipairs(files) do
+ local searchable = (file.name .. " " .. file.path):lower()
+ local match_pos = 1
+ local matched = true
+ for i = 1, #query do
+ local char = query:sub(i, i)
+ local found = searchable:find(char, match_pos, true)
+ if not found then
+ matched = false
+ break
+ end
+ match_pos = found + 1
+ end
+
+ if matched then
+ table.insert(matches, file)
+ end
+ end
+
+ return matches
+end
+
+--- @param path string
+--- @return string | nil
+function M.read_file(path)
+ local full_path = cache.root .. "/" .. path
+
+ if path:sub(1, 1) == "/" then
+ full_path = path
+ end
+
+ local stat = vim.uv.fs_stat(full_path)
+ if not stat then
+ return nil
+ end
+
+ if stat.size > config.max_file_size then
+ return nil
+ end
+
+ local fd = vim.uv.fs_open(full_path, "r", 438)
+ if not fd then
+ return nil
+ end
+
+ local data = vim.uv.fs_read(fd, stat.size, 0)
+ vim.uv.fs_close(fd)
+
+ return data
+end
+
+--- @param path string
+--- @return boolean
+function M.is_project_file(path)
+ local files = M.get_files()
+ for _, file in ipairs(files) do
+ if file.path == path or file.name == path then
+ return true
+ end
+ end
+ return false
+end
+
+--- @param opts _99.Files.Config?
+--- @param rule_dirs string[]? Directories containing rules to exclude from file search
+function M.setup(opts, rule_dirs)
+ if opts then
+ config.enabled = opts.enabled ~= false
+ config.max_file_size = opts.max_file_size or config.max_file_size
+ config.max_files = opts.max_files or config.max_files
+ if opts.exclude then
+ config.exclude = opts.exclude
+ end
+ end
+
+ -- Add rule directories to exclude list
+ if rule_dirs then
+ for _, dir in ipairs(rule_dirs) do
+ -- Normalize the directory path (remove trailing slash, get basename for relative paths)
+ local normalized = dir:gsub("/$", ""):gsub("^%./", "")
+ table.insert(config.exclude, normalized)
+ end
+ end
+end
+
+--- @return _99.CompletionProvider
+function M.completion_provider()
+ return {
+ trigger = "@",
+ name = "files",
+ get_items = function()
+ local files = M.find_matches("")
+ local items = {}
+ for _, file in ipairs(files) do
+ table.insert(items, {
+ label = file.name,
+ insertText = "@" .. file.path,
+ filterText = "@" .. file.name .. " " .. file.path,
+ kind = 17, -- LSP CompletionItemKind.Reference
+ documentation = {
+ kind = "markdown",
+ value = "File: `" .. file.path .. "`",
+ },
+ detail = file.path,
+ })
+ end
+ return items
+ end,
+ is_valid = function(token)
+ return M.is_project_file(token)
+ end,
+ resolve = function(token)
+ local content = M.read_file(token)
+ if not content then
+ return nil
+ end
+ local ext = token:match("%.([^%.]+)$") or ""
+ return string.format("```%s\n-- %s\n%s\n```", ext, token, content)
+ end,
+ }
+end
+
+return M
diff --git a/lua/99/extensions/init.lua b/lua/99/extensions/init.lua
index debc1a8..95629a2 100644
--- a/lua/99/extensions/init.lua
+++ b/lua/99/extensions/init.lua
@@ -1,3 +1,5 @@
+local Files = require("99.extensions.files")
+
--- @class _99.Extensions.Source
--- @field init_for_buffer fun(_99: _99.State): nil
--- @field init fun(_99: _99.State): nil
@@ -26,6 +28,12 @@ return {
source.init(_99)
end,
+ capture_project_root = function()
+ local cwd = vim.fn.getcwd()
+ local git_root = vim.fs.root(cwd, ".git")
+ Files.set_project_root(git_root or cwd)
+ end,
+
--- @param _99 _99.State
setup_buffer = function(_99)
local source = get_source(_99.completion)
diff --git a/lua/99/init.lua b/lua/99/init.lua
index 2340065..d48c26d 100644
--- a/lua/99/init.lua
+++ b/lua/99/init.lua
@@ -90,6 +90,7 @@ end
--- @class _99.Completion
--- @field source "cmp" | nil
--- @field custom_rules string[]
+--- @field files _99.Files.Config?
--- @class _99.Options
--- @field logger _99.Logger.Options?
@@ -336,7 +337,10 @@ end
--- @param opts _99.ops.Opts
function _99.visual_prompt(opts)
- warn("use visual, visual_prompt has been deprecated")
+ vim.notify(
+ "use visual, visual_prompt has been deprecated",
+ vim.log.levels.WARN
+ )
_99.visual(opts)
end
@@ -445,6 +449,7 @@ function _99.setup(opts)
}
_99_state.completion.custom_rules = _99_state.completion.custom_rules or {}
_99_state.auto_add_skills = opts.auto_add_skills or false
+ _99_state.completion.files = _99_state.completion.files or {}
local crules = _99_state.completion.custom_rules
for i, rule in ipairs(crules) do
@@ -482,6 +487,7 @@ function _99.setup(opts)
_99_state:refresh_rules()
Languages.initialize(_99_state)
Extensions.init(_99_state)
+ Extensions.capture_project_root()
end
--- @param md string
diff --git a/lua/99/ops/over-range.lua b/lua/99/ops/over-range.lua
index 209be9f..efb0a93 100644
--- a/lua/99/ops/over-range.lua
+++ b/lua/99/ops/over-range.lua
@@ -3,7 +3,7 @@ local RequestStatus = require("99.ops.request_status")
local Mark = require("99.ops.marks")
local geo = require("99.geo")
local make_clean_up = require("99.ops.clean-up")
-local Agents = require("99.extensions.agents")
+local Completions = require("99.extensions.completions")
local Range = geo.Range
local Point = geo.Point
@@ -50,8 +50,8 @@ local function over_range(context, range, opts)
full_prompt =
context._99.prompts.prompts.prompt(additional_prompt, full_prompt)
- local rules = Agents.find_rules(context._99.rules, additional_prompt)
- context:add_agent_rules(rules)
+ local refs = Completions.parse(additional_prompt)
+ context:add_references(refs)
end
local additional_rules = opts.additional_rules
diff --git a/lua/99/request-context.lua b/lua/99/request-context.lua
index 74aaa0b..358cdd2 100644
--- a/lua/99/request-context.lua
+++ b/lua/99/request-context.lua
@@ -64,7 +64,8 @@ function RequestContext:add_agent_rules(rules)
for _, rule in ipairs(rules) do
-- Handle both string paths and rule objects
self.logger:debug("adding custom rule to agent", "rule", rule)
- local ok, file = pcall(io.open, rule.path, "r")
+ local file_path = rule.absolute_path or rule.path
+ local ok, file = pcall(io.open, file_path, "r")
if ok and file then
local content = file:read("*a")
file:close()
@@ -91,6 +92,14 @@ function RequestContext:add_agent_rules(rules)
end
end
+--- @param refs _99.Reference[]
+function RequestContext:add_references(refs)
+ for _, ref in ipairs(refs) do
+ self.logger:debug("adding reference to context")
+ table.insert(self.ai_context, ref.content)
+ end
+end
+
function RequestContext:_read_md_files()
local cwd = vim.uv.cwd()
local dir = vim.fn.fnamemodify(self.full_path, ":h")
diff --git a/lua/99/test/agents_spec.lua b/lua/99/test/agents_spec.lua
index 7616734..fa0a60e 100644
--- a/lua/99/test/agents_spec.lua
+++ b/lua/99/test/agents_spec.lua
@@ -7,15 +7,40 @@ local function a(p)
end
local custom_mds = {
- { name = "back-end", path = a("scratch/custom_rules/back-end/SKILL.md") },
- { name = "foo", path = a("scratch/custom_rules/foo/SKILL.md") },
- { name = "front-end", path = a("scratch/custom_rules/front-end/SKILL.md") },
- { name = "vim.lsp", path = a("scratch/custom_rules/vim.lsp/SKILL.md") },
- { name = "vim", path = a("scratch/custom_rules/vim/SKILL.md") },
- { name = "vim", path = a("scratch/custom_rules_2/vim/SKILL.md") },
+ {
+ name = "back-end",
+ path = "scratch/custom_rules/back-end/SKILL.md",
+ absolute_path = a("scratch/custom_rules/back-end/SKILL.md"),
+ },
+ {
+ name = "foo",
+ path = "scratch/custom_rules/foo/SKILL.md",
+ absolute_path = a("scratch/custom_rules/foo/SKILL.md"),
+ },
+ {
+ name = "front-end",
+ path = "scratch/custom_rules/front-end/SKILL.md",
+ absolute_path = a("scratch/custom_rules/front-end/SKILL.md"),
+ },
+ {
+ name = "vim.lsp",
+ path = "scratch/custom_rules/vim.lsp/SKILL.md",
+ absolute_path = a("scratch/custom_rules/vim.lsp/SKILL.md"),
+ },
+ {
+ name = "vim",
+ path = "scratch/custom_rules/vim/SKILL.md",
+ absolute_path = a("scratch/custom_rules/vim/SKILL.md"),
+ },
+ {
+ name = "vim",
+ path = "scratch/custom_rules_2/vim/SKILL.md",
+ absolute_path = a("scratch/custom_rules_2/vim/SKILL.md"),
+ },
{
name = "vim.treesitter",
- path = a("scratch/custom_rules/vim.treesitter/SKILL.md"),
+ path = "scratch/custom_rules/vim.treesitter/SKILL.md",
+ absolute_path = a("scratch/custom_rules/vim.treesitter/SKILL.md"),
},
}
@@ -72,6 +97,7 @@ describe("rules: <name>/SKILL.md", function()
end
end
end)
+
it("find rules", function()
local _99 = r({
"scratch/custom_rules/",
@@ -84,4 +110,34 @@ describe("rules: <name>/SKILL.md", function()
eq({ "front-end" }, found.names)
eq(rules.by_name["front-end"], found.rules)
end)
+
+ it("should validate that tokens exist by path and name", function()
+ local _99 = r({
+ "scratch/custom_rules/",
+ "scratch/custom_rules_2/",
+ })
+ local rules = Agents.rules(_99)
+
+ -- Test by path
+ eq(true, Agents.is_rule(rules, "scratch/custom_rules/back-end/SKILL.md"))
+ eq(true, Agents.is_rule(rules, "scratch/custom_rules/foo/SKILL.md"))
+ eq(true, Agents.is_rule(rules, "scratch/custom_rules/front-end/SKILL.md"))
+ eq(true, Agents.is_rule(rules, "scratch/custom_rules/vim.lsp/SKILL.md"))
+ eq(true, Agents.is_rule(rules, "scratch/custom_rules/vim/SKILL.md"))
+ eq(
+ true,
+ Agents.is_rule(rules, "scratch/custom_rules/vim.treesitter/SKILL.md")
+ )
+
+ -- Test by name
+ eq(true, Agents.is_rule(rules, "back-end"))
+ eq(true, Agents.is_rule(rules, "foo"))
+ eq(true, Agents.is_rule(rules, "front-end"))
+ eq(true, Agents.is_rule(rules, "vim"))
+
+ -- Test invalid
+ eq(false, Agents.is_rule(rules, "nonexistent"))
+ eq(false, Agents.is_rule(rules, "invalid-token"))
+ eq(false, Agents.is_rule(rules, ""))
+ end)
end)
diff --git a/lua/99/test/completions_spec.lua b/lua/99/test/completions_spec.lua
new file mode 100644
index 0000000..3385713
--- /dev/null
+++ b/lua/99/test/completions_spec.lua
@@ -0,0 +1,162 @@
+-- luacheck: globals describe it assert before_each
+---@diagnostic disable: undefined-field, missing-fields
+local Completions = require("99.extensions.completions")
+local eq = assert.are.same
+
+local function mock_provider(trigger, name, valid_tokens)
+ return {
+ trigger = trigger,
+ name = name,
+ get_items = function()
+ local items = {}
+ for token, _ in pairs(valid_tokens) do
+ table.insert(items, {
+ label = token,
+ insertText = trigger .. token,
+ filterText = trigger .. token,
+ kind = 1,
+ })
+ end
+ return items
+ end,
+ is_valid = function(token)
+ return valid_tokens[token] ~= nil
+ end,
+ resolve = function(token)
+ return valid_tokens[token]
+ end,
+ }
+end
+
+describe("completions", function()
+ before_each(function()
+ Completions._reset()
+ end)
+
+ it("register and get_trigger_characters", function()
+ Completions.register(mock_provider("#", "rules", {}))
+ Completions.register(mock_provider("@", "files", {}))
+ eq({ "#", "@" }, Completions.get_trigger_characters())
+ end)
+
+ it("register replaces provider with same trigger", function()
+ Completions.register(
+ mock_provider("#", "rules-v1", { old = "old-content" })
+ )
+ Completions.register(
+ mock_provider("#", "rules-v2", { new = "new-content" })
+ )
+
+ local triggers = Completions.get_trigger_characters()
+ eq({ "#" }, triggers)
+
+ local refs = Completions.parse("use #new in prompt")
+ eq(1, #refs)
+ eq("new-content", refs[1].content)
+
+ local old_refs = Completions.parse("use #old in prompt")
+ eq(0, #old_refs)
+ end)
+
+ it("get_keyword_pattern builds pattern from triggers", function()
+ Completions.register(mock_provider("#", "rules", {}))
+ Completions.register(mock_provider("@", "files", {}))
+ eq("[#@]\\k*", Completions.get_keyword_pattern())
+ end)
+
+ it("get_completions returns items for known trigger", function()
+ Completions.register(mock_provider("#", "rules", { debug = "content" }))
+ local items = Completions.get_completions("#")
+ eq(1, #items)
+ eq("debug", items[1].label)
+ eq("#debug", items[1].insertText)
+ end)
+
+ it("get_completions returns empty for unknown trigger", function()
+ Completions.register(mock_provider("#", "rules", {}))
+ eq({}, Completions.get_completions("@"))
+ end)
+
+ it("parse extracts valid tokens and resolves content", function()
+ Completions.register(mock_provider("#", "rules", {
+ ["debug.md"] = "<debug>content</debug>",
+ }))
+ Completions.register(mock_provider("@", "files", {
+ ["utils.lua"] = "```lua\ncode\n```",
+ }))
+
+ local refs = Completions.parse("add logging #debug.md and read @utils.lua")
+ eq(2, #refs)
+ eq("<debug>content</debug>", refs[1].content)
+ eq("```lua\ncode\n```", refs[2].content)
+ end)
+
+ it("parse skips invalid tokens", function()
+ Completions.register(mock_provider("#", "rules", {
+ ["valid.md"] = "resolved",
+ }))
+
+ local refs = Completions.parse("#valid.md #nonexistent")
+ eq(1, #refs)
+ eq("resolved", refs[1].content)
+ end)
+
+ it("parse returns empty for no tokens", function()
+ Completions.register(mock_provider("#", "rules", { a = "b" }))
+ eq({}, Completions.parse("just a plain prompt"))
+ end)
+
+ it("real providers register and resolve through the registry", function()
+ local Agents = require("99.extensions.agents")
+ local Files = require("99.extensions.files")
+
+ -- Set up files module
+ local default_exclude = {
+ ".env",
+ ".env.*",
+ "node_modules",
+ ".git",
+ "dist",
+ "build",
+ "*.log",
+ ".DS_Store",
+ "tmp",
+ ".cursor",
+ }
+ Files.setup({ enabled = true, exclude = default_exclude }, {})
+ Files.set_project_root(vim.uv.cwd() or "")
+ Files.discover_files()
+
+ -- Build a minimal state
+ local state = {
+ rules = Agents.rules({
+ completion = {
+ cursor_rules = "scratch/cursor/rules/",
+ custom_rules = {},
+ },
+ }),
+ }
+
+ -- Register real providers through the registry
+ Completions.register(Agents.completion_provider(state))
+ Completions.register(Files.completion_provider())
+
+ -- Verify triggers registered
+ local triggers = Completions.get_trigger_characters()
+ eq(2, #triggers)
+
+ -- Parse a prompt with a real @file reference
+ local refs = Completions.parse("check @scratch/refresh.lua")
+ assert.is_true(#refs > 0, "expected at least one resolved reference")
+
+ -- Verify resolved content is a real code fence with non-empty body
+ assert.is_true(
+ refs[1].content:sub(1, 6) == "```lua",
+ "expected code fence from real file provider"
+ )
+ assert.is_true(#refs[1].content > 20, "expected non-trivial file content")
+
+ -- Clean up
+ Files.set_project_root("")
+ end)
+end)
diff --git a/lua/99/test/files_spec.lua b/lua/99/test/files_spec.lua
new file mode 100644
index 0000000..217a199
--- /dev/null
+++ b/lua/99/test/files_spec.lua
@@ -0,0 +1,186 @@
+-- luacheck: globals describe it assert before_each after_each
+---@diagnostic disable: undefined-field, need-check-nil
+local Files = require("99.extensions.files")
+local eq = assert.are.same
+
+describe("files", function()
+ local default_exclude = {
+ ".env",
+ ".env.*",
+ "node_modules",
+ ".git",
+ "dist",
+ "build",
+ "*.log",
+ ".DS_Store",
+ "tmp",
+ ".cursor",
+ }
+
+ before_each(function()
+ Files.setup({ enabled = true, exclude = default_exclude }, {})
+ Files.set_project_root(vim.uv.cwd())
+ end)
+
+ after_each(function()
+ Files.set_project_root("")
+ end)
+
+ it("discover_files finds known files and excludes .git", function()
+ local files = Files.discover_files()
+ local paths = {}
+ for _, f in ipairs(files) do
+ paths[f.path] = f
+ end
+
+ -- known fixture files must be present
+ assert.is_not_nil(paths["scratch/refresh.lua"])
+ assert.is_not_nil(paths["scratch/test.ts"])
+ eq("refresh.lua", paths["scratch/refresh.lua"].name)
+ eq("test.ts", paths["scratch/test.ts"].name)
+
+ -- .git must be excluded
+ for path, _ in pairs(paths) do
+ assert.is_nil(
+ path:match("^%.git/"),
+ "expected .git to be excluded but found: " .. path
+ )
+ end
+ end)
+
+ it("discover_files returns sorted paths", function()
+ local files = Files.discover_files()
+ for i = 2, #files do
+ assert.is_true(
+ files[i - 1].path < files[i].path,
+ "expected sorted order but "
+ .. files[i - 1].path
+ .. " >= "
+ .. files[i].path
+ )
+ end
+ end)
+
+ it("is_project_file by path and name, rejects invalid", function()
+ Files.discover_files()
+ eq(true, Files.is_project_file("scratch/refresh.lua"))
+ eq(true, Files.is_project_file("refresh.lua"))
+ eq(false, Files.is_project_file("nonexistent/file.lua"))
+ eq(false, Files.is_project_file(""))
+ end)
+
+ it("find_matches fuzzy matches non-contiguous characters", function()
+ Files.discover_files()
+
+ -- "rfrsh" should fuzzy match "refresh.lua" (r-f-r-s-h appear in order)
+ local matches = Files.find_matches("rfrsh")
+ local found = false
+ for _, f in ipairs(matches) do
+ if f.name == "refresh.lua" then
+ found = true
+ end
+ end
+ assert.is_true(found, "expected 'rfrsh' to fuzzy match refresh.lua")
+
+ -- "zzzzz" should match nothing
+ local no_matches = Files.find_matches("zzzzz")
+ eq(0, #no_matches)
+ end)
+
+ it("read_file returns actual file content", function()
+ local content = Files.read_file("scratch/refresh.lua")
+ assert.is_not_nil(content)
+ assert.is_true(#content > 0, "expected non-empty file content")
+ end)
+
+ it("read_file returns nil for missing file", function()
+ eq(nil, Files.read_file("nonexistent/file.lua"))
+ end)
+
+ it("setup excludes configured patterns and keeps others", function()
+ Files.setup(
+ { enabled = true, exclude = { "scratch", ".git", "node_modules" } },
+ {}
+ )
+ Files.set_project_root(vim.uv.cwd())
+ local files = Files.discover_files()
+
+ local has_non_scratch = false
+ for _, f in ipairs(files) do
+ assert.is_nil(
+ f.path:match("^scratch"),
+ "expected scratch excluded but found: " .. f.path
+ )
+ if not f.path:match("^scratch") then
+ has_non_scratch = true
+ end
+ end
+ assert.is_true(
+ has_non_scratch,
+ "expected non-scratch files to still be present"
+ )
+ end)
+
+ it(
+ "completion_provider get_items returns items with correct shape and values",
+ function()
+ Files.discover_files()
+ local provider = Files.completion_provider()
+
+ eq("@", provider.trigger)
+ eq("files", provider.name)
+
+ local items = provider.get_items()
+ assert.is_true(#items > 0)
+
+ -- find the refresh.lua item specifically and check every field
+ local refresh_item = nil
+ for _, item in ipairs(items) do
+ if item.label == "refresh.lua" then
+ refresh_item = item
+ end
+ end
+ assert.is_not_nil(
+ refresh_item,
+ "expected to find refresh.lua in completion items"
+ )
+ eq("@scratch/refresh.lua", refresh_item.insertText)
+ assert.is_true(
+ refresh_item.filterText:match("refresh%.lua") ~= nil,
+ "expected filterText to contain filename"
+ )
+ eq(17, refresh_item.kind) -- LSP CompletionItemKind.Reference
+ eq("scratch/refresh.lua", refresh_item.detail)
+ eq("markdown", refresh_item.documentation.kind)
+ end
+ )
+
+ it(
+ "completion_provider resolve wraps content in code fence with extension",
+ function()
+ local provider = Files.completion_provider()
+ local content = provider.resolve("scratch/refresh.lua")
+ assert.is_not_nil(content)
+
+ assert.is_true(
+ content:sub(1, 6) == "```lua",
+ "expected code fence to start with ```lua"
+ )
+ assert.is_true(
+ content:sub(-4) == "\n```",
+ "expected code fence to end with ```"
+ )
+ assert.is_true(
+ content:match("-- scratch/refresh%.lua") ~= nil,
+ "expected path comment in fence"
+ )
+ local inner = content:match("```lua\n.-\n(.+)\n```$")
+ assert.is_not_nil(inner, "expected non-empty content inside code fence")
+ end
+ )
+
+ it("completion_provider resolve returns nil for missing file", function()
+ local provider = Files.completion_provider()
+ eq(nil, provider.resolve("does/not/exist.lua"))
+ end)
+end)
diff --git a/scripts/ci/install_treesitter_parsers.lua b/scripts/ci/install_treesitter_parsers.lua
index 6393fd8..b0f59a4 100644
--- a/scripts/ci/install_treesitter_parsers.lua
+++ b/scripts/ci/install_treesitter_parsers.lua
@@ -1,72 +1,71 @@
local install_dir = vim.fn.stdpath("data") .. "/site"
local ok_setup, setup_err = pcall(function()
- require("nvim-treesitter").setup({
- install_dir = install_dir,
- })
+ require("nvim-treesitter").setup({
+ install_dir = install_dir,
+ })
end)
if not ok_setup then
- vim.api.nvim_echo(
- { { "Error: " .. tostring(setup_err), "ErrorMsg" } },
- true,
- {}
- )
- vim.cmd("cq")
+ vim.api.nvim_echo(
+ { { "Error: " .. tostring(setup_err), "ErrorMsg" } },
+ true,
+ {}
+ )
+ vim.cmd("cq")
end
local ok_install, install_err = pcall(function()
- require("nvim-treesitter").install({
- "c",
- "cpp",
- "go",
- "lua",
- "php",
- "python",
- "typescript",
- "javascript",
- "java",
- "ruby",
- "tsx",
- "c_sharp",
- "vue",
- }):wait(300000)
+ require("nvim-treesitter")
+ .install({
+ "c",
+ "cpp",
+ "go",
+ "lua",
+ "php",
+ "python",
+ "typescript",
+ "javascript",
+ "java",
+ "ruby",
+ "tsx",
+ "c_sharp",
+ "vue",
+ })
+ :wait(300000)
end)
if not ok_install then
- vim.api.nvim_echo({
- { "Error: " .. tostring(install_err), "ErrorMsg" },
- }, true, {})
- vim.cmd("cq")
+ vim.api.nvim_echo({
+ { "Error: " .. tostring(install_err), "ErrorMsg" },
+ }, true, {})
+ vim.cmd("cq")
end
local required_parsers = {
- c = "c.so",
- cpp = "cpp.so",
- go = "go.so",
- lua = "lua.so",
- php = "php.so",
- python = "python.so",
- typescript = "typescript.so",
- javascript = "javascript.so",
- java = "java.so",
- ruby = "ruby.so",
- tsx = "tsx.so",
- c_sharp = "c_sharp.so",
- vue = "vue.so",
+ c = "c.so",
+ cpp = "cpp.so",
+ go = "go.so",
+ lua = "lua.so",
+ php = "php.so",
+ python = "python.so",
+ typescript = "typescript.so",
+ javascript = "javascript.so",
+ java = "java.so",
+ ruby = "ruby.so",
+ tsx = "tsx.so",
+ c_sharp = "c_sharp.so",
+ vue = "vue.so",
}
for lang, filename in pairs(required_parsers) do
- local parser_path = install_dir .. "/parser/" .. filename
- if not vim.uv.fs_stat(parser_path) then
- vim.api.nvim_echo({
- {
- "Error: "
- .. lang
- .. " parser missing after install: "
- .. parser_path,
- "ErrorMsg",
- },
- }, true, {})
- vim.cmd("cq")
- end
+ local parser_path = install_dir .. "/parser/" .. filename
+ if not vim.uv.fs_stat(parser_path) then
+ vim.api.nvim_echo({
+ {
+ "Error: " .. lang .. " parser missing after install: " .. parser_path,
+ "ErrorMsg",
+ },
+ }, true, {})
+ vim.cmd("cq")
+ end
end
diff --git a/syntax/99prompt.vim b/syntax/99prompt.vim
new file mode 100644
index 0000000..31e4b7b
--- /dev/null
+++ b/syntax/99prompt.vim
@@ -0,0 +1,14 @@
+" Syntax file for 99 prompt window
+" Highlights #rules in cyan and @files in goldenrod
+
+if exists("b:current_syntax")
+ finish
+endif
+
+syntax match 99RuleRef /#\S\+/
+syntax match 99FileRef /@\S\+/
+
+highlight default 99RuleRef guifg=#00FFFF ctermfg=cyan
+highlight default 99FileRef guifg=#DAA520 ctermfg=178
+
+let b:current_syntax = "99prompt"