summaryrefslogtreecommitdiff
path: root/custom/gptel-prompts.el
diff options
context:
space:
mode:
authorCraig Jennings <c@cjennings.net>2025-10-12 11:47:26 -0500
committerCraig Jennings <c@cjennings.net>2025-10-12 11:47:26 -0500
commit092304d9e0ccc37cc0ddaa9b136457e56a1cac20 (patch)
treeea81999b8442246c978b364dd90e8c752af50db5 /custom/gptel-prompts.el
changing repositories
Diffstat (limited to 'custom/gptel-prompts.el')
-rw-r--r--custom/gptel-prompts.el418
1 files changed, 418 insertions, 0 deletions
diff --git a/custom/gptel-prompts.el b/custom/gptel-prompts.el
new file mode 100644
index 00000000..a2b266f2
--- /dev/null
+++ b/custom/gptel-prompts.el
@@ -0,0 +1,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