diff options
| author | Craig Jennings <c@cjennings.net> | 2025-10-12 11:47:26 -0500 |
|---|---|---|
| committer | Craig Jennings <c@cjennings.net> | 2025-10-12 11:47:26 -0500 |
| commit | 092304d9e0ccc37cc0ddaa9b136457e56a1cac20 (patch) | |
| tree | ea81999b8442246c978b364dd90e8c752af50db5 /custom/gptel-prompts.el | |
changing repositories
Diffstat (limited to 'custom/gptel-prompts.el')
| -rw-r--r-- | custom/gptel-prompts.el | 418 |
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 |
