diff options
| author | Craig Jennings <c@cjennings.net> | 2026-06-23 20:12:58 -0400 |
|---|---|---|
| committer | Craig Jennings <c@cjennings.net> | 2026-06-23 20:12:58 -0400 |
| commit | e41c25068d0cec9434895a6d3e3a25d3a26f645f (patch) | |
| tree | 5e30938a3fd6d80f501ffe3e6c1c187c5ddeb2c9 /archive/gptel/modules | |
| parent | a936e081b7270fbd4f1e7e9cb67ca1d4c2291ce6 (diff) | |
| download | dotemacs-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.el | 585 | ||||
| -rw-r--r-- | archive/gptel/modules/ai-conversations-browser.el | 241 | ||||
| -rw-r--r-- | archive/gptel/modules/ai-conversations.el | 369 | ||||
| -rw-r--r-- | archive/gptel/modules/ai-mcp.el | 416 | ||||
| -rw-r--r-- | archive/gptel/modules/ai-quick-ask.el | 141 | ||||
| -rw-r--r-- | archive/gptel/modules/ai-rewrite.el | 108 |
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 |
