aboutsummaryrefslogtreecommitdiff
path: root/archive/gptel/modules
diff options
context:
space:
mode:
authorCraig Jennings <c@cjennings.net>2026-06-23 20:12:58 -0400
committerCraig Jennings <c@cjennings.net>2026-06-23 20:12:58 -0400
commite41c25068d0cec9434895a6d3e3a25d3a26f645f (patch)
tree5e30938a3fd6d80f501ffe3e6c1c187c5ddeb2c9 /archive/gptel/modules
parenta936e081b7270fbd4f1e7e9cb67ca1d4c2291ce6 (diff)
downloaddotemacs-e41c25068d0cec9434895a6d3e3a25d3a26f645f.tar.gz
dotemacs-e41c25068d0cec9434895a6d3e3a25d3a26f645f.zip
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.
Diffstat (limited to 'archive/gptel/modules')
-rw-r--r--archive/gptel/modules/ai-config.el585
-rw-r--r--archive/gptel/modules/ai-conversations-browser.el241
-rw-r--r--archive/gptel/modules/ai-conversations.el369
-rw-r--r--archive/gptel/modules/ai-mcp.el416
-rw-r--r--archive/gptel/modules/ai-quick-ask.el141
-rw-r--r--archive/gptel/modules/ai-rewrite.el108
6 files changed, 1860 insertions, 0 deletions
diff --git a/archive/gptel/modules/ai-config.el b/archive/gptel/modules/ai-config.el
new file mode 100644
index 000000000..97af1296d
--- /dev/null
+++ b/archive/gptel/modules/ai-config.el
@@ -0,0 +1,585 @@
+;;; 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/archive/gptel/modules/ai-conversations-browser.el b/archive/gptel/modules/ai-conversations-browser.el
new file mode 100644
index 000000000..9f2a7de43
--- /dev/null
+++ b/archive/gptel/modules/ai-conversations-browser.el
@@ -0,0 +1,241 @@
+;;; 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/archive/gptel/modules/ai-conversations.el b/archive/gptel/modules/ai-conversations.el
new file mode 100644
index 000000000..8061051a8
--- /dev/null
+++ b/archive/gptel/modules/ai-conversations.el
@@ -0,0 +1,369 @@
+;;; 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/archive/gptel/modules/ai-mcp.el b/archive/gptel/modules/ai-mcp.el
new file mode 100644
index 000000000..510805be4
--- /dev/null
+++ b/archive/gptel/modules/ai-mcp.el
@@ -0,0 +1,416 @@
+;;; 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/archive/gptel/modules/ai-quick-ask.el b/archive/gptel/modules/ai-quick-ask.el
new file mode 100644
index 000000000..16f3afae4
--- /dev/null
+++ b/archive/gptel/modules/ai-quick-ask.el
@@ -0,0 +1,141 @@
+;;; 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/archive/gptel/modules/ai-rewrite.el b/archive/gptel/modules/ai-rewrite.el
new file mode 100644
index 000000000..fb25c1379
--- /dev/null
+++ b/archive/gptel/modules/ai-rewrite.el
@@ -0,0 +1,108 @@
+;;; 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