diff options
Diffstat (limited to 'modules/ai-config.el')
| -rw-r--r-- | modules/ai-config.el | 577 |
1 files changed, 0 insertions, 577 deletions
diff --git a/modules/ai-config.el b/modules/ai-config.el deleted file mode 100644 index 20bf6ec88..000000000 --- a/modules/ai-config.el +++ /dev/null @@ -1,577 +0,0 @@ -;;; ai-config.el --- Configuration for AI Integrations -*- lexical-binding: t; coding: utf-8; -*- -;; author Craig Jennings <c@cjennings.net> -;; -;;; Commentary: -;; -;; Layer: 3 (Domain Workflow). -;; Category: D/P. -;; Load shape: eager. -;; Eager reason: registers the cj/ai-keymap (C-; a); GPTel itself should load on -;; command, a Phase 5 deferral candidate. -;; Top-level side effects: defines cj/ai-keymap, registers it under cj/custom-keymap. -;; Runtime requires: keybindings, system-lib. -;; Direct test load: yes (requires keybindings explicitly). -;; -;; Configuration for AI integrations in Emacs, focused on GPTel. -;; -;; Main Features: -;; - Quick toggle for AI assistant window (C-; a t) -;; - Custom keymap (C-; a prefix) for AI-related commands. -;; - Enhanced org-mode conversation formatting with timestamps -;; allows switching models and easily compare and track responses. -;; - Various specialized AI directives (coder, reviewer, etc.) -;; - Context management for adding files/buffers to conversations -;; - Conversation persistence with save/load functionality -;; - Integration with Magit for code review -;; -;; Basic Workflow -;; -;; Using a side-chat window: -;; - Launch GPTel via C-; a t, and chat in the AI-Assistant side window (C-<return> to send) -;; - Change system prompt (expertise, personalities) with C-; a p -;; - Add context from files (C-; a f) or current buffer (C-; a .) -;; - Save conversations with C-; a s, load previous ones with C-; a l -;; - Clear the conversation and start over with C-; a x -;; Or in any buffer: -;; - Add directive as above, and select a region to rewrite with C-; a r. -;; - -;;; Code: - -(require 'keybindings) ;; provides cj/custom-keymap -(require 'system-lib) ;; provides cj/auth-source-secret-value -(require 'cj-window-toggle-lib) ;; side-window size memory for the panel - -(autoload 'cj/gptel-save-conversation "ai-conversations" "Save the AI conversation to a file." t) -(autoload 'cj/gptel-load-conversation "ai-conversations" "Load a saved AI conversation." t) -(autoload 'cj/gptel-delete-conversation "ai-conversations" "Delete a saved AI conversation." t) -(autoload 'cj/gptel-autosave-toggle "ai-conversations" "Toggle autosave in the current GPTel buffer." t) -(autoload 'cj/gptel-quick-ask "ai-quick-ask" "One-shot quick-ask in a transient buffer." t) -(autoload 'cj/gptel-rewrite-with-directive "ai-rewrite" "Pick a directive and run gptel-rewrite on the region." t) -(autoload 'cj/gptel-rewrite-redo-with-different-directive "ai-rewrite" "Re-run the previous rewrite with a different directive." t) -(autoload 'cj/gptel-browse-conversations "ai-conversations-browser" "Browse saved GPTel conversations." t) - -;;; ------------------------- AI Config Helper Functions ------------------------ - -;; Define variables upfront -(defvar cj/anthropic-api-key-cached nil "Cached Anthropic API key.") -(defvar cj/openai-api-key-cached nil "Cached OpenAI API key.") -(defvar gptel-claude-backend nil "Claude backend, lazy-initialized.") -(defvar gptel-chatgpt-backend nil "ChatGPT backend, lazy-initialized.") - -(defcustom cj/gptel-tools-directory - (expand-file-name "gptel-tools/" user-emacs-directory) - "Directory containing optional local GPTel tool modules." - :type 'directory - :group 'cj) - -(defcustom cj/gptel-local-tool-features - '(read_buffer - read_text_file - write_text_file - update_text_file - list_directory_files - move_to_trash - git_status - git_log - git_diff - web_fetch) - "Feature symbols for optional local GPTel tool modules." - :type '(repeat symbol) - :group 'cj) - -(defun cj/gptel-load-local-tools - (&optional tools-directory tool-features) - "Load optional GPTel tools from TOOLS-DIRECTORY. -TOOL-FEATURES defaults to `cj/gptel-local-tool-features'. Return a list -of loaded feature symbols. Missing directories or individual optional -tools are reported with `message' and do not signal." - (let ((dir (file-name-as-directory - (expand-file-name (or tools-directory cj/gptel-tools-directory)))) - (features (or tool-features cj/gptel-local-tool-features)) - (loaded nil)) - (cond - ((not (file-directory-p dir)) - (message "GPTel tools directory not found: %s" dir) - nil) - (t - (add-to-list 'load-path dir) - (dolist (feature features) - (condition-case err - (if (require feature nil 'noerror) - (push feature loaded) - (message "Optional GPTel tool not found: %s" feature)) - (error - (message "Failed to load GPTel tool %s: %s" - feature - (error-message-string err))))) - (nreverse loaded))))) - -(with-eval-after-load 'gptel - (require 'ai-conversations) - (cj/gptel-load-local-tools)) - -(defun cj/auth-source-secret (host user) - "Fetch a required secret from auth-source for HOST and USER. - -HOST and USER must be strings that identify the credential to return. -Errors when no secret is found." - (or (cj/auth-source-secret-value host user) - (error "No usable secret found for host %s and user %s" host user))) - -(defun cj/anthropic-api-key () - "Return the Anthropic API key, caching the result after first retrieval." - (or cj/anthropic-api-key-cached - (setq cj/anthropic-api-key-cached - (cj/auth-source-secret "api.anthropic.com" "apikey")))) - -(defun cj/openai-api-key () - "Return the OpenAI API key, caching the result after first retrieval." - (or cj/openai-api-key-cached - (setq cj/openai-api-key-cached - (cj/auth-source-secret "api.openai.com" "apikey")))) - -(defun cj/--gptel-load-backend-libs () - "Require the gptel backend libraries so their `gptel-make-*' constructors exist. -The local fork (`:load-path \"~/code/gptel\"', `:ensure nil') ships no generated -autoloads, so requiring `gptel' alone never loads `gptel-anthropic' / -`gptel-openai', where the constructors are defined." - (require 'gptel-anthropic) - (require 'gptel-openai)) - -(defun cj/ensure-gptel-backends () - "Initialize GPTel backends if they are not already available. -Loads the backend libraries first so the `gptel-make-*' constructors are -defined even when gptel is the local fork without generated autoloads." - (cj/--gptel-load-backend-libs) - (unless gptel-claude-backend - (setq gptel-claude-backend - (gptel-make-anthropic - "Claude" - :key (cj/anthropic-api-key) - :models '( - "claude-opus-4-7" - "claude-sonnet-4-6" - "claude-haiku-4-5-20251001" - ) - :stream t))) - (unless gptel-chatgpt-backend - (setq gptel-chatgpt-backend - (gptel-make-openai - "ChatGPT" - :key (cj/openai-api-key) - :models '( - "gpt-5.5" - "gpt-5.4-mini" - "o3" - ) - :stream t))) - ;; Set default backend and model - (unless gptel-backend - (setq gptel-backend (or gptel-chatgpt-backend gptel-claude-backend)) - (setq gptel-model 'gpt-5.5))) - -;; ------------------ GPTel Conversation And Utility Commands ------------------ - -(defun cj/gptel--available-backends () - "Return an alist of (NAME . BACKEND). -Ensures gptel and backends are initialized." - (unless (featurep 'gptel) - (require 'gptel)) - (cj/ensure-gptel-backends) - (delq nil - (list (and (bound-and-true-p gptel-claude-backend) - (cons "Anthropic - Claude" gptel-claude-backend)) - (and (bound-and-true-p gptel-chatgpt-backend) - (cons "OpenAI - ChatGPT" gptel-chatgpt-backend))))) - -(defun cj/gptel--model-to-string (m) - "Return model M as a string regardless of its type." - (cond - ((stringp m) m) - ((symbolp m) (symbol-name m)) - (t (format "%s" m)))) - -(defun cj/gptel--model-to-symbol (m) - "Return model M as a symbol regardless of its type. -`gptel-model' must be a symbol: gptel's modeline code calls `symbolp' -on it and signals `wrong-type-argument' on a string, which surfaces as a -redisplay hang. Coerce any model value through this before assigning it." - (cond - ((symbolp m) m) - ((stringp m) (intern m)) - (t (intern (format "%s" m))))) - -;; Backend/model switching helpers (pure logic, extracted for testability) - -(defun cj/gptel--build-model-list (backends model-fn) - "Build a flat list of all models across BACKENDS. -BACKENDS is an alist of (NAME . BACKEND-OBJECT). MODEL-FN is called -with each backend object and should return a list of model identifiers. -Returns a list of entries: (DISPLAY-STRING BACKEND MODEL-STRING BACKEND-NAME) -where DISPLAY-STRING is \"Backend: model\" for use in completing-read." - (mapcan - (lambda (pair) - (let* ((backend-name (car pair)) - (backend (cdr pair)) - (models (funcall model-fn backend))) - (mapcar (lambda (m) - (list (format "%s: %s" backend-name (cj/gptel--model-to-string m)) - backend - (cj/gptel--model-to-string m) - backend-name)) - models))) - backends)) - -(defun cj/gptel--current-model-selection (backends current-backend current-model) - "Format the current backend/model as a display string. -BACKENDS is the alist from `cj/gptel--available-backends'. -CURRENT-BACKEND and CURRENT-MODEL are the active gptel settings. -Returns a string like \"Anthropic - Claude: claude-opus-4-7\"." - (let ((backend-name (car (rassoc current-backend backends)))) - (format "%s: %s" - (or backend-name "AI") - (cj/gptel--model-to-string current-model)))) - -;; Backend/model switching commands -(defun cj/gptel-change-model () - "Change the GPTel backend and select a model from that backend. -Present all available models from every backend, switching backends when -necessary. Prompt for whether to apply the selection globally or buffer-locally." - (interactive) - (let* ((backends (cj/gptel--available-backends)) - (all-models (cj/gptel--build-model-list - backends - (lambda (b) - (when (fboundp 'gptel-backend-models) - (gptel-backend-models b))))) - (current-selection (cj/gptel--current-model-selection - backends - (bound-and-true-p gptel-backend) - (bound-and-true-p gptel-model))) - (scope (completing-read "Set model for: " '("buffer" "global") nil t)) - (selected (completing-read - (format "Select model (current: %s): " current-selection) - (mapcar #'car all-models) nil t nil nil current-selection))) - (let* ((model-info (assoc selected all-models)) - (backend (nth 1 model-info)) - (model (intern (nth 2 model-info))) - (backend-name (nth 3 model-info))) - (if (string= scope "global") - (progn - (setq gptel-backend backend) - (setq gptel-model model) - (message "Changed to %s model: %s (global)" backend-name model)) - (setq-local gptel-backend backend) - (setq-local gptel-model (if (stringp model) (intern model) model)) - (message "Changed to %s model: %s (buffer-local)" backend-name model))))) - -(defun cj/gptel-switch-backend () - "Switch the GPTel backend and then choose one of its models." - (interactive) - (let* ((backends (cj/gptel--available-backends)) - (choice (completing-read "Select GPTel backend: " (mapcar #'car backends) nil t)) - (backend (cdr (assoc choice backends)))) - (unless backend - (user-error "Invalid GPTel backend: %s" choice)) - (let* ((models (when (fboundp 'gptel-backend-models) - (gptel-backend-models backend))) - (model (completing-read (format "Select %s model: " choice) - (mapcar #'cj/gptel--model-to-string models) - nil t nil nil (cj/gptel--model-to-string (bound-and-true-p gptel-model))))) - (setq gptel-backend backend - gptel-model (cj/gptel--model-to-symbol model)) - (message "Switched to %s with model: %s" choice model)))) - -;; Clear assistant buffer (moved out so it's always available) -(defun cj/gptel-clear-buffer () - "Erase the current GPTel buffer while preserving the initial Org heading. -Operate only when `gptel-mode' is active in an Org buffer so the heading -can be reinserted." - (interactive) - (let ((is-gptel (bound-and-true-p gptel-mode)) - (is-org (derived-mode-p 'org-mode))) - (if (and is-gptel is-org) - (progn - (erase-buffer) - (when (fboundp 'cj/gptel--fresh-org-prefix) - (insert (cj/gptel--fresh-org-prefix))) - (message "GPTel buffer cleared and heading reset")) - (message "Not a GPTel buffer in org-mode. Nothing cleared.")))) - -;; ----------------------------- Context Management ---------------------------- - -(defun cj/gptel--add-file-to-context (file-path) - "Add FILE-PATH to the GPTel context. -Returns t on success, nil on failure. -Provides consistent user feedback about the context state." - (when (and file-path (file-exists-p file-path)) - (gptel-add-file file-path) - (let ((context-count (if (boundp 'gptel-context--alist) - (length gptel-context--alist) - 0))) - (message "Added %s to GPTel context (%d sources total)" - (file-name-nondirectory file-path) - context-count)) - t)) - -(defun cj/gptel-add-file () - "Add a file to the GPTel context. -If inside a Projectile project, prompt from that project's file list. -Otherwise, prompt with `read-file-name'." - (interactive) - (let* ((in-proj (and (featurep 'projectile) - (fboundp 'projectile-project-p) - (projectile-project-p))) - (file-name (if in-proj - (let ((cands (projectile-current-project-files))) - (if (fboundp 'projectile-completing-read) - (projectile-completing-read "GPTel add file: " cands) - (completing-read "GPTel add file: " cands nil t))) - (read-file-name "GPTel add file: "))) - (file-path (if in-proj - (expand-file-name file-name (projectile-project-root)) - file-name))) - (unless (cj/gptel--add-file-to-context file-path) - (error "Failed to add file: %s" file-path)))) - -(defun cj/gptel-add-buffer-file () - "Select a buffer and add its associated file to the GPTel context. -Lists all open buffers for selection. If the selected buffer is visiting -a file, that file is added to the GPTel context. Otherwise, an error -message is displayed." - (interactive) - (let* ((buffers (mapcar #'buffer-name (buffer-list))) - (selected-buffer-name (completing-read "Add file from buffer: " buffers nil t)) - (selected-buffer (get-buffer selected-buffer-name)) - (file-path (and selected-buffer - (buffer-file-name selected-buffer)))) - (if file-path - (cj/gptel--add-file-to-context file-path) - (message "Buffer '%s' is not visiting a file" selected-buffer-name)))) - -(defun cj/gptel-add-this-buffer () - "Add the current buffer to the GPTel context. -Works for any buffer, whether it's visiting a file or not." - (interactive) - ;; Load gptel-context if needed - (unless (featurep 'gptel-context) - (require 'gptel-context)) - ;; Use gptel-add with prefix arg '(4) to add current buffer - (gptel-add '(4)) - (message "Added buffer '%s' to GPTel context" (buffer-name))) - -;;; -------------------------- Org Header Construction -------------------------- - -(defun cj/gptel--fresh-org-prefix () - "Generate a fresh org-mode header with current timestamp for user messages." - (concat "* " user-login-name " " (format-time-string "[%Y-%m-%d %H:%M:%S]") "\n")) - -(defun cj/gptel--refresh-org-prefix (&rest _) - "Update the org-mode prefix with fresh timestamp before sending message." - (setf (alist-get 'org-mode gptel-prompt-prefix-alist) - (cj/gptel--fresh-org-prefix))) - -(defun cj/gptel-backend-and-model () - "Return backend, model, and timestamp as a single string." - (let* ((backend (pcase (bound-and-true-p gptel-backend) - ((and v (pred vectorp)) (aref v 1)) - (_ "AI"))) - (model (format "%s" (or (bound-and-true-p gptel-model) ""))) - (ts (format-time-string "[%Y-%m-%d %H:%M:%S]"))) - (format "%s: %s %s" backend model ts))) - -(defun cj/gptel-insert-model-heading (response-begin-pos _response-end-pos) - "Insert an Org heading for the AI reply at RESPONSE-BEGIN-POS." - (save-excursion - (goto-char response-begin-pos) - (insert (format "* %s\n" (cj/gptel-backend-and-model))))) - -;;; ---------------------------- GPTel Configuration ---------------------------- - -(use-package gptel - :load-path "~/code/gptel" - :ensure nil - :defer t - :commands (gptel gptel-send gptel-menu) - :bind - (:map gptel-mode-map - ("C-<return>" . gptel-send)) - :custom - (gptel-default-mode 'org-mode) - (gptel-expert-commands t) - (gptel-track-media t) - ;; Options: t (include + resend), 'ignore (show but don't resend), - ;; nil (discard), or a buffer name to redirect reasoning to - (gptel-include-reasoning "*AI-Reasoning*") - (gptel-log-level 'info) - (gptel--debug nil) - :config - (cj/ensure-gptel-backends) - ;; Set ChatGPT (gpt-5.5) as default after initialization. Model - ;; must be a symbol -- gptel's modeline-display code calls `symbolp' - ;; on it and signals `wrong-type-argument' otherwise. - (setq gptel-backend gptel-chatgpt-backend) - (setq gptel-model 'gpt-5.5) - - (setq gptel-confirm-tool-calls nil) ;; allow tool access by default - - ;; Initialize org-mode user prefix and wire up hooks - (setf (alist-get 'org-mode gptel-prompt-prefix-alist) - (cj/gptel--fresh-org-prefix)) - (advice-add 'gptel-send :before #'cj/gptel--refresh-org-prefix) - (add-hook 'gptel-post-response-functions #'cj/gptel-insert-model-heading)) - -;;; ---------------------------- Toggle GPTel Window ---------------------------- - -(defvar cj/ai-assistant-window-width 0.4 - "Default fraction of frame width for the *AI-Assistant* side window. -Used until the panel is resized and toggled off this session; after that, -the toggled-off width is remembered in `cj/--ai-assistant-width'.") - -(defvar cj/--ai-assistant-width nil - "Last width fraction the *AI-Assistant* side window was toggled off at. -nil falls back to `cj/ai-assistant-window-width'. Shared by the panel's -entry points (toggle, load-conversation, quick-ask escalation) so the -panel reopens at one consistent width. In-memory only -- resets each -Emacs session.") - -(defun cj/toggle-gptel () - "Toggle the visibility of the AI-Assistant buffer, and place point at its end. -The panel opens at `cj/ai-assistant-window-width'; once it has been resized -and toggled off this session, it reopens at that remembered width." - (interactive) - (let* ((buf-name "*AI-Assistant*") - (buffer (get-buffer buf-name)) - (win (and buffer (get-buffer-window buffer)))) - (if win - (progn - (cj/side-window-capture-size win 'right 'cj/--ai-assistant-width) - (delete-window win)) - ;; Ensure GPTel and our backends are initialized before creating the buffer - (unless (featurep 'gptel) - (require 'gptel)) - (cj/ensure-gptel-backends) - (unless buffer - ;; Pass backend, not model - (gptel buf-name gptel-backend)) - (setq buffer (get-buffer buf-name)) - (setq win - (cj/side-window-display - buffer 'right 'cj/--ai-assistant-width - cj/ai-assistant-window-width)) - (select-window win) - (with-current-buffer buffer - (goto-char (point-max)))))) - -;; ------------------------------- Clear Context ------------------------------- - -(defun cj/gptel-context-clear () - "Clear all GPTel context sources, with compatibility across GPTel versions." - (interactive) - (cond - ((fboundp 'gptel-context-remove-all) - (call-interactively 'gptel-context-remove-all) - (message "GPTel context cleared")) - ((fboundp 'gptel-context-clear) - (call-interactively 'gptel-context-clear) - (message "GPTel context cleared")) - ((boundp 'gptel-context--alist) - (setq gptel-context--alist nil) - (message "GPTel context cleared")) - (t - (message "No known GPTel context clearing function available")))) - -;;; -------------------------------- GPTel-Magit -------------------------------- - -;; Each integration point waits on its actual dependency, not on `magit' -;; broadly. `magit.el' calls `(provide 'magit)' BEFORE its -;; `cl-eval-when (load eval) ...' block requires `magit-commit' and -;; `magit-stash', so a single `with-eval-after-load 'magit' fires while -;; the transient prefixes the wiring references are still undefined. -;; `transient-append-suffix' silently no-ops on missing prefixes (it -;; calls `message' unless `transient-error-on-insert-failure' is set), -;; which is how the failure stayed invisible. -;; -;; Keys: -;; M-g — generate commit message (in commit message buffer) -;; g — generate commit (in magit-commit transient) -;; x — explain diff (in magit-diff transient) - -(use-package gptel-magit - :defer t - :commands (gptel-magit-generate-message - gptel-magit-commit-generate - gptel-magit-diff-explain) - :init - (with-eval-after-load 'git-commit - (define-key git-commit-mode-map (kbd "M-g") #'gptel-magit-generate-message)) - (with-eval-after-load 'magit-commit - (transient-append-suffix 'magit-commit #'magit-commit-create - '("g" "Generate commit" gptel-magit-commit-generate))) - (with-eval-after-load 'magit-diff - (transient-append-suffix 'magit-diff #'magit-stash-show - '("x" "Explain" gptel-magit-diff-explain)))) - -;; ------------------------------ GPTel Directives ----------------------------- - -(use-package gptel-prompts - :load-path (lambda () (expand-file-name "custom/" user-emacs-directory)) - :after gptel - :if (file-exists-p (expand-file-name "custom/gptel-prompts.el" user-emacs-directory)) - :custom - (gptel-prompts-directory (concat user-emacs-directory "ai-prompts")) - :config - (gptel-prompts-update) - (gptel-prompts-add-update-watchers) - ;; gptel--system-message is set at gptel load time, before gptel-prompts - ;; replaces the default directive. Re-apply it now. - (when-let* ((dir (alist-get 'default gptel-directives))) - (setq gptel--system-message dir))) - -;;; --------------------------------- AI Keymap --------------------------------- - -(defvar-keymap cj/ai-keymap - :doc "Keymap for gptel and other AI operations." - "A" #'cj/gptel-autosave-toggle ;; toggle autosave on the current GPTel buffer - "B" #'cj/gptel-switch-backend ;; change the backend (OpenAI, Anthropic, etc. - "M" #'gptel-menu ;; gptel's transient menu - "d" #'cj/gptel-delete-conversation ;; delete conversation - "." #'cj/gptel-add-this-buffer ;; add buffer to context - "f" #'cj/gptel-add-file ;; add a file to context - "b" #'cj/gptel-browse-conversations ;; browse saved conversations - "l" #'cj/gptel-load-conversation ;; load and continue conversation - "m" #'cj/gptel-change-model ;; change the LLM model - "p" #'gptel-system-prompt ;; change prompt - "q" #'cj/gptel-quick-ask ;; one-shot quick ask - "r" #'cj/gptel-rewrite-with-directive ;; rewrite region with a chosen directive - "R" #'cj/gptel-rewrite-redo-with-different-directive ;; redo last rewrite, new directive - "c" #'cj/gptel-context-clear ;; clear all context - "s" #'cj/gptel-save-conversation ;; save conversation - "t" #'cj/toggle-gptel ;; toggles the ai-assistant window - "x" #'cj/gptel-clear-buffer) ;; clears the assistant buffer -(cj/register-prefix-map "a" cj/ai-keymap) - -(with-eval-after-load 'which-key - (which-key-add-key-based-replacements - "C-; a" "AI assistant menu" - "C-; a A" "toggle autosave" - "C-; a B" "switch backend" - "C-; a M" "gptel menu" - "C-; a b" "browse conversations" - "C-; a d" "delete conversation" - "C-; a ." "add buffer" - "C-; a f" "add file" - "C-; a l" "load conversation" - "C-; a m" "change model" - "C-; a p" "change prompt" - "C-; a q" "quick ask" - "C-; a r" "rewrite region (directive)" - "C-; a R" "redo rewrite, new directive" - "C-; a c" "clear context" - "C-; a s" "save conversation" - "C-; a t" "toggle window" - "C-; a x" "clear buffer")) - -(provide 'ai-config) -;;; ai-config.el ends here. |
