1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
|
;;; 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:
(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.")
(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
(bound-and-true-p gptel-mode)
cj/gptel-autosave-enabled
(stringp cj/gptel-autosave-filepath)
(> (length cj/gptel-autosave-filepath) 0))
(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--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"))))
;;;###autoload
(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))
(message "Conversation saved to: %s" filepath))))
;;;###autoload
(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)))))
;;;###autoload
(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))
(let ((buf (get-buffer "*AI-Assistant*")))
(unless (get-buffer-window buf)
(display-buffer-in-side-window
buf `((side . ,cj/gptel-conversations-window-side)
(window-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 (and (bound-and-true-p gptel-mode)
cj/gptel-autosave-enabled
(stringp cj/gptel-autosave-filepath)
(> (length cj/gptel-autosave-filepath) 0))
(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
|