aboutsummaryrefslogtreecommitdiff
path: root/modules
diff options
context:
space:
mode:
Diffstat (limited to 'modules')
-rw-r--r--modules/ai-config.el585
-rw-r--r--modules/ai-conversations-browser.el241
-rw-r--r--modules/ai-conversations.el369
-rw-r--r--modules/ai-mcp.el416
-rw-r--r--modules/ai-quick-ask.el141
-rw-r--r--modules/ai-rewrite.el108
-rw-r--r--modules/font-config.el5
7 files changed, 2 insertions, 1863 deletions
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 <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))))
-
-(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-<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.
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 <c@cjennings.net>
-
-;;; 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 <c@cjennings.net>
-;; Maintainer: Craig Jennings <c@cjennings.net>
-;; 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 <c@cjennings.net>
-;; Maintainer: Craig Jennings <c@cjennings.net>
-;; 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 <c@cjennings.net>
-
-;;; 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
- "<escape>" #'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: <prompt>\\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 <c@cjennings.net>
-
-;;; 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