gptel-got/gptel-org-tools.el
2025-04-29 01:49:54 +02:00

506 lines
20 KiB
EmacsLisp

;;; gptel-org-tools.el --- LLM Tools for org-mode interaction. -*- lexical-binding: t; -*-
;; Copyright (C) 2025 Phil Bajsicki
;; Author: Phil Bajsicki <phil@bajsicki.com>
;; Keywords: extensions, comm, tools, matching, convenience,
;;
;; Author: Phil Bajsicki <phil@bajsicki.com>
;; Version: 0.0.2
;; Package-Requires: ((emacs "30.1") (gptel 0.9.8) (org-ql 0.9))
;; URL: https://github.com/phil/gptel-org-tools
;; SPDX-License-Identifier: GPL-3.0
;; 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, version 3 of the License.
;; 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 this program. If not, see <https://www.gnu.org/licenses/>.
;; This file is NOT part of GNU Emacs.
;; This file is not part of GNU Emacs.
;;; Commentary:
;; All documentation regarding these functions is in the README.org file.
;; Repository: https://git.bajsicki.com/phil/gptel-org-tools
;;; Code:)
(defvar gptel-org-tools '())
(defvar gptel-org-tools-skip-heading-extraction '())
(defvar gptel-org-tools-result-limit 40000)
(defun gptel-org-tools--result-limit (result)
(if (>= (length (format "%s" result)) gptel-org-tools-result-limit)
(format "Results over %s character. Please try with a more specific query." gptel-org-tools-result-limit)
result))
(defun gptel-org-tools--heading ()
"Return the org-mode heading."
(concat
(buffer-substring-no-properties
(line-beginning-position)
(line-end-position))))
(defun gptel-org-tools--heading-body ()
"Return the org-mode heading and body text."
(concat
(buffer-substring-no-properties
(line-beginning-position)
(progn
(outline-next-heading)
(line-beginning-position)))))
(defun gptel-org-tools--heading-subtree ()
"Return the org-mode heading and all subheadings, with their body text."
(concat
(buffer-substring-no-properties
(line-beginning-position)
(org-end-of-subtree))))
(defun gptel-org-tool--list-buffers (&optional arg)
"Return list of buffers."
(list-buffers-noselect)
(with-current-buffer "*Buffer List*"
(let ((content (buffer-string)))
content)))
(add-to-list 'gptel-org-tools
(gptel-make-tool
:function #'gptel-org-tool--list-buffers
:name "gptel-org-tool--list-buffers"
:args (list '(:name "arg"
:type string
:description "Does nothing."
:optional t))
:category "emacs"
:description "List buffers open in Emacs, including file names and full paths. After using this, evaluate which files are most likely to be relevant to the user's request."))
(defun gptel-org-tools--dir (dir)
"Return directory listing."
(with-temp-buffer
(dired (or dir "~"))
(let ((content (buffer-string)))
(kill-buffer (current-buffer))
content)))
(add-to-list 'gptel-org-tools
(gptel-make-tool
:function #'gptel-org-tools--dir
:name "gptel-org-tools--dir"
:description "List directory contents."
:args (list '(:name "dir"
:type string
:description "Directory path"
:optional t))
:category "filesystem"))
(defun gptel-org-tools--open-file-inactive (file)
"Open FILE in a buffer."
(with-current-buffer (get-buffer-create file)
(insert-file-contents file)
(concat
(current-buffer))))
(add-to-list 'gptel-org-tools
(gptel-make-tool
:function #'gptel-org-tools--open-file-inactive
:name "gptel-org-tools--open-file-inactive"
:description "Open the file in a background buffer. This doesn't interfere with the user."
:args (list '(:name "file"
:type string
:description "Path to file.."))
:category "filesystem"))
(defun gptel-org-tools--describe-variable (var)
"Return documentation for VAR."
(let ((symbol (intern var)))
(if (boundp symbol)
(prin1-to-string (symbol-value symbol))
(format "Variable %s is not bound. This means the variable doesn't exist. Reassess what you're trying to do, examine the situation, and continue. " var))))
(add-to-list 'gptel-org-tools
(gptel-make-tool
:function #'gptel-org-tools--describe-variable
:name "gptel-org-tools--describe-variable"
:description "Returns variable contents."
:args (list '(:name "var"
:type string
:description "Variable name"))
:category "emacs"))
(defun gptel-org-tools--describe-function (fun)
"Return documentation for FUN."
(let ((symbol (intern fun)))
(if (fboundp symbol)
(prin1-to-string (documentation symbol 'function))
(format "Function %s is not defined." fun))))
(add-to-list 'gptel-org-tools
(gptel-make-tool
:function #'gptel-org-tools--describe-function
:name "gptel-org-tools--describe-function"
:description "Returns function description"
:args (list '(:name "fun"
:type string
:description "Function name"
:optional t))
:category "emacs"))
(defun gptel-org-tools--org-extract-tags (buffer)
"Return all tags from BUFFER."
(with-current-buffer buffer
(let ((tags '()))
(org-map-entries
(lambda ()
(let* ((components (org-heading-components))
(tag-string (car (last components))))
(when tag-string
(dolist (tag (split-string tag-string ":" t))
(push tag tags))))))
(sort (-uniq tags) #'string<))))
(add-to-list 'gptel-org-tools
(gptel-make-tool
:function #'gptel-org-tools--org-extract-tags
:name "gptel-org-tools--org-extract-tags"
:args (list '(:name "buffer"
:type string
:description "The Org buffer to extract tags from."))
:category "org-mode"
:description "Returns all tags from an org-mode buffer. When using this, evaluate the relevance of each tag to the user's request."))
(defun gptel-org-tools--org-extract-headings (buffer)
"Return all headings from BUFFER."
(if (member buffer gptel-org-tools-skip-heading-extraction)
(user-error "Buffer %s has been blocked from this function by the user with reason: headings contain timestamps, no useful information. Use a different tool." buffer)
(with-current-buffer buffer
(org-map-entries
#'gptel-org-tools--heading
t
'file))))
(add-to-list 'gptel-org-tools
(gptel-make-tool
:function #'gptel-org-tools--org-extract-headings
:name "gptel-org-tools--org-extract-headings"
:description "Returns all headings from an org-mode buffer. After using this, evaluate the relevance of the headings to the user's request."
:args (list '(:name "buffer"
:type string
:description "The Org buffer to extract headings from."))
:category "org-mode"))
(defun gptel-org-tools--org-ql-select-date (buf date)
"Returns all timestamped headings matching the specified date or date range.
The date can be in the format YYYY, YYYY-MM, or YYYY-MM-DD.
BUFFER is the name of the buffer to search.
DATE is the date or date range to match."
(let* ((buffer (get-buffer buf))
(mode (buffer-local-value 'major-mode buffer)))
(if (bufferp buffer)
(if (eq mode 'org-mode)
(let ((result
(concat
(org-ql-select buffer
`(heading ,date)
:action #'gptel-org-tools--heading-subtree)
"Results end here. Proceed with the next action.")))
(gptel-org-tools--result-limit result))
(message "Buffer '%s' isn't an org-mode buffer." buf))
(message "Buffer '%s' does not exist." buf))))
(add-to-list 'gptel-org-tools
(gptel-make-tool
:function #'gptel-org-tools--org-ql-select-date
:name "gptel-org-tools--org-ql-select-date"
:args (list '(:name "buffer"
:type string
:description "Buffer name.")
'(:name "date"
:type string
:description "Date string in YYYY or YYYY-MM format. No <, >, [, ]. Just the numbers and dashes."))
:category "org"
:description "Returns all timestamped headings and all of their subheadings matching query. Query may be: YYYY, YYYY-MM, YYYY-MM-DD.
Examples:
- \"YYYY\": gets all entries from year YYYY
- \"YYYY-MM\": gets all entries from month MM of year YYYY"))
(defun gptel-org-tools--org-agenda-seek (days)
"Return the results of org-agenda-list spanning now to DAYS."
(with-temp-buffer
(org-agenda-list (or days 14))
(let ((content (buffer-string)))
(kill-buffer (current-buffer))
(gptel-org-tools--result-limit content))))
(add-to-list 'gptel-org-tools
(gptel-make-tool
:function #'gptel-org-tools--org-agenda-seek
:name "gptel-org-tools--org-agenda-seek"
:args (list '(:name "days"
:type integer
:description "Days. Positive = future. Negative = past. Default: 14"))
:category "org"
:description "Return user's agenda (tasking) spanning X days from the current moment. This can be used to get all agenda tasks that are due or scheduled in the next X days, or in the past X days, depending on whether the days argument is positive or negative. Example: get all agenda tasks due in the next 7 days: \"7\""))
(defun gptel-org-tools--org-ql-select-headings (buf query)
"Return headings matching QUERY from BUFFER."
(let* ((buffer (get-buffer buf))
(mode (buffer-local-value 'major-mode buffer)))
(if buffer
(if (eq mode 'org-mode)
(let ((result
(org-ql-select
(get-buffer buf)
`(heading ,query)
:action #''gptel-org-tools--heading)))
(gptel-org-tools--result-limit result))
(message "Buffer '%s' isn't an org-mode buffer." buf))
(message "Buffer '%s' does not exist." buf))))
(add-to-list 'gptel-org-tools
(gptel-make-tool
:function #'gptel-org-tools--org-ql-select-headings
:name "gptel-org-tools--org-ql-select-headings"
:args (list '(:name "buffer"
:type string
:description "The name of the buffer. See the NAME column in ~list-buffers~.")
'(:name "query"
:type string
:description "The string to match entry headings against."))
:category "org-ql"
:description "Returns entries matching QUERY from BUFFER. Matches only a single string. After using this, evaluate which entries are relevant, and continue with user's request."))
(defun gptel-org-tools--org-ql-select-headings-rifle (buf query)
"Return headings of entries (body included) that match keyword QUERY from BUFFER."
(let ((result
(org-ql-select
(get-buffer buf)
`(rifle ,query)
:action #'gptel-org-tools--heading)))
(gptel-org-tools--result-limit result)))
(add-to-list 'gptel-org-tools
(gptel-make-tool
:function #'gptel-org-tools--org-ql-select-headings-rifle
:name "gptel-org-tools--org-ql-select-headings-rifle"
:description "Returns headings matching QUERY from BUFFER. Matches against both heading and content, but only returns headings. After using this, continue completing user's request."
:args (list '(:name "buffer"
:type string
:description "The name of the buffer. See the NAME column in ~list-buffers~.")
'(:name "query"
:type string
:description "The string to match entry headings against."))
:category "org-ql"))
(defun gptelg-tools--org-ql-select-tags-local (buf query)
"Return entries whose tags match QUERY in BUFFER, without inheritance."
(let* ((buffer (get-buffer buf))
(mode (buffer-local-value 'major-mode buffer)))
(if buffer
(if (eq mode 'org-mode)
(let ((result
(org-ql-select
(get-buffer buf)
`(tags-local ,query)
:action #'gptel-org-tools--heading-body)))
(gptel-org-tools--result-limit result))
(message "Buffer '%s' isn't an org-mode buffer." buf))
(message "Buffer '%s' does not exist." buf))))
(add-to-list 'gptel-org-tools
(gptel-make-tool
:function #'gptel-org-tools--org-ql-select-tags-local
:name "gptel-org-tools--org-ql-select-tags-local"
:args (list '(:name "buffer"
:type string
:description "The name of the buffer. See the NAME column in `list-buffers`. Using filename fails.")
'(:name "query"
:type string
:description "The string match entry tags against."))
:category "org-ql"
:description "Returns entries whose tags match QUERY from BUFFER, without tag inheritance. After using this, evaluate results for relevance, and proceed with completing user's request."))
(defun gptel-org-tools--org-ql-select-tags-local-count (buf query)
"Return count of entries tagged QUERY in BUFFER."
(let* ((buffer (get-buffer buf))
(mode (buffer-local-value 'major-mode buffer)))
(if buffer
(if (eq mode 'org-mode)
(length (org-ql-select
(get-buffer buf)
`(tags-local ,query)
:action #'gptel-org-tools--heading-body))
(message "Buffer '%s' isn't an org-mode buffer." buf))
(message "Buffer '%s' does not exist." buf))))
(add-to-list 'gptel-org-tools
(gptel-make-tool
:function #'gptel-org-tools--org-ql-select-tags-local-count
:name "gptel-org-tools--org-ql-select-tags-local"
:args (list '(:name "buffer"
:type string
:description "The name of the buffer. See the NAME column in `list-buffers`. Using filename fails.")
'(:name "query"
:type string
:description "The string match entry tags against."))
:category "org-ql"
:description "Returns count of entries tagged with tag QUERY from BUFFER, without tag inheritance."))
(defun gptel-org-tools--org-ql-select-tags (buf query)
"Return every entry tagged QUERY from BUFFER."
(let* ((buffer (get-buffer buf))
(mode (buffer-local-value 'major-mode buffer)))
(if buffer
(if (eq mode 'org-mode)
(let ((result
(org-ql-select
(get-buffer buf)
`(tags ,query)
:action #'gptel-org-tools--heading-body))
(gptel-org-tools--result-limit result)))
(message "Buffer '%s' isn't an org-mode buffer." buf))
(message "Buffer '%s' does not exist." buf))))
(add-to-list 'gptel-org-tools
(gptel-make-tool
:function #'gptel-org-tools--org-ql-select-tags
:name "gptel-org-tools--org-ql-select-tags"
:args (list '(:name "buffer"
:type string
:description "The name of the buffer. See the NAME column in `list-buffers`. Using filename fails.")
'(:name "query"
:type string
:description "The string to match entry headings against."))
:category "org-ql"
:description "Returns entries tagged QUERY from BUFFER, with tag inheritance."))
(defun gptel-org-tools--org-ql-select-rifle (buf query)
"Return every entry matching keyword QUERY from BUFFER."
(let* ((buffer (get-buffer buf))
(mode (buffer-local-value 'major-mode buffer)))
(if buffer
(if (eq mode 'org-mode)
(let ((result
(org-ql-select
buffer
`(rifle ,query)
:action #'gptel-org-tools--heading-body)))
(gptel-org-tools--result-limit result))
(message "Buffer '%s' isn't an org-mode buffer." buf))
(message "Buffer '%s' does not exist." buf))))
(add-to-list 'gptel-org-tools
(gptel-make-tool
:function #'gptel-org-tools--org-ql-select-rifle
:name "gptel-org-tools--org-ql-select-rifle"
:args (list '(:name "buffer"
:type string
:description "The name of the buffer. See the NAME column in `list-buffers`. Using filename fails.")
'(:name "query"
:type string
:description "A single string to search for."))
:category "org-ql"
:description "Returns entries (heading and body) matching QUERY from BUFFER. This may pull too many results, only use if other tools fail."))
(defun gptel-org-tools--org-ql-select-all-tags-local (query)
"Return entries whose tags match QUERY in org-agenda-files.
QUERY is the tag to search for."
(let ((result
(org-ql-select
(org-agenda-files)
`(tags-local ,query)
:action #'gptel-org-tools--heading-body)))
(gptel-org-tools--result-limit result)))
(add-to-list 'gptel-org-tools
(gptel-make-tool
:function #'gptel-org-tools--org-ql-select-all-tags-local
:name "gptel-org-tools--org-ql-select-all-tags-local"
:args (list '(:name "query"
:type string
:description "A single word to scan for."))
:category "org-ql"
:description "Returns entries whose tags match QUERY from all files, without tag inheritance. After using this, evaluate results for relevance, and proceed with completing user's request."))
(defun gptel-org-tools--org-ql-select-all-tags (query)
"Return entries whose tags match QUERY,
with inheritance, in org-agenda-files.
QUERY is the tag to search for."
(let ((result
(org-ql-select
(org-agenda-files)
`(tags ,query)
:action #'gptel-org-tools--heading-body)))
(gptel-org-tools--result-limit result)))
(add-to-list 'gptel-org-tools
(gptel-make-tool
:function #'gptel-org-tools--org-ql-select-all-tags
:name "gptel-org-tools--org-ql-select-all-tags"
:args (list '(:name "query"
:type string
:description "A simple (single) string to scan for."))
:category "org-ql"
:description "Returns entries whose tags match QUERY from BUFFER, with tag inheritance. After using this, evaluate results for relevance, and proceed with completing user's request."))
(defun gptel-org-tools--org-ql-select-all-rifle (query)
"Return entries containing QUERY from org-agenda-files.
QUERY is the keyword to search for."
(let ((result
(org-ql-select
(org-agenda-files)
`(rifle ,query)
:action #'gptel-org-tools--heading-body)))
(gptel-org-tools--result-limit result)))
(add-to-list 'gptel-org-tools
(gptel-make-tool
:function #'gptel-org-tools--org-ql-select-all-rifle
:name "gptel-org-tools--org-ql-select-all-rifle"
:description "Run simple (single) string against ALL org-mode files (notes)."
:args (list '(:name "query"
:type string
:description "The string to match entry headings and content against."))
:category "org-ql"
:description "Returns entries matching QUERY from all files. After using this, evaluate results for relevance, and proceed with completing user's request." ))
(defun gptel-org-tools--org-ql-select-all-regexp (query)
"Return all entries matching regexp QUERY in org-agenda-files.
QUERY is a regular expression."
(let ((result
(org-ql-select
(org-agenda-files)
`(regexp ,query)
:action #'gptel-org-tools--heading-body)))
(gptel-org-tools--result-limit result)))
(add-to-list 'gptel-org-tools
(gptel-make-tool
:function #'gptel-org-tools--org-ql-select-all-regexp
:name "gptel-org-tools--org-ql-select-all-regexp"
:description "Run regexp on ALL files at once."
:args (list '(:name "query"
:type string
:description "Regexp, Emacs Lisp format. Automatically wrapped in a word boundary by the tool."))
:category "org-ql"
:description "Returns entries matching regexp QUERY from BUFFER. After using this, evaluate results for relevance, and proceed with completing user's request. The regexp *must* be in Emacs rx format!" ))
(provide 'gptel-org-tools)
;;; gptel-org-tools.el ends here