summaryrefslogtreecommitdiff
path: root/custom/gptel-prompts.el
blob: a2b266f27c02b1aa69eb1238d12385c1e7483aad (plain)
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
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
;;; gptel-prompts.el --- GPTel directive management using files -*- lexical-binding: t -*-

;; Copyright (C) 2025 John Wiegley

;; Author: John Wiegley <johnw@gnu.org>
;; Created: 19 May 2025
;; Version: 1.0
;; Keywords: ai gptel prompts
;; X-URL: https://github.com/jwiegley/dot-emacs
;; Package-Requires: ((emacs "24.1"))

;; This file is NOT part of GNU Emacs.

;;; License:

;; This program is free software; you can redistribute it and/or
;; modify it under the terms of the GNU General Public License as
;; published by the Free Software Foundation; either version 2, or (at
;; your option) any later version.

;; This program is distributed in the hope that it will be useful, but
;; WITHOUT ANY WARRANTY; without even the implied warranty of
;; MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the GNU
;; General Public License for more details.

;; You should have received a copy of the GNU General Public License
;; along with GNU Emacs; see the file COPYING.  If not, write to the
;; Free Software Foundation, Inc., 59 Temple Place - Suite 330,
;; Boston, MA 02111-1307, USA.

;;; Commentary:

;; This package provides enhanced prompt management capabilities for GPTel,
;; allowing you to organize and dynamically load AI prompts from external
;; files rather than hardcoding them in your Emacs configuration.

;; Key Features:
;;
;; * Multi-format prompt support: Load prompts from .txt, .md, .org, .json,
;;   .eld (Emacs Lisp data), .el (Emacs Lisp functions), and .poet/.jinja
;;   (Prompt Poet/Jinja2 templates)
;;
;; * Template interpolation: Use Jinja2-style {{variable}} syntax with
;;   customizable variables and dynamic functions
;;
;; * File watching: Automatically reload prompts when files change
;;
;; * Project-aware prompts: Automatically load project-specific conventions
;;   from CONVENTIONS.md or CLAUDE.md files
;;
;; * Conversation format support: Handle multi-turn conversations with
;;   system/user/assistant roles

;; Setup:
;;
;;   (use-package gptel-prompts
;;     :after (gptel)
;;     :custom
;;     (gptel-prompts-directory "~/my-prompts")
;;     :config
;;     (gptel-prompts-update)
;;     ;; Optional: auto-reload on file changes
;;     (gptel-prompts-add-update-watchers))

;; File Formats:
;;
;; * Plain text (.txt, .md, .org): Used as-is for system prompts
;; * JSON (.json): Array of {role: "system/user/assistant", content: "..."}
;; * Emacs Lisp data (.eld): List format for conversations
;; * Emacs Lisp code (.el): Lambda functions for dynamic prompts
;; * Prompt Poet (.poet, .j2, .jinja, .jinja2): YAML + Jinja2 templates

;; Template Variables:
;;
;; Use {{variable_name}} in your prompts. Variables can be defined in
;; `gptel-prompts-template-variables' or generated dynamically by functions
;; in `gptel-prompts-template-functions'.

;; Project Integration:
;;
;; Add `gptel-prompts-project-conventions' to `gptel-directives' to
;; automatically load project-specific prompts from CONVENTIONS.md or
;; CLAUDE.md files in your project root.

;;; Code:

(require 'cl-lib)
(require 'cl-macs)
(require 'rx)
(require 'filenotify)
(require 'gptel)

(defgroup gptel-prompts nil
  "Helper library for managing GPTel prompts (aka directives)."
  :group 'gptel)

(defcustom gptel-prompts-directory "~/.emacs.d/prompts"
  "*Directory where GPTel prompts are defined, one per file.

Note that files can be of different types, which will cause them to be
represented as directives differently. See `gptel-prompts-file-regexp'
for more information."
  :type 'file
  :group 'gptel-prompts)

(defcustom gptel-prompts-file-regexp
  (rx "." (group
           (or "txt"
               "md"
               "org"
               "eld"
               "el"
               (seq "j" (optional "inja") (optional "2"))
               "poet"
               "json"))
      string-end)
  "*Directory where GPTel prompts are defined, one per file.

Note that files can be of different types, which will cause them
to be represented as directives differently:

  .txt, .md, .org    Purely textual prompts that are used as-is
  .eld               Must be a Lisp list represent a conversation:
                       SYSTEM, USER, ASSISTANT, [USER, ASSISTANT, ...]
  .el                Must evaluate to a Lisp function
  .poet              See https://github.com/character-ai/prompt-poet
  .json              JSON list of role-assigned prompts"
  :type 'regexp
  :group 'gptel-prompts)

(defcustom gptel-prompts-template-variables nil
  "*An alist of names to strings used during template expansion.

Example:
  ((\"name\" . \"John\")
   (\"hobbies\" . \"Emacs\"))

These would referred to using {{ name }} and {{ hobbies }} in the
prompt template."
  :type '(alist :key-type string :value-type string)
  :group 'gptel-prompts)

(defcustom gptel-prompts-template-functions
  '(gptel-prompts-add-current-time)
  "*Set of functions run when a template prompt is used.

These are called when the template is going to be used by
`gptel-request'. Each function receives the name of the template file,
and must return either nil or an alist of variable values to prepend to
`gptel-prompts-template-variables'. See that variable's documentation
for the expected format."
  :type '(list function)
  :group 'gptel-prompts)

(defun gptel-prompts-process-prompts (prompts)
  "Convert from a list of PROMPTS in dialog format, to GPTel.

For example:

  (((role . \"system\")
    (content . \"Sample\")
    (name . \"system instructions\"))
   ((role . \"system\")
    (content . \"Sample\")
    (name . \"further system instructions\"))
   ((role . \"user\")
    (content . \"Sample\")
    (name . \"User message\"))
   ((role . \"assistant\")
    (content . \"Sample\")
    (name . \"Model response\"))
   ((role . \"user\")
    (content . \"Sample\")
    (name . \"Second user message\")))

Becomes:

   (\"system instructions\nfurther system instructions\"
    (prompt \"User message\")
    (response \"Model response\")
    (prompt \"Second user message\"))"
  (let ((system "") result)
    (dolist (prompt prompts)
      (let ((content (alist-get 'content prompt))
            (role (alist-get 'role prompt)))
        (cond
         ((string= role "system")
          (setq system (if (string-empty-p system)
                           content
                         (concat system "\n" content))))
         ((string= role "user")
          (setq result (cons (list 'prompt content) result)))
         ((string= role "assistant")
          (setq result (cons (list 'response content) result)))
         ((string= role "tool")
          (error "Tools not yet supported in Poet prompts"))
         (t
          (error "Role not recognized in prompt: %s"
                 (pp-to-string prompt))))))
    (cons system (nreverse result))))

(defun gptel-prompts-interpolate (prompt &optional file)
  "Expand Jinja-style references to `gptel-prompts-template-variables'.
The references are expected in the string PROMPT, possibly from FILE.
`gptel-prompts-template-functions' are called to add to this list as
well, so some variables can be dynamic in nature."
  (require 'templatel)
  (let ((vars (apply #'append
                     (mapcar #'(lambda (f) (funcall f file))
                             gptel-prompts-template-functions))))
    (templatel-render-string
     prompt
     (cl-remove-duplicates
      (append vars gptel-prompts-template-variables)
      :test #'string= :from-end t :key #'car))))

(defun gptel-prompts-interpolate-buffer ()
  "Expand Jinja-style references to `gptel-prompts-template-variables'.
See `gptel-prompts-interpolate'.
This function can be added to `gptel-prompt-transform-functions'."
  (let ((replacement (gptel-prompts-interpolate (buffer-string))))
    (delete-region (point-min) (point-max))
    (insert replacement)))

(defun gptel-prompts-poet (file)
  "Read Yaml + Jinja FILE in prompt-poet format."
  (require 'yaml)
  (gptel-prompts-process-prompts
   (mapcar #'yaml--hash-table-to-alist
           (yaml-parse-string
            (gptel-prompts-interpolate
             (with-temp-buffer
               (insert-file-contents file)
               (buffer-string))
             file)))))

(defun gptel-prompts-process-file (file)
  "Process FILE and return appropriate content.

FILE is a string path to the file to be processed.

Handles different file types based on extension:
- .eld files: Read as Emacs Lisp data, must evaluate to a list
- .el files: Read as Emacs Lisp code, must evaluate to a function/lambda
- .json files: Parse as JSON array and process as prompts via
  `gptel-prompts-process-prompts'
- .j2/.jinja/.jinja2/.poet files: Return lambda that calls
  `gptel-prompts-poet' with FILE
- Other files: Return trimmed file contents as plain text string

Returns the processed content in the appropriate format for each file
type. Signals an error if the file content doesn't match expected format
for typed files."
  (cond ((string-match "\\.eld\\'" file)
         (with-temp-buffer
           (insert-file-contents file)
           (goto-char (point-min))
           (let ((lst (read (current-buffer))))
             (if (listp lst)
                 lst
               (error "Emacs Lisp data prompts must evaluate to a list")))))
        ((string-match "\\.el\\'" file)
         (with-temp-buffer
           (insert-file-contents file)
           (goto-char (point-min))
           (let ((func (read (current-buffer))))
             (if (and (functionp func)
                      (listp func)
                      (eq 'lambda (car func)))
                 func
               (error "Emacs Lisp prompts must evaluate to a function/lambda")))))
        ((string-match "\\.json\\'" file)
         (with-temp-buffer
           (insert-file-contents file)
           (goto-char (point-min))
           (let ((conversation (json-read)))
             (if (vectorp conversation)
                 (gptel-prompts-process-prompts (seq-into conversation 'list))
               (error "Emacs Lisp prompts must evaluate to a list")))))
        ((string-match "\\.\\(j\\(inja\\)?2?\\|poet\\)\\'" file)
         `(lambda () (gptel-prompts-poet ,file)))
        (t
         (with-temp-buffer
           (insert-file-contents file)
           (string-trim (buffer-string))))))

(defun gptel-prompts-read-directory (dir)
  "Read prompts from directory DIR and establish them in `gptel-directives'."
  (cl-loop for file in (directory-files dir t gptel-prompts-file-regexp)
           collect (cons (intern (file-name-sans-extension
                                  (file-name-nondirectory file)))
                         (gptel-prompts-process-file file))))

(defun gptel-prompts-update ()
  "Update `gptel-directives' from files in `gptel-prompts-directory'."
  (interactive)
  (dolist (prompt (gptel-prompts-read-directory gptel-prompts-directory))
    (setq gptel-directives
          (cl-delete-if #'(lambda (x) (eq (car x) (car prompt)))
                        gptel-directives))
    (add-to-list 'gptel-directives prompt)))

(defun gptel-prompts-add-current-time (_file)
  "Add the current time as a variable for Poet interpolation."
  `(("current_time" . ,(format-time-string "%F %T"))))

(defun gptel-prompts-add-update-watchers ()
  "Watch all files in DIR and run CALLBACK when any is modified."
  (let ((watches (list (file-notify-add-watch
                        gptel-prompts-directory '(change)
                        #'(lambda (&rest _events)
                            (gptel-prompts-update))))))
    (dolist (file (directory-files gptel-prompts-directory
                                   t gptel-prompts-file-regexp))
      (when (file-regular-p file)
        (push (file-notify-add-watch file '(change)
                                     #'(lambda (&rest _events)
                                         (gptel-prompts-update)))
              watches)))
    watches))

(defvar gptel-prompts--project-conventions-alist nil
  "Alist mapping projects to project conventions for LLMs.")

(defcustom gptel-prompts-project-files
  '("CONVENTIONS.md"
    "CLAUDE.md"
    "AGENTS.md"
    (".github" . "copilot-instructions\\.md")
    (".instructions.d" . "^.*\\.md$")
    ".instructions.md")
  "A list of files or directories with prompts for the current project.
Entries can be strings (file/directory names) or cons cells where the
CAR is a directory path and the CDR is either a regexp string or a
filter function for selecting which files in that directory should be
chosen.

The first matching rule in the list for a given project is used, with
the rest ignored.

If a directory is specified without a filter (as a plain string), all
markdown files within it will be aggregated into a single prompt."
  :type '(repeat (choice file directory
                         (cons directory (choice regexp function))))
  :group 'gptel-prompts)

(defun gptel-prompts--read-directory-filtered (dir regexp-or-function)
  "Read files from DIR for which REGEXP-OR-FUNCTION is a match."
  (when (and (file-directory-p dir)
             (file-readable-p dir))
    (let ((files
           (cl-remove-if-not
            (cond
             ((functionp regexp-or-function)
              (lambda (f)
                (funcall regexp-or-function (file-name-nondirectory f))))
             ((stringp regexp-or-function)
              (lambda (f)
                (string-match-p regexp-or-function (file-name-nondirectory f))))
             (t (error "Invalid filter: %s" regexp-or-function)))
            (directory-files dir t "^[^.].*" t))))
      (unless (null files)
        (mapconcat
         (lambda (file)
           (when (and (file-regular-p file)
                      (file-readable-p file))
             (with-temp-buffer
               (insert-file-contents file)
               (buffer-string))))
         files "\n\n")))))

(defun gptel-prompts--read-directory (dir)
  "Read all Markdown files from DIR, concated together."
  (let ((contents
         (mapconcat
          (lambda (file)
            (when (and (file-regular-p file)
                       (file-readable-p file))
              (with-temp-buffer
                (insert-file-contents file)
                (buffer-string))))
          (directory-files dir t "^[^.].*\\.md$" t)
          "\n\n")))
    (unless (string-empty-p contents)
      contents)))

(defun gptel-prompts-project-conventions ()
  "System prompt is obtained from project CONVENTIONS.
This function should be added to `gptel-directives'. To replace
the default directive, use:

  (setf (alist-get \\'default gptel-directives)
        #\\'gptel-project-conventions)"
  (when-let* ((project (project-current))
              (root (project-root project)))
    (with-memoization
        (alist-get root gptel-prompts--project-conventions-alist
                   nil nil #'equal)
      (or (cl-loop
           for item in gptel-prompts-project-files
           for path = (expand-file-name
                       (if (consp item) (car item) item)
                       root)
           when (file-readable-p path)
           return (cond
                   ((consp item)
                    (gptel-prompts--read-directory-filtered (car item) (cdr item)))
                   ((file-directory-p path)
                    (gptel-prompts--read-directory path))
                   (t
                    (with-temp-buffer
                      (insert-file-contents path)
                      (buffer-string)))))
          "You are a helpful assistant. Respond concisely."))))

(provide 'gptel-prompts)

;;; gptel-prompts.el ends here