diff --git a/local/ai-commit/lua/ai-commit/config.lua b/local/ai-commit/lua/ai-commit/config.lua index fcb6a46..51b3b07 100644 --- a/local/ai-commit/lua/ai-commit/config.lua +++ b/local/ai-commit/lua/ai-commit/config.lua @@ -1,16 +1,13 @@ local M = {} -M.source_id = 'ai-commit-message' M.summary_source_id = 'ai-commit-summarize' M.message_source_id = 'ai-commit-message' M.values = { - provider = 'copilot', - model = nil, - model_name = nil, - openrouter = { - endpoint = 'https://openrouter.ai/api/v1', - reasoning = false, + context = { + opencode = true, + recent_commits = true, + staged_changes = true, }, max_tokens = 10000, spinner_interval = 80, @@ -20,11 +17,9 @@ M.values = { small_changed_lines = 100, medium_changed_lines = 500, }, - chat_timeout = 30000, model_highlight_group = 'Special', prompt_dump_path = vim.fn.stdpath 'log' .. '/ai-commit-last-prompt.md', opencode_context = { - enabled = true, db_path = vim.fn.expand '~/.local/share/opencode/opencode.db', recent_ms = 60 * 60 * 1000, assistant_messages = 4, diff --git a/local/ai-commit/lua/ai-commit/generator.lua b/local/ai-commit/lua/ai-commit/generator.lua index be0bc8d..33a5c45 100644 --- a/local/ai-commit/lua/ai-commit/generator.lua +++ b/local/ai-commit/lua/ai-commit/generator.lua @@ -102,9 +102,22 @@ function M.insert() local done = false local aborted = false local http_jobs = {} - local context_total = 5 + local context_opts = config.values.context or {} + local include_recent_commits = context_opts.recent_commits ~= false + local include_opencode = context_opts.opencode ~= false + local include_staged_changes = context_opts.staged_changes ~= false + local context_total = 2 + if include_recent_commits then + context_total = context_total + 1 + end + if include_opencode then + context_total = context_total + 1 + end + if include_staged_changes then + context_total = context_total + 1 + end local context_done = 0 - local pending = 5 + local pending = context_total local failed = false local last_status_line = nil local context = {} @@ -210,64 +223,70 @@ function M.insert() mark_done() end) - git.recent_commits(5, function(commits) - if done then - return - end - context.recent_commits = commits - logger.debug('Recent commit context ready (chars=' .. tostring(#commits) .. ')') - mark_done() - end) - - session_context.get_recent(function(session) - if done then - return - end - if not session then - logger.debug 'No assistant session context available' - mark_done() - return - end - logger.debug( - 'Assistant session context loaded (provider=' - .. tostring(session.label or session.provider) - .. ' transcript_chars=' - .. tostring(#(session.transcript or '')) - .. ')' - ) - providers.summarize_session(session, function(summary) + if include_recent_commits then + git.recent_commits(5, function(commits) if done then return end - context.session_summary = summary - logger.debug('Assistant session summary ready (chars=' .. tostring(summary and #summary or 0) .. ')') + context.recent_commits = commits + logger.debug('Recent commit context ready (chars=' .. tostring(#commits) .. ')') mark_done() - end, status, request_context) - end, status) + end) + end - git.staged_diff(function(diff, diff_meta) - if done then - return - end - if not diff then - failed = true - finalize(nil) - return - end - context.diff = diff - context.diff_meta = diff_meta - logger.debug( - string.format( - 'Staged diff context ready (chars=%d changed_lines=%s context_lines=%s truncated=%s original_chars=%s)', - #diff, - tostring(diff_meta and diff_meta.changed_lines), - tostring(diff_meta and diff_meta.context_lines), - tostring(diff_meta and diff_meta.truncated), - tostring(diff_meta and diff_meta.original_chars) + if include_opencode then + session_context.get_recent(function(session) + if done then + return + end + if not session then + logger.debug 'No assistant session context available' + mark_done() + return + end + logger.debug( + 'Assistant session context loaded (provider=' + .. tostring(session.label or session.provider) + .. ' transcript_chars=' + .. tostring(#(session.transcript or '')) + .. ')' ) - ) - mark_done() - end) + providers.summarize_session(session, function(summary) + if done then + return + end + context.session_summary = summary + logger.debug('Assistant session summary ready (chars=' .. tostring(summary and #summary or 0) .. ')') + mark_done() + end, status, request_context) + end, status) + end + + if include_staged_changes then + git.staged_diff(function(diff, diff_meta) + if done then + return + end + if not diff then + failed = true + finalize(nil) + return + end + context.diff = diff + context.diff_meta = diff_meta + logger.debug( + string.format( + 'Staged diff context ready (chars=%d changed_lines=%s context_lines=%s truncated=%s original_chars=%s)', + #diff, + tostring(diff_meta and diff_meta.changed_lines), + tostring(diff_meta and diff_meta.context_lines), + tostring(diff_meta and diff_meta.truncated), + tostring(diff_meta and diff_meta.original_chars) + ) + ) + mark_done() + end) + end git.staged_diff_stat(function(diff_stat) if done then diff --git a/local/ai-commit/lua/ai-commit/init.lua b/local/ai-commit/lua/ai-commit/init.lua index bc4e4e5..94cf296 100644 --- a/local/ai-commit/lua/ai-commit/init.lua +++ b/local/ai-commit/lua/ai-commit/init.lua @@ -1,7 +1,6 @@ local config = require 'ai-commit.config' local generator = require 'ai-commit.generator' local log = require('ai-commit.log').get -local preferences = require 'ai-commit.preferences' local providers = require 'ai-commit.providers' local state = require 'ai-commit.state' @@ -10,7 +9,6 @@ local M = {} ---@param opts table|nil function M.setup(opts) config.setup(opts) - preferences.load() local logger = log() local ok, ai_provider = pcall(require, 'ai-provider') @@ -28,7 +26,6 @@ function M.setup(opts) end end end - logger.info('Using fallback model: ' .. providers.selected_model_name()) state.ns_id = vim.api.nvim_create_namespace 'ai_commit_spinner' diff --git a/local/ai-commit/lua/ai-commit/preferences.lua b/local/ai-commit/lua/ai-commit/preferences.lua deleted file mode 100644 index 804af3b..0000000 --- a/local/ai-commit/lua/ai-commit/preferences.lua +++ /dev/null @@ -1,60 +0,0 @@ -local config = require 'ai-commit.config' - -local M = {} - ----@return string|nil -local function preferences_file() - if vim and vim.fn then - return vim.fn.stdpath 'data' .. '/ai-commit-preferences.json' - end - return nil -end - -function M.load() - local path = preferences_file() - if not path then - return - end - - local file = io.open(path, 'r') - if not file then - return - end - - local content = file:read '*a' - file:close() - - local ok, prefs = pcall(vim.json.decode, content) - if ok and type(prefs) == 'table' then - config.values.provider = prefs.provider or 'copilot' - config.values.model = prefs.model - if type(prefs.model_name) == 'string' and prefs.model_name ~= '' then - config.values.model_name = prefs.model_name - else - config.values.model_name = nil - end - end -end - ----@return boolean -function M.save() - local path = preferences_file() - if not path then - return false - end - - local file = io.open(path, 'w') - if not file then - return false - end - - file:write(vim.json.encode { - provider = config.values.provider, - model = config.values.model, - model_name = config.values.model_name, - }) - file:close() - return true -end - -return M diff --git a/local/ai-commit/lua/ai-commit/prompts.lua b/local/ai-commit/lua/ai-commit/prompts.lua index fd7e31e..a4cd667 100644 --- a/local/ai-commit/lua/ai-commit/prompts.lua +++ b/local/ai-commit/lua/ai-commit/prompts.lua @@ -1,6 +1,6 @@ local M = {} -M.commit = [[You are a git commit message generator following Conventional Commits v1.0.0 specification. +M.commit_header = [[You are a git commit message generator following Conventional Commits v1.0.0 specification. STRUCTURE: [optional scope]: @@ -29,34 +29,34 @@ SPECIFICATION (https://www.conventionalcommits.org/en/v1.0.0/): ADDITIONAL GUIDELINES: - Description: Use lowercase, imperative mood, no ending period, max 50 chars - Header Only: Most of the time, ONLY output the single header line (type[scope]: description). -- Body: FORBIDDEN for 75%% of commits. DO NOT include a body for small changes, simple fixes, or minor features. +- Body: FORBIDDEN for 75% of commits. DO NOT include a body for small changes, simple fixes, or minor features. - Body: ONLY include a body if the change is a massive architectural shift, highly complex, or a BREAKING CHANGE. - Body Formatting: If a body is absolutely necessary, wrap at 72 chars, explain WHAT and WHY (not HOW). DO NOT ramble or over-explain. - Type casing: Any casing may be used, but be consistent (prefer lowercase) - SemVer relationship: fix = PATCH, feat = MINOR, BREAKING CHANGE = MAJOR - Revert commits: Use "revert" type with footer referencing commit SHAs - BREAKING CHANGE: Use SPARINGLY. ONLY for big, actual breaking changes. -- BREAKING CHANGE: Adding new features is NOT breaking. Only for removed/changed functionality. - -Current branch: %s - -Recent commits: -%s - -Recent assistant session context: -%s - -Staged Files: -``` -%s -``` - -Staged changes: -``` -%s -``` - -Generate ONLY the commit message following the specification above:]] +- BREAKING CHANGE: Adding new features is NOT breaking. Only for removed/changed functionality.]] + +---@param branch string +---@param sections table[] +---@return string +function M.commit(branch, sections) + local parts = { M.commit_header, 'Current branch: ' .. branch } + + for _, section in ipairs(sections or {}) do + if section.body and section.body ~= '' then + if section.fenced then + table.insert(parts, section.title .. ':\n```\n' .. section.body .. '\n```') + else + table.insert(parts, section.title .. ':\n' .. section.body) + end + end + end + + table.insert(parts, 'Generate ONLY the commit message following the specification above:') + return table.concat(parts, '\n\n') +end M.session_summary = [[Summarize the following %s session for a git commit message generator. diff --git a/local/ai-commit/lua/ai-commit/providers.lua b/local/ai-commit/lua/ai-commit/providers.lua index 93322be..8186f01 100644 --- a/local/ai-commit/lua/ai-commit/providers.lua +++ b/local/ai-commit/lua/ai-commit/providers.lua @@ -32,30 +32,6 @@ local function child_request_context(request_context, overrides) return vim.tbl_extend('force', request_context or {}, overrides or {}) end ----@return string -function M.selected_model_name() - local values = config.values - local provider_prefix = '' - if values.provider == 'ollama' then - provider_prefix = '[Ollama] ' - elseif values.provider == 'openrouter' then - provider_prefix = '[OpenRouter] ' - elseif values.provider == 'copilot' and values.model then - provider_prefix = '[Copilot] ' - end - - if type(values.model_name) == 'string' and values.model_name ~= '' then - return provider_prefix .. values.model_name - end - if type(values.model) == 'string' and values.model ~= '' then - return provider_prefix .. values.model - end - if values.provider == 'copilot' then - return 'Copilot default' - end - return 'Unknown' -end - function M.select_model() local logger = log() local ai_provider = require 'ai-provider' @@ -141,128 +117,6 @@ local function complete_ai_provider(source_id, full_prompt, callback, status_cal }) end ----@param full_prompt string ----@param callback function(string|nil, table|nil) ----@param status_callback function(string)|nil ----@param request_context table|nil -local function complete_copilot(source_id, full_prompt, callback, status_callback, request_context) - local logger = log() - local ai_provider = require 'ai-provider' - local model = config.values.model or ai_provider.get_selected_model('copilot', source_id) or 'auto' - - if status_callback then - status_callback('Waiting for response from ' .. model) - end - - ai_provider.chat('copilot', { - model = model, - prompt = full_prompt, - stream = false, - max_tokens = config.values.max_tokens, - timeout = config.values.chat_timeout, - is_cancelled = request_context and request_context.is_cancelled, - register_http_job = request_context and request_context.register_http_job, - on_status = function(status) - if not status_callback or type(status) ~= 'table' then - return - end - if status.phase == 'authenticating' then - status_callback 'Authenticating with Copilot' - elseif status.phase == 'generating' then - status_callback('Generating response with ' .. (status.model or model)) - elseif status.phase == 'error' then - status_callback 'Provider error from Copilot' - end - end, - callback = function(message, meta) - if request_context and request_context.is_cancelled and request_context.is_cancelled() then - return - end - if not message then - logger.error('Copilot chat request failed through ai-provider: ' .. tostring(meta and meta.error or 'unknown error')) - vim.notify('Copilot request failed. See ai-commit logs.', vim.log.levels.ERROR) - callback(nil, nil) - return - end - callback(util.clean_message(message), { requested_model = model, used_model = meta and meta.used_model or model }) - end, - }) -end - ----@param full_prompt string ----@param callback function(string|nil, table|nil) ----@param status_callback function(string)|nil ----@param request_context table|nil -local function complete_openrouter(source_id, full_prompt, callback, status_callback, request_context) - local logger = log() - local api_key = os.getenv 'AVANTE_OPENROUTER_API_KEY' or os.getenv 'OPENROUTER_API_KEY' - if not api_key then - logger.error 'OpenRouter API key not found' - vim.notify('OpenRouter API key not found. Please set AVANTE_OPENROUTER_API_KEY or OPENROUTER_API_KEY.', vim.log.levels.ERROR) - callback(nil, nil) - return - end - - local model = config.values.model or 'anthropic/claude-sonnet-4-20250514' - if status_callback then - status_callback('Waiting for response from ' .. model) - end - - local curl = require 'plenary.curl' - local job = curl.post(config.values.openrouter.endpoint .. '/chat/completions', { - headers = { - ['Authorization'] = 'Bearer ' .. api_key, - ['Content-Type'] = 'application/json', - ['HTTP-Referer'] = 'https://github.com/opencode-sh/ai-commit', - ['X-Title'] = 'ai-commit.lua', - }, - body = vim.json.encode { - messages = { { role = 'user', content = full_prompt } }, - stream = false, - max_tokens = config.values.max_tokens, - model = model, - include_reasoning = config.values.openrouter.reasoning, - }, - timeout = config.values.chat_timeout, - callback = vim.schedule_wrap(function(response) - if request_context and request_context.is_cancelled and request_context.is_cancelled() then - return - end - if response.status ~= 200 then - logger.error(string.format('OpenRouter API error: %d body=%s', response.status, util.format_body_for_log(response.body))) - vim.notify('OpenRouter API request failed (' .. response.status .. '). See ai-commit logs.', vim.log.levels.ERROR) - callback(nil, nil) - return - end - - local ok, data = pcall(vim.json.decode, response.body) - local message_obj = ok and data.choices and data.choices[1] and data.choices[1].message - if not message_obj then - logger.error('Failed to parse OpenRouter response body=' .. util.format_body_for_log(response.body)) - callback(nil, nil) - return - end - - local content = message_obj.content or '' - if (content == '' or content:match '^%s*$') and message_obj.reasoning then - content = message_obj.reasoning - end - callback(util.clean_message(content), { requested_model = model, used_model = data.model or model }) - end), - on_error = vim.schedule_wrap(function(err) - if request_context and request_context.is_cancelled and request_context.is_cancelled() then - return - end - logger.error('OpenRouter chat request failed: ' .. tostring(err and err.stderr or 'unknown error')) - callback(nil, nil) - end), - }) - - if request_context and request_context.register_http_job then - request_context.register_http_job(job) - end -end - ---@param full_prompt string ---@param callback function(string|nil, table|nil) ---@param status_callback function(string)|nil @@ -272,15 +126,14 @@ function M.complete_prompt(full_prompt, callback, status_callback, request_conte local ai_provider = require 'ai-provider' local source_id = request_context and request_context.source_id or config.message_source_id local selection = ai_provider.get_source_selection(source_id) - local local_provider = selection and selection.provider or ai_provider.get_default_provider() or 'ollama' + local provider = selection and selection.provider or ai_provider.get_default_provider() or 'ollama' logger.debug( string.format( - 'Completing AI prompt (source=%s chars=%d selected_provider=%s selected_model=%s fallback=%s)', + 'Completing AI prompt (source=%s chars=%d provider=%s model=%s)', source_id, #full_prompt, - tostring(local_provider), - tostring(selection and selection.model or nil), - tostring(config.values.provider) + tostring(provider), + tostring(selection and selection.model or nil) ) ) if status_callback then @@ -288,26 +141,23 @@ function M.complete_prompt(full_prompt, callback, status_callback, request_conte if action and selection and selection.model then status_callback(action .. ' with ' .. selection.model) else - status_callback('Checking ' .. local_provider) + status_callback('Checking ' .. provider) end end - ai_provider.check(local_provider, function(working) + ai_provider.check(provider, function(working) if request_context and request_context.is_cancelled and request_context.is_cancelled() then return end if working then - logger.info('Routing prompt source=' .. source_id .. ' provider=' .. local_provider) + logger.info('Routing prompt source=' .. source_id .. ' provider=' .. provider) complete_ai_provider(source_id, full_prompt, callback, status_callback, request_context) return end - logger.info(string.format('%s unavailable for source=%s, falling back to %s provider', local_provider, source_id, config.values.provider)) - if config.values.provider == 'openrouter' then - complete_openrouter(source_id, full_prompt, callback, status_callback, request_context) - else - complete_copilot(source_id, full_prompt, callback, status_callback, request_context) - end + logger.error(string.format('AI provider %s unavailable for source=%s', provider, source_id)) + vim.notify('AI provider ' .. provider .. ' unavailable. Check :AIProvider.', vim.log.levels.ERROR) + callback(nil, nil) end) end @@ -361,19 +211,28 @@ end ---@param status_callback function(string)|nil ---@param request_context table|nil function M.generate_commit_message(branch, recent_commits, session_summary, diff_stat, diff, callback, status_callback, request_context) - local session_context = session_summary - if type(session_context) ~= 'string' or session_context:match '^%s*$' then - session_context = 'No recent assistant session context available.' + local sections = {} + if type(diff_stat) == 'string' and not diff_stat:match '^%s*$' then + table.insert(sections, { title = 'Staged files', body = diff_stat, fenced = true }) + end + if type(recent_commits) == 'string' and not recent_commits:match '^%s*$' then + table.insert(sections, { title = 'Recent commits', body = recent_commits }) + end + if type(session_summary) == 'string' and not session_summary:match '^%s*$' then + table.insert(sections, { title = 'Recent assistant session context', body = session_summary }) + end + if type(diff) == 'string' and not diff:match '^%s*$' then + table.insert(sections, { title = 'Staged changes', body = diff, fenced = true }) end - local prompt = string.format(prompts.commit, branch, recent_commits, session_context, diff_stat, diff) + local prompt = prompts.commit(branch, sections) log().debug( string.format( 'Commit prompt built (branch=%s commits_chars=%d session_context_chars=%d diff_stat_chars=%d diff_chars=%d prompt_chars=%d has_session_context=%s)', branch, - #recent_commits, - #session_context, - #diff_stat, - #diff, + type(recent_commits) == 'string' and #recent_commits or 0, + type(session_summary) == 'string' and #session_summary or 0, + type(diff_stat) == 'string' and #diff_stat or 0, + type(diff) == 'string' and #diff or 0, #prompt, session_summary and 'yes' or 'no' ) diff --git a/local/ai-commit/lua/ai-commit/session_context/opencode.lua b/local/ai-commit/lua/ai-commit/session_context/opencode.lua index ea99b60..7e627d0 100644 --- a/local/ai-commit/lua/ai-commit/session_context/opencode.lua +++ b/local/ai-commit/lua/ai-commit/session_context/opencode.lua @@ -165,7 +165,9 @@ local function read_session_messages(db_path, session, callback, status_callback return end - logger.debug(string.format('OpenCode session context ready (session=%s title=%s transcript_chars=%d)', tostring(session.id), tostring(session.title), #transcript)) + logger.debug( + string.format('OpenCode session context ready (session=%s title=%s transcript_chars=%d)', tostring(session.id), tostring(session.title), #transcript) + ) callback { provider = 'opencode', label = 'OpenCode', @@ -182,7 +184,7 @@ end function M.get_recent(callback, status_callback) local opts = config.values.opencode_context or {} local logger = log() - if opts.enabled == false then + if config.values.context and config.values.context.opencode == false then logger.debug 'OpenCode context disabled' callback(nil) return @@ -201,7 +203,9 @@ function M.get_recent(callback, status_callback) if status_callback then status_callback 'Inspecting OpenCode session' end - logger.debug(string.format('Looking for recent OpenCode session (cwd=%s db=%s recent_ms=%s since_ms=%s)', cwd, db_path, tostring(opts.recent_ms), tostring(since_ms))) + logger.debug( + string.format('Looking for recent OpenCode session (cwd=%s db=%s recent_ms=%s since_ms=%s)', cwd, db_path, tostring(opts.recent_ms), tostring(since_ms)) + ) local sql = table.concat({ 'select id, title, directory, time_updated from session', 'where directory = ' .. sql_quote(cwd),