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
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
|
;;; 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
(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--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 (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
|