Tooling for LLM interactions with org-mode. Requires gptel and org-ql.
Find a file
2025-05-03 20:27:06 +02:00
.gitignore Initial commit 2025-04-14 22:34:57 +02:00
gptel-org-tools.el Add per-file regex 2025-05-03 20:27:06 +02:00
LICENSE Initial commit 2025-04-14 22:34:57 +02:00
README.org Add per-file regex 2025-05-03 20:27:06 +02:00

gptel-org-tools

Disclaimer

  • This package is an active privacy risk. It allows the LLM to autonomously expand its context in any direction it chooses.
  • Only connect to third-party systems if you understand and accept the risk of *any* of your files becoming publicly accessible.

Intro

This is a collection of tools I wrote to help me review my life.

Summary:

  • An explanation of each tool is below.
  • The tools are tangled into gptel-org-tools.el from this file.

Premise:

  • LLMs are not very smart and unreliable, but they do okay with basic text comprehension, and can generate completions/ responses which fit the vibe of the request.
  • LLMs can then fit the vibe of the request better if there is more relevant and accurate request data in their context window.
  • LLMs lose the vibe when there's irrelevant (garbage) data in the context window.

Therefore (philosophy, I may change my mind later):

  • Each tool has to limit the garbage data it returns as much as possible.
  • Each tool has to have as little LLM-facing documentation as possible (while being enough for the LLM to understand and use the tool.) Extra words is extra garbage.
  • Each tool should handle as wide an range of even remotely valid inputs from an LLM as possible.

    • Different models are biased toward different outputs.
    • user-error isn't addressable when a model only has 5 minutes worth of memory.
    • Failure caused by LLM mis-use should be solved in such a way that failure becomes increasingly less likely.
    • We never know when an LLM will respond with a string, json, s-exp, or ASCII codes (no, that last one hasn't happened… yet).
  • Each tool should work in harmony with other tools to form a toolbox which serves these goals.

    • Avoid tool overlap.
    • One tool for one task.
    • Tool names are documentation.
    • Argument names are documentation.
    • As few arguments per tool as possible.

Installation:

I only use Doom Emacs, so here's how I load:

packages.el:

(package! gptel-org-tools
  :recipe (:host nil
	   :repo "https://git.bajsicki.com/phil/gptel-org-tools"))

config.el:

(require 'gptel-org-tools)
(setq gptel-tools gptel-org-tools)

This will overwrite any other tools you have defined before this call takes place.

If you want to just append them to your existing tool list:

config.el:

(require 'gptel-org-tools)
(mapcar (lambda (tool) (cl-pushnew tool gptel-tools)) gptel-org-tools)

Code

Preamble

Stuff, headers, etc.

;;; 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:)

gptel-org-tools

Collects into gptel-org-tools list, distinct from gptel-tools

(defvar gptel-org-tools '())

Variables

skip-heading-extraction

If you're like me, you may have structured headings based on timestamps in your org-mode notes.

To that end, there are a few variables that may help you from blowing out the context, specifically for the org-extract-headings tool.

(defvar gptel-org-tools-skip-heading-extraction '())

Use setq in your configuration, with a list of buffer names whose headings you never want to extract. E.g.

(setq gptel-org-tools-skip-heading-extraction '("journal.org" ".org"))

result-limit

It is with great disappointment that I admit to a terrible flaw of this package.

When in local use, with a large dataset, a lot of the commands which return entire org-mode entries… end up grabbing way, way too much text.

This can end up with Emacs freezing as it tries to parse immeasurable amounts of text (for fonts, formatting, etc) at the same time as the LLM is screeching in electric pain trying to actually process all that data.

To that end, the stop-gap solution is to implement a hard limit on the amount of text a tool can return.

I don't think there's much reason to ever allow partial results, which leads me to this:

(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. Stop. Analyze. Find a different solution, or use a more specific query." gptel-org-tools-result-limit)
    result))

Use setq in your configuration, e.g.:

(setq gptel-org-tools-result-limit 40000)

This will prevent tools from returning results longer than 40,000 characters. Instead, the LLM will receive a message saying it should be much more specific in its queries, which will hopefully guide it to be more specific.

By default the limit is 40k, as that's just over the longest months I've kept my journal through.

The functionality for withholding results is only applied to select functions that are known to cause issues.

Helper Functions

These abstract away some of the tool definitions. They're called from each function, depending on their intended output.

Return heading (line)

(defun gptel-org-tools--heading ()
  "Return the org-mode heading."
  (concat
   (buffer-substring-no-properties
    (line-beginning-position)
    (line-end-position))))

Return heading and body (without subheadings)

(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)))))

Return heading and subheadings (until next same-level heading)

(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))))

The tools

Emacs

These tools are primarily concerned with Emacs, Emacs Lisp, and files-or-buffers.

eval [disabled by default]

Dangerous, but occasionally useful for pure chaos and amusement… I would like to say. But in actuality, especially with the 'smarter' models, they can surprise with the varied approaches they have to problem-solving.

E.g. can't find the right org-ql-select query? Let's use an eldritch abomination of a regular expression while mapping over each heading in the org-mode buffer and do a close enough job.

Highly not recommended, but sometimes an LLM can pull a rabbit out of pure entropy.

(defun gptel-org-tools--eval (elisp)
  (unless (stringp elisp) (error "elisp code must be a string"))
  (with-temp-buffer
    (insert (eval (read elisp)))
    (buffer-string)))

(add-to-list 'gptel-org-tools
	     (gptel-make-tool
	      :function #'gptel-org-tools--eval
	      :name  "gptel-org-tools--eval"
	      :args (list '(:name "eval"
			    :type string
			    :description "The Emacs Lisp code to evaluate."))
	      :category "emacs"
	      :confirm t
	      :description "Execute Emacs Lisp code"))
list-buffers

Not using ibuffer to avoid customization differences between users. Argument required by gptel.

(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, stop. Then evaluate which files are most likely to be relevant to the user's request."))
dired

I originally wanted to use directory-files for this, but it turns out that it's much easier to use dired for this.

You can customize the function to point to your org directory, if you wish. I'm not entirely sure if it makes a big difference, but may keep your LLM from getting lost in the sauce.

(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. After using this, stop. Evaluate for relevance. Then use your findings to fulfill user's request."
	      :args (list '(:name "dir"
			    :type string
			    :description "Directory path"
			    :optional t))
	      :category "filesystem"))
find-buffer-visiting [disabled by default]

Disabled for now, as it's causing some issues.

(defun gptel-org-tools--find-buffer-visiting (filename)
  "Return the buffer visiting file FILENAME."
  (concat
   (bufferp
    (find-buffer-visiting
     (expand-file-name filename)))))

(add-to-list 'gptel-org-tools
	     (gptel-make-tool
	      :function #'gptel-org-tools--find-buffer-visiting
	      :name  "gptel-org-tools--find-buffer-visiting"
	      :description "Check if the file is open in a buffer. Usage (find-buffer-visiting filename)"
	      :args (list '(:name "filename"
			    :type string
			    :description "The filename to compare to open buffers."))
	      :category "org-mode"))
open-file-inactive

Opens a file into an inactive (background) buffer for processing.

(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. After calling this tool, stop. Then continue fulfilling user's request."
	      :args (list '(:name "file"
			    :type string
			    :description "Path to file.."))
	      :category "filesystem"))
read-file-contents
(defun gptel-org-tools--read-file-contents (file)
  "Return contents of FILE."
  (with-temp-buffer
    (insert-file-contents (expand-file-name filename))
    (concat
     (buffer-string))))

(add-to-list 'gptel-org-tools
             (gptel-make-tool
              :function #'gptel-org-tools--read-file-contents
              :name  "gptel-org-tools--read-file-contents"
              :description "Reads and return the contents of a specified file. After calling this tool, stop. Then continue fulfilling user's request."
              :args (list '(:name "filename"
                            :type string
                            :description "The filename to read."))
              :category "org-mode"
	      :confirm t))
describe-variable
(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. Stop. 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. After calling this tool, stop. Evaluate if the result helps. Then continue fulfilling user's request."
              :args (list '(:name "var"
                            :type string
                            :description "Variable name"))
              :category "emacs"))
describe-function
(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. After calling this tool, stop. Evaluate if the result helps. Then continue fulfilling user's request."
              :args (list '(:name "fun"
                            :type string
                            :description "Function name"
                            :optional t))
              :category "emacs"))

Org-mode

Given the complexity of org-mode and org-ql queries, the following points are important:

  • Separate tools for each key predicate.
  • Separate tools for either single-buffer scans, or org-agenda-files.

These tools will still pull garbage in, inevitably, especially for queries such as '(rifle "cat"), which will then also match "cataclysm", "cataract", "vocation", etc. etc.

Ensuring the functions are easy to call and very specific will help this happen significantly less than if we just give the LLM org-ql-select and let it go wild.

LLMs are not intelligent, despite claims to the contrary.

org-extract-tags
(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. After calling this tool, stop. Then continue fulfilling user's request."))
org-extract-headings
(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, stop. Then evaluate the relevance of the headings to the user's request. Then continue."
	      :args (list '(:name "buffer"
			    :type string
			    :description "The Org buffer to extract headings from."))
	      :category "org-mode"))
org-ql-select [disabled by default]

Ugly. LLMs sometimes return a sexp, and sometimes a quoted string for query. So I had to work around that. It works… some of the time. In general, this is deprecated, but left here for historical reasons. I may resurrect this if I find a good way of prompting the LLM to structure queries well.

(defun gptel-org-tools--org-ql-select (buf query)
  "Return entries matching QUERY from BUFFER.

QUERY can be any valid org-ql-select query."
  (let ((result
	 (org-ql-select (get-buffer buf)
	   (if (stringp query)
	       (read query)
	     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
	      :name  "gptel-org-tools--org-ql-select"
	      :args (list '(:name "buffer"
			    :type string
			    :description "The name of the buffer. Can be multiple buffers. See the NAME column in `list-buffers`.")
			  ;; I'm not actually sure if it can be multiple, haven't tested.
			  '(:name "query"
			    :type string
			    :description "The query to pass into org-ql-select. See org-ql documentation for syntax. Usually `(tags \"tag1\" \"tag2\")` is sufficient. Possible predicates: `tags` (finds both local and inherited tags), `tags-local` (finds only local tags), `rifle` (matches against both heading and body text). This is a sexp, not a string."))
	      :category "org"
	      :description "Return entries matching QUERY from BUFFER. After using this, stop. Then evaluate the relevance of the results to the user's request. Then continue."))
Somewhat working tools
org-ql-select-by-date

My journal is a single file, with a hierarchy like so:

* [YYYY]
** [YYYY-MM]
*** [YYYY-MM-DD Day HH:MM])

This tool kinda sorta works. It has two main failure modes:

  1. the LLM chooses to apply this tool to buffers other than my journal.
  2. the LLM ignores the existence of this tool and instead uses regexp with terms… and explodes the context window.

The reason for this tool existing (it's the same as select-heading), is that it may help guide the LLM toward higher accuracy. Unclear if that's the case.

(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
		    (org-ql-select buffer
		     `(heading ,date)
		     :action #'gptel-org-tools--heading-subtree)))
	      (concat (gptel-org-tools--result-limit result) "Results end here. Proceed with the next action."))
	  (message "Buffer '%s' isn't an org-mode buffer." buf))
      (message "Buffer '%s' does not exist." buf))))
gptel-org-tools--org-ql-select-date
(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

After using this, stop. Then evaluate the relevance of the entries to the user's request. Then continue."))
org-agenda-seek

This is still work in progress, the idea is to have the LLM check my calendar and see what my plans are. I have not had time to really dig into this yet.

It works, in principle, but I haven't been able to find a use for it yet. The real challenge is in building a context where the tools integrate with each-other in a way that makes sense. For now, this exists.

(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. Example:
- Get all tasks due in the next 7 days: \"7\"
- Get all tasks in the last 14 days: \"-14\"
After using this, stop. Evaluate the relevance of the results. Then continue."))
Completely WIP tools
org-ql-select-headings

Retrieve the headings where the heading matches query. This is very much the same as org-ql-select-by-date, but the descriptions are different to ensure the LLM knows what to do.

(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)))
	      (concat (gptel-org-tools--result-limit result) "Results end here. Proceed with the next action."))
      	  (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."))
org-ql-select-headings-rifle

Retrieve all the headings where either heading or content matches query.

(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, stop. Evaluate results. Then 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"))
org-ql-select-tags-local

This pulls all the headings (and their contents) when they match tags (without inheritance.)

(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, stop. Evaluate results for relevance, then proceed with completing user's request."))
org-ql-select-tags-local-count

This pulls all the local tags (without inheritance) from buffer, and returns the number of these tagged headings.

(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. After using this, stop. Evaluate results. Then proceed."))
org-ql-select-tags

This pulls all the headings (and their contents) when they match tags (with inheritance; if a parent entry has the tag, descendant entries do, too.)

(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. After using this, stop. Evaluate results. Only then proceed."))
org-ql-select-rifle

And, the "grab everything that matches" tool.

(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. After using this, stop. Evaluate results. If necessary, re-plan. Only then proceed."))
org-ql-select-regexp
(defun gptel-org-tools--org-ql-select-regexp (buf query)
  (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
		     `(regexp ,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-regexp
	      :name  "gptel-org-tools--org-ql-select-regexp"
	      :description "Run regexp on ALL files at once."
	      :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 "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, stop. Evaluate results for relevance, and proceed with completing user's request. The regexp *must* be in Emacs rx format!" ))
org-ql-select-all-tags-local

This pulls all the headings (and their contents) when they match tags (without inheritance.)

(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, stop. Evaluate results. Then proceed with completing user's request."))
org-ql-select-all-tags

This pulls all the headings (and their contents) when they match tags (with inheritance; if a parent entry has the tag, descendant entries do, too.)

(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, stop. Evaluate results for relevance, and proceed with completing user's request."))
org-ql-select-all-rifle

And for the entire (org-agenda-files).

Note that I define my agenda in this way:

(setq org-agenda-files (directory-files-recursively "~/enc/org/" ".org$"))

This means that every org-mode file I have is part of this search. If you're using a different set-up, or want to only use the LLM on specific files, then you should modify this function appropriately.

(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, stop. Evaluate results for relevance, and proceed with completing user's request." ))
org-ql-select-all-regexp
(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 all files. After using this, stop. Evaluate results for relevance, and proceed with completing user's request. The regexp *must* be in Emacs rx format!" ))

End

(provide 'gptel-org-tools)
;;; gptel-org-tools.el ends here