From e41c25068d0cec9434895a6d3e3a25d3a26f645f Mon Sep 17 00:00:00 2001 From: Craig Jennings Date: Tue, 23 Jun 2026 20:12:58 -0400 Subject: chore(ai): archive gptel and remove it from the live config I archived gptel to archive/gptel/ since I rarely use it. Moved there: the six gptel modules (ai-config, ai-conversations, ai-conversations-browser, ai-mcp, ai-quick-ask, ai-rewrite), the gptel-tools/ directory, custom/gptel-prompts.el, their test files and utilities, and the four gptel-only specs. Scrubbed from the live config: the ai-config require in init.el, which also drops the whole C-; a keymap; the gptel-mode emojify hook in font-config.el; the gptel-tools entries in the Makefile clean target and the coverage runner; and the gptel feature notes in README. Cancelled the open gptel tasks in todo.org (the AI Open Work issues, the feature-extension brainstorm, the velox gptel-magit bug). ai-term stays. It is the ghostel Claude launcher, independent of gptel. Verified: every module loads, a batch init launch reaches completion clean, and the full test suite shows only pre-existing coverage failures unrelated to this change. --- modules/ai-config.el | 585 ------------------------------------ modules/ai-conversations-browser.el | 241 --------------- modules/ai-conversations.el | 369 ----------------------- modules/ai-mcp.el | 416 ------------------------- modules/ai-quick-ask.el | 141 --------- modules/ai-rewrite.el | 108 ------- modules/font-config.el | 5 +- 7 files changed, 2 insertions(+), 1863 deletions(-) delete mode 100644 modules/ai-config.el delete mode 100644 modules/ai-conversations-browser.el delete mode 100644 modules/ai-conversations.el delete mode 100644 modules/ai-mcp.el delete mode 100644 modules/ai-quick-ask.el delete mode 100644 modules/ai-rewrite.el (limited to 'modules') diff --git a/modules/ai-config.el b/modules/ai-config.el deleted file mode 100644 index 97af1296d..000000000 --- a/modules/ai-config.el +++ /dev/null @@ -1,585 +0,0 @@ -;;; ai-config.el --- Configuration for AI Integrations -*- lexical-binding: t; coding: utf-8; -*- -;; author Craig Jennings -;; -;;; 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- 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)))) - -(defun cj/--gptel-apply-model-selection (scope backend model backend-name) - "Set gptel BACKEND and MODEL, globally or buffer-locally per SCOPE. -SCOPE is \"global\" or \"buffer\"; any non-\"global\" value is buffer-local. -MODEL is a symbol. BACKEND-NAME is the display name for the confirmation. -Returns the confirmation message string." - (if (string= scope "global") - (progn - (setq gptel-backend backend) - (setq gptel-model model) - (format "Changed to %s model: %s (global)" backend-name model)) - (setq-local gptel-backend backend) - (setq-local gptel-model model) - (format "Changed to %s model: %s (buffer-local)" backend-name 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))) - (message "%s" (cj/--gptel-apply-model-selection - scope backend model backend-name))))) - -(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-" . 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. diff --git a/modules/ai-conversations-browser.el b/modules/ai-conversations-browser.el deleted file mode 100644 index 9f2a7de43..000000000 --- a/modules/ai-conversations-browser.el +++ /dev/null @@ -1,241 +0,0 @@ -;;; ai-conversations-browser.el --- Browse saved GPTel conversations -*- lexical-binding: t; coding: utf-8; -*- - -;; Author: Craig Jennings - -;;; Commentary: -;; Provides `cj/gptel-browse-conversations': a dired-style buffer -;; listing saved conversations in `cj/gptel-conversations-directory'. -;; Each row shows date, time, topic, and a short preview of the most -;; recent message. Single-key bindings load / delete / rename a -;; conversation in place. -;; -;; RET, l Load the conversation under point -;; d Delete the conversation under point -;; r Rename the conversation under point (renames the file) -;; g Refresh the listing -;; n / p Move to next / previous row -;; q Quit the browser window - -;;; Code: - -(require 'cl-lib) -(require 'subr-x) - -(declare-function cj/gptel-load-conversation "ai-conversations" ()) -(declare-function cj/gptel--slugify-topic "ai-conversations" (s)) -(declare-function cj/gptel--timestamp-from-filename "ai-conversations" (filename)) - -(defcustom cj/gptel-browser-preview-length 60 - "Number of preview characters shown per row in the browser." - :type 'integer - :group 'cj/ai-conversations) - -(defconst cj/gptel-browser--buffer-name "*GPTel-Conversations*" - "Buffer name for the saved-conversations browser.") - -(defvar-keymap cj/gptel-browser-mode-map - :doc "Keymap for `cj/gptel-browser-mode'." - "RET" #'cj/gptel-browser-load - "l" #'cj/gptel-browser-load - "d" #'cj/gptel-browser-delete - "r" #'cj/gptel-browser-rename - "g" #'cj/gptel-browser-refresh - "n" #'next-line - "p" #'previous-line - "q" #'quit-window) - -(define-derived-mode cj/gptel-browser-mode special-mode "GPTel-Browser" - "Major mode for browsing saved GPTel conversations." - (setq-local truncate-lines t)) - -;; -------------------------- helpers (pure where possible) - -(defun cj/gptel-browser--topic-from-filename (filename) - "Return the topic slug from FILENAME, or nil if it isn't a gptel file." - (when (string-match "\\`\\(.+\\)_[0-9]\\{8\\}-[0-9]\\{6\\}\\.gptel\\'" filename) - (match-string 1 filename))) - -(defun cj/gptel-browser--strip-headers (text) - "Drop the org #+STARTUP / #+VISIBILITY headers from TEXT and return the rest." - (let ((s text)) - (while (string-match "\\`#\\+\\(STARTUP\\|VISIBILITY\\):.*\n" s) - (setq s (substring s (match-end 0)))) - (while (and (> (length s) 0) (eq (aref s 0) ?\n)) - (setq s (substring s 1))) - s)) - -(defun cj/gptel-browser--last-message (text) - "Return a short preview of the last user/AI message in TEXT. -Returns the empty string when no message body is present." - (let* ((stripped (cj/gptel-browser--strip-headers text)) - ;; Last org-mode top-level heading body, or the whole text if - ;; there isn't one. - (body (if (string-match "\\`\\*+[^\n]*\n\\(\\(?:.\\|\n\\)*\\)\\'" stripped) - (let* ((all-text stripped) - ;; Walk backward to find the last '* ' or '** ' heading - (idx (or (cl-loop for i from (1- (length all-text)) downto 0 - when (and (or (zerop i) - (eq (aref all-text (1- i)) ?\n)) - (eq (aref all-text i) ?*)) - return i) - 0))) - (substring all-text idx)) - stripped))) - ;; Drop the heading line itself, then collapse whitespace. - (when (string-match "\\`\\*+[^\n]*\n" body) - (setq body (substring body (match-end 0)))) - (setq body (replace-regexp-in-string "[\n\t ]+" " " body)) - (string-trim body))) - -(defun cj/gptel-browser--preview (text length) - "Return a LENGTH-char preview from TEXT, ellipsized when truncated." - (let* ((line (cj/gptel-browser--last-message text)) - (max-len (max 1 length))) - (cond - ((string-empty-p line) "") - ((> (length line) max-len) - (concat (substring line 0 (1- max-len)) "…")) - (t line)))) - -(defun cj/gptel-browser--row-for-file (file dir) - "Return a propertized row string for FILE under DIR, or nil." - (let* ((filename (file-name-nondirectory file)) - (topic (cj/gptel-browser--topic-from-filename filename)) - (ts (and topic (cj/gptel--timestamp-from-filename filename)))) - (when (and topic ts) - (let* ((preview (with-temp-buffer - (ignore-errors (insert-file-contents file)) - (cj/gptel-browser--preview - (buffer-string) cj/gptel-browser-preview-length))) - (row (format "%s %-22s %s" - (format-time-string "%Y-%m-%d %H:%M" ts) - topic preview))) - (propertize row - 'cj/gptel-browser-file filename - 'cj/gptel-browser-topic topic))))) - -(defun cj/gptel-browser--rows () - "Return propertized row strings for every conversation in the directory." - (when (and (boundp 'cj/gptel-conversations-directory) - (file-directory-p cj/gptel-conversations-directory)) - (let ((dir cj/gptel-conversations-directory)) - (delq nil - (mapcar (lambda (f) (cj/gptel-browser--row-for-file f dir)) - (directory-files dir t "\\.gptel\\'")))))) - -(defun cj/gptel-browser--render () - "Replace the current buffer's contents with the conversation listing. -Sort newest first." - (let ((inhibit-read-only t) - (rows (sort (cj/gptel-browser--rows) - (lambda (a b) - (string> (substring-no-properties a 0 16) - (substring-no-properties b 0 16)))))) - (erase-buffer) - (insert (propertize - "Saved GPTel conversations -- RET/l load d delete r rename g refresh q quit\n\n" - 'face 'header-line)) - (cond - ((null rows) - (insert " (no saved conversations)\n")) - (t - (dolist (row rows) - (insert row "\n")))) - (goto-char (point-min)) - (forward-line 2))) - -;; -------------------------- entry point - -;;;###autoload -(defun cj/gptel-browse-conversations () - "Open the saved GPTel conversations browser." - (interactive) - (let ((buf (get-buffer-create cj/gptel-browser--buffer-name))) - (with-current-buffer buf - (cj/gptel-browser-mode) - (cj/gptel-browser--render)) - (pop-to-buffer buf))) - -(defun cj/gptel-browser-refresh () - "Re-read the conversations directory and refresh the browser." - (interactive) - (cj/gptel-browser--render)) - -;; -------------------------- row-level actions - -(defun cj/gptel-browser--filename-at-point () - "Return the conversation filename on the current line, or nil." - (get-text-property (line-beginning-position) 'cj/gptel-browser-file)) - -(defun cj/gptel-browser--filepath-at-point () - "Return the absolute filepath for the row at point, or nil." - (when-let ((filename (cj/gptel-browser--filename-at-point))) - (expand-file-name filename cj/gptel-conversations-directory))) - -(defun cj/gptel-browser-load () - "Load the conversation on the current row via `cj/gptel-load-conversation'. -The browser is buried after the load fires." - (interactive) - (let ((filepath (cj/gptel-browser--filepath-at-point))) - (unless filepath - (user-error "No conversation on this line")) - (let ((filename (file-name-nondirectory filepath))) - ;; Stand in for cj/gptel-load-conversation's completing-read so - ;; the user doesn't get prompted twice. - (cl-letf (((symbol-function 'completing-read) - (lambda (_p cands &rest _) - (or (car (cl-find filename cands - :key (lambda (c) (cdr c)) - :test #'equal)) - (caar cands)))) - ((symbol-function 'y-or-n-p) (lambda (&rest _) nil))) - (cj/gptel-load-conversation))) - (quit-window))) - -(defun cj/gptel-browser-delete () - "Delete the conversation file on the current row, after confirmation." - (interactive) - (let ((filepath (cj/gptel-browser--filepath-at-point))) - (unless filepath - (user-error "No conversation on this line")) - (let ((filename (file-name-nondirectory filepath))) - (when (y-or-n-p (format "Delete %s? " filename)) - (delete-file filepath) - (message "Deleted %s" filename) - (cj/gptel-browser--render))))) - -(defun cj/gptel-browser--rename-target (filepath new-topic) - "Compute the renamed FILEPATH for NEW-TOPIC, preserving the timestamp. -NEW-TOPIC is slugified. Returns the new absolute filepath." - (let* ((dir (file-name-directory filepath)) - (filename (file-name-nondirectory filepath)) - (timestamp (and (string-match "_\\([0-9]\\{8\\}-[0-9]\\{6\\}\\)\\.gptel\\'" - filename) - (match-string 1 filename))) - (slug (cj/gptel--slugify-topic new-topic))) - (unless timestamp - (error "Cannot extract timestamp from filename: %s" filename)) - (expand-file-name (format "%s_%s.gptel" slug timestamp) dir))) - -(defun cj/gptel-browser-rename () - "Rename the conversation file on the current row, preserving its timestamp." - (interactive) - (let ((filepath (cj/gptel-browser--filepath-at-point))) - (unless filepath - (user-error "No conversation on this line")) - (let* ((old (file-name-nondirectory filepath)) - (current-topic (cj/gptel-browser--topic-from-filename old)) - (new-topic (read-string - (format "New topic (was %s): " current-topic) - current-topic)) - (target (cj/gptel-browser--rename-target filepath new-topic))) - (when (equal target filepath) - (user-error "Topic unchanged")) - (when (file-exists-p target) - (user-error "Target already exists: %s" (file-name-nondirectory target))) - (rename-file filepath target) - (message "Renamed to %s" (file-name-nondirectory target)) - (cj/gptel-browser--render)))) - -(provide 'ai-conversations-browser) -;;; ai-conversations-browser.el ends here diff --git a/modules/ai-conversations.el b/modules/ai-conversations.el deleted file mode 100644 index 8061051a8..000000000 --- a/modules/ai-conversations.el +++ /dev/null @@ -1,369 +0,0 @@ -;;; ai-conversations.el --- GPTel conversation persistence and autosave -*- lexical-binding: t; coding: utf-8; -*- -;; Author: Craig Jennings -;; Maintainer: Craig Jennings -;; Version 0.1 -;; Package-Requires: ((emacs "27.1")) -;; Keywords: convenience, tools -;; -;;; Commentary: -;; Provides conversation save/load/delete, autosave after responses, and -;; org-visibility headers for GPTel-powered assistant buffers. -;; -;; Loads lazily via autoloads for the interactive entry points. - -;;; Code: - -(require 'cj-window-toggle-lib) ;; cj/side-window-display - -;; Shared *AI-Assistant* remembered-width state, owned by ai-config.el. -;; Forward-declared so loading a conversation reopens the panel at the same -;; width as the F-key toggle without a circular require. -(defvar cj/--ai-assistant-width) - -(defgroup cj/ai-conversations nil - "Conversation persistence for GPTel (save/load/delete, autosave)." - :group 'gptel - :prefix "cj/") - -(defcustom cj/gptel-conversations-directory - (expand-file-name "ai-conversations" user-emacs-directory) - "Directory where GPTel conversations are stored." - :type 'directory - :group 'cj/ai-conversations) - -(defcustom cj/gptel-conversations-window-side 'right - "Side to display the AI-Assistant buffer when loading a conversation." - :type '(choice (const :tag "Right" right) - (const :tag "Left" left) - (const :tag "Bottom" bottom) - (const :tag "Top" top)) - :group 'cj/ai-conversations) - -(defcustom cj/gptel-conversations-window-width 0.4 - "Set the side window width when loading a conversation. - -If displaying on the top or bottom, treat this value as a height fraction." - :type 'number - :group 'cj/ai-conversations) - -(defcustom cj/gptel-conversations-sort-order 'newest-first - "Sort order for conversation selection prompts." - :type '(choice (const :tag "Newest first" newest-first) - (const :tag "Oldest first" oldest-first)) - :group 'cj/ai-conversations) - -(defvar-local cj/gptel-autosave-enabled nil - "Non-nil means auto-save after each AI response in GPTel buffers.") - -(defvar-local cj/gptel-autosave-filepath nil - "File path used for auto-saving the conversation buffer.") - -(defvar-local cj/gptel-autosave--timer nil - "Repeating timer used to auto-save the current GPTel buffer.") - -(defcustom cj/gptel-autosave-interval 60 - "Seconds between periodic GPTel conversation autosaves." - :type 'number - :group 'cj/ai-conversations) - -(defvar cj/gptel-autosave-mode-line-format - '(:eval (when (bound-and-true-p cj/gptel-autosave-enabled) " [AS]")) - "Mode-line construct that surfaces autosave state in GPTel buffers.") -(put 'cj/gptel-autosave-mode-line-format 'risky-local-variable t) - -(defun cj/gptel--autosave-active-p () - "Return non-nil when the current buffer has an autosave target." - (and (bound-and-true-p gptel-mode) - cj/gptel-autosave-enabled - (stringp cj/gptel-autosave-filepath) - (> (length cj/gptel-autosave-filepath) 0))) - -(defun cj/gptel--autosave-stop-timer () - "Cancel the current buffer's periodic autosave timer, if any." - (when cj/gptel-autosave--timer - (cancel-timer cj/gptel-autosave--timer) - (setq-local cj/gptel-autosave--timer nil))) - -(defun cj/gptel--autosave-timer-callback (buffer) - "Auto-save BUFFER from a periodic timer when autosave is still active." - (when (buffer-live-p buffer) - (with-current-buffer buffer - (if (cj/gptel--autosave-active-p) - (condition-case err - (cj/gptel--save-buffer-to-file (current-buffer) cj/gptel-autosave-filepath) - (error (message "cj/gptel periodic autosave failed: %s" - (error-message-string err)))) - (cj/gptel--autosave-stop-timer))))) - -(defun cj/gptel--autosave-start-timer () - "Start the current buffer's periodic autosave timer when autosave is active." - (when (and (cj/gptel--autosave-active-p) - (not cj/gptel-autosave--timer)) - (setq-local cj/gptel-autosave--timer - (run-with-timer cj/gptel-autosave-interval - cj/gptel-autosave-interval - #'cj/gptel--autosave-timer-callback - (current-buffer))))) - -(defun cj/gptel-autosave-toggle () - "Toggle autosave on/off in the current GPTel buffer. -Flips `cj/gptel-autosave-enabled' and forces a mode-line redisplay so -the [AS] indicator updates immediately. When turning autosave ON -without a configured filepath, prompt to save the conversation first -so a path exists to autosave to." - (interactive) - (unless (bound-and-true-p gptel-mode) - (user-error "Not a GPTel buffer")) - (if cj/gptel-autosave-enabled - (progn - (setq-local cj/gptel-autosave-enabled nil) - (cj/gptel--autosave-stop-timer) - (message "Autosave disabled")) - (cond - ((and (stringp cj/gptel-autosave-filepath) - (> (length cj/gptel-autosave-filepath) 0)) - (setq-local cj/gptel-autosave-enabled t) - (cj/gptel--autosave-start-timer) - (message "Autosave enabled (saving to %s)" - (file-name-nondirectory cj/gptel-autosave-filepath))) - ((y-or-n-p "No save target yet. Save conversation first? ") - (call-interactively #'cj/gptel-save-conversation)) - (t - (message "Autosave not enabled (no save target)")))) - (force-mode-line-update)) - -(defcustom cj/gptel-conversations-autosave-on-send t - "Non-nil means auto-save the conversation immediately after `gptel-send'." - :type 'boolean - :group 'cj/ai-conversations) - -(defun cj/gptel--autosave-after-send (&rest _args) - "Auto-save current GPTel buffer right after `gptel-send' if enabled." - (when (and cj/gptel-conversations-autosave-on-send - (cj/gptel--autosave-active-p)) - (condition-case err - (cj/gptel--save-buffer-to-file (current-buffer) cj/gptel-autosave-filepath) - (error (message "cj/gptel autosave-on-send failed: %s" (error-message-string err)))))) - -(with-eval-after-load 'gptel - (unless (advice-member-p #'cj/gptel--autosave-after-send #'gptel-send) - (advice-add 'gptel-send :after #'cj/gptel--autosave-after-send))) - -(defun cj/gptel--install-autosave-mode-line () - "Add the [AS] autosave indicator to the current buffer's mode-line. -Idempotent: re-running in the same buffer does not duplicate the -construct." - (unless (member 'cj/gptel-autosave-mode-line-format mode-line-format) - (setq-local mode-line-format - (append mode-line-format - (list 'cj/gptel-autosave-mode-line-format))))) - -(defun cj/gptel--install-autosave-buffer-hooks () - "Install buffer-local cleanup hooks for GPTel autosave." - (add-hook 'kill-buffer-hook #'cj/gptel--autosave-stop-timer nil t)) - -(with-eval-after-load 'gptel - (add-hook 'gptel-mode-hook #'cj/gptel--install-autosave-mode-line) - (add-hook 'gptel-mode-hook #'cj/gptel--install-autosave-buffer-hooks)) - -(defun cj/gptel--slugify-topic (s) - "Return a filesystem-friendly slug for topic string S." - (let* ((down (downcase (or s ""))) - (repl (replace-regexp-in-string "[^a-z0-9]+" "-" down)) - (trim (replace-regexp-in-string "^-+\\|-+$" "" repl))) - (or (and (> (length trim) 0) trim) "conversation"))) - -(defun cj/gptel--existing-topics () - "Return topic slugs, without timestamps, present in the conversations directory." - (when (file-exists-p cj/gptel-conversations-directory) - (let* ((files (directory-files cj/gptel-conversations-directory nil "\\.gptel$"))) - (delete-dups - (mapcar - (lambda (f) - (replace-regexp-in-string "_[0-9]\\{8\\}-[0-9]\\{6\\}\\.gptel$" "" f)) - files))))) - -(defun cj/gptel--latest-file-for-topic (topic-slug) - "Return the newest saved conversation filename for TOPIC-SLUG, or nil." - (let* ((rx (format "^%s_[0-9]\\{8\\}-[0-9]\\{6\\}\\.gptel$" - (regexp-quote topic-slug))) - (files (and (file-exists-p cj/gptel-conversations-directory) - (directory-files cj/gptel-conversations-directory nil rx)))) - (car (sort files #'string>)))) - -(defun cj/gptel--timestamp-from-filename (filename) - "Return an Emacs timestamp extracted from FILENAME, or nil. - -Expect FILENAME to match _YYYYMMDD-HHMMSS.gptel." - (when (string-match "_\\([0-9]\\{8\\}\\)-\\([0-9]\\{6\\}\\)\\.gptel\\'" filename) - (let* ((date (match-string 1 filename)) - (time (match-string 2 filename)) - (Y (string-to-number (substring date 0 4))) - (M (string-to-number (substring date 4 6))) - (D (string-to-number (substring date 6 8))) - (h (string-to-number (substring time 0 2))) - (m (string-to-number (substring time 2 4))) - (s (string-to-number (substring time 4 6)))) - (encode-time s m h D M Y)))) - -(defun cj/gptel--conversation-candidates () - "Return conversation candidates sorted per `cj/gptel-conversations-sort-order'." - (unless (file-exists-p cj/gptel-conversations-directory) - (user-error "Conversations directory doesn't exist: %s" cj/gptel-conversations-directory)) - (let* ((files (directory-files cj/gptel-conversations-directory nil "\\.gptel$")) - (enriched - (mapcar - (lambda (f) - (let* ((full (expand-file-name f cj/gptel-conversations-directory)) - (ptime (or (cj/gptel--timestamp-from-filename f) - (nth 5 (file-attributes full)))) - (disp (format "%s [%s]" f (format-time-string "%Y-%m-%d %H:%M" ptime)))) - (list :file f :time ptime :display disp))) - files)) - (sorted - (sort enriched - (lambda (a b) - (let ((ta (plist-get a :time)) - (tb (plist-get b :time))) - (if (eq cj/gptel-conversations-sort-order 'newest-first) - (time-less-p tb ta) ;; tb earlier than ta => a first - (time-less-p ta tb)))))) - (cands (mapcar (lambda (pl) - (cons (plist-get pl :display) - (plist-get pl :file))) - sorted))) - cands)) - -(defun cj/gptel--save-buffer-to-file (buffer filepath) - "Save BUFFER content to FILEPATH with Org visibility properties." - (with-current-buffer buffer - (let ((content (buffer-string))) - (with-temp-buffer - (insert "#+STARTUP: showeverything\n") - (insert "#+VISIBILITY: all\n\n") - (insert content) - (write-region (point-min) (point-max) filepath nil 'silent)))) - filepath) - -(defun cj/gptel--ensure-ai-buffer () - "Return the *AI-Assistant* buffer, creating it via `gptel' if needed." - (let* ((buf-name "*AI-Assistant*") - (buffer (get-buffer buf-name))) - (unless buffer - (gptel buf-name)) - (or (get-buffer buf-name) - (user-error "Could not create or find *AI-Assistant* buffer")))) - -(defun cj/gptel-save-conversation () - "Save the current AI-Assistant buffer to a .gptel file. - -Enable autosave for subsequent AI responses to the same file." - (interactive) - (let ((buf (get-buffer "*AI-Assistant*"))) - (unless buf - (user-error "No AI-Assistant buffer found")) - (unless (file-exists-p cj/gptel-conversations-directory) - (make-directory cj/gptel-conversations-directory t) - (message "Created directory: %s" cj/gptel-conversations-directory)) - (let* ((topics (or (cj/gptel--existing-topics) '())) - (input (completing-read "Conversation topic: " topics nil nil)) - (topic-slug (cj/gptel--slugify-topic input)) - (latest (cj/gptel--latest-file-for-topic topic-slug)) - (use-existing (and latest - (y-or-n-p (format "Update existing file %s? " latest)))) - (filepath (if use-existing - (expand-file-name latest cj/gptel-conversations-directory) - (let* ((timestamp (format-time-string "%Y%m%d-%H%M%S")) - (filename (format "%s_%s.gptel" topic-slug timestamp))) - (expand-file-name filename cj/gptel-conversations-directory))))) - (cj/gptel--save-buffer-to-file buf filepath) - (with-current-buffer buf - (setq-local cj/gptel-autosave-filepath filepath) - (setq-local cj/gptel-autosave-enabled t) - (cj/gptel--autosave-start-timer)) - (message "Conversation saved to: %s" filepath)))) - -(defun cj/gptel-delete-conversation () - "Delete a saved GPTel conversation file (chronologically sorted candidates)." - (interactive) - (unless (file-exists-p cj/gptel-conversations-directory) - (user-error "Conversations directory doesn't exist: %s" cj/gptel-conversations-directory)) - (let* ((cands (cj/gptel--conversation-candidates))) - (unless cands - (user-error "No saved conversations found in %s" cj/gptel-conversations-directory)) - (let* ((completion-extra-properties '(:display-sort-function identity - :cycle-sort-function identity)) - (selection (completing-read "Delete conversation: " cands nil t)) - (filename (cdr (assoc selection cands))) - (filepath (and filename - (expand-file-name filename cj/gptel-conversations-directory)))) - (unless filename - (user-error "No conversation selected")) - (when (y-or-n-p (format "Really delete %s? " filename)) - (delete-file filepath) - (message "Deleted conversation: %s" filename))))) - -(defun cj/gptel--strip-visibility-headers () - "Strip org visibility headers at the top of the current buffer if present." - (save-excursion - (goto-char (point-min)) - (while (looking-at "^#\\+\\(STARTUP\\|VISIBILITY\\):.*\n") - (delete-region (match-beginning 0) (match-end 0))) - (when (looking-at "^\n+") - (delete-region (point) (match-end 0))))) - -(defun cj/gptel-load-conversation () - "Load a saved GPTel conversation into the AI-Assistant buffer. - -Prompt to save the current conversation first when appropriate, then -enable autosave." - (interactive) - (let ((ai-buffer (get-buffer-create "*AI-Assistant*"))) - (when (and (with-current-buffer ai-buffer (> (buffer-size) 0)) - (with-current-buffer ai-buffer (bound-and-true-p gptel-mode))) - (when (y-or-n-p "Save current conversation before loading new one? ") - (with-current-buffer ai-buffer - (call-interactively #'cj/gptel-save-conversation))))) - (unless (file-exists-p cj/gptel-conversations-directory) - (user-error "Conversations directory doesn't exist: %s" cj/gptel-conversations-directory)) - (let* ((cands (cj/gptel--conversation-candidates))) - (unless cands - (user-error "No saved conversations found in %s" cj/gptel-conversations-directory)) - (let* ((completion-extra-properties '(:display-sort-function identity - :cycle-sort-function identity)) - (selection (completing-read "Load conversation: " cands nil t)) - (filename (cdr (assoc selection cands))) - (filepath (and filename - (expand-file-name filename cj/gptel-conversations-directory)))) - (unless filename - (user-error "No conversation selected")) - (with-current-buffer (cj/gptel--ensure-ai-buffer) - (erase-buffer) - (insert-file-contents filepath) - (cj/gptel--strip-visibility-headers) - (goto-char (point-max)) - (set-buffer-modified-p t) - (setq-local cj/gptel-autosave-filepath filepath) - (setq-local cj/gptel-autosave-enabled t) - (cj/gptel--autosave-start-timer)) - (let ((buf (get-buffer "*AI-Assistant*"))) - (unless (get-buffer-window buf) - (cj/side-window-display - buf cj/gptel-conversations-window-side - 'cj/--ai-assistant-width cj/gptel-conversations-window-width))) - (select-window (get-buffer-window "*AI-Assistant*")) - (message "Loaded conversation from: %s" filepath)))) - -(defun cj/gptel--autosave-after-response (&rest _args) - "Auto-save the current GPTel buffer when enabled." - (when (cj/gptel--autosave-active-p) - (condition-case err - (cj/gptel--save-buffer-to-file (current-buffer) cj/gptel-autosave-filepath) - (error (message "cj/gptel autosave failed: %s" (error-message-string err)))))) - -(with-eval-after-load 'gptel - (unless (member #'cj/gptel--autosave-after-response gptel-post-response-functions) - (add-hook 'gptel-post-response-functions #'cj/gptel--autosave-after-response))) - -(provide 'ai-conversations) -;;; ai-conversations.el ends here diff --git a/modules/ai-mcp.el b/modules/ai-mcp.el deleted file mode 100644 index 510805be4..000000000 --- a/modules/ai-mcp.el +++ /dev/null @@ -1,416 +0,0 @@ -;;; ai-mcp.el --- MCP server integration for GPTel -*- lexical-binding: t; coding: utf-8; -*- -;; Author: Craig Jennings -;; Maintainer: Craig Jennings -;; Version 0.1 -;; Package-Requires: ((emacs "30.1") (mcp "0.1.0") (gptel "0.9.8")) -;; Keywords: convenience, tools, ai -;; -;;; Commentary: -;; Wires mcp.el's MCP server inventory into GPTel. GPTel agents gain -;; access to the MCP servers Claude Code already uses (linear, notion, -;; figma, slack-deepsat, drawio, google-calendar, google-docs-personal, -;; google-docs-work, google-keep), with write-confirmation gating and a -;; doctor for diagnosing prerequisites. -;; -;; Design doc: docs/specs/mcp-el-gptel-integration-spec-doing.org -;; -;; File organization (seven sections, populated by phases): -;; 1. Constants and defcustoms <- this phase -;; 2. Public commands <- later phase -;; 3. Pure helpers <- this phase -;; 4. mcp.el compatibility layer <- later phase -;; 5. Registration pipeline <- later phase -;; 6. Async state machine <- later phase -;; 7. UI <- later phase - -;;; Code: - -(require 'cl-lib) -(require 'json) - -;;;; --- 1. Constants and defcustoms ----------------------------------- - -(defgroup cj/ai-mcp nil - "MCP server integration for GPTel." - :group 'gptel - :prefix "cj/") - -(defcustom cj/mcp-claude-config - (expand-file-name "~/.claude.json") - "Path to the Claude Code config that holds MCP server env vars. -The config is read at server-spawn time and cached by mtime." - :type 'file - :group 'cj/ai-mcp) - -(defconst cj/mcp-server-specs - '((:name "linear" - :transport http - :url "https://mcp.linear.app/mcp" - :auth in-protocol - :risk write-capable) - (:name "notion" - :transport http - :url "https://mcp.notion.com/mcp" - :auth in-protocol - :risk write-capable) - (:name "figma" - :transport stdio - :command "npx" - :args ("-y" "figma-developer-mcp" "--stdio") - :secret-args ("--figma-api-key" :figma-api-key) - :auth args-token - :risk arg-leak) - (:name "slack-deepsat" - :transport sse - :url "http://127.0.0.1:13080/sse" - :auth local - :risk write-capable) - (:name "drawio" - :transport stdio - :command "npx" - :args ("-y" "@drawio/mcp") - :auth none - :risk none) - (:name "google-calendar" - :transport stdio - :command "npx" - :args ("-y" "@cocal/google-calendar-mcp") - :env (:GOOGLE_OAUTH_CREDENTIALS t) - :auth oauth - :risk write-capable) - (:name "google-docs-personal" - :transport stdio - :command "npx" - :args ("-y" "@a-bonus/google-docs-mcp") - :env (:GOOGLE_CLIENT_ID t :GOOGLE_CLIENT_SECRET t :GOOGLE_MCP_PROFILE t) - :auth oauth - :risk write-capable) - (:name "google-docs-work" - :transport stdio - :command "npx" - :args ("-y" "@a-bonus/google-docs-mcp") - :env (:GOOGLE_CLIENT_ID t :GOOGLE_CLIENT_SECRET t :GOOGLE_MCP_PROFILE t) - :auth oauth - :risk write-capable) - (:name "google-keep" - :transport stdio - :command "uvx" - :args ("--from" "keep-mcp" "python" "-m" "server.cli") - :env (:GOOGLE_EMAIL t :GOOGLE_MASTER_TOKEN t) - :auth token - :risk write-capable)) - "Static, secret-free description of the MCP servers we wire to GPTel. -Each entry is a plist describing one server. `:env' values are -placeholders (t) replaced at spawn time from `cj/mcp-claude-config'. -`:secret-args' (e.g. for figma) names the flag whose value is pulled -from the Claude config's args at spawn time.") - -(defcustom cj/mcp-enabled-servers - (mapcar (lambda (s) (plist-get s :name)) cj/mcp-server-specs) - "List of MCP server names to start. -Defaults to every server in `cj/mcp-server-specs'. Set to a -shorter list to disable specific servers without editing the -spec. Changes take effect on next `cj/mcp-restart-failed' or -Emacs restart." - :type '(repeat string) - :group 'cj/ai-mcp) - -(defcustom cj/mcp-start-on-entry-points - '(toggle-gptel) - "GPTel entry points that trigger MCP startup. -Symbols correspond to commands: `toggle-gptel', `gptel-send', -`gptel-quick-ask', `gptel-rewrite-with-directive', -`gptel-magit-generate-message'. Default: only full chat -\(`toggle-gptel')." - :type '(repeat symbol) - :group 'cj/ai-mcp) - -(defcustom cj/mcp-startup-timeout 30 - "Seconds before a still-starting MCP server is marked failed." - :type 'integer - :group 'cj/ai-mcp) - -(defcustom cj/mcp-tool-timeout 60 - "Seconds before an in-flight MCP tool call times out." - :type 'integer - :group 'cj/ai-mcp) - -(defcustom cj/mcp-tool-confirm-overrides nil - "Per-tool confirmation overrides. -Alist mapping fully qualified MCP tool name (e.g., -\"mcp__linear__create_issue\") to t or nil. Wins over the -pattern-based classifier in `cj/mcp--confirm-p'." - :type '(alist :key-type string :value-type boolean) - :group 'cj/ai-mcp) - -(defcustom cj/mcp-tool-audit-log-enabled t - "When non-nil, append metadata for every MCP tool call to the audit log." - :type 'boolean - :group 'cj/ai-mcp) - -;; Classifier patterns: name prefixes that indicate read vs write. - -(defconst cj/mcp--write-name-patterns - '("\\`create\\b" "\\`update\\b" "\\`delete\\b" "\\`remove\\b" - "\\`send\\b" "\\`post\\b" "\\`add\\b" "\\`move\\b" - "\\`invite\\b" "\\`share\\b" "\\`upload\\b" "\\`set\\b" - "\\`patch\\b" "\\`import\\b" "\\`sync\\b" "\\`merge\\b" - "\\`close\\b" "\\`reopen\\b" "\\`archive\\b" "\\`unarchive\\b" - "\\`approve\\b" "\\`reject\\b" "\\`label\\b" "\\`assign\\b" - "\\`reply\\b" "\\`comment\\b" "\\`trash\\b" "\\`restore\\b" - "\\`pin\\b" "\\`unpin\\b" "\\`copy\\b" "\\`rename\\b") - "Tool-name prefixes that indicate a write/mutate operation. -Matched after the `mcp__SERVER__' prefix is stripped.") - -(defconst cj/mcp--read-name-patterns - '("\\`get\\b" "\\`list\\b" "\\`read\\b" "\\`search\\b" - "\\`find\\b" "\\`fetch\\b" "\\`view\\b" "\\`query\\b" - "\\`describe\\b" "\\`show\\b" "\\`check\\b") - "Tool-name prefixes that indicate a read-only operation.") - -;; Secret-pattern list for redaction. Each entry is (REGEX -;; . GROUP-NUMBER); the substring matched by GROUP-NUMBER is replaced -;; with "***". - -(defconst cj/mcp--secret-redaction-patterns - '(("\\(--token\\)\\(=\\|\\s-+\\)\\(\\S-+\\)" . 3) - ("\\(--secret\\)\\(=\\|\\s-+\\)\\(\\S-+\\)" . 3) - ("\\(--password\\)\\(=\\|\\s-+\\)\\(\\S-+\\)" . 3) - ("\\(--figma-api-key\\)\\(=\\|\\s-+\\)\\(\\S-+\\)" . 3) - ("\\(Authorization:\\s-*\\)\\(\\S-[^\"\n]*\\)" . 2) - ("\\([?&]token=\\)\\([^&[:space:]\"]+\\)" . 2)) - "List of (REGEX . GROUP-NUMBER) for masking secrets in user-facing strings. -Applied in order by `cj/mcp--redact'.") - -;;;; --- 3. Pure helpers ----------------------------------------------- - -;; ---- secrets redaction ---- - -(defun cj/mcp--redact (str) - "Return STR with known secret patterns replaced by `***'. -Returns nil when STR is not a string. See -`cj/mcp--secret-redaction-patterns' for the matched patterns." - (when (stringp str) - (let ((result str)) - (dolist (entry cj/mcp--secret-redaction-patterns result) - (let ((re (car entry)) - (group (cdr entry)) - (start 0)) - (while (and (< start (length result)) - (string-match re result start)) - (setq result - (concat (substring result 0 (match-beginning group)) - "***" - (substring result (match-end group)))) - (setq start (+ (match-beginning group) 3)))))))) - -;; ---- confirm-policy classifier ---- - -(defun cj/mcp--strip-name-prefix (name) - "Strip the `mcp__SERVER__' prefix from NAME, if present." - (replace-regexp-in-string "\\`mcp__[^_]+__" "" name)) - -(defun cj/mcp--name-matches-p (name patterns) - "Non-nil if NAME matches any regexp in PATTERNS." - (cl-some (lambda (p) (string-match-p p name)) patterns)) - -(defun cj/mcp--confirm-p (gptel-name &optional remote-name) - "Return non-nil if a tool should register with `:confirm t'. -GPTEL-NAME is the fully qualified `mcp__SERVER__TOOL' string. -REMOTE-NAME, if provided, overrides the prefix-strip of GPTEL-NAME. - -Decision order: -1. `cj/mcp-tool-confirm-overrides' alist entry wins. -2. Bare name matches a write pattern → t. -3. Bare name matches a read pattern → nil. -4. Neither → t (fail closed)." - (let ((override (assoc gptel-name cj/mcp-tool-confirm-overrides))) - (cond - (override (cdr override)) - (t - (let ((bare (or remote-name (cj/mcp--strip-name-prefix gptel-name)))) - (cond - ((cj/mcp--name-matches-p bare cj/mcp--write-name-patterns) t) - ((cj/mcp--name-matches-p bare cj/mcp--read-name-patterns) nil) - (t t))))))) - -;; ---- description normalizer ---- - -(defun cj/mcp--normalize-description (server-name raw-tool) - "Return a normalized description string for RAW-TOOL from SERVER-NAME. -Prefix `[SERVER]' for reads, `[SERVER WRITE]' for writes, -`[SERVER ?]' for unknown classification, then the upstream -description unchanged." - (let* ((remote-name (plist-get raw-tool :name)) - (upstream (or (plist-get raw-tool :description) - "(no description provided by server)")) - (suffix (cond - ((cj/mcp--name-matches-p remote-name - cj/mcp--write-name-patterns) - " WRITE") - ((cj/mcp--name-matches-p remote-name - cj/mcp--read-name-patterns) - "") - (t " ?")))) - (format "[%s%s] %s" server-name suffix upstream))) - -;; ---- Claude config reader (mtime-cached, structured returns) ---- - -(defvar cj/mcp--config-cache nil - "Cache for the parsed Claude config. -Plist of (:path P :mtime M :data PARSED) or nil when empty.") - -(defun cj/mcp--invalidate-config-cache () - "Force the next `cj/mcp--read-claude-config' call to reparse." - (setq cj/mcp--config-cache nil)) - -(defun cj/mcp--read-claude-config (&optional path) - "Return a structured plist describing the Claude config state. -PATH defaults to `cj/mcp-claude-config'. - -Result shape: - (:ok t :data PLIST) - (:ok nil :reason missing-file) - (:ok nil :reason unreadable) - (:ok nil :reason malformed-json :message STR) - -The parsed result is cached by (PATH, MTIME); subsequent calls -reparse only if the file has changed." - (let ((path (or path cj/mcp-claude-config))) - (cond - ((not (file-exists-p path)) - (list :ok nil :reason 'missing-file)) - ((not (file-readable-p path)) - (list :ok nil :reason 'unreadable)) - (t - (let ((mtime (file-attribute-modification-time - (file-attributes path)))) - (if (and cj/mcp--config-cache - (equal (plist-get cj/mcp--config-cache :path) path) - (equal (plist-get cj/mcp--config-cache :mtime) mtime)) - (list :ok t :data (plist-get cj/mcp--config-cache :data)) - (condition-case err - (let* ((json-object-type 'plist) - (json-array-type 'list) - (data (with-temp-buffer - (insert-file-contents path) - (goto-char (point-min)) - (json-read)))) - (setq cj/mcp--config-cache - (list :path path :mtime mtime :data data)) - (list :ok t :data data)) - (error - (setq cj/mcp--config-cache nil) - (list :ok nil :reason 'malformed-json - :message (error-message-string err)))))))))) - -;; ---- env / secret-args resolution ---- - -(defun cj/mcp--get-server-entry (server-name &optional config-result) - "Return the parsed Claude-config entry plist for SERVER-NAME. -CONFIG-RESULT, if provided, is a return value from -`cj/mcp--read-claude-config' (avoids re-reading). Returns nil -when the config is unavailable or SERVER-NAME is unknown." - (let ((result (or config-result (cj/mcp--read-claude-config)))) - (when (plist-get result :ok) - (let* ((data (plist-get result :data)) - (servers (plist-get data :mcpServers)) - (server-key (intern (concat ":" server-name)))) - (plist-get servers server-key))))) - -(defun cj/mcp--get-env (server-name &optional config-result) - "Return the env plist for SERVER-NAME from the parsed Claude config. -CONFIG-RESULT, if provided, is reused to avoid re-reading the -config. Returns nil when the config is unavailable, the server -is unknown, or the server has no env section." - (plist-get (cj/mcp--get-server-entry server-name config-result) :env)) - -(defun cj/mcp--get-secret-arg (server-name flag &optional config-result) - "Return the secret value for SERVER-NAME's FLAG from the Claude config. -FLAG is the option name (e.g. \"--figma-api-key\"). Returns the -value following `FLAG=' in the server entry's args, or nil if -not found." - (let* ((entry (cj/mcp--get-server-entry server-name config-result)) - (args (plist-get entry :args)) - (prefix (concat flag "="))) - (cl-some - (lambda (a) - (when (and (stringp a) (string-prefix-p prefix a)) - (substring a (length prefix)))) - args))) - -;; ---- server-alist builder (pure transform from specs + config) ---- - -(defun cj/mcp--resolve-env (env-spec server-name config-result) - "Return a flat (KEY1 VAL1 KEY2 VAL2 ...) list for ENV-SPEC. -ENV-SPEC is a plist of `(:VAR1 t :VAR2 t)`. Values come from -SERVER-NAME's env subtree in the parsed Claude config. Vars -without a value are omitted." - (let ((source-env (cj/mcp--get-env server-name config-result)) - (result nil)) - (cl-loop for (key _placeholder) on env-spec by #'cddr - do (let ((value (plist-get source-env key))) - (when value - (push key result) - (push value result)))) - (nreverse result))) - -(defun cj/mcp--resolve-args (args secret-args-spec server-name config-result) - "Return ARGS with `:secret-args' placeholders filled in. -SECRET-ARGS-SPEC is (FLAG-STRING SLOT-KEYWORD). When the value is -available in the Claude config, append `FLAG=VALUE' to ARGS; -otherwise return ARGS unchanged." - (if (not secret-args-spec) - args - (let* ((flag (car secret-args-spec)) - (value (cj/mcp--get-secret-arg server-name flag config-result))) - (if value - (append args (list (format "%s=%s" flag value))) - args)))) - -(defun cj/mcp--spec-to-alist-entry (spec config-result) - "Translate one SPEC plist into a `(NAME . PLIST)' alist entry. -Pulls env values from CONFIG-RESULT; splices `:secret-args' into -`:args' for stdio specs that declare one." - (let* ((name (plist-get spec :name)) - (transport (plist-get spec :transport)) - (entry (list :type (symbol-name transport))) - (env-spec (plist-get spec :env)) - (secret-args-spec (plist-get spec :secret-args))) - (pcase transport - ('stdio - (setq entry (append entry - (list :command (plist-get spec :command) - :args (cj/mcp--resolve-args - (plist-get spec :args) - secret-args-spec - name - config-result))))) - ((or 'http 'sse) - (setq entry (append entry - (list :url (plist-get spec :url)))))) - (when env-spec - (let ((env-pairs (cj/mcp--resolve-env env-spec name config-result))) - (when env-pairs - (setq entry (append entry (list :env env-pairs)))))) - (cons name entry))) - -(defun cj/mcp--build-server-alist (&optional specs enabled-names config-result) - "Return an alist suitable for `mcp-hub-servers'. -SPECS defaults to `cj/mcp-server-specs'. ENABLED-NAMES defaults -to `cj/mcp-enabled-servers'. CONFIG-RESULT, if provided, is a -parsed Claude-config result (reused for env/secret resolution). -Does not mutate SPECS." - (let* ((specs (or specs cj/mcp-server-specs)) - (enabled-names (or enabled-names cj/mcp-enabled-servers)) - (config-result (or config-result (cj/mcp--read-claude-config)))) - (delq nil - (mapcar - (lambda (spec) - (let ((name (plist-get spec :name))) - (when (member name enabled-names) - (cj/mcp--spec-to-alist-entry spec config-result)))) - specs)))) - -(provide 'ai-mcp) -;;; ai-mcp.el ends here diff --git a/modules/ai-quick-ask.el b/modules/ai-quick-ask.el deleted file mode 100644 index 16f3afae4..000000000 --- a/modules/ai-quick-ask.el +++ /dev/null @@ -1,141 +0,0 @@ -;;; ai-quick-ask.el --- One-shot GPTel quick-ask -*- lexical-binding: t; coding: utf-8; -*- - -;; Author: Craig Jennings - -;;; Commentary: -;; Provides `cj/gptel-quick-ask': read a single prompt in the -;; minibuffer, stream the response into a transient *GPTel-Quick* -;; buffer. The transient buffer is dismissible with q or escape and -;; can be escalated with c into a full *AI-Assistant* conversation -;; seeded with the prompt + response. -;; -;; Designed for impromptu help where the conversation thread doesn't -;; matter. Doesn't touch the *AI-Assistant* side window unless the -;; user explicitly escalates, doesn't autosave anywhere. - -;;; Code: - -(require 'cj-window-toggle-lib) ;; cj/side-window-display - -;; Shared *AI-Assistant* panel-width state, owned by ai-config.el. Forward- -;; declared here so the escalation reopens the panel at the same remembered -;; width as the F-key toggle without a circular require. -(defvar cj/ai-assistant-window-width) -(defvar cj/--ai-assistant-width) - -(defvar-local cj/gptel-quick--prompt nil - "Buffer-local: the prompt used for the current *GPTel-Quick* session.") - -(defconst cj/gptel-quick--buffer-name "*GPTel-Quick*" - "Buffer used for one-shot quick-ask Q&A.") - -(defconst cj/gptel-quick--response-marker "A: " - "String inserted before the response in the quick-ask buffer.") - -(defvar-keymap cj/gptel-quick-mode-map - :doc "Keymap for `cj/gptel-quick-mode'." - "q" #'cj/gptel-quick-dismiss - "" #'cj/gptel-quick-dismiss - "c" #'cj/gptel-quick-continue) - -(define-derived-mode cj/gptel-quick-mode special-mode "GPTel-Quick" - "Major mode for the one-shot *GPTel-Quick* buffer." - ;; Allow gptel-request to stream into the buffer despite the - ;; special-mode read-only default. - (setq-local buffer-read-only nil)) - -(defun cj/gptel-quick--initial-text (prompt) - "Return the initial buffer body for a quick-ask of PROMPT. -The result is \"Q: \\n\\nA: \", with the response marker at -the end so the streamed response lands right after it." - (format "Q: %s\n\n%s" prompt cj/gptel-quick--response-marker)) - -(defun cj/gptel-quick--extract-response (text) - "Return the response portion of TEXT, or nil if not found. -TEXT is the contents of a *GPTel-Quick* buffer. The response is -everything after the first occurrence of `cj/gptel-quick--response-marker' -on its own line. Returns nil when the marker is absent." - (when (string-match - (concat "^" (regexp-quote cj/gptel-quick--response-marker)) - text) - (substring text (match-end 0)))) - -(defun cj/gptel-quick--seed-text (prompt response) - "Format a *AI-Assistant* seed from PROMPT and RESPONSE. -Matches the org-heading shape that `cj/gptel--fresh-org-prefix' and -`cj/gptel-insert-model-heading' produce: a user heading followed by -the prompt body, followed by an AI heading followed by the response." - (let ((ts (format-time-string "[%Y-%m-%d %H:%M:%S]"))) - (format "* %s %s\n%s\n\n* AI %s\n%s\n" - user-login-name ts prompt - ts (or response "")))) - -;;;###autoload -(defun cj/gptel-quick-ask (prompt) - "Read a one-shot PROMPT in the minibuffer and stream the answer. -The response lands in a transient *GPTel-Quick* buffer. Press q or -escape to dismiss, or c to escalate into a full *AI-Assistant* -conversation seeded with the prompt and response." - (interactive (list (read-string "Quick ask: "))) - (when (string-empty-p prompt) - (user-error "Empty prompt")) - (let ((buf (get-buffer-create cj/gptel-quick--buffer-name))) - (with-current-buffer buf - (cj/gptel-quick-mode) - (let ((inhibit-read-only t)) - (erase-buffer) - (insert (cj/gptel-quick--initial-text prompt)) - (setq-local cj/gptel-quick--prompt prompt))) - (unless (featurep 'gptel) - (require 'gptel)) - (when (fboundp 'cj/ensure-gptel-backends) - (cj/ensure-gptel-backends)) - (gptel-request prompt - :buffer buf - :position (with-current-buffer buf (point-max)) - :stream t) - (display-buffer buf - '((display-buffer-reuse-window - display-buffer-pop-up-window) - (window-height . 0.3))) - buf)) - -(defun cj/gptel-quick-dismiss () - "Kill the *GPTel-Quick* buffer if it exists." - (interactive) - (when-let ((buf (get-buffer cj/gptel-quick--buffer-name))) - (when-let ((win (get-buffer-window buf))) - (delete-window win)) - (kill-buffer buf))) - -(defun cj/gptel-quick-continue () - "Escalate the current quick-ask into a full *AI-Assistant* conversation. -Reads the prompt and response from the *GPTel-Quick* buffer, seeds -them into *AI-Assistant* under proper org headings, displays the -side window, then dismisses the quick buffer." - (interactive) - (unless (eq major-mode 'cj/gptel-quick-mode) - (user-error "Not in a *GPTel-Quick* buffer")) - (let* ((prompt cj/gptel-quick--prompt) - (response (cj/gptel-quick--extract-response (buffer-string))) - (seed (cj/gptel-quick--seed-text prompt response))) - (unless prompt - (user-error "No prompt recorded in this buffer")) - ;; Ensure *AI-Assistant* exists in gptel-mode. - (unless (featurep 'gptel) - (require 'gptel)) - (let ((ai-buf (get-buffer "*AI-Assistant*"))) - (unless ai-buf - (when (fboundp 'cj/ensure-gptel-backends) - (cj/ensure-gptel-backends)) - (gptel "*AI-Assistant*") - (setq ai-buf (get-buffer "*AI-Assistant*"))) - (with-current-buffer ai-buf - (goto-char (point-max)) - (insert seed)) - (cj/side-window-display - ai-buf 'right 'cj/--ai-assistant-width cj/ai-assistant-window-width) - (cj/gptel-quick-dismiss)))) - -(provide 'ai-quick-ask) -;;; ai-quick-ask.el ends here diff --git a/modules/ai-rewrite.el b/modules/ai-rewrite.el deleted file mode 100644 index fb25c1379..000000000 --- a/modules/ai-rewrite.el +++ /dev/null @@ -1,108 +0,0 @@ -;;; ai-rewrite.el --- Directive-picker wrappers for gptel-rewrite -*- lexical-binding: t; coding: utf-8; -*- - -;; Author: Craig Jennings - -;;; Commentary: -;; Adds two ergonomic wrappers around `gptel-rewrite': -;; -;; cj/gptel-rewrite-with-directive Pick a named directive, -;; then rewrite the region. -;; cj/gptel-rewrite-redo-with-different-directive -;; Re-run the previous region -;; with a different directive. -;; -;; A directive is a short system-message snippet attached to a name -;; (e.g. "terse", "fix-grammar"). The directive body is injected -;; into the rewrite via `gptel-rewrite-directives-hook' just for that -;; call -- no global state changes. - -;;; Code: - -;; Declare the hook variable special so our `let'-binding below is -;; dynamic (visible across the `call-interactively' that follows) -;; rather than lexical when this file is byte-compiled. -(defvar gptel-rewrite-directives-hook) - -(defcustom cj/gptel-rewrite-directives - '(("terse" - . "Rewrite the text to be as terse as possible without losing meaning.\nDo not add commentary. Return only the rewritten text.") - ("fix-grammar" - . "Fix grammar and spelling errors only. Do not rephrase, restructure,\nor change tone. Return only the corrected text.") - ("refactor-readability" - . "Refactor the code for readability. Improve naming, split long\nfunctions when appropriate, remove unnecessary complexity, and preserve\nbehavior exactly. Return only the refactored code.") - ("add-docstring" - . "Add or improve docstrings for every function in the region. Use the\nidiomatic docstring style for the language. Do not change executable\ncode. Return the whole region with the updated docstrings.") - ("explain-as-comment" - . "Replace the region with the original code, preceded by a concise\nblock comment explaining what the code does. Use the language's\nidiomatic comment syntax. Return code + comment, nothing else.") - ("shorten" - . "Shorten the text while preserving meaning, technical accuracy, and\nthe author's voice. Remove rhetorical padding. Return only the\nshortened text.")) - "Named system-message directives for `cj/gptel-rewrite-with-directive'. -Each entry is a (NAME . BODY) pair where NAME is the directive label -presented in the completing-read prompt and BODY is the system -message injected into the next `gptel-rewrite' call." - :type '(alist :key-type string :value-type string) - :group 'cj) - -(defvar-local cj/gptel-rewrite--last-region nil - "Cons (BEG-MARKER . END-MARKER) of the last directive-driven rewrite.") - -(defvar-local cj/gptel-rewrite--last-directive nil - "Name of the directive used in the last directive-driven rewrite.") - -(defun cj/gptel-rewrite--call-with-directive (directive-name beg end) - "Run `gptel-rewrite' over BEG..END with DIRECTIVE-NAME's system message. -Stores the region (as markers) and directive name on buffer-local -variables so `cj/gptel-rewrite-redo-with-different-directive' can -revisit them." - (let ((body (alist-get directive-name cj/gptel-rewrite-directives - nil nil #'equal))) - (unless body - (user-error "Unknown rewrite directive: %s" directive-name)) - (setq-local cj/gptel-rewrite--last-region - (cons (copy-marker beg) (copy-marker end))) - (setq-local cj/gptel-rewrite--last-directive directive-name) - (let ((gptel-rewrite-directives-hook - (cons (lambda () body) gptel-rewrite-directives-hook))) - (save-excursion - (goto-char beg) - (push-mark end t t) - (call-interactively #'gptel-rewrite))))) - -;;;###autoload -(defun cj/gptel-rewrite-with-directive (directive-name) - "Pick DIRECTIVE-NAME from `cj/gptel-rewrite-directives' and rewrite the region. -Requires an active region. The directive is applied only to this -call -- it does not modify global `gptel-directives'." - (interactive - (progn - (unless (use-region-p) - (user-error "No region selected")) - (list (completing-read - "Rewrite directive: " - (mapcar #'car cj/gptel-rewrite-directives) nil t)))) - (cj/gptel-rewrite--call-with-directive - directive-name (region-beginning) (region-end))) - -;;;###autoload -(defun cj/gptel-rewrite-redo-with-different-directive () - "Re-run the previous directive-driven rewrite with a different directive. -The region is restored from the markers captured at the last call; -the user picks a new directive from the remaining choices." - (interactive) - (unless cj/gptel-rewrite--last-region - (user-error "No previous rewrite to redo in this buffer")) - (let* ((beg-mk (car cj/gptel-rewrite--last-region)) - (end-mk (cdr cj/gptel-rewrite--last-region)) - (current cj/gptel-rewrite--last-directive) - (others (cl-remove - current - (mapcar #'car cj/gptel-rewrite-directives) - :test #'equal)) - (chosen (completing-read - (format "Re-rewrite with (was %s): " current) - others nil t))) - (cj/gptel-rewrite--call-with-directive - chosen (marker-position beg-mk) (marker-position end-mk)))) - -(provide 'ai-rewrite) -;;; ai-rewrite.el ends here diff --git a/modules/font-config.el b/modules/font-config.el index 4821b89e1..1c431c864 100644 --- a/modules/font-config.el +++ b/modules/font-config.el @@ -265,13 +265,12 @@ the fontset repeatedly is harmless, so it can be called from (setq emojify-display-style (if (env-gui-p) 'image 'unicode)) (setq emojify-emoji-styles '(ascii unicode github)) - ;; Disable emojify in programming and gptel modes + ;; Disable emojify in programming modes (defun cj/disable-emojify-mode () "Disable emojify-mode in the current buffer." (emojify-mode -1)) - (add-hook 'prog-mode-hook #'cj/disable-emojify-mode) - (add-hook 'gptel-mode-hook #'cj/disable-emojify-mode)) + (add-hook 'prog-mode-hook #'cj/disable-emojify-mode)) ;; -------------------------- Display Available Fonts -------------------------- ;; display all available fonts on the system in a side panel -- cgit v1.2.3