diff options
| -rw-r--r-- | .stylua.toml | 2 | ||||
| -rw-r--r-- | AGENTS.md | 45 | ||||
| -rw-r--r-- | README.md | 85 | ||||
| -rw-r--r-- | lua/99/example_response.json | 36 | ||||
| -rw-r--r-- | lua/99/extensions/completions.lua | 76 | ||||
| -rw-r--r-- | lua/99/init.lua | 469 | ||||
| -rw-r--r-- | lua/99/logger/logger.lua | 373 | ||||
| -rw-r--r-- | lua/99/ops/clean-up.lua | 74 | ||||
| -rw-r--r-- | lua/99/ops/init.lua | 10 | ||||
| -rw-r--r-- | lua/99/ops/make-prompt.lua | 44 | ||||
| -rw-r--r-- | lua/99/ops/over-range.lua | 155 | ||||
| -rw-r--r-- | lua/99/prompt-settings.lua | 151 | ||||
| -rw-r--r-- | lua/99/prompt.lua | 568 | ||||
| -rw-r--r-- | lua/99/providers.lua | 498 | ||||
| -rw-r--r-- | lua/99/state.lua | 94 |
15 files changed, 1363 insertions, 1317 deletions
diff --git a/.stylua.toml b/.stylua.toml index de815a2..b909a06 100644 --- a/.stylua.toml +++ b/.stylua.toml @@ -1,6 +1,4 @@ column_width = 80 line_endings = "Unix" indent_type = "Spaces" -indent_width = 2 quote_style = "AutoPreferDouble" - diff --git a/AGENTS.md b/AGENTS.md deleted file mode 100644 index 38a9d38..0000000 --- a/AGENTS.md +++ /dev/null @@ -1,45 +0,0 @@ -* always use neovim provided functions -* this is not a standard lua project. package resolution and all things related to lua and std should be ignored in favor of neovim and its utitlites. - -## Testing -* make lua_test -* make pr_ready - -## e2e / Integration style testing -every testing file should have roughly the same setup. - -```lua --- In this example we are testing the visual function for 99 requests --- test utils has utilities to setup buffers and request context and --- test providers that we can control when to resolve --- test utils also has functions to schedule syncronously, very powerful -local _99 = require("99") -local test_utils = require("99.test.test_utils") -local visual_fn = require("99.ops.over-range") --- ... imports that need to be tested - -describe("<name of test group>", function() - - it("specific test condition", function() - -- we setup the world with test provider, context, and state - local p, buffer, range = setup(content, 2, 1, 2, 23) - local state = _99.__get_state() - local context = Prompt.visual(state) - - -- now this test is simple, its just proving that we keep track - -- of inflight requests. - -- - -- all tests should have simple conditions we are testing for - -- and the logic should attempt to be as simple as possible. - eq(0, state:active_request_count()) - visual_fn(context, { - additional_prompt = "test prompt", - }) - eq(1, state:active_request_count()) - p:resolve("success", " return 'implemented!'") - test_utils.next_frame() - eq(0, state:active_request_count()) - - end) -end) -``` @@ -1,35 +1,14 @@ -# 99 +# A4 The AI client that Neovim deserves, built by those that still enjoy to code. -## IF YOU ARE HERE FROM [THE YT VIDEO](https://www.youtube.com/watch?v=ws9zR-UzwTE) -So many things have changed. So please be careful! - -## WARNING :: API CHANGES RIGHT NOW -It will happen that apis will disapear or be changed. Sorry, this is an BETA product. - -## Project Direction -This repo is meant to be my exploration grounds for using AI mixed with tradcoding. - -I believe that hand coding is still very important and the best products i know -of today still do that (see opencode vs claude code) - -## Warning -1. Prompts are temporary right now. they could be massively improved -2. Officially in beta, but api can still change. unlikely at this point - -# 99 +# A4 The AI Neovim experience -## _99 -99 is an agentic workflow that is meant to meld the current programmers ability +## _A4 +A4 is an completion workflow that is meant to meld the current programmers ability with the amazing powers of LLMs. Instead of being a replacement, its meant to augment the programmer. -As of now, the direction of 99 is to progress into agentic programming and surfacing -of information. In the beginning and the original youtube video was about replacing -specific pieces of code. The more i use 99 the more i realize the better use is -through `search` and `work` - ### Basic Setup ```lua { @@ -134,13 +113,6 @@ through `search` and `work` ``` ### Usage -I would highly recommend trying out `search` as its the direction the library is going - -```lua -_99.search() -``` - -See search for more details ### Description | Name | Type | Default Value | @@ -162,19 +134,6 @@ Sets up _99. Must be called for this library to work. This is how we setup in flight request spinners, set default values, get completion to work the way you want it to. -#### search -Performs a search across your project with the prompt you provide and return out a list of -locations with notes that will be put into your quick fix list. - -#### vibe -No description. - -#### open -Opens a selection window for you to select the last interaction to open -and display its contents in a way that makes sense for its type. For -search and vibe, it will open the qfix window. For tutorial, it will open -the tutorial window. - #### visual takes your current selection and sends that along with the prompt provided and replaces your visual selection with the results @@ -190,35 +149,8 @@ be killed (OpenCode) and any result will be discared #### clear_previous_requests clears all previous search and visual operations -#### Extensions -check out Worker for cool abstraction on search and vibe - -## _99.Extensions.Worker -A persistent way to keep track of work. - -this will likely be where the most change and focus goes into. I would like -to take this into worktree territory and be able to swap between stuff super -slick. - -Until then, it is going to be a single bit of work that you can provide -the description and then use search to find what is left that needs to be done. - -### Description -| Name | Type | Default Value | -| --- | --- | --- | -| `set_work` | `fun(opts?: _99.WorkOpts): nil` | - | -| `search` | `fun(): nil` | - | - ### API -#### set_work -will set the work for the project. If opts provide a description then no -input capture of work description will be required - -#### search -will use _99.search to find what is left to be done for this work item to be -considered done - ## _99.Options No description. @@ -482,15 +414,6 @@ vim.keymap.set("n", "<leader>9p", function() require("99.extensions.fzf_lua").select_provider() end) ``` - -## Reporting a bug - -To report a bug, please provide the full running debug logs. This may require -a bit of back and forth. - -Please do not request features. We will hold a public discussion on Twitch about -features, which will be a much better jumping point then a bunch of requests that i have to close down. If you do make a feature request ill just shut it down instantly. - ### The logs To get the _last_ run's logs execute `:lua require("99").view_logs()`. diff --git a/lua/99/example_response.json b/lua/99/example_response.json new file mode 100644 index 0000000..7497461 --- /dev/null +++ b/lua/99/example_response.json @@ -0,0 +1,36 @@ +{ + "choices": [ + { + "finish_reason": "stop", + "index": 0, + "message": { + "role": "assistant", + "content": "```go\nfunc reverseString(s string) string {\n runes := []rune(s)\n for i, j := 0, len(runes)-1; i < j; i, j = i+1, j-1 {\n runes[i], runes[j] = runes[j], runes[i]\n }\n return string(runes)\n}\n```" + } + } + ], + "created": 1780788059, + "model": "qwen2.5-coder-3b-instruct-q8_0.gguf", + "system_fingerprint": "b9381-91eb8ffa", + "object": "chat.completion", + "usage": { + "completion_tokens": 75, + "prompt_tokens": 128, + "total_tokens": 203, + "prompt_tokens_details": { + "cached_tokens": 127 + } + }, + "id": "chatcmpl-O7YWbG9gzzlOtN1NXT955UIkhTJ8X1Y6", + "timings": { + "cache_n": 127, + "prompt_n": 1, + "prompt_ms": 138.134, + "prompt_per_token_ms": 138.134, + "prompt_per_second": 7.239347300447393, + "predicted_n": 75, + "predicted_ms": 10140.874, + "predicted_per_token_ms": 135.21165333333332, + "predicted_per_second": 7.395812234724541 + } +} diff --git a/lua/99/extensions/completions.lua b/lua/99/extensions/completions.lua index 4eeec08..742f6e3 100644 --- a/lua/99/extensions/completions.lua +++ b/lua/99/extensions/completions.lua @@ -18,70 +18,74 @@ local M = {} --- @param str string The string to escape --- @return string The escaped string safe for use in Lua patterns function M.escape_pattern(str) - return str:gsub("([%%%^%$%(%)%.%[%]%*%+%-%?])", "%%%1") + return str:gsub("([%%%^%$%(%)%.%[%]%*%+%-%?])", "%%%1") end --- @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 + for i, p in ipairs(providers) do + if p.trigger == provider.trigger then + providers[i] = provider + return + end end - end - table.insert(providers, provider) + 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 + 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*" + local triggers = {} + for _, p in ipairs(providers) do + table.insert(triggers, p.trigger) + end + return "[" .. table.concat(triggers) .. "]\\k*" end ---- @param prompt_text string +--- @param prompt_text string|boolean|nil --- @return _99.Reference[] function M.parse(prompt_text) - local refs = {} - for _, provider in ipairs(providers) do - local pattern = M.escape_pattern(provider.trigger) .. "%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 }) + local refs = {} + if type(prompt_text) ~= "string" then + return refs + end + + for _, provider in ipairs(providers) do + local pattern = M.escape_pattern(provider.trigger) .. "%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 end - end - return refs + 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() + for _, provider in ipairs(providers) do + if provider.trigger == trigger_char then + return provider.get_items() + end end - end - return {} + return {} end function M._reset() - providers = {} + providers = {} end return M diff --git a/lua/99/init.lua b/lua/99/init.lua index 884250f..35ba93f 100644 --- a/lua/99/init.lua +++ b/lua/99/init.lua @@ -17,28 +17,29 @@ local Providers = require("99.providers") ---@param path_or_rule string | _99.Agents.Rule ---@return _99.Agents.Rule | string local function expand(path_or_rule) - if type(path_or_rule) == "string" then - return vim.fn.expand(path_or_rule) - end - return { - name = path_or_rule.name, - path = vim.fn.expand(path_or_rule.path), - } + if type(path_or_rule) == "string" then + return vim.fn.expand(path_or_rule) + end + return { + name = path_or_rule.name, + path = vim.fn.expand(path_or_rule.path), + } end --- @param opts _99.ops.Opts? --- @return _99.ops.Opts local function process_opts(opts) - opts = opts or {} - for i, rule in ipairs(opts.additional_rules or {}) do - local r = expand(rule) - assert( - type(r) ~= "string", - "broken configuration. additional_rules must never be a string" - ) - opts.additional_rules[i] = r - end - return opts + opts = opts or {} + for i, rule in ipairs(opts.additional_rules or {}) do + local r = expand(rule) + assert( + type(r) ~= "string", + "broken configuration. additional_rules must never be a string" + ) + opts.additional_rules[i] = r + end + + return opts end --- @class _99.Completion @@ -55,6 +56,7 @@ end --- @field md_files? string[] --- @field provider? _99.Providers.BaseProvider --- @field provider_extra_args? string[] +--- @field endpoint? string --- @field display_errors? boolean --- @field auto_add_skills? boolean --- @field completion? _99.Completion @@ -66,14 +68,13 @@ local _99_state --- @alias _99.TraceID number --- @class _99 ---- 99 is an agentic workflow that is meant to meld the current programmers ability ---- with the amazing powers of LLMs. Instead of being a replacement, its meant to ---- augment the programmer. +--- A4 is an agentic / trigger based completion workflow that is meant to meld the +--- current programmers ability with the amazing powers of LLMs. Instead of being +--- a replacement, its meant to augment the programmer. The power is the intentionality +--- of the programmer, not the clanker. --- ---- As of now, the direction of 99 is to progress into agentic programming and surfacing ---- of information. In the beginning and the original youtube video was about replacing ---- specific pieces of code. The more i use 99 the more i realize the better use is ---- through `search` and `work` +--- A4 is a fork of 99, the direction of A4 is to progress into "ghost" programming and surfacing +--- of language information to the programmer. --- --- ### Basic Setup --- ```lua @@ -213,11 +214,11 @@ local _99_state --- @field Extensions _99.Extensions --- check out Worker for cool abstraction on search and vibe local _99 = { - DEBUG = Level.DEBUG, - INFO = Level.INFO, - WARN = Level.WARN, - ERROR = Level.ERROR, - FATAL = Level.FATAL, + DEBUG = Level.DEBUG, + INFO = Level.INFO, + WARN = Level.WARN, + ERROR = Level.ERROR, + FATAL = Level.FATAL, } --- @param cb fun(context: _99.Prompt, o: _99.ops.Opts?): nil @@ -226,56 +227,56 @@ local _99 = { --- @param opts _99.ops.Opts --- @param capture_content string[] | nil local function capture_prompt(cb, name, context, opts, capture_content) - Window.capture_input(name, { - keymap = { - [":w"] = "submit", - }, - content = capture_content, + Window.capture_input(name, { + keymap = { + [":w"] = "submit", + }, + content = capture_content, - --- @param ok boolean - --- @param response string - cb = function(ok, response) - context.logger:debug( - "capture_prompt", - "success", - ok, - "response", - response - ) - if not ok then - return - end - local rules_and_names = Agents.by_name(_99_state.rules, response) - opts.additional_rules = opts.additional_rules or {} - for _, r in ipairs(rules_and_names.rules) do - table.insert(opts.additional_rules, r) - end - opts.additional_prompt = response - context.user_prompt = response - cb(context, opts) - end, - on_load = function() - Extensions.setup_buffer(_99_state) - end, - rules = _99_state.rules, - }) + --- @param ok boolean + --- @param response string + cb = function(ok, response) + context.logger:debug( + "capture_prompt", + "success", + ok, + "response", + response + ) + if not ok then + return + end + local rules_and_names = Agents.by_name(_99_state.rules, response) + opts.additional_rules = opts.additional_rules or {} + for _, r in ipairs(rules_and_names.rules) do + table.insert(opts.additional_rules, r) + end + opts.additional_prompt = response + context.user_prompt = response + cb(context, opts) + end, + on_load = function() + Extensions.setup_buffer(_99_state) + end, + rules = _99_state.rules, + }) end function _99.info() - local info = {} - _99_state:refresh_rules() - table.insert( - info, - string.format("Previous Requests: %d", _99_state.tracking:completed()) - ) - table.insert( - info, - string.format("custom rules(%d):", #(_99_state.rules.custom or {})) - ) - for _, rule in ipairs(_99_state.rules.custom or {}) do - table.insert(info, string.format("* %s", rule.name)) - end - Window.display_centered_message(info) + local info = {} + _99_state:refresh_rules() + table.insert( + info, + string.format("Previous Requests: %d", _99_state.tracking:completed()) + ) + table.insert( + info, + string.format("custom rules(%d):", #(_99_state.rules.custom or {})) + ) + for _, rule in ipairs(_99_state.rules.custom or {}) do + table.insert(info, string.format("* %s", rule.name)) + end + Window.display_centered_message(info) end -- elseif #tutorials == 1 then @@ -286,238 +287,268 @@ end --- @param context _99.Prompt function _99.open_tutorial(context) - local tutorial = context:tutorial_data() - Window.create_split(tutorial.tutorial, tutorial.buffer, { - split_direction = "vertical", - window_opts = { - wrap = true, - }, - }) + local tutorial = context:tutorial_data() + Window.create_split(tutorial.tutorial, tutorial.buffer, { + split_direction = "vertical", + window_opts = { + wrap = true, + }, + }) end function _99.open() - local requests = _99_state.tracking:successful() - local str_requests = Tracking.to_selectable_list(requests) - select_window(str_requests, function(idx) - local r = requests[idx] - assert(r:valid(), "encountered unexpected issue. malformated data") - if r.operation == "visual" then - --- TODO: this is its own work item for being able to have a global mark - --- section in which i keep track of marks for the lifetime of the - --- editor and when you close the editor, then it should lose them - print("visual not supported: i will figure this out... at some point") - elseif r.operation == "search" or r.operation == "vibe" then - _99.open_qfix_for_request(r) - elseif r.operation == "tutorial" then - _99.open_tutorial(r) - end - end) + local requests = _99_state.tracking:successful() + local str_requests = Tracking.to_selectable_list(requests) + select_window(str_requests, function(idx) + local r = requests[idx] + assert(r:valid(), "encountered unexpected issue. malformated data") + if r.operation == "visual" then + --- TODO: this is its own work item for being able to have a global mark + --- section in which i keep track of marks for the lifetime of the + --- editor and when you close the editor, then it should lose them + print( + "visual not supported: i will figure this out... at some point" + ) + elseif r.operation == "search" or r.operation == "vibe" then + _99.open_qfix_for_request(r) + elseif r.operation == "tutorial" then + _99.open_tutorial(r) + end + end) end --- @param opts? _99.ops.Opts --- @return _99.TraceID function _99.vibe(opts) - local o = process_opts(opts) - local context = Prompt.vibe(_99_state) - if o.additional_prompt then - context.user_prompt = o.additional_prompt - ops.vibe(context, o) - else - capture_prompt(ops.vibe, "Vibe", context, o) - end - return context.xid + local o = process_opts(opts) + local context = Prompt.vibe(_99_state) + if o.additional_prompt then + context.user_prompt = o.additional_prompt + ops.vibe(context, o) + else + capture_prompt(ops.vibe, "Vibe", context, o) + end + return context.xid end --- @param opts? _99.ops.SearchOpts --- @return _99.TraceID function _99.search(opts) - local o = process_opts(opts) --[[ @as _99.ops.SearchOpts ]] - local context = Prompt.search(_99_state) - if o.additional_prompt then - context.user_prompt = o.additional_prompt - ops.search(context, o) - else - capture_prompt(ops.search, "Search", context, o) - end - return context.xid + local o = process_opts(opts) --[[ @as _99.ops.SearchOpts ]] + local context = Prompt.search(_99_state) + if o.additional_prompt then + context.user_prompt = o.additional_prompt + ops.search(context, o) + else + capture_prompt(ops.search, "Search", context, o) + end + return context.xid end --- @param opts _99.ops.Opts function _99.tutorial(opts) - opts = process_opts(opts) - local context = Prompt.tutorial(_99_state) - if opts.additional_prompt then - context.user_prompt = opts.additional_prompt - ops.tutorial(context, opts) - else - capture_prompt(ops.tutorial, "Tutorial", context, opts) - end + opts = process_opts(opts) + local context = Prompt.tutorial(_99_state) + if opts.additional_prompt then + context.user_prompt = opts.additional_prompt + ops.tutorial(context, opts) + else + capture_prompt(ops.tutorial, "Tutorial", context, opts) + end end --- @param opts _99.ops.Opts? --- @return _99.TraceID function _99.visual(opts) - opts = process_opts(opts) - local context = Prompt.visual(_99_state) - if opts.additional_prompt then - context.user_prompt = opts.additional_prompt - ops.over_range(context, opts) - else - capture_prompt(ops.over_range, "Visual", context, opts) - end - return context.xid + opts = process_opts(opts) + local context = Prompt.visual(_99_state) --[[ @as _99.Prompt ]] + + if opts.additional_prompt then + if type(opts.additional_prompt) == "string" then + context.user_prompt = string(opts.additional_prompt) + elseif type(opts.additional_prompt) == "boolean" then + capture_prompt(ops.over_range, "Visual", context, opts) + end + else + ops.over_range(context, opts) + end + + return context.xid end function _99.view_logs() - local requests = _99_state.tracking.history - local str_requests = Tracking.to_selectable_list(requests) - select_window(str_requests, function(idx) - local r = requests[idx] - local logs = Logger.logs_by_id(r.xid) - if logs == nil then - logs = { "No logs found for request: " .. r.xid } - end - Window.display_full_screen_message(logs) - end) + local requests = _99_state.tracking.history + local str_requests = Tracking.to_selectable_list(requests) + select_window(str_requests, function(idx) + local r = requests[idx] + local logs = Logger.logs_by_id(r.xid) + if logs == nil then + logs = { "No logs found for request: " .. r.xid } + end + Window.display_full_screen_message(logs) + end) end --- @param request _99.Prompt function _99.open_qfix_for_request(request) - local items = request:qfix_data() - if #items == 0 then - print("there are no quickfix items to show") - return - end + local items = request:qfix_data() + if #items == 0 then + print("there are no quickfix items to show") + return + end - vim.fn.setqflist({}, "r", { title = "99 Results", items = items }) - vim.cmd("copen") + vim.fn.setqflist({}, "r", { title = "99 Results", items = items }) + vim.cmd("copen") end function _99.stop_all_requests() - _99_state.tracking:stop_all_requests() + _99_state.tracking:stop_all_requests() end function _99.clear_previous_requests() - _99_state.tracking:clear_history() + _99_state.tracking:clear_history() end --- if you touch this function you will be fired --- @return _99.State function _99.__get_state() - return _99_state + return _99_state end --- @param opts _99.Options? function _99.setup(opts) - opts = opts or {} + opts = opts or {} - _99_state = State.new(opts) + _99_state = State.new(opts) - local crules = _99_state.completion.custom_rules - for i, rule in ipairs(crules) do - local str = expand(rule) - assert(type(str) == "string", "error parsing rule: path must be a string") - crules[i] = str - end + local crules = _99_state.completion.custom_rules + for i, rule in ipairs(crules) do + local str = expand(rule) + assert( + type(str) == "string", + "error parsing rule: path must be a string" + ) + crules[i] = str + end - vim.api.nvim_create_autocmd("VimLeavePre", { - callback = function() - _99.stop_all_requests() - _99_state:sync() - end, - }) + vim.api.nvim_create_autocmd("VimLeavePre", { + callback = function() + _99.stop_all_requests() + _99_state:sync() + end, + }) - Logger:configure(opts.logger) + Logger:configure(opts.logger) - if opts.model then - assert(type(opts.model) == "string", "opts.model is not a string") - _99_state.model = opts.model - else - local provider = opts.provider or Providers.OpenCodeProvider - if provider._get_default_model then - _99_state.model = provider._get_default_model() + if opts.model then + assert(type(opts.model) == "string", "opts.model is not a string") + _99_state.model = opts.model + else + local provider = opts.provider or Providers.BareMetalProvider + if provider._get_default_model then + _99_state.model = provider._get_default_model() + end end - end - if opts.provider_extra_args then - assert( - type(opts.provider_extra_args) == "table", - "opts.provider_extra_args must be a table" - ) - end + if opts.endpoint then + if type(opts.endpoint) ~= "string" then + vim.notify( + "config error: opts.endpoint must be a string", + vim.log.levels.ERROR + ) + return false + end - if opts.md_files then - assert(type(opts.md_files) == "table", "opts.md_files is not a table") - for _, md in ipairs(opts.md_files) do - _99.add_md_file(md) + _99_state.endpoint = opts.endpoint + else + if opts.provider == _99.Providers.BareMetalProvider then + vim.notify( + "config error: opts.endpoint must be set for BareMetalProvider", + vim.log.levels.ERROR + ) + return false + end end - end - if opts.tmp_dir then - assert(type(opts.tmp_dir) == "string", "opts.tmp_dir must be a string") - end - _99_state.__tmp_dir = opts.tmp_dir + if opts.provider_extra_args then + assert( + type(opts.provider_extra_args) == "table", + "opts.provider_extra_args must be a table" + ) + end + + if opts.md_files then + assert(type(opts.md_files) == "table", "opts.md_files is not a table") + for _, md in ipairs(opts.md_files) do + _99.add_md_file(md) + end + end - _99_state.display_errors = opts.display_errors or false - _99_state:refresh_rules() - Extensions.init(_99_state) - Extensions.capture_project_root() + if opts.tmp_dir then + assert(type(opts.tmp_dir) == "string", "opts.tmp_dir must be a string") + end + _99_state.__tmp_dir = opts.tmp_dir - local sw = StatusWindow.new(_99_state, opts.in_flight_options) - sw:start() + _99_state.display_errors = opts.display_errors or false + _99_state:refresh_rules() + Extensions.init(_99_state) + Extensions.capture_project_root() + + local sw = StatusWindow.new(_99_state, opts.in_flight_options) + sw:start() end --- @param md string --- @return _99 function _99.add_md_file(md) - table.insert(_99_state.md_files, md) - return _99 + table.insert(_99_state.md_files, md) + return _99 end --- @param md string --- @return _99 function _99.rm_md_file(md) - for i, name in ipairs(_99_state.md_files) do - if name == md then - table.remove(_99_state.md_files, i) - break + for i, name in ipairs(_99_state.md_files) do + if name == md then + table.remove(_99_state.md_files, i) + break + end end - end - return _99 + return _99 end --- @param model string --- @return _99 function _99.set_model(model) - _99_state.model = model - return _99 + _99_state.model = model + return _99 end --- @return string function _99.get_model() - return _99_state.model + return _99_state.model end --- @return _99.Providers.BaseProvider function _99.get_provider() - return _99_state.provider_override or Providers.OpenCodeProvider + return _99_state.provider_override or Providers.OpenCodeProvider end --- @param provider _99.Providers.BaseProvider --- @return _99 function _99.set_provider(provider) - _99_state.provider_override = provider - if provider._get_default_model then - _99_state.model = provider._get_default_model() - end - return _99 + _99_state.provider_override = provider + if provider._get_default_model then + _99_state.model = provider._get_default_model() + end + return _99 end function _99.__debug() - Logger:configure({ - path = nil, - level = Level.DEBUG, - }) + Logger:configure({ + path = nil, + level = Level.DEBUG, + }) end _99.Providers = Providers @@ -525,6 +556,6 @@ _99.Providers = Providers --- @class _99.Extensions --- @field Worker _99.Extensions.Worker _99.Extensions = { - Worker = require("99.extensions.work.worker"), + Worker = require("99.extensions.work.worker"), } return _99 diff --git a/lua/99/logger/logger.lua b/lua/99/logger/logger.lua index daf65fa..f5901bf 100644 --- a/lua/99/logger/logger.lua +++ b/lua/99/logger/logger.lua @@ -1,6 +1,6 @@ local levels = require("99.logger.level") local time = require("99.time") -local MAX_REQUEST_DEFAULT = 5 +local MAX_REQUEST_DEFAULT = 3 --- @type table<number, _99.Logger.RequestLogs> local logger_cache = {} @@ -18,29 +18,29 @@ local max_requests_in_logger_cache = MAX_REQUEST_DEFAULT --- @param ... any --- @return table<string, any> local function to_args(...) - local count = select("#", ...) - local out = {} - assert( - count % 2 == 0, - "you cannot call logging with an odd number of args. e.g: msg, [k, v]..." - ) - for i = 1, count, 2 do - local key = select(i, ...) - local value = select(i + 1, ...) - assert(type(key) == "string", "keys in logging must be strings") - assert(out[key] == nil, "key collision in logs: " .. key) - out[key] = value - end - return out + local count = select("#", ...) + local out = {} + assert( + count % 2 == 0, + "you cannot call logging with an odd number of args. e.g: msg, [k, v]..." + ) + for i = 1, count, 2 do + local key = select(i, ...) + local value = select(i + 1, ...) + assert(type(key) == "string", "keys in logging must be strings") + assert(out[key] == nil, "key collision in logs: " .. key) + out[key] = value + end + return out end --- @param log_statement table<string, any> --- @param args table<string, any> local function put_args(log_statement, args) - for k, v in pairs(args) do - assert(log_statement[k] == nil, "key collision in logs: " .. k) - log_statement[k] = v - end + for k, v in pairs(args) do + assert(log_statement[k] == nil, "key collision in logs: " .. k) + log_statement[k] = v + end end --- @class LoggerSink @@ -51,12 +51,12 @@ local VoidSink = {} VoidSink.__index = VoidSink function VoidSink.new() - return setmetatable({}, VoidSink) + return setmetatable({}, VoidSink) end --- @param _ string function VoidSink:write_line(_) - _ = self + _ = self end --- @class FileSink : LoggerSink @@ -67,27 +67,29 @@ FileSink.__index = FileSink --- @param path string --- @return LoggerSink function FileSink:new(path) - -- Ensure the directory is already there (*thanks Windows*) - vim.fn.mkdir(vim.fn.fnamemodify(path, ":h"), "p") + -- Ensure the directory is already there (*thanks Windows*) + -- normalize the path for cases of ~ and \\ etc + local normpath = vim.fs.normalize(path) + vim.fn.mkdir(vim.fn.fnamemodify(normpath, ":h"), "p") - -- 420 decimal == 644 octal (rw-r--r--) - local fd, err = vim.uv.fs_open(path, "w", 420) - if not fd then - error("unable to file sink: " .. err) - end + -- 420 decimal == 644 octal (rw-r--r--) + local fd, err = vim.uv.fs_open(normpath, "w", 420) + if not fd then + error("unable to file sink: " .. err) + end - return setmetatable({ - fd = fd, - }, self) + return setmetatable({ + fd = fd, + }, self) end --- @param str string function FileSink:write_line(str) - local success, err = vim.uv.fs_write(self.fd, str .. "\n") - if not success then - error("unable to write to file sink", err) - end - vim.uv.fs_fsync(self.fd) + local success, err = vim.uv.fs_write(self.fd, str .. "\n") + if not success then + error("unable to write to file sink", err) + end + vim.uv.fs_fsync(self.fd) end --- @class PrintSink : LoggerSink @@ -96,13 +98,13 @@ PrintSink.__index = PrintSink --- @return LoggerSink function PrintSink:new() - return setmetatable({}, self) + return setmetatable({}, self) end --- @param str string function PrintSink:write_line(str) - local _ = self - print(str) + local _ = self + print(str) end --- @class _99.Logger.RequestLogs @@ -120,265 +122,274 @@ Logger.__index = Logger --- @param level number? --- @return _99.Logger function Logger:new(level) - level = level or levels.FATAL - return setmetatable({ - sink = VoidSink:new(), - level = level, - print_on_error = false, - extra_params = {}, - }, self) + level = level or levels.FATAL + + return setmetatable({ + sink = VoidSink:new(), + level = level, + print_on_error = false, + extra_params = {}, + }, self) end --- @return _99.Logger function Logger:clone() - local params = {} - for k, v in pairs(self.extra_params) do - params[k] = v - end - return setmetatable({ - sink = self.sink, - level = self.level, - print_on_error = self.print_on_error, - extra_params = params, - }, Logger) + local params = {} + for k, v in pairs(self.extra_params) do + params[k] = v + end + return setmetatable({ + sink = self.sink, + level = self.level, + print_on_error = self.print_on_error, + extra_params = params, + }, Logger) end --- @param path string --- @return _99.Logger function Logger:file_sink(path) - self.sink = FileSink:new(path) - return self + self.sink = FileSink:new(path) + return self end --- @return _99.Logger function Logger:void_sink() - self.sink = VoidSink:new() - return self + self.sink = VoidSink:new() + return self end --- @return _99.Logger function Logger:print_sink() - self.sink = PrintSink:new() - return self + self.sink = PrintSink:new() + return self end --- @param area string --- @return _99.Logger function Logger:set_area(area) - local new_logger = self:clone() - new_logger.extra_params["Area"] = area - return new_logger + local new_logger = self:clone() + new_logger.extra_params["Area"] = area + return new_logger end --- @param xid number --- @return _99.Logger function Logger:set_id(xid) - local new_logger = self:clone() - new_logger.extra_params["id"] = xid - return new_logger + local new_logger = self:clone() + new_logger.extra_params["id"] = xid + return new_logger end --- @param level number --- @return _99.Logger function Logger:set_level(level) - self.level = level - return self + self.level = level + return self end --- @return _99.Logger function Logger:on_error_print_message() - self.print_on_error = true - return self + self.print_on_error = true + return self end --- @param opts _99.Logger.Options? function Logger:configure(opts) - if not opts then - return - end + if not opts then + return + end + + if opts.level then + self:set_level(opts.level) + end - if opts.level then - self:set_level(opts.level) - end + if opts.type == "print" then + self:print_sink() + elseif opts.type == "file" then + assert( + opts.path, + "if you choose file for logger, you must have a path specified" + ) + self:file_sink(opts.path) + else + if opts.path then + vim.notify( + "config error: opts.path set but no opts.type, defaulting to void logger", + vim.log.levels.ERROR + ) + end - if opts.type == "print" then - self:print_sink() - elseif opts.type == "file" then - assert( - opts.path, - "if you choose file for logger, you must have a path specified" - ) - self:file_sink(opts.path) - else - self:void_sink() - end + self:void_sink() + end - if opts.print_on_error then - self:on_error_print_message() - end + if opts.print_on_error then + self:on_error_print_message() + end - max_requests_in_logger_cache = opts.max_requests_cached or MAX_REQUEST_DEFAULT + max_requests_in_logger_cache = opts.max_requests_cached + or MAX_REQUEST_DEFAULT end --- @param line string function Logger:_cache_log(line) - local id = self.extra_params.id - if not id then - return - end + local id = self.extra_params.id + if not id then + return + end - local cache = logger_cache[id] - local new_cache = false - if not cache then - cache = { - last_access = time.now(), - logs = {}, - } - logger_cache[id] = cache - table.insert(logger_list, id) - new_cache = true - end - cache.last_access = time.now() - table.insert(cache.logs, line) - table.sort(logger_list, function(a, b) - assert( - logger_cache[a] and logger_cache[b], - "logger list is out of sync with logger cache: " - .. tostring(a) - .. " and " - .. tostring(b) - ) - local a_time = logger_cache[a].last_access - local b_time = logger_cache[b].last_access - return a_time > b_time - end) + local cache = logger_cache[id] + local new_cache = false + if not cache then + cache = { + last_access = time.now(), + logs = {}, + } + logger_cache[id] = cache + table.insert(logger_list, id) + new_cache = true + end + cache.last_access = time.now() + table.insert(cache.logs, line) + table.sort(logger_list, function(a, b) + assert( + logger_cache[a] and logger_cache[b], + "logger list is out of sync with logger cache: " + .. tostring(a) + .. " and " + .. tostring(b) + ) + local a_time = logger_cache[a].last_access + local b_time = logger_cache[b].last_access + return a_time > b_time + end) - if not new_cache then - return - end + if not new_cache then + return + end - Logger._trim_cache() + Logger._trim_cache() end --- This is a _TEST ONLY_ function. you should not call this function outside --- of unit tests function Logger.reset() - logger_cache = {} - max_requests_in_logger_cache = MAX_REQUEST_DEFAULT + logger_cache = {} + max_requests_in_logger_cache = MAX_REQUEST_DEFAULT end --- @return string[][] function Logger.logs() - local out = {} - for _, id in ipairs(logger_list) do - local request_logs = logger_cache[id] - table.insert(out, request_logs.logs) - end - return out + local out = {} + for _, id in ipairs(logger_list) do + local request_logs = logger_cache[id] + table.insert(out, request_logs.logs) + end + return out end --- @param xid number --- @return string[] | nil function Logger.logs_by_id(xid) - local logs = logger_cache[xid] - return logs and logs.logs + local logs = logger_cache[xid] + return logs and logs.logs end --- @param level number ---@param msg string ---@param ... any function Logger:_log(level, msg, ...) - if self.level > level then - return - end + if self.level > level then + return + end - local log_statement = { - level = levels.levelToString(level), - msg = msg, - } + local log_statement = { + level = levels.levelToString(level), + msg = msg, + } - put_args(log_statement, to_args(...)) - put_args(log_statement, self.extra_params) + put_args(log_statement, to_args(...)) + put_args(log_statement, self.extra_params) - assert(log_statement["id"], "every log must have an id associated with it") + assert(log_statement["id"], "every log must have an id associated with it") - local json_string = vim.json.encode(log_statement) - if self.print_on_error and level == levels.ERROR then - print(json_string) - end + local json_string = vim.json.encode(log_statement) + if self.print_on_error and level == levels.ERROR then + print(json_string) + end - self:_cache_log(json_string) - self.sink:write_line(json_string) + self:_cache_log(json_string) + self.sink:write_line(json_string) end --- @param msg string --- @param ... any function Logger:info(msg, ...) - self:_log(levels.INFO, msg, ...) + self:_log(levels.INFO, msg, ...) end --- @param msg string --- @param ... any function Logger:warn(msg, ...) - self:_log(levels.WARN, msg, ...) + self:_log(levels.WARN, msg, ...) end --- @param msg string --- @param ... any function Logger:debug(msg, ...) - self:_log(levels.DEBUG, msg, ...) + self:_log(levels.DEBUG, msg, ...) end --- @param msg string --- @param ... any function Logger:error(msg, ...) - self:_log(levels.ERROR, msg, ...) + self:_log(levels.ERROR, msg, ...) end --- @param msg string --- @param ... any function Logger:fatal(msg, ...) - self:_log(levels.FATAL, msg, ...) - assert(false, "fatal msg recieved: " .. msg, ...) + self:_log(levels.FATAL, msg, ...) + assert(false, "fatal msg recieved: " .. msg, ...) end --- @param test any ---@param msg string ---@param ... any function Logger:assert(test, msg, ...) - if not test then - self:fatal(msg, ...) - end + if not test then + self:fatal(msg, ...) + end end function Logger._trim_cache() - local count = 0 - local oldest = nil - local oldest_key = nil - for k, log in pairs(logger_cache) do - if oldest == nil or log.last_access < oldest.last_access then - oldest = log - oldest_key = k + local count = 0 + local oldest = nil + local oldest_key = nil + for k, log in pairs(logger_cache) do + if oldest == nil or log.last_access < oldest.last_access then + oldest = log + oldest_key = k + end + count = count + 1 end - count = count + 1 - end - if count > max_requests_in_logger_cache then - assert(oldest_key, "oldest key must exist") - logger_cache[oldest_key] = nil + if count > max_requests_in_logger_cache then + assert(oldest_key, "oldest key must exist") + logger_cache[oldest_key] = nil - for i, id in ipairs(logger_list) do - if id == oldest_key then - table.remove(logger_list, i) - break - end + for i, id in ipairs(logger_list) do + if id == oldest_key then + table.remove(logger_list, i) + break + end + end end - end end function Logger.set_max_cached_requests(count) - max_requests_in_logger_cache = count - Logger._trim_cache() + max_requests_in_logger_cache = count + Logger._trim_cache() end local module_logger = Logger:new(levels.DEBUG) diff --git a/lua/99/ops/clean-up.lua b/lua/99/ops/clean-up.lua index 9eea6da..ce7cc74 100644 --- a/lua/99/ops/clean-up.lua +++ b/lua/99/ops/clean-up.lua @@ -11,49 +11,49 @@ local M = {} --- @param obs_or_fn _99.Providers.PartialObserver | _99.Providers.on_complete --- @return _99.Providers.Observer M.make_observer = function(context, obs_or_fn) - --- @type _99.Providers.PartialObserver - local obs = type(obs_or_fn) == "table" and obs_or_fn - or { - on_complete = obs_or_fn, - } - return { - on_start = function() - if obs.on_start then - obs.on_start() - end - end, - on_complete = function(status, res) - pcall(obs.on_complete, status, res) - vim.schedule(function() - context:stop() - context._99:sync() - end) - end, - on_stderr = function(line) - if obs.on_stderr then - obs.on_stderr(line) - end - end, - on_stdout = function(line) - if obs.on_stdout then - obs.on_stdout(line) - end - end, - } --[[@as _99.Providers.Observer ]] + --- @type _99.Providers.PartialObserver + local obs = type(obs_or_fn) == "table" and obs_or_fn + or { + on_complete = obs_or_fn, + } + return { + on_start = function() + if obs.on_start then + obs.on_start() + end + end, + on_complete = function(status, res) + pcall(obs.on_complete, status, res) + vim.schedule(function() + context:stop() + context._99:sync() + end) + end, + on_stderr = function(line) + if obs.on_stderr then + obs.on_stderr(line) + end + end, + on_stdout = function(line) + if obs.on_stdout then + obs.on_stdout(line) + end + end, + } --[[@as _99.Providers.Observer ]] end ---@param clean_up_fn fun(): nil ---@return fun(): nil M.make_clean_up = function(clean_up_fn) - local called = false - local function clean_up() - if called then - return + local called = false + local function clean_up() + if called then + return + end + called = true + clean_up_fn() end - called = true - clean_up_fn() - end - return clean_up + return clean_up end return M diff --git a/lua/99/ops/init.lua b/lua/99/ops/init.lua index 5cb7a74..ef85bf6 100644 --- a/lua/99/ops/init.lua +++ b/lua/99/ops/init.lua @@ -3,7 +3,7 @@ --- includes search, visual, and others --- --- @docs included ---- @field additional_prompt? string +--- @field additional_prompt? string | boolean --- by providing `additional_prompt` you will not be required to provide a prompt. --- this allows you to define actions based on remaps --- @@ -32,8 +32,8 @@ --- @docs included return { - search = require("99.ops.search"), - tutorial = require("99.ops.tutorial"), - over_range = require("99.ops.over-range"), - vibe = require("99.ops.vibe"), + search = require("99.ops.search"), + tutorial = require("99.ops.tutorial"), + over_range = require("99.ops.over-range"), + vibe = require("99.ops.vibe"), } diff --git a/lua/99/ops/make-prompt.lua b/lua/99/ops/make-prompt.lua index 091eade..f60904b 100644 --- a/lua/99/ops/make-prompt.lua +++ b/lua/99/ops/make-prompt.lua @@ -6,27 +6,33 @@ local Agents = require("99.extensions.agents") --- @param opts _99.ops.Opts --- @return string, _99.Reference[] return function(context, prompt, opts) - local user_prompt = opts.additional_prompt - assert( - user_prompt and type(user_prompt) == "string" and #user_prompt > 0, - "you must add a prompt to you request" - ) + local user_prompt - local full_prompt = prompt - full_prompt = context._99.prompts.prompts.prompt(user_prompt, full_prompt) + if opts.additional_prompt then + if type(opts.additional_prompt) == "string" then + user_prompt = type(opts.additional_prompt) == "string" + and opts.additional_prompt + or nil + end + else + user_prompt = nil + end + + local full_prompt = prompt + full_prompt = context._99.prompts.prompts.prompt(user_prompt, full_prompt) - local refs = Completions.parse(user_prompt) - local additional_rules = opts.additional_rules - if additional_rules then - for _, r in ipairs(additional_rules) do - local content = Agents.get_rule_content(r) - if content then - table.insert(refs, { - content = content, - }) - end + local refs = Completions.parse(user_prompt) + local additional_rules = opts.additional_rules + if additional_rules then + for _, r in ipairs(additional_rules) do + local content = Agents.get_rule_content(r) + if content then + table.insert(refs, { + content = content, + }) + end + end end - end - return full_prompt, refs + return full_prompt, refs end diff --git a/lua/99/ops/over-range.lua b/lua/99/ops/over-range.lua index e8512b8..0562a7d 100644 --- a/lua/99/ops/over-range.lua +++ b/lua/99/ops/over-range.lua @@ -1,3 +1,6 @@ +-- TODO: YOU HAVE TO FIX THIS FILE BROOOOO.... +-- actually important file!!!! + local RequestStatus = require("99.ops.request_status") local Mark = require("99.ops.marks") local geo = require("99.geo") @@ -13,90 +16,94 @@ local Point = geo.Point --- @param context _99.Prompt --- @param opts? _99.ops.Opts local function over_range(context, opts) - opts = opts or {} - local logger = context.logger:set_area("visual") + opts = opts or {} + local logger = context.logger:set_area("visual") - local data = context:visual_data() - local range = data.range - local top_mark = Mark.mark_above_range(range) - local bottom_mark = Mark.mark_point(range.buffer, range.end_) - context.marks.top_mark = top_mark - context.marks.bottom_mark = bottom_mark + local data = context:visual_data() + local range = data.range + local top_mark = Mark.mark_above_range(range) + local bottom_mark = Mark.mark_point(range.buffer, range.end_) + context.marks.top_mark = top_mark + context.marks.bottom_mark = bottom_mark - logger:debug( - "visual request start", - "start", - Point.from_mark(top_mark), - "end", - Point.from_mark(bottom_mark) - ) + logger:debug( + "visual request start", + "start", + Point.from_mark(top_mark), + "end", + Point.from_mark(bottom_mark) + ) - local display_ai_status = context._99.ai_stdout_rows > 1 - local top_status = RequestStatus.new( - 250, - context._99.ai_stdout_rows or 1, - "Implementing", - top_mark - ) - local bottom_status = RequestStatus.new(250, 1, "Implementing", bottom_mark) - local clean_up = make_clean_up(function() - top_status:stop() - bottom_status:stop() - end) + local display_ai_status = context._99.ai_stdout_rows > 1 + local top_status = RequestStatus.new( + 250, + context._99.ai_stdout_rows or 1, + "Implementing", + top_mark + ) + local bottom_status = RequestStatus.new(250, 1, "Implementing", bottom_mark) + local clean_up = make_clean_up(function() + top_status:stop() + bottom_status:stop() + end) - local system_cmd = context._99.prompts.prompts.visual_selection(range) - local prompt, refs = make_prompt(context, system_cmd, opts) + local system_cmd = context._99.prompts.prompts.visual_selection(range) + local prompt, refs = make_prompt(context, system_cmd, opts) - context:add_prompt_content(prompt) - context:add_references(refs) - context:add_clean_up(clean_up) + context:add_prompt_content(prompt) + context:add_references(refs) + context:add_clean_up(clean_up) - top_status:start() - bottom_status:start() - context:start_request(make_observer(context, { - on_complete = function(status, response) - if status == "cancelled" then - logger:debug("request cancelled for visual selection, removing marks") - elseif status == "failed" then - logger:error( - "request failed for visual_selection", - "error response", - response or "no response provided" - ) - elseif status == "success" then - local valid = top_mark:is_valid() and bottom_mark:is_valid() - if not valid then - logger:fatal( - -- luacheck: ignore 631 - "the original visual_selection has been destroyed. You cannot delete the original visual selection during a request" - ) - return - end + top_status:start() + bottom_status:start() + context:start_request(make_observer(context, { + on_complete = function(status, response) + if status == "cancelled" then + logger:debug( + "request cancelled for visual selection, removing marks" + ) + elseif status == "failed" then + logger:error( + "request failed for visual_selection", + "error response", + response or "no response provided" + ) + elseif status == "success" then + local valid = top_mark:is_valid() and bottom_mark:is_valid() + if not valid then + logger:fatal( + -- luacheck: ignore 631 + "the original visual_selection has been destroyed. You cannot delete the original visual selection during a request" + ) + return + end - if vim.trim(response) == "" then - print("response was empty, visual replacement aborted") - logger:debug("response was empty, visual replacement aborted") - return - end + if vim.trim(response) == "" then + print("response was empty, visual replacement aborted") + logger:debug( + "response was empty, visual replacement aborted" + ) + return + end - local new_range = Range.from_marks(top_mark, bottom_mark) - local lines = vim.split(response, "\n") + local new_range = Range.from_marks(top_mark, bottom_mark) + local lines = vim.split(response, "\n") - --- HACK: i am adding a new line here because above range will add a mark to the line above. - --- that way this appears to be added to "the same line" as the visual selection was - --- originally take from - table.insert(lines, 1, "") + --- HACK: i am adding a new line here because above range will add a mark to the line above. + --- that way this appears to be added to "the same line" as the visual selection was + --- originally take from + table.insert(lines, 1, "") - new_range:replace_text(lines) - context._99:sync() - end - end, - on_stdout = function(line) - if display_ai_status then - top_status:push(line) - end - end, - })) + new_range:replace_text(lines) + context._99:sync() + end + end, + on_stdout = function(line) + if display_ai_status then + top_status:push(line) + end + end, + })) end return over_range diff --git a/lua/99/prompt-settings.lua b/lua/99/prompt-settings.lua index a688a0e..c221995 100644 --- a/lua/99/prompt-settings.lua +++ b/lua/99/prompt-settings.lua @@ -2,14 +2,14 @@ ---@param n number lines of context above and below the selection ---@return string local function get_surrounding_context(range, n) - local start_row, _ = range.start:to_vim() - local end_row, _ = range.end_:to_vim() - local line_count = vim.api.nvim_buf_line_count(range.buffer) - local from = math.max(start_row - n, 0) - local to = math.min(end_row + 1 + n, line_count) - local lines = vim.api.nvim_buf_get_lines(range.buffer, from, to, false) + local start_row, _ = range.start:to_vim() + local end_row, _ = range.end_:to_vim() + local line_count = vim.api.nvim_buf_line_count(range.buffer) + local from = math.max(start_row - n, 0) + local to = math.min(end_row + 1 + n, line_count) + local lines = vim.api.nvim_buf_get_lines(range.buffer, from, to, false) - return table.concat(lines, "\n") + return table.concat(lines, "\n") end --- @class _99.Prompts.SpecificOperations @@ -17,15 +17,15 @@ end --- @field semantic_search fun(): string --- @field vibe fun(): string --- @field tutorial fun(): string ---- @field prompt fun(prompt: string, action: string, name?: string): string +--- @field prompt fun(prompt?: string|boolean|nil, action: string, name?: string): string --- @field role fun(): string --- @field read_tmp fun(): string local prompts = { - role = function() - return [[ You are a software engineering assistant mean to create robust and conanical code ]] - end, - tutorial = function() - return [[ + role = function() + return [[ You are a strict code completion backend for Neovim. Your input will be a code snippet, function signature, or a comment requesting code. CRITICAL DIRECTIONS: 1. Output ONLY valid, executable programming code. 2. Do NOT wrap your response in code blocks. 3. Do NOT include any conversational filler, explanations, greetings, or sign-offs. 4. If you cannot fulfill the request, output nothing or a code comment explaining why. ]] + end, + tutorial = function() + return [[ You are given a prompt and context and you must craft a tutorial. If a set of context has links, read through them thoroughly and decide which ones to retrieve. Once you have fetched all the relavent content, review it thoroughly before @@ -34,9 +34,9 @@ crafting the tutorial <Rule>The response format must be valid Markdown</Rule> <Rule>The first line of the response must be the title of the tutorial</Rule> ]] - end, - semantic_search = function() - return [[ + end, + semantic_search = function() + return [[ <Output> /path/to/project/src/foo.js:24:8,3,Some notes here about some stuff, it can contain commas /path/to/project/src/foo.js:71:12,7,more notes, everything is great! @@ -73,9 +73,9 @@ baz.js at line 1, char 1 and the next 51 lines you are given a prompt and you must search through this project and return code that matches the description provided. </TaskDescription> ]] - end, - vibe = function() - return [[ + end, + vibe = function() + return [[ <Output> /path/to/project/src/foo.js:24:8,3,Some notes here about some stuff, it can contain commas /path/to/project/src/foo.js:71:12,7,more notes, everything is great! @@ -114,22 +114,15 @@ Never respond as output what you have done. Always use the temporary file as the place to describe your actions according to Output rules </TaskDescription> ]] - end, - output_file = function() - return [[ -NEVER alter any file other than TEMP_FILE. -never provide the requested changes as conversational output. Return only the code. -ONLY provide requested changes by writing the change to TEMP_FILE -]] - end, - --- @param prompt string - --- @param action string - --- @param name? string defaults to DIRECTIONS - --- @return string - prompt = function(prompt, action, name) - name = name or "Prompt" - return string.format( - [[ + end, + --- @param prompt string + --- @param action string + --- @param name? string defaults to DIRECTIONS + --- @return string + prompt = function(prompt, action, name) + name = name or "Prompt" + return string.format( + [[ <Context> %s </Context> @@ -137,77 +130,43 @@ ONLY provide requested changes by writing the change to TEMP_FILE %s </%s> ]], - action, - name, - prompt, - name - ) - end, - visual_selection = function(range) - return string.format( - [[ -You receive a selection in neovim that you need to replace with new code. -The selection's contents may contain notes, incorporate the notes every time if there are some. -consider the context of the selection and what you are suppose to be implementing -<SELECTION_LOCATION> -%s -</SELECTION_LOCATION> + action, + name, + prompt, + name + ) + end, + visual_selection = function(range) + return string.format( + [[ <SELECTION_CONTENT> %s </SELECTION_CONTENT> -<SURROUNDING_CONTEXT> -%s -</SURROUNDING_CONTEXT> ]], - range:to_string(), - range:to_text(), - get_surrounding_context(range, 100) - ) - end, - read_tmp = function() - return [[ -never attempt to read TEMP_FILE. -It is purely for output. -Previous contents, which may not exist, can be written over without worry -After writing TEMP_FILE once you should be done. Be done and end the session. -]] - end, + range:to_text() + ) + end, } --- @class _99.Prompts local prompt_settings = { - prompts = prompts, - - --- @param tmp_file string - --- @return string - tmp_file_location = function(tmp_file) - return string.format("<TEMP_FILE>%s</TEMP_FILE>", tmp_file) - end, - - --- @return string - only_tmp_file_change = function() - return string.format( - "<MustObey>\n%s\n%s\n</MustObey>", - prompts.output_file(), - prompts.read_tmp() - ) - end, + prompts = prompts, - ---@param full_path string - ---@param range _99.Range - ---@return string - get_file_location = function(full_path, range) - return string.format( - "<Location><File>%s</File><Function>%s</Function></Location>", - full_path, - range:to_string() - ) - end, + ---@param full_path string + ---@param range _99.Range + ---@return string + get_file_location = function(full_path, range) + return string.format( + "<Location><File>%s</File><Function>%s</Function></Location>", + full_path, + range:to_string() + ) + end, - --- @param range _99.Range - get_range_text = function(range) - return string.format("<FunctionText>%s</FunctionText>", range:to_text()) - end, + --- @param range _99.Range + get_range_text = function(range) + return string.format("<FunctionText>%s</FunctionText>", range:to_text()) + end, } return prompt_settings diff --git a/lua/99/prompt.lua b/lua/99/prompt.lua index ca24ba7..6623987 100644 --- a/lua/99/prompt.lua +++ b/lua/99/prompt.lua @@ -9,15 +9,15 @@ local Time = require("99.time") --- you can only set those marks after the visual selection is removed local function set_selection_marks() - vim.api.nvim_feedkeys( - vim.api.nvim_replace_termcodes("<Esc>", true, false, true), - "x", - false - ) + vim.api.nvim_feedkeys( + vim.api.nvim_replace_termcodes("<Esc>", true, false, true), + "x", + false + ) end local filetype_map = { - typescriptreact = "typescript", + typescriptreact = "typescript", } -- luacheck: ignore @@ -31,6 +31,7 @@ local filetype_map = { --- @class _99.Prompt.Serialized --- @field data _99.Prompt.Data --- @field user_prompt string + --- @class _99.Prompt.Data.Search --- @field type "search" --- @field qfix_items _99.Search.Result[] @@ -57,6 +58,8 @@ local filetype_map = { --- @class _99.Prompt --- @field md_file_names string[] --- @field model string +--- @field endpoint string +--- @field req_response string --- @field user_prompt string --- @field operation _99.Prompt.Operation --- @field state _99.Prompt.State @@ -84,439 +87,438 @@ Prompt.__context_by_id = {} --- @param context _99.Prompt --- @param _99 _99.State local function set_defaults(context, _99) - local xid = get_id() - local full_path = vim.api.nvim_buf_get_name(0) + local xid = get_id() + local full_path = vim.api.nvim_buf_get_name(0) - context.state = "ready" - context._99 = _99 - context.user_prompt = "" - context.clean_ups = {} - context.md_file_names = copy(_99.md_files) - context.model = _99.model - context.agent_context = {} - context.tmp_file = random_file(_99:tmp_dir()) - context.logger = Logger:set_id(xid) - context.xid = xid - context.full_path = full_path - context.marks = {} - context.started_at = Time.now() + context.state = "ready" + context._99 = _99 + context.user_prompt = "" + context.clean_ups = {} + context.md_file_names = copy(_99.md_files) + context.model = _99.model + context.endpoint = _99.endpoint + context.agent_context = {} + context.tmp_file = random_file(_99:tmp_dir()) + context.logger = Logger:set_id(xid) + context.xid = xid + context.full_path = full_path + context.marks = {} + context.started_at = Time.now() end --- TODO: Work item for "TODO implementation" function Prompt.todo(_99) - _ = _99 - assert(false, "not implemented") + _ = _99 + assert(false, "not implemented") end --- @param _99 _99.State --- @param data _99.Prompt.Serialized --- @return _99.Prompt function Prompt.deserialize(_99, data) - local prompt = setmetatable({ - _99 = _99, - data = data.data, - operation = data.data.type, - user_prompt = data.user_prompt, - started_at = Time.now(), + local prompt = setmetatable({ + _99 = _99, + data = data.data, + operation = data.data.type, + user_prompt = data.user_prompt, + started_at = Time.now(), - --- we should only sync successful requests - state = "success", + --- we should only sync successful requests + state = "success", - xid = get_id(), - }, Prompt) - assert(prompt:valid(), "prompt is not valid from data") - return prompt + xid = get_id(), + }, Prompt) + assert(prompt:valid(), "prompt is not valid from data") + return prompt end --- @return _99.Prompt.Serialized function Prompt:serialize() - assert(self.state == "success", "you can only serialize successful prompts") - return { - data = self.data, - user_prompt = self.user_prompt, - } + assert(self.state == "success", "you can only serialize successful prompts") + return { + data = self.data, + user_prompt = self.user_prompt, + } end --- @param _99 _99.State --- @return _99.Prompt function Prompt.vibe(_99) - _99:refresh_rules() + _99:refresh_rules() - --- @type _99.Prompt - local context = setmetatable({}, Prompt) - set_defaults(context, _99) - context.operation = "vibe" - context.data = { - type = "vibe", - response = "", - qfix_items = {}, - } - context.logger:debug("99 Request", "method", "vibe") + --- @type _99.Prompt + local context = setmetatable({}, Prompt) + set_defaults(context, _99) + context.operation = "vibe" + context.data = { + type = "vibe", + response = "", + qfix_items = {}, + } + context.logger:debug("99 Request", "method", "vibe") - return context + return context end --- @param _99 _99.State --- @return _99.Prompt function Prompt.visual(_99) - _99:refresh_rules() + _99:refresh_rules() - set_selection_marks() - local range = Range.from_visual_selection() + set_selection_marks() + local range = Range.from_visual_selection() - local file_type = vim.bo[0].ft - local buffer = vim.api.nvim_get_current_buf() - file_type = filetype_map[file_type] or file_type + local file_type = vim.bo[0].ft + local buffer = vim.api.nvim_get_current_buf() + file_type = filetype_map[file_type] or file_type - local mds = {} - for _, md in ipairs(_99.md_files) do - table.insert(mds, md) - end + local mds = {} + for _, md in ipairs(_99.md_files) do + table.insert(mds, md) + end - --- @type _99.Prompt - local context = setmetatable({}, Prompt) - set_defaults(context, _99) - context.operation = "visual" - context.data = { - type = "visual", - buffer = buffer, - file_type = file_type, - range = range, - } - context.logger:debug("99 Request", "method", "visual") + --- @type _99.Prompt + local context = setmetatable({}, Prompt) + set_defaults(context, _99) + context.operation = "visual" + context.data = { + type = "visual", + buffer = buffer, + file_type = file_type, + range = range, + } + context.logger:debug("99 Request", "method", "visual") - return context + return context end --- @return string function Prompt:summary() - local prompt_str = utils.split_with_count(self.user_prompt, 8) - return string.format("%s: %s", self.operation, table.concat(prompt_str, " ")) + local prompt_str = utils.split_with_count(self.user_prompt, 8) + return string.format( + "%s: %s", + self.operation, + table.concat(prompt_str, " ") + ) end --- @param _99 _99.State --- @return _99.Prompt function Prompt.tutorial(_99) - _99:refresh_rules() + _99:refresh_rules() - --- @type _99.Prompt - local context = setmetatable({}, Prompt) - set_defaults(context, _99) - context.operation = "tutorial" - context.data = { - type = "tutorial", - xid = context.xid, -- TODO: i want to get rid of this when i implement rehydration of the data. - buffer = 0, - window = 0, - tutorial = {}, - } - context.logger:debug("99 Request", "method", "tutorial") + --- @type _99.Prompt + local context = setmetatable({}, Prompt) + set_defaults(context, _99) + context.operation = "tutorial" + context.data = { + type = "tutorial", + xid = context.xid, -- TODO: i want to get rid of this when i implement rehydration of the data. + buffer = 0, + window = 0, + tutorial = {}, + } + context.logger:debug("99 Request", "method", "tutorial") - return context + return context end --- @param _99 _99.State --- @return _99.Prompt function Prompt.search(_99) - _99:refresh_rules() + _99:refresh_rules() - --- @type _99.Prompt - local context = setmetatable({}, Prompt) - set_defaults(context, _99) - context.operation = "search" - context.data = { - type = "search", - qfix_items = {}, - response = "", - } - context.logger:debug("99 Request", "method", "search") + --- @type _99.Prompt + local context = setmetatable({}, Prompt) + set_defaults(context, _99) + context.operation = "search" + context.data = { + type = "search", + qfix_items = {}, + response = "", + } + context.logger:debug("99 Request", "method", "search") - return context + return context end --- @param obs _99.Providers.Observer | nil function Prompt:_observer(obs) - return { - on_start = function() - self.state = "requesting" - self._99.tracking:track(self) + return { + on_start = function() + self.state = "requesting" + self._99.tracking:track(self) - if obs then - obs.on_start() - end - end, - on_complete = function(status, res) - self.state = status - if obs then - obs.on_complete(status, res) - end - end, - on_stderr = function(line) - if obs then - obs.on_stderr(line) - end - end, - on_stdout = function(line) - if obs then - obs.on_stdout(line) - end - end, - } + if obs then + obs.on_start() + end + end, + on_complete = function(status, res) + self.state = status + if obs then + obs.on_complete(status, res) + end + end, + on_stderr = function(line) + if obs then + obs.on_stderr(line) + end + end, + on_stdout = function(line) + if obs then + obs.on_stdout(line) + end + end, + } end local allowed_context_types = { - "visual", - "search", - "tutorial", - "vibe", + "visual", + "search", + "tutorial", + "vibe", } --- @return boolean function Prompt:valid() - local t = self.data.type - for _, allowed in ipairs(allowed_context_types) do - if t == allowed then - return true + local t = self.data.type + for _, allowed in ipairs(allowed_context_types) do + if t == allowed then + return true + end end - end - return false + return false end --- @param observer _99.Providers.Observer? function Prompt:start_request(observer) - local l = self.logger - l:assert( - self.state == "ready", - 'state is not "ready" when attempting to start a request' - ) + local l = self.logger + l:assert( + self.state == "ready", + 'state is not "ready" when attempting to start a request' + ) - local ok = self:finalize() - l:assert(ok, "context failed to finalize") + local ok = self:finalize() + l:assert(ok, "context failed to finalize") - --- TODO: create a prompt context class that can actually organize. - --- do not do this during the request context refactoring, but next - local prompt = table.concat(self.agent_context, "\n") - local obs = self:_observer(observer) - local provider = self._99.provider_override or BaseProvider.OpenCodeProvider + --- TODO: create a prompt context class that can actually organize. + --- do not do this during the request context refactoring, but next + local prompt = table.concat(self.agent_context, "\n") + local obs = self:_observer(observer) + local provider = self._99.provider_override + or BaseProvider.BareMetalProvider - self:save_prompt(prompt) - l:debug("start", "prompt", prompt) + self:save_prompt(prompt) + l:debug("start", "prompt", prompt) - provider:make_request(prompt, self, obs) + provider:make_request(prompt, self, obs) end function Prompt:is_cancelled() - return self.state == "cancelled" + return self.state == "cancelled" end function Prompt:is_completed() - return self.state == "success" or self.state == "failed" + return self.state == "success" or self.state == "failed" end ---@diagnostic disable-next-line: undefined-doc-name --- @param proc vim.SystemObj? function Prompt:_set_process(proc) - self._proc = proc + self._proc = proc end function Prompt:cancel() - if self:is_cancelled() or self:is_completed() then - return - end + if self:is_cancelled() or self:is_completed() then + return + end - self.state = "cancelled" - local proc = self._proc - ---@diagnostic disable-next-line: undefined-field - if proc and proc.pid then - self._proc = nil - pcall(function() - local sigterm = (vim.uv and vim.uv.constants and vim.uv.constants.SIGTERM) - or 15 - ---@diagnostic disable-next-line: undefined-field - proc:kill(sigterm) - end) - end + self.state = "cancelled" + local proc = self._proc + ---@diagnostic disable-next-line: undefined-field + if proc and proc.pid then + self._proc = nil + pcall(function() + local sigterm = ( + vim.uv + and vim.uv.constants + and vim.uv.constants.SIGTERM + ) or 15 + ---@diagnostic disable-next-line: undefined-field + proc:kill(sigterm) + end) + end end --- @return _99.Prompt.Data.Visual function Prompt:visual_data() - assert( - self.data.type == "visual", - "you cannot get visual data if its not type visual" - ) - return self.data --[[@as _99.Prompt.Data.Visual]] + assert( + self.data.type == "visual", + "you cannot get visual data if its not type visual" + ) + return self.data --[[@as _99.Prompt.Data.Visual]] end --- @return _99.Prompt.Data.Tutorial function Prompt:tutorial_data() - assert( - self.data.type == "tutorial", - "you cannot get tutorial data if its not type tutorial" - ) - return self.data --[[@as _99.Prompt.Data.Tutorial]] + assert( + self.data.type == "tutorial", + "you cannot get tutorial data if its not type tutorial" + ) + return self.data --[[@as _99.Prompt.Data.Tutorial]] end --- @return _99.Prompt.Data.Search function Prompt:search_data() - assert( - self.data.type == "search", - "you cannot get search data if its not type search" - ) - return self.data --[[@as _99.Prompt.Data.Search]] + assert( + self.data.type == "search", + "you cannot get search data if its not type search" + ) + return self.data --[[@as _99.Prompt.Data.Search]] end --- @return _99.Search.Result[] function Prompt:qfix_data() - assert( - self.data.type == "search" or self.data.type == "vibe", - "data type is not search or vibe: " .. self.data.type - ) - return self.data.qfix_items + assert( + self.data.type == "search" or self.data.type == "vibe", + "data type is not search or vibe: " .. self.data.type + ) + return self.data.qfix_items end function Prompt:stop() - self:cancel() - self:clear_marks() + self:cancel() + self:clear_marks() - for _, cb in ipairs(self.clean_ups) do - cb() - end + for _, cb in ipairs(self.clean_ups) do + cb() + end end --- @param clean_up fun(): nil function Prompt:add_clean_up(clean_up) - table.insert(self.clean_ups, clean_up) + table.insert(self.clean_ups, clean_up) end --- @param md_file_name string --- @return self function Prompt:add_md_file_name(md_file_name) - table.insert(self.md_file_names, md_file_name) - return self + table.insert(self.md_file_names, md_file_name) + return self end --- @param content string --- @return self function Prompt:add_prompt_content(content) - table.insert(self.agent_context, content) - return self + table.insert(self.agent_context, content) + return self end --- @param refs _99.Reference[] function Prompt:add_references(refs) - for _, ref in ipairs(refs) do - self.logger:debug("adding reference to context") - table.insert(self.agent_context, ref.content) - end + for _, ref in ipairs(refs) do + self.logger:debug("adding reference to context") + table.insert(self.agent_context, ref.content) + end end function Prompt:_read_md_files() - local cwd = vim.uv.cwd() - local dir = vim.fn.fnamemodify(self.full_path, ":h") + local cwd = vim.uv.cwd() + local dir = vim.fn.fnamemodify(self.full_path, ":h") - while dir:find(cwd, 1, true) == 1 do - for _, md_file_name in ipairs(self.md_file_names) do - local md_path = dir .. "/" .. md_file_name - local file = io.open(md_path, "r") - if file then - local content = file:read("*a") - file:close() - self.logger:info( - "Context#adding md file to the context", - "md_path", - md_path - ) - table.insert(self.agent_context, content) - end - end + while dir:find(cwd, 1, true) == 1 do + for _, md_file_name in ipairs(self.md_file_names) do + local md_path = dir .. "/" .. md_file_name + local file = io.open(md_path, "r") + if file then + local content = file:read("*a") + file:close() + self.logger:info( + "Context#adding md file to the context", + "md_path", + md_path + ) + table.insert(self.agent_context, content) + end + end - if dir == cwd then - break - end + if dir == cwd then + break + end - dir = vim.fn.fnamemodify(dir, ":h") - end + dir = vim.fn.fnamemodify(dir, ":h") + end end --- @return string[] function Prompt:content() - return self.agent_context + return self.agent_context end --- @return boolean function Prompt:_ready_request_files() - local response_file = self.tmp_file - local prompt_file = self.tmp_file .. "-prompt" + local response_file = self.tmp_file + local prompt_file = self.tmp_file .. "-prompt" - local dir = vim.fs.dirname(prompt_file) + local dir = vim.fs.dirname(prompt_file) - if dir and not vim.uv.fs_stat(dir) then - vim.fn.mkdir(dir, "p") - end + if dir and not vim.uv.fs_stat(dir) then + vim.fn.mkdir(dir, "p") + end - local files = { prompt_file, response_file } - for _, f in ipairs(files) do - local file = io.open(f, "w") - if file then - file:write("") - file:close() - else - self.logger:error("unable to create prompt file") - return false + local files = { prompt_file, response_file } + for _, f in ipairs(files) do + local file = io.open(f, "w") + if file then + file:write("") + file:close() + else + self.logger:error("unable to create prompt file") + return false + end end - end - return true + return true end --- @param prompt string function Prompt:save_prompt(prompt) - local prompt_file = self.tmp_file .. "-prompt" - local file = io.open(prompt_file, "w") - if file then - file:write(prompt) - file:close() - self.logger:debug("saved prompt to file", "path", prompt_file) - else - self.logger:error("failed to save prompt", "path", prompt_file) - end + local prompt_file = self.tmp_file .. "-prompt" + local file = io.open(prompt_file, "w") + if file then + file:write(prompt) + file:close() + self.logger:debug("saved prompt to file", "path", prompt_file) + else + self.logger:error("failed to save prompt", "path", prompt_file) + end end --- @return boolean, self function Prompt:finalize() - if self:_ready_request_files() == false then - return false, self - end - self:_read_md_files() - - local ok, visual_data = pcall(self.visual_data, self) - if ok then - local f_loc = - self._99.prompts.get_file_location(self.full_path, visual_data.range) - table.insert(self.agent_context, f_loc) - table.insert( - self.agent_context, - self._99.prompts.get_range_text(visual_data.range) - ) - end - table.insert( - self.agent_context, - self._99.prompts.tmp_file_location(self.tmp_file) - ) + if self:_ready_request_files() == false then + return false, self + end + self:_read_md_files() - if - self.operation == "visual" - or self.operation == "tutorial" - or self.operation == "search" - then - table.insert(self.agent_context, self._99.prompts.only_tmp_file_change()) - end + local ok, visual_data = pcall(self.visual_data, self) + if ok then + local f_loc = self._99.prompts.get_file_location( + self.full_path, + visual_data.range + ) + table.insert(self.agent_context, f_loc) + table.insert( + self.agent_context, + self._99.prompts.get_range_text(visual_data.range) + ) + end - return true, self + return true, self end function Prompt:clear_marks() - for _, mark in pairs(self.marks) do - mark:delete() - end + for _, mark in pairs(self.marks) do + mark:delete() + end end return Prompt diff --git a/lua/99/providers.lua b/lua/99/providers.lua index 7df0174..8875ca8 100644 --- a/lua/99/providers.lua +++ b/lua/99/providers.lua @@ -7,14 +7,107 @@ --- @param fn fun(...: any): nil --- @return fun(...: any): nil local function once(fn) - local called = false - return function(...) - if called then - return + local called = false + return function(...) + if called then + return + end + called = true + fn(...) end - called = true - fn(...) - end +end + +--- @class _A4.OpenAIResponse +--- @field completion _A4.OpenAIResponse.Completion +--- @field metadata _A4.OpenAIResponse.MetaData +--- @field id _A4.OpenAIResponse.Id +--- @field timings _A4.OpenAIResponse.Timings + +--- @class _A4.OpenAIResponse.Completion +--- @field finishReason string +--- @field index integer +--- @field message _A4.OpenAIResponse.Completion.Message + +--- @class _A4.OpenAIResponse.Completion.Message +--- @field role string +--- @field content string + +--- @class _A4.OpenAIResponse.MetaData +--- @field created integer +--- @field model string +--- @field systemFingerPrint string +--- @field object string +--- @field usage _A4.OpenAIResponse.MetaData.Usage + +--- @class _A4.OpenAIResponse.MetaData.Usage +--- @field completionTks integer +--- @field promptTks integer +--- @field totalTks integer +--- @field promptTksDetails _A4.OpenAIResponse.MetaData.Usage.PromptTksDetails + +--- @class _A4.OpenAIResponse.MetaData.Usage.PromptTksDetails +--- @field cachedTks integer + +--- @class _A4.OpenAIResponse.Id +--- @field id string + +--- @class _A4.OpenAIResponse.Timings +--- @field cacheN integer +--- @field promptN integer +--- @field promptMs number +--- @field promptPerTkMs number +--- @field promptPerSec number +--- @field predictedN integer +--- @field predictedMs number +--- @field predictedPerTkMs number +--- @field predictedPerSec number + +--- @param raw table +--- @return _A4.OpenAIResponse +local function parse_openai_response(raw) + local choice = raw.choices[1] + + --- @type _A4.OpenAIResponse + local response = { + completion = { + finishReason = choice.finish_reason, + index = choice.index, + message = { + role = choice.message.role, + content = choice.message.content, + }, + }, + metadata = { + created = raw.created, + model = raw.model, + systemFingerPrint = raw.system_fingerprint, + object = raw.object, + usage = { + completionTks = raw.usage.completion_tokens, + promptTks = raw.usage.prompt_tokens, + totalTks = raw.usage.total_tokens, + promptTksDetails = { + cachedTks = raw.usage.prompt_tokens_details.cached_tokens, + }, + }, + }, + id = { + id = raw.id, + }, + timings = { + cacheN = raw.timings.cache_n, + promptN = raw.timings.prompt_n, + promptMs = raw.timings.prompt_ms, + promptPerTkMs = raw.timings.prompt_per_token_ms, + promptPerSec = raw.timings.prompt_per_second, + predictedN = raw.timings.predicted_n, + predictedMs = raw.timings.predicted_ms, + predictedPerTkMs = raw.timings.predicted_per_token_ms, + predictedPerSec = raw.timings.predicted_per_second, + }, + } + + return response end --- @class _99.Providers.BaseProvider @@ -25,121 +118,96 @@ local BaseProvider = {} --- @param callback fun(models: string[]|nil, err: string|nil): nil function BaseProvider.fetch_models(callback) - callback(nil, "This provider does not support listing models") + callback(nil, "This provider does not support listing models") end +--- @param data string +--- @return string +function BaseProvider:_clean_response(data) + local _data, _ = data:gsub("```[a-z]*\n", ""):gsub(" ```", "") + return _data +end + +-- TODO: Rewrite this... --- @param context _99.Prompt function BaseProvider:_retrieve_response(context) - local logger = context.logger:set_area(self:_get_provider_name()) - local tmp = context.tmp_file - local success, result = pcall(function() - return vim.fn.readfile(tmp) - end) + local logger = context.logger:set_area(self:_get_provider_name()) - if not success then - logger:error( - "retrieve_results: failed to read file", - "tmp_name", - tmp, - "error", - result - ) - return false, "" - end + if self._get_provider_name ~= "BareMetalProvider" then + local tmp = context.tmp_file + local success, result = pcall(function() + return vim.fn.readfile(tmp) + end) - local str = table.concat(result, "\n") - logger:debug("retrieve_results", "results", str) + if not success then + logger:error( + "retrieve_results: failed to read file", + "tmp_name", + tmp, + "error", + result + ) + return false, "" + end + + local str = table.concat(result, "\n") + logger:debug("retrieve_results", "results", str) - return true, str + return true, str + else + assert( + context.req_response, + "_retrieve_response: BareMetalProvider has no response" + ) + logger:debug("retrieve_results", "results", context.req_response) + return true, context.req_response + end end +-- TODO: Remember that we are ditching the tmp_file; likely 90% +-- You can just grab the json data and dump it in logs honestly +-- files only make sense for agentic flows --- @param query string --- @param context _99.Prompt --- @param observer _99.Providers.Observer function BaseProvider:make_request(query, context, observer) - observer.on_start() + observer.on_start() - local logger = context.logger:set_area(self:_get_provider_name()) - logger:debug("make_request", "tmp_file", context.tmp_file) + local logger = context.logger:set_area(self:_get_provider_name()) + logger:debug("make_request", "reason", context.operation) + logger:debug("make_request", "endpoint", context.endpoint) - local once_complete = once( - --- @param status "success" | "failed" | "cancelled" - ---@param text string - function(status, text) - observer.on_complete(status, text) - end - ) + local once_complete = once( + --- @param status "success" | "failed" | "cancelled" + --- @param resp string + function(status, resp) + observer.on_complete(status, resp) + end + ) - local command = self:_build_command(query, context) - local extra_args = context._99 and context._99.provider_extra_args or {} - if #extra_args > 0 then - vim.list_extend(command, extra_args) - end - logger:debug("make_request", "command", command) + local command = self:_build_command(query, context) + logger:debug("make_request", "command", command) - local proc = vim.system( - command, - { - text = true, - stdout = vim.schedule_wrap(function(err, data) - logger:debug("stdout", "data", data) - if context:is_cancelled() then - once_complete("cancelled", "") - return - end - if err and err ~= "" then - logger:debug("stdout#error", "err", err) - end - if not err and data then - observer.on_stdout(data) - end - end), - stderr = vim.schedule_wrap(function(err, data) - logger:debug("stderr", "data", data) - if context:is_cancelled() then - once_complete("cancelled", "") - return - end - if err and err ~= "" then - logger:debug("stderr#error", "err", err) - end - if not err then - observer.on_stderr(data) - end - end), - }, - vim.schedule_wrap(function(obj) - if context:is_cancelled() then - once_complete("cancelled", "") - logger:debug("on_complete: request has been cancelled") - return - end - if obj.code ~= 0 then - local str = - string.format("process exit code: %d\n%s", obj.code, vim.inspect(obj)) - once_complete("failed", str) - logger:fatal( - self:_get_provider_name() .. " make_query failed", - "obj from results", - obj - ) - else - vim.schedule(function() - local ok, res = self:_retrieve_response(context) - if ok then - once_complete("success", res) - else - once_complete( - "failed", - "unable to retrieve response from temp file" - ) - end + local proc = vim.system( + command, + { text = true }, + vim.schedule_wrap(function(obj) + if obj.code ~= 0 then + once_complete("failed", obj.stderr) + return + end + + local data = parse_openai_response(vim.json.decode(obj.stdout)) + if self._get_provider_name == "BareMetalProvider" then + data.completion.message.content = + self:_clean_response(data.completion.message.content) + context.req_response = data.completion.message.content + end + once_complete("success", data.completion.message.content) end) - end - end) - ) + ) - context:_set_process(proc) + context:_set_process(proc) end --- @class OpenCodeProvider : _99.Providers.BaseProvider @@ -149,38 +217,38 @@ local OpenCodeProvider = setmetatable({}, { __index = BaseProvider }) --- @param context _99.Prompt --- @return string[] function OpenCodeProvider._build_command(_, query, context) - return { - "opencode", - "run", - "--agent", - "build", - "-m", - context.model, - query, - } + return { + "opencode", + "run", + "--agent", + "build", + "-m", + context.model, + query, + } end --- @return string function OpenCodeProvider._get_provider_name() - return "OpenCodeProvider" + return "OpenCodeProvider" end --- @return string function OpenCodeProvider._get_default_model() - return "opencode/claude-sonnet-4-5" + return "opencode/claude-sonnet-4-5" end function OpenCodeProvider.fetch_models(callback) - vim.system({ "opencode", "models" }, { text = true }, function(obj) - vim.schedule(function() - if obj.code ~= 0 then - callback(nil, "Failed to fetch models from opencode") - return - end - local models = vim.split(obj.stdout, "\n", { trimempty = true }) - callback(models, nil) + vim.system({ "opencode", "models" }, { text = true }, function(obj) + vim.schedule(function() + if obj.code ~= 0 then + callback(nil, "Failed to fetch models from opencode") + return + end + local models = vim.split(obj.stdout, "\n", { trimempty = true }) + callback(models, nil) + end) end) - end) end --- @class ClaudeCodeProvider : _99.Providers.BaseProvider @@ -190,24 +258,24 @@ local ClaudeCodeProvider = setmetatable({}, { __index = BaseProvider }) --- @param context _99.Prompt --- @return string[] function ClaudeCodeProvider._build_command(_, query, context) - return { - "claude", - "--dangerously-skip-permissions", - "--model", - context.model, - "--print", - query, - } + return { + "claude", + "--dangerously-skip-permissions", + "--model", + context.model, + "--print", + query, + } end --- @return string function ClaudeCodeProvider._get_provider_name() - return "ClaudeCodeProvider" + return "ClaudeCodeProvider" end --- @return string function ClaudeCodeProvider._get_default_model() - return "claude-sonnet-4-5" + return "claude-sonnet-4-5" end -- TODO: the claude CLI has no way to list available models. @@ -216,16 +284,16 @@ end -- Until Anthropic adds a CLI command for this, we have to hardcode the list here. -- See https://github.com/anthropics/claude-code/issues/12612 function ClaudeCodeProvider.fetch_models(callback) - callback({ - "claude-opus-4-6", - "claude-sonnet-4-5", - "claude-haiku-4-5", - "claude-opus-4-5", - "claude-opus-4-1", - "claude-sonnet-4-0", - "claude-opus-4-0", - "claude-3-7-sonnet-latest", - }, nil) + callback({ + "claude-opus-4-6", + "claude-sonnet-4-5", + "claude-haiku-4-5", + "claude-opus-4-5", + "claude-opus-4-1", + "claude-sonnet-4-0", + "claude-opus-4-0", + "claude-3-7-sonnet-latest", + }, nil) end --- @class CursorAgentProvider : _99.Providers.BaseProvider @@ -235,38 +303,40 @@ local CursorAgentProvider = setmetatable({}, { __index = BaseProvider }) --- @param context _99.Prompt --- @return string[] function CursorAgentProvider._build_command(_, query, context) - return { "cursor-agent", "--model", context.model, "--print", query } + return { "cursor-agent", "--model", context.model, "--print", query } end --- @return string function CursorAgentProvider._get_provider_name() - return "CursorAgentProvider" + return "CursorAgentProvider" end --- @return string function CursorAgentProvider._get_default_model() - return "sonnet-4.5" + return "sonnet-4.5" end function CursorAgentProvider.fetch_models(callback) - vim.system({ "cursor-agent", "models" }, { text = true }, function(obj) - vim.schedule(function() - if obj.code ~= 0 then - callback(nil, "Failed to fetch models from cursor-agent") - return - end - local models = {} - for _, line in ipairs(vim.split(obj.stdout, "\n", { trimempty = true })) do - -- `cursor-agent models` outputs lines like "model-id - description", - -- so we grab everything before the first " - " separator - local id = line:match("^(%S+)%s+%-") - if id then - table.insert(models, id) - end - end - callback(models, nil) + vim.system({ "cursor-agent", "models" }, { text = true }, function(obj) + vim.schedule(function() + if obj.code ~= 0 then + callback(nil, "Failed to fetch models from cursor-agent") + return + end + local models = {} + for _, line in + ipairs(vim.split(obj.stdout, "\n", { trimempty = true })) + do + -- `cursor-agent models` outputs lines like "model-id - description", + -- so we grab everything before the first " - " separator + local id = line:match("^(%S+)%s+%-") + if id then + table.insert(models, id) + end + end + callback(models, nil) + end) end) - end) end --- @class KiroProvider : _99.Providers.BaseProvider @@ -276,25 +346,25 @@ local KiroProvider = setmetatable({}, { __index = BaseProvider }) --- @param context _99.Prompt --- @return string[] function KiroProvider._build_command(_, query, context) - return { - "kiro-cli", - "chat", - "--no-interactive", - "--model", - context.model, - "--trust-all-tools", - query, - } + return { + "kiro-cli", + "chat", + "--no-interactive", + "--model", + context.model, + "--trust-all-tools", + query, + } end --- @return string function KiroProvider._get_provider_name() - return "KiroProvider" + return "KiroProvider" end --- @return string function KiroProvider._get_default_model() - return "claude-sonnet-4.5" + return "claude-sonnet-4.5" end --- @class GeminiCLIProvider : _99.Providers.BaseProvider @@ -304,36 +374,74 @@ local GeminiCLIProvider = setmetatable({}, { __index = BaseProvider }) --- @param context _99.Prompt --- @return string[] function GeminiCLIProvider._build_command(_, query, context) - return { - "gemini", - "--approval-mode", - -- Allow writing to temp files by default. See: - -- https://geminicli.com/docs/core/policy-engine/#default-policies - "auto_edit", - "--model", - context.model, - "--prompt", - query, - } + return { + "gemini", + "--approval-mode", + -- Allow writing to temp files by default. See: + -- https://geminicli.com/docs/core/policy-engine/#default-policies + "auto_edit", + "--model", + context.model, + "--prompt", + query, + } end --- @return string function GeminiCLIProvider._get_provider_name() - return "GeminiCLIProvider" + return "GeminiCLIProvider" end --- @return string function GeminiCLIProvider._get_default_model() - -- Default to auto-routing between pro and flash. See: - -- https://geminicli.com/docs/cli/model/ - return "auto" + -- Default to auto-routing between pro and flash. See: + -- https://geminicli.com/docs/cli/model/ + return "auto" +end + +--- @class BareMetalProvider : _99.Providers.BaseProvider +--- @field _clean_response fun(self: _99.Providers.BaseProvider, data : string): string +local BareMetalProvider = setmetatable({}, { __index = BaseProvider }) + +--- @param query string +--- @param context _99.Prompt +--- @return string[] +function BareMetalProvider._build_command(_, query, context) + return { + "curl", + "-s", + context.endpoint, + "-H", + '"Content-Type: applications/json"', + "-d", + '"{"messages":[{"role":"system","content":"You are a strict code completion backend for Neovim. Your input will be a code snippet, function signature, or a comment requesting code. CRITICAL DIRECTIONS: 1. Output ONLY valid, executable programming code. 2. Do NOT wrap your response in markdown code blocks. 3. Do NOT include any conversational filler, explanations, greetings, or sign-offs. 4. If you cannot fulfill the request, output nothing or a code comment explaining why."},{"role":"user","content":"' + .. query + .. '"}],"temperature":0.0,"stream":false}"', + } +end + +--- @return string +function BareMetalProvider._get_provider_name() + return "BareMetalProvider" +end + +--- @return string +function BareMetalProvider._get_default_model() + return "Qwen2.5-Coder-3B-Instruct" +end + +function BareMetalProvider.fetch_models(callback) + callback({ + "Qwen2.5-Coder-3B-Instruct", + }, nil) end return { - BaseProvider = BaseProvider, - OpenCodeProvider = OpenCodeProvider, - ClaudeCodeProvider = ClaudeCodeProvider, - CursorAgentProvider = CursorAgentProvider, - KiroProvider = KiroProvider, - GeminiCLIProvider = GeminiCLIProvider, + BaseProvider = BaseProvider, + OpenCodeProvider = OpenCodeProvider, + ClaudeCodeProvider = ClaudeCodeProvider, + CursorAgentProvider = CursorAgentProvider, + KiroProvider = KiroProvider, + GeminiCLIProvider = GeminiCLIProvider, + BareMetalProvider = BareMetalProvider, } diff --git a/lua/99/state.lua b/lua/99/state.lua index b4ac692..3c2b64d 100644 --- a/lua/99/state.lua +++ b/lua/99/state.lua @@ -6,11 +6,12 @@ local Window = require("99.window") local _99_STATE_FILE = "state" local function default_completion() - return { source = nil, custom_rules = {} } + return { source = nil, custom_rules = {} } end --- @class _99.StateProps --- @field model string +--- @field endpoint string --- @field md_files string[] --- @field prompts _99.Prompts --- @field ai_stdout_rows number @@ -25,6 +26,7 @@ end --- @class _99.State --- @field completion _99.Completion --- @field model string +--- @field endpoint string? --- @field md_files string[] --- @field prompts _99.Prompts --- @field ai_stdout_rows number @@ -39,77 +41,81 @@ State.__index = State --- @return _99.StateProps local function create() - return { - model = "opencode/claude-sonnet-4-5", - md_files = {}, - ai_stdout_rows = 3, - display_errors = false, - provider_override = nil, - tmp_dir = nil, - } + return { + model = "qwen2.5-coder-3b-instruct-gguf", + endpoint = "", + md_files = {}, + ai_stdout_rows = 3, + display_errors = false, + provider_override = nil, + tmp_dir = nil, + } end --- @param oos _99.Options | _99.State local function get_tmp_dir(oos) - local tmp_dir = oos.tmp_dir and type(oos.tmp_dir) == "string" and oos.tmp_dir - or oos.__tmp_dir and oos.__tmp_dir - or "./tmp" - if tmp_dir then - tmp_dir = vim.fn.expand(tmp_dir) - end - return tmp_dir + local tmp_dir = oos.tmp_dir + and type(oos.tmp_dir) == "string" + and oos.tmp_dir + or oos.__tmp_dir and oos.__tmp_dir + or "./tmp" + if tmp_dir then + tmp_dir = vim.fn.expand(tmp_dir) + end + return tmp_dir end --- @param opts _99.Options --- @return _99.State.Tracking.Serialized | nil local function read_state_from_tmp(opts) - local state_file = utils.named_tmp_file(get_tmp_dir(opts), _99_STATE_FILE) - return utils.read_file_json_safe(state_file) --[[@as _99.State.Tracking.Serialized]] + local state_file = utils.named_tmp_file(get_tmp_dir(opts), _99_STATE_FILE) + return utils.read_file_json_safe(state_file) --[[@as _99.State.Tracking.Serialized]] end --- @param opts _99.Options --- @return _99.State function State.new(opts) - local props = create() - local _99_state = setmetatable(props, State) --[[@as _99.State]] + local props = create() + local _99_state = setmetatable(props, State) --[[@as _99.State]] - _99_state.provider_override = opts.provider - _99_state.provider_extra_args = opts.provider_extra_args or {} - _99_state.completion = opts.completion or default_completion() - _99_state.completion.custom_rules = _99_state.completion.custom_rules or {} - _99_state.completion.files = _99_state.completion.files or {} + _99_state.provider_override = opts.provider + _99_state.endpoint = opts.endpoint + _99_state.provider_extra_args = opts.provider_extra_args or {} + _99_state.completion = opts.completion or default_completion() + _99_state.completion.custom_rules = _99_state.completion.custom_rules or {} + _99_state.completion.files = _99_state.completion.files or {} - --- TODO: Prompt overrides would be a great thing, we just have to get there - --- for now, i am going to have this as just a hardcoded ... thing - _99_state.prompts = require("99.prompt-settings") + --- TODO: Prompt overrides would be a great thing, we just have to get there + --- for now, i am going to have this as just a hardcoded ... thing + _99_state.prompts = require("99.prompt-settings") - local previous = read_state_from_tmp(opts) - _99_state.tracking = Tracking.new(_99_state, previous) + local previous = read_state_from_tmp(opts) + _99_state.tracking = Tracking.new(_99_state, previous) - return _99_state + return _99_state end function State:sync() - local tracking = self.tracking:serialize() - local tmp = self:tmp_dir() - local file = utils.named_tmp_file(tmp, _99_STATE_FILE) - utils.write_file_json_safe(tracking, file) + local tracking = self.tracking:serialize() + local tmp = self:tmp_dir() + local file = utils.named_tmp_file(tmp, _99_STATE_FILE) + utils.write_file_json_safe(tracking, file) end --- @return string function State:tmp_dir() - return get_tmp_dir(self) + return get_tmp_dir(self) end --- @return boolean function State:active() - _ = self - if Window.has_active_window() then - return true - end + _ = self + if Window.has_active_window() then + return true + end - local qf = vim.fn.getqflist({ winid = 0 }) - return qf.winid ~= 0 + local qf = vim.fn.getqflist({ winid = 0 }) + return qf.winid ~= 0 end --- TODO: This is something to understand. I bet that this is going to need @@ -122,8 +128,8 @@ end --- 3. do the operation once at setup instead of every time. --- likely not needed to do this all the time. function State:refresh_rules() - self.rules = Agents.rules(self) - Extensions.refresh(self) + self.rules = Agents.rules(self) + Extensions.refresh(self) end return State |
