summaryrefslogtreecommitdiff
path: root/modules
diff options
context:
space:
mode:
authorCraig Jennings <c@cjennings.net>2025-09-06 16:34:01 -0500
committerCraig Jennings <c@cjennings.net>2025-09-06 16:34:01 -0500
commit06f31ba9a488c301406c6786a3522a4bda78a443 (patch)
treebf05ecd82514ca09c5a835249fc0512096d1dc1c /modules
parent0d0bbf0ea7c73b0e26060d9cf45967d406d96d30 (diff)
downloaddotemacs-06f31ba9a488c301406c6786a3522a4bda78a443.tar.gz
dotemacs-06f31ba9a488c301406c6786a3522a4bda78a443.zip
feat(ai): Enhance GPTel workflow with save/load functionality
Add comprehensive conversation management with file persistence and improved context handling. The update includes a dedicated directory for storing conversations, new keybindings for save/load operations, and better buffer/file context management. Also improves documentation, cleans up the code structure, and enhances org-mode formatting with proper hierarchical headings.
Diffstat (limited to 'modules')
-rw-r--r--modules/ai-config.el394
-rw-r--r--modules/ai-directives.el13
2 files changed, 314 insertions, 93 deletions
diff --git a/modules/ai-config.el b/modules/ai-config.el
index a2c89cad..ba1c6ecc 100644
--- a/modules/ai-config.el
+++ b/modules/ai-config.el
@@ -2,60 +2,49 @@
;; author Craig Jennings <c@cjennings.net>
;;; Commentary:
-
-;; Here is my basic workflow:
-
-;; - Launch GPTel via F9 or M-a t, and chat with the AI in the side window.
-;; Remember that sending the message requires C-<return>.
-;; ... or ...
-;; - Select a region to rewrite, key M-a r, and add the directive in the menu.
-
-;; Note;
-;; - you can save a file for a later session. Just open the file and change the buffer to gptel-mode to resume work.
-;; - add files to the context with M-a f
-;; - add buffers to the context with M-a b.
+;; Configuration for AI integrations in Emacs, focused on GPTel.
+;;
+;; Main Features:
+;; - Quick toggle for AI assistant window (F9 or M-a t)
+;; - Custom keymap (M-a prefix) for all 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 F9 or M-a t, and chat in the AI-Assistant side window (C-<return> to send)
+;; - Change system prompt (expertise, personalities) with M-a p
+;; - Add context from files (M-a f) or buffers (M-a b)
+;; - Save conversations with M-a s, load previous ones with M-a l
+;; - Clear the conversation and start over with M-a x
+;; Or in any buffer:
+;; - Add directive as above, and select a region to rewrite with M-a r.
+;;
+;; Uses AI directives from ai-directives.el for specialized AI behaviors.
;;; Code:
(add-to-list 'load-path (concat user-emacs-directory "modules/"))
(require 'ai-directives)
-;;; ------------------------------ Toggle GPTel --------------------------------
-
-(defun cj/toggle-gptel ()
- "Toggle the visibility of the ChatGPT buffer, and place point at its end."
- (interactive)
- (let* ((buf-name "*AI-Assistant*")
- (buffer (get-buffer buf-name))
- (win (and buffer (get-buffer-window buffer))))
- (if win
- ;; If it's already visible, just close it
- (delete-window win)
- ;; Otherwise ensure the buffer exists
- (unless buffer
- (gptel buf-name gptel-model))
- (setq buffer (get-buffer buf-name))
- ;; Display in a side window, select it, and move point to end
- (setq win
- (display-buffer-in-side-window
- buffer
- '((side . right)
- (window-width . 0.4))))
- (select-window win)
- (with-current-buffer buffer
- (goto-char (point-max))))))
-
-;; ------------------------- GPTel Config And AI-Keymap ------------------------
+;;; ------------------------- GPTel Config And AI-Keymap ------------------------
(defvar ai-keymap
(let ((map (make-sparse-keymap)))
- (define-key map (kbd "t") #'cj/toggle-gptel)
- (define-key map (kbd "x") #'cj/gptel-clear-buffer)
- (define-key map (kbd "m") #'gptel-menu)
- (define-key map (kbd "r") #'gptel-rewrite)
- (define-key map (kbd "f") #'gptel-add-file)
- (define-key map (kbd "b") #'gptel-add-buffer)
- (define-key map (kbd "p") #'gptel-system-prompt)
+ (define-key map (kbd "t") #'cj/toggle-gptel) ;; toggles the ai-assistant window
+ (define-key map (kbd "x") #'cj/gptel-clear-buffer) ;; clears the assistant buffer
+ (define-key map (kbd "m") #'gptel-menu) ;; shows the full transient window
+ (define-key map (kbd "r") #'gptel-rewrite) ;; rewrite a region of code/text
+ (define-key map (kbd "f") #'cj/gptel-add-file) ;; add a file to context
+ (define-key map (kbd "b") #'cj/gptel-add-buffer) ;; add a buffer to context
+ (define-key map (kbd "p") #'gptel-system-prompt) ;; change prompt
+ (define-key map (kbd "s") #'cj/gptel-save-conversation) ;; save conversation
+ (define-key map (kbd "l") #'cj/gptel-load-conversation) ;; load and continue conversation
map)
"Keymap for AI-related commands (prefix \\<ai-keymap>).")
(global-set-key (kbd "M-a") ai-keymap)
@@ -68,7 +57,8 @@
(:map gptel-mode-map
("C-<return>" . gptel-send))
:custom
- (gptel-default-directive 'default-directive)
+ ;; don't single quote directive as we want to send the content
+ (gptel-default-directive default-directive)
(gptel-default-mode 'org-mode)
(gptel-expert-commands t)
(gptel-track-media t)
@@ -84,8 +74,8 @@
(coder . ,coder-directive)
(chat . ,chat-directive)
(contractor . ,contractor-directive)
- (emacs . ,emacs-directive)
- (package-pm . ,package-pm-directive)
+ (emacs . ,emacs-directive)
+ (package-pm . ,package-pm-directive)
(email . ,email-directive)
(historian . ,historian-directive)
(proofreader . ,proofreader-directive)
@@ -93,9 +83,26 @@
(qa . ,qa-directive)
(reviewer . ,reviewer-directive)))
+ (setq gptel-default-directive default-directive)
+
+ ;; ---- Auth: pick the API key from your auth source
+ (setq auth-sources `((:source ,authinfo-file)))
+ (setq gptel-api-key (auth-source-pick-first-password :host "api.openai.com"))
+
+ ;; Setup Anthropic's Claude
+ (setq anthropic-api-key (auth-source-pick-first-password :host "api.anthropic.com"))
+ (setq gptel-backend (gptel-make-anthropic "Claude"
+ :stream t :key anthropic-api-key))
+ (setq gptel-model 'claude-3-opus-4-20250514)
+ ) ;; end use-package gptel
+
+;;; -------------------- User And Model Names In Org Headers --------------------
+
+(with-eval-after-load 'gptel
;; Dynamic user prefix for org-mode heading (string, refreshed just before send)
(defun cj/gptel--fresh-org-prefix ()
- (concat "*** " user-login-name " " (format-time-string "[%Y-%m-%d %H:%M:%S]") "\n"))
+ "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"))
;; Initialize as a string (GPTel expectation)
(setf (alist-get 'org-mode gptel-prompt-prefix-alist)
@@ -103,6 +110,7 @@
;; Refresh immediately before each send for accurate timestamp
(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)))
(advice-add 'gptel-send :before #'cj/gptel--refresh-org-prefix)
@@ -121,58 +129,270 @@
"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)))))
+ (insert (format "* %s\n" (cj/gptel-backend-and-model)))))
+ ;; Hook is now at the proper level - will be executed when with-eval-after-load runs
+ (add-hook 'gptel-post-response-functions #'cj/gptel-insert-model-heading))
+
+;;; ---------------------------- Toggle GPTel Window ----------------------------
+
+(with-eval-after-load 'gptel
+ (defun cj/toggle-gptel ()
+ "Toggle the visibility of the AI-Assistant buffer, and place point at its end."
+ (interactive)
+ (let* ((buf-name "*AI-Assistant*")
+ (buffer (get-buffer buf-name))
+ (win (and buffer (get-buffer-window buffer))))
+ (if win
+ ;; If it's already visible, just close it
+ (delete-window win)
+ ;; Otherwise ensure the buffer exists
+ (unless buffer
+ (gptel buf-name gptel-model))
+ (setq buffer (get-buffer buf-name))
+ ;; Display in a side window, select it, and move point to end
+ (setq win
+ (display-buffer-in-side-window
+ buffer
+ '((side . right)
+ (window-width . 0.4))))
+ (select-window win)
+ (with-current-buffer buffer
+ (goto-char (point-max)))))))
+
+;;; ----------------------------- Clear Gptel Buffer ----------------------------
+
+(with-eval-after-load 'gptel
(defun cj/gptel-clear-buffer ()
- "Erase the contents of the *AI-Assistant* buffer leaving initial org heading."
+ "Erase the contents of the current GPTel buffer leaving initial org heading.
+Only works in buffers with gptel-mode active."
+ (interactive)
+ (let ((is-gptel (bound-and-true-p gptel-mode))
+ (is-org (derived-mode-p 'org-mode)))
+ ;; debug info to Messages
+ ;; (message "Debug: gptel-mode: %s, org-mode: %s, major-mode: %s"
+ ;; is-gptel is-org major-mode)
+
+ (if (and is-gptel is-org)
+ (progn
+ (erase-buffer)
+ ;; re-insert the user heading with fresh timestamp
+ (insert (cj/gptel--fresh-org-prefix))
+ (message "GPTel buffer cleared and heading reset"))
+ (message "Not a GPTel buffer in org-mode. Nothing cleared.")))))
+
+;;; ---------------------------- Context Manipulation ---------------------------
+
+(with-eval-after-load 'gptel
+ (defun cj/gptel-add-buffer ()
+ "Add a buffer to the GPTel context.
+Prompts for a buffer name and adds its entire content as context.
+By default shows only regular buffers (not special buffers), but
+allows access to all buffers via completion."
+ (interactive)
+ (let* ((buffers (mapcar #'buffer-name (buffer-list)))
+ ;; Filter out special buffers by default (those starting with space or *)
+ ;; But keep them in the collection for completion
+ (default-buffers (cl-remove-if (lambda (name)
+ (or (string-prefix-p " " name)
+ (string-prefix-p "*" name)))
+ buffers))
+ (buffer-name (completing-read
+ "Buffer to add as context: "
+ buffers
+ nil ; No predicate
+ t ; Require match
+ nil ; No initial input
+ nil ; No history
+ (car default-buffers))) ; Default to first regular buffer
+ (buffer (get-buffer buffer-name)))
+ (when buffer
+ (if (and (buffer-file-name buffer)
+ (y-or-n-p "This buffer is associated with a file. Add file instead? "))
+ ;; If it's a file buffer and user confirms, add the file instead
+ (gptel-add-file (buffer-file-name buffer))
+ ;; Otherwise add the buffer content
+ (gptel-context--add-region
+ buffer
+ (with-current-buffer buffer (point-min))
+ (with-current-buffer buffer (point-max))
+ t)
+ (message "Buffer '%s' added as context." buffer-name))))))
+
+(with-eval-after-load 'gptel
+ (with-eval-after-load 'projectile
+ (defun cj/gptel-add-file ()
+ "Add a file to the GPTel context.
+If inside a Projectile project, prompt from the project's file list;
+otherwise use `read-file-name'."
+ (interactive)
+ (let* ((in-proj (and (fboundp 'projectile-project-p)
+ (projectile-project-p)))
+ (file-name (if in-proj
+ (projectile-completing-read
+ "GPTel add file: "
+ (projectile-current-project-files))
+ (read-file-name "GPTel add file: ")))
+ ;; Ensure we have a full path when using projectile
+ (file-path (if in-proj
+ (expand-file-name file-name (projectile-project-root))
+ file-name)))
+ ;; Debug output
+ (message "Adding file to context: %s" file-path)
+
+ ;; Call the gptel built-in function directly
+ (gptel-add-file file-path)
+
+ ;; Verify context was added
+ (message "Current context has %d sources"
+ (length gptel-context--alist))))))
+
+;;; ----------------------- GPTel Conversation Management -----------------------
+
+(defcustom cj/gptel-conversations-directory
+ (expand-file-name "ai-conversations" user-emacs-directory)
+ "Directory where GPTel conversations are stored.
+Defaults to ~/.emacs.d/ai-conversations/"
+ :type 'directory
+ :group 'gptel)
+
+(defun cj/gptel--save-buffer-to-file (buffer filepath)
+ "Save the BUFFER content to FILEPATH with org visibility properties.
+Adds org-mode startup properties to ensure content is visible when reopened."
+ (with-current-buffer buffer
+ (let ((content (buffer-string)))
+ ;; Create temp buffer to add properties
+ (with-temp-buffer
+ ;; Add org properties to ensure everything is shown on load
+ (insert "#+STARTUP: showeverything\n")
+ (insert "#+VISIBILITY: all\n\n")
+ (insert content)
+ (write-region (point-min) (point-max) filepath nil 'silent))))
+ filepath)
+
+(with-eval-after-load 'gptel
+ (defun cj/gptel-save-conversation ()
+ "Save the current AI-Assistant buffer to a file with .gptel extension.
+Offers existing conversation topics as options but allows entering new topics."
(interactive)
(let ((buf (get-buffer "*AI-Assistant*")))
- (if (not buf)
- (message "No AI buffer found")
- (with-current-buffer buf
- (erase-buffer)
- ;; re-insert the user heading with fresh timestamp
- (insert (cj/gptel--fresh-org-prefix))
- (message "AI buffer cleared and heading reset")))))
+ (unless buf
+ (user-error "No AI-Assistant buffer found"))
- ;; Hook is called with (BEG END); add our per-reply heading
- (add-hook 'gptel-post-response-functions #'cj/gptel-insert-model-heading)
+ ;; Ensure directory exists
+ (unless (file-exists-p cj/gptel-conversations-directory)
+ (make-directory cj/gptel-conversations-directory t)
+ (message "Created directory: %s" cj/gptel-conversations-directory))
- ;; ---- Auth: pick the API key from your auth source
- (setq auth-sources `((:source ,authinfo-file)))
- (setq gptel-api-key (auth-source-pick-first-password :host "api.openai.com"))
- (setq anthropic-api-key (auth-source-pick-first-password :host "api.anthropic.com"))
+ ;; Get existing topic names (without timestamps)
+ (let* ((files (directory-files cj/gptel-conversations-directory nil "\\.gptel$"))
+ (topics (delete-dups
+ (mapcar (lambda (f)
+ (replace-regexp-in-string "_[0-9]\\{8\\}-[0-9]\\{6\\}\\.gptel$" "" f))
+ files)))
+ (topic (completing-read "Conversation topic: " topics nil nil))
+ (clean-topic (replace-regexp-in-string "[^a-zA-Z0-9-_]" "-" topic))
+ (existing-files (directory-files cj/gptel-conversations-directory nil
+ (format "^%s_[0-9]\\{8\\}-[0-9]\\{6\\}\\.gptel$"
+ (regexp-quote clean-topic))))
+ (newest-file (car (sort existing-files #'string>)))
+ (use-existing (and newest-file
+ (y-or-n-p (format "Update existing file %s? " newest-file))))
+ (filepath (if use-existing
+ (expand-file-name newest-file cj/gptel-conversations-directory)
+ ;; Create new file with timestamp
+ (let* ((timestamp (format-time-string "%Y%m%d-%H%M%S"))
+ (filename (format "%s_%s.gptel" clean-topic timestamp)))
+ (expand-file-name filename cj/gptel-conversations-directory)))))
- ;; Setup Anthropic's Claude
- (setq gptel-backend (gptel-make-anthropic "Claude"
- :stream t :key anthropic-api-key))
- (setq gptel-model 'claude-3-opus-4-20250514)
- ) ;; end use-package declaration
+ ;; Save the buffer
+ (cj/gptel--save-buffer-to-file buf filepath)
+ (message "Conversation saved to: %s" filepath)))))
-(with-eval-after-load 'projectile
- (defun cj/gptel-add-file ()
- "Add a file to the GPTel context.
-If inside a Projectile project, prompt from the project's file list;
-otherwise use =read-file-name'."
+(with-eval-after-load 'gptel
+ (defun cj/gptel-load-conversation ()
+ "Load a saved GPTel conversation into the AI-Assistant buffer.
+If the current buffer has content, prompts to save it first.
+Presents a list of .gptel files for selection and loads the chosen file."
(interactive)
- (let* ((in-proj (and (fboundp 'projectile-project-p)
- (projectile-project-p)))
- (file (if in-proj
- (projectile-completing-read
- "GPTel add file: "
- (projectile-current-project-files))
- (read-file-name "GPTel add file: "))))
- (gptel-add-file file)))
- (define-key ai-keymap (kbd "f") #'cj/gptel-add-file))
-
-;; -------------------------------- GPTel-Magit --------------------------------
+
+ ;; Check if AI-Assistant buffer exists, create if needed
+ (let ((ai-buffer (get-buffer-create "*AI-Assistant*")))
+
+ ;; If buffer has content and gptel-mode is active, offer to save
+ (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))))
+
+ ;; Check directory exists
+ (unless (file-exists-p cj/gptel-conversations-directory)
+ (user-error "Conversations directory doesn't exist: %s"
+ cj/gptel-conversations-directory))
+
+ ;; Get all .gptel files
+ (let* ((files (directory-files cj/gptel-conversations-directory nil "\\.gptel$"))
+ (files-with-dates
+ (mapcar (lambda (f)
+ (let* ((full-path (expand-file-name f cj/gptel-conversations-directory))
+ (mod-time (nth 5 (file-attributes full-path)))
+ (time-str (format-time-string "%Y-%m-%d %H:%M" mod-time)))
+ (cons (format "%-40s [%s]" f time-str) f)))
+ files)))
+
+ (unless files
+ (user-error "No saved conversations found in %s"
+ cj/gptel-conversations-directory))
+
+ ;; Let user select a file
+ (let* ((selection (completing-read "Load conversation: " files-with-dates nil t))
+ (filename (cdr (assoc selection files-with-dates)))
+ (filepath (expand-file-name filename cj/gptel-conversations-directory)))
+
+ ;; Clear buffer and insert file contents
+ (with-current-buffer ai-buffer
+ ;; Ensure gptel-mode is active
+ (unless (bound-and-true-p gptel-mode)
+ (gptel "*AI-Assistant*") ;; Initialize gptel if not already active
+ (org-mode)
+ (gptel-mode 1))
+
+ ;; Clear and insert the conversation
+ (erase-buffer)
+ (insert-file-contents filepath)
+
+ ;; Remove the org properties if present at the beginning
+ (goto-char (point-min))
+ (when (looking-at "^#\\+STARTUP:.*\n#\\+VISIBILITY:.*\n\n")
+ (delete-region (point) (match-end 0)))
+
+ ;; Position at end and mark as modified
+ (goto-char (point-max))
+ (set-buffer-modified-p t))
+
+ ;; Show buffer in a side window if not already visible
+ (unless (get-buffer-window ai-buffer)
+ (if (fboundp 'cj/toggle-gptel)
+ (cj/toggle-gptel)
+ ;; Fallback to display in side window
+ (display-buffer-in-side-window
+ ai-buffer
+ '((side . right)
+ (window-width . 0.4)))))
+
+ ;; Select the window
+ (select-window (get-buffer-window ai-buffer))
+ (message "Loaded conversation from: %s" filepath))))))
+
+;;; -------------------------------- GPTel-Magit --------------------------------
(use-package gptel-magit
:defer t
:hook (magit-mode . gptel-magit-install))
-
-
(provide 'ai-config)
;;; ai-config.el ends here
diff --git a/modules/ai-directives.el b/modules/ai-directives.el
index cc8dcb7f..68ec7d31 100644
--- a/modules/ai-directives.el
+++ b/modules/ai-directives.el
@@ -30,10 +30,10 @@ potential overcharges, you suggest specific follow-up questions I should ask the
(defvar chat-directive
"I want you to act as an old friend and highly intelligent person who is good at conversation. You are deeply
-knowledgeable about academic philosophy and can discuss philosophical topics at a PhD level. When you do, you often
-indicate the book or article relevant to the topic you discuss. You are very well educated in history. You have a kind
-personality. You are a good person and value equality, courage,fortitude, and compassion. You ask very good questions.
-You encourage people to improve themselves and you believe in them.")
+ knowledgeable about academic philosophy and can discuss philosophical topics at a PhD level. When you do, you often
+ indicate the book or article relevant to the topic you discuss. You are very well educated in history. You have a kind
+ personality. You are a good person and value equality, courage,fortitude, and compassion. You ask very good
+ questions. You encourage people to improve themselves and you believe in them.")
(defvar coder-directive
"You are an expert in emacs-lisp, Python, Golang, Shell scripting, and the git version control system. I want you
@@ -60,8 +60,9 @@ motivational talk; just practical guidance.")
(defvar default-directive
"You are a large language model living in Emacs. You understand philosophy, critical theory, and comparative
-literature at a university graduate student level. You have deep knowledge of the You are concise and always provide
-references to source materia you refer to. You are a good-natured conversation partner and ask thoughtful questions.")
+ literature at a university graduate student level. You are also excellent at Emacs and Emacs configuration You have
+ strong knowledge of history and political science. You are concise and always provide references to source materia you refer to. You are a
+ good-natured conversation partner and ask thoughtful questions.")
(defvar emacs-directive
"You are an expert Emacs configuration assistant with complete knowledge of Emacs-Lisp, the latest packages, and