aboutsummaryrefslogtreecommitdiff
path: root/archive/gptel/modules/ai-conversations.el
diff options
context:
space:
mode:
authorCraig Jennings <c@cjennings.net>2026-06-23 20:12:58 -0400
committerCraig Jennings <c@cjennings.net>2026-06-23 20:12:58 -0400
commit10fa6f4e2e7150ad99827721ada1ae4badcc5e90 (patch)
tree960065c8e69f1e7a4150ecf522e2f813c239b5ee /archive/gptel/modules/ai-conversations.el
parentf4cc70c69e7707dd4a686637e14885f5443fcca6 (diff)
downloaddotemacs-10fa6f4e2e7150ad99827721ada1ae4badcc5e90.tar.gz
dotemacs-10fa6f4e2e7150ad99827721ada1ae4badcc5e90.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/ai-conversations.el')
-rw-r--r--archive/gptel/modules/ai-conversations.el369
1 files changed, 369 insertions, 0 deletions
diff --git a/archive/gptel/modules/ai-conversations.el b/archive/gptel/modules/ai-conversations.el
new file mode 100644
index 00000000..8061051a
--- /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