From 4b650dfd842f6eac6038e9c36bef10e3ddc5c607 Mon Sep 17 00:00:00 2001 From: Phil Bajsicki <phil@bajsicki.com> Date: Tue, 15 Apr 2025 01:35:20 +0200 Subject: [PATCH] First commit. --- README.md | 3 - README.org | 557 +++++++++++++++++++++++++++++++++++++++++++++ gptel-org-tools.el | 312 +++++++++++++++++++++++++ 3 files changed, 869 insertions(+), 3 deletions(-) delete mode 100644 README.md create mode 100644 README.org create mode 100644 gptel-org-tools.el diff --git a/README.md b/README.md deleted file mode 100644 index 1679484..0000000 --- a/README.md +++ /dev/null @@ -1,3 +0,0 @@ -# gptel-org-tools - -Tooling for LLM interactions with org-mode. Requires gptel and org-ql. \ No newline at end of file diff --git a/README.org b/README.org new file mode 100644 index 0000000..017a96a --- /dev/null +++ b/README.org @@ -0,0 +1,557 @@ +#+title: gptel-org-tools +#+author: Phil Bajsicki +#+auto_tangle: t +#+PROPERTY: header-args :elisp :tangle gptel-org-tools.el +* Intro +This is a collection of tools I wrote to help me review my life. + +To that end, it's proven useful enough. + +Summary: +- An explanation of each tool is below. +- The tools are tangled into =gptel-org-tools.el= from this file. +- There are no docstrings unless there are, in which case that's a mistake and I apologize. + +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. + - Documentation strings are (ideally) for examples. + +These are primarily for my own use. Please don't expect quality. + +This forge is for my personal use, and as such: [[https://bajsicki.com/contact/][contact]], just in case there's questions, issues, whatnot. + +* My system +I'm running LLMs exclusively locally, on the smaller side. +With Qwen 14B, I'm getting reasonably good results with the following set-up: +- Hardware: RX 7900XTX +- Software: + - [[https://github.com/ggml-org/llama.cpp][llama.cpp]] compiled with ROCm 6.4. + - Emacs: [[https://github.com/doomemacs/doomemacs][Doom Emacs]], [[https://github.com/karthink/gptel][gptel]], [[https://github.com/alphapapa/org-ql/][org-ql]]. + +** My set-up +These are the settings I use, for reproducibility. + +Yes, this is somewhat odd, that I would use the deepseek option, but I have found that it handles reasoning a little bit better than gptel's openai backend. + +I change models a lot, and this /just works/ for most models, even if some aren't compatible outright. + +#+begin_src elisp :tangle no +(use-package! gptel) +(setq gptel-model 'llamacpp) +(setq gptel-include-reasoning t) +(setq gptel-backend (gptel-make-deepseek "llamacpp" + :host "localhost:8080" + :protocol "http" + :stream nil + :models '("llamacpp" + :capabilities (reasoning)) + :request-params '(:thinking t + :enable_thinking t + :include_reasoning t + :parallel_tool_calls t))) + +(setf (alist-get 'org-mode gptel-prompt-prefix-alist) "@user\n") +(setf (alist-get 'org-mode gptel-response-prefix-alist) "@assistant\n") +(setq gptel-default-mode 'org-mode) +(setq gptel-use-tools t) +(setq gptel-log-level 'debug) +(setq gptel--debug t) +(require 'gptel) +#+end_src + +With that out of the way, let's get to the tools. +* Code +** Preamble +:PROPERTIES: +:VISIBILITY: folded +:END: +Stuff, headers, etc. +#+begin_src elisp +;;; 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.1 +;; 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: +#+end_src +** The tools +*** Emacs +These tools are primarily concerned with Emacs, Emacs Lisp, and files-or-buffers. + + +**** exec_lisp +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. + +#+begin_src elisp :tangle no +(gptel-make-tool + :function (lambda (elisp) + (unless (stringp elisp) (error "elisp code must be a string")) + (with-temp-buffer + (insert (eval (read elisp))) + (buffer-string))) + :name "eval" + :description "Execute arbitrary Emacs Lisp code" + :args (list '(:name "eval" + :type string + :description "The Emacs Lisp code to evaluate.")) + :category "emacs") +#+end_src + +**** emacs-list-buffers +I wanted the assistant to have an easier time finding my files and buffers, and this has proven to be a great choice. I have yet to manage to get rid of the =:args=, but having them optional/ do nothing works well enough. + +The rationale behind using ~ibuffer~ is the same as with dired. They both display a lot of data, densely. So instead of trying to use some workaround with ~buffer-file-name~ or other functions, I'd rather just grab a 'text capture' of the same UI I'm looking at, and call it a day. + +Seems to be one of the most reliable tools in the basket... mostly because +#+begin_src elisp +(gptel-make-tool + :function (lambda (arg) + (with-temp-buffer + (ibuffer) + (let ((content (buffer-string))) + (kill-buffer (current-buffer)) + content))) + :name "list-buffers" + :description "Access the list of buffers open in Emacs, including file names and full paths." + :args (list '(:name "arg" + :type string + :description "Does nothing." + :optional t)) + :category "emacs") +#+end_src +**** dired-list +See above, same reasoning. There's very little reason to use the ~directory-files~ function for this (as in another tool I saw.) The reason is, ~directory-files~ doesn't provide nearly as much information about the items in that directory. Not even a distinction between files and directories. + +~directory-files-and-attributes~ might, but I personally found its output horrendous to read, and still somehow more expensive context-wise than just plain ol' dired. + +#+begin_comment +Be sure to customize the function to point to your org directory, if you wish. I find it makes a big difference. Having the argument be optional also helps when the LLM starts stumbling, as it gives it a /reset point/ so it can re-orient itself (although by that time it has usually forgotten what it was supposed to be doing...) +#+end_comment + +#+begin_src elisp +(gptel-make-tool + :function (lambda (dir) + (with-temp-buffer + (dired (or dir "~")) + (let ((content (buffer-string))) + (kill-buffer (current-buffer)) + content))) + :name "dired" + :description "List directory contents" + :args (list '(:name "dir" + :type string + :description "Directory path" + :optional t)) + :category "filesystem") +#+end_src + +**** emacs-find-buffer-visiting +#+begin_src elisp +(gptel-make-tool + :function (lambda (filename) + (bufferp (find-buffer-visiting (expand-file-name filename)))) + :name "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") +#+end_src +**** emacs-find-file-noselect +Continuation from above. Open a file into a buffer for processing. Onec it's found by dired-list. +#+begin_src elisp +(gptel-make-tool + :function (lambda (file) + (find-file-noselect file)) + :name "find-file-noselect" + :description "Open the file in a buffer. This doesn't interfere with the user." + :args (list '(:name "file" + :type string + :description "Path to file..")) + :category "filesystem") +#+end_src + +*** Org-mode +And here we start getting into the weeds. + +The core of these functions is to ensure that the LLM doesn't go astray. + +It will, inevitably. + +Then, the core of these functions is to ensure it can be gently prodded into doing what it's asked, instead of being "asked politely" until it complies. + +We want to minimize the effect of entropy on the use of these tools. To that end, we're making /separate tools for separate purposes/. + +This isn't proven, by any means, but given what little I know about LLMs, I believe it's easier for them to choose between many single-purpose tools, than from few multi-purpose tools. + +Even us humans experience less cognitive load when we're given clear options up-front, instead of being given a multi-tool that we then need to learn our way around to get the job done. + +With that secondary purpose in mind, the real aim of these tools is to /limit/ the information that the LLM has access to /only/ to that which is relevant. In the ideal. + +In the real world, we'll still see garbage being pulled in, and the LLM being led astray by an unfortunate sentence, or an ~org-ql~ query that explodes the context. + +At the same time, that will happen /significantly/ less than if we were to give it more freedom. + +#+begin_comment +LLMs are not intelligent, despite claims to the contrary. +#+end_comment + +**** org-extract-tags +Pretty simple, does what it says on the tin. It gets all the tags from the =buffer=. This is useful as a first line of research for the LLM, as it can then get a surface-level understanding of the contents of a file. + +This is not, by any means, sufficient, but I do tag people and specific events frequently enough that it helps save on the context window. +#+begin_src elisp +(defun gptel-org-tools-org-extract-tags (buffer) + (interactive "bBuffer: ") + (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<)))) + +(gptel-make-tool + :function #'gptel-org-tools-org-extract-tags + :name "org-extract-tags" + :description "Extract all unique tags from an org-mode buffer" + :args (list '(:name "buffer" + :type string + :description "The Org buffer to extract tags from.")) + :category "org-mode") +#+end_src +**** org-extract-headings +But what if there's no tags related to the topic? + +Then we need to pull /some/ information from the buffer, without dragging the entire 500kb in and exploding the context window. + +Therefore, headings. A reasonable amount of information, and still keeping the signal-to-noise ratio pretty decent. +#+begin_src elisp +(defun gptel-org-tools-org-extract-headings (buffer) + (interactive "bBuffer: ") + (with-current-buffer buffer + (org-map-entries + #'(buffer-substring-no-properties + (line-beginning-position) + (line-end-position)) + t + 'file))) + +(gptel-make-tool + :function #'gptel-org-tools-org-extract-headings + :name "org-extract-headings" + :description "Extract all headings from an org-mode buffer" + :args (list '(:name "buffer" + :type string + :description "The Org buffer to extract headings from.")) + :category "org-mode") +#+end_src + +**** org-ql-select +This is the big one. It works, and it's ugly, and I'm in the middle of replacing it. The tool argument descriptions do /a lot/ of lifting here, and even then the models stumble and fall on their face very often. + +They also sometimes return a sexp, and sometimes a quoted string. So I had to work around that. It works... some of the time. + +My current goal is to replace this monstrosity with individual functions for each of the main ~org-ql~ predicates, such that the LLMs have an easier time with the syntax, so they can just choose their desired query instead of having to both choose and properly form the syntax. + +But in the interim, this works. Kind of. + +#+begin_src elisp +(defun gptel-org-tools-org-ql-select (buf query) + (org-ql-select + (get-buffer buf) + (if (stringp query) + (read query) + query) + :action #'(lambda () + (concat + (buffer-substring-no-properties + (line-beginning-position) + (progn + (outline-next-heading) + (line-beginning-position))))))) + +(gptel-make-tool + :function #'gptel-org-tools-org-ql-select + :name "org-ql-select" + :description "Run org-ql-select against buffer with query. Using filename fails." + :args (list '(:name "buffer" + :type string + :description "The name of the buffer. Can be multiple buffers. See the NAME column in `emacs-list-buffers`.") + '(: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") +#+end_src +**** Somewhat working tools +***** org-ql-select-dates +My journal is a single file, with a hierarchy like so: + +#+begin_src org :tangle no +,* [YYYY] +,** [YYYY-MM] +,*** [YYYY-MM-DD Day HH:MM] +#+end_src + +I wanted to make sure that if I asked about a time period, the LLM would be able to pull at least roughly around the right time from my journal, without blowing out its context window. + +This /kinda sorta/ works. The only time I have seen this fail is when the LLM chooses to apply it to buffers that don't follow that convention. + +I doubt it'll be useful for anyone else, but it's here and fairly easy to adapt to other needs. + +Notice it pulls to the end of the subtree. So for months, that's at least 28 entries in a year, and during busy months, possibly hundreds. + +But, any customizations to tweak this is left to the user, as everyone has their own conventions. + + +#+begin_src elisp +(defun gptel-org-tools-org-ql-select-dates (buf date) + (interactive "fBuffer: \nsDate (YYYY or YYYY-MM): ") + (org-ql-select + (get-buffer buf) + `(heading ,date) + :action #'(lambda () + (buffer-substring-no-properties + (line-beginning-position) + (org-end-of-subtree))))) + + +(gptel-make-tool + :function #'gptel-org-tools-org-ql-select-dates + :name "org-ql-select-dates" + :description "Extract org subtree by date in YYYY or YYYY-MM format" + :args (list '(:name "buffer" + :type string + :description "Buffer name.") + '(:name "date" + :type string + :description "Date in YYYY or YYYY-MM format. Can add multiple like so: \"YYYY-MM\" \"YYYY-MM\"")) + :category "org") +#+end_src +***** org-agenda-fortnight +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. +#+begin_src elisp +(gptel-make-tool + :function (lambda (days) + (with-temp-buffer + (org-agenda-list (or days 14)) + (let ((content (buffer-string))) + (kill-buffer (current-buffer)) + content))) + :name "org-agenda-fortnight" + :description "Get the next 14 days of user's org-agenda." + :args (list '(:name "days" + :type integer + :description "The number of days to look ahead. Default: 14")) + :category "org") +#+end_src +**** Completely WIP tools +The following tools are still very much WIP, and I think they're self-explanatory enough. They have /NOT/ been tested in any way, shape, form, or capacity. + +(I mean, /some/ of them work... but they don't sometimes, too.) + +***** org-ql-select-headings +Retrieve the headings where the heading matches query.. +#+begin_src elisp +(defun gptel-org-tools-org-ql-select-headings (buf query) + (org-ql-select + (get-buffer buf) + `(heading ,query) + :action #'(lambda () + (concat + (buffer-substring-no-properties + (line-beginning-position) + (line-end-position)))))) + + +(gptel-make-tool + :function #'gptel-org-tools-org-ql-select-headings + :name "org-ql-select-headings" + :description "Retreive matching headings from buffer using org-ql-select. Matches only against heading. Using filename fails." + :args (list '(:name "buffer" + :type string + :description "The name of the buffer. See the NAME column in ~emacs-list-buffers~.") + '(:name "query" + :type string + :description "The string to pass into org-ql-select-headings. This is a bare string. Example: \"searchterm\"")) + :category "org-ql") +#+end_src + +***** org-ql-select-headings-rifle +Retrieve all the headings where either heading or content matches query. +#+begin_src elisp +(defun gptel-org-tools-org-ql-select-headings-rifle (buf query) + (org-ql-select + (get-buffer buf) + `(rifle ,query) + :action :action #'(lambda () + (concat + (buffer-substring-no-properties + (line-beginning-position) + (line-end-position)))))) + + + (gptel-make-tool + :function #'gptel-org-tools-org-ql-select-headings-rifle + :name "org-ql-select-headings-rifle" + :description "Retreive headings from buffer using org-ql-select. Matches against both heading and content. Using filename fails." + :args (list '(:name "buffer" + :type string + :description "The name of the buffer. See the NAME column in ~emacs-list-buffers~.") + '(:name "query" + :type string + :description "The string to pass into org-ql-select-headings-rifle. This is a bare string. Example: \"searchterm\"")) + :category "org-ql") +#+end_src + +***** org-ql-select-tags-local +This pulls all the headings (and their contents) when they match tags (without inheritance.) +#+begin_src elisp +(defun gptel-org-tools-org-ql-select-tags-local (buf query) + (org-ql-select + (get-buffer buf) + `(tags-local ,query) + :action #'(lambda () + (concat + (buffer-substring-no-properties + (line-beginning-position) + (progn + (outline-next-heading) + (line-beginning-position))))))) + + +(gptel-make-tool + :function #'gptel-org-tools-org-ql-select-tags-local + :name "org-ql-select-tags-local" + :description "Run org-ql-select-tags-local against buffer with query. No tag inheritance." + :args (list '(:name "buffer" + :type string + :description "The name of the buffer. See the NAME column in `emacs-list-buffers`. Using filename fails.") + '(:name "query" + :type string + :description "The tags to match entry headings against. Example: \"tag1\" \"tag2\"")) + :category "org-ql") +#+end_src + +***** 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.) +#+begin_src elisp +(defun gptel-org-tools-org-ql-select-tags (buf query) + (org-ql-select + (get-buffer buf) + `(tags ,query) + :action #'(lambda () + (concat + (buffer-substring-no-properties + (line-beginning-position) + (progn + (outline-next-heading) + (line-beginning-position))))))) + + +(gptel-make-tool + :function #'gptel-org-tools-org-ql-select-tags + :name "org-ql-select-tags" + :description "Run org-ql-select-tags against buffer with query. Supports tag inheritance." + :args (list '(:name "buffer" + :type string + :description "The name of the buffer. See the NAME column in `emacs-list-buffers`. Using filename fails.") + '(:name "query" + :type string + :description "The tags to match entry headings against. Example: \"tag1\" \"tag2\"")) + :category "org-ql") +#+end_src + +***** org-ql-select-rifle +And, the "grab everything that matches" tool. +#+begin_src elisp +(defun gptel-org-tools-org-ql-select-rifle (buf query) + (org-ql-select + (get-buffer buf) + `(rifle ,query) + :action #'(lambda () + (concat + (buffer-substring-no-properties + (line-beginning-position) + (progn + (outline-next-heading) + (line-beginning-position))))))) + +(gptel-make-tool + :function #'gptel-org-tools-org-ql-select-rifle + :name "org-ql-select-rifle" + :description "Run org-ql-select-rifle against buffer with query." + :args (list '(:name "buffer" + :type string + :description "The name of the buffer. See the NAME column in `emacs-list-buffers`. Using filename fails.") + '(:name "query" + :type string + :description "The strings to match entry headings and content against. Example: \"tag1\" \"tag2\"")) + :category "org-ql") +#+end_src + +** End +:PROPERTIES: +:CREATED: <2025-04-14 Mon 22:46> +:VISIBILITY: folded +:END: +#+begin_src elisp +(provide 'gptel-org-tools) +;;; gptel-org-tools.el ends here +#+end_src diff --git a/gptel-org-tools.el b/gptel-org-tools.el new file mode 100644 index 0000000..f61ee1b --- /dev/null +++ b/gptel-org-tools.el @@ -0,0 +1,312 @@ +;;; 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.1 +;; 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-make-tool + :function (lambda (arg) + (with-temp-buffer + (ibuffer) + (let ((content (buffer-string))) + (kill-buffer (current-buffer)) + content))) + :name "list-buffers" + :description "Access the list of buffers open in Emacs, including file names and full paths." + :args (list '(:name "arg" + :type string + :description "Does nothing." + :optional t)) + :category "emacs") + +(gptel-make-tool + :function (lambda (dir) + (with-temp-buffer + (dired (or dir "~")) + (let ((content (buffer-string))) + (kill-buffer (current-buffer)) + content))) + :name "dired" + :description "List directory contents" + :args (list '(:name "dir" + :type string + :description "Directory path" + :optional t)) + :category "filesystem") + +(gptel-make-tool + :function (lambda (filename) + (bufferp (find-buffer-visiting (expand-file-name filename)))) + :name "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") + +(gptel-make-tool + :function (lambda (file) + (find-file-noselect file)) + :name "find-file-noselect" + :description "Open the file in a buffer. This doesn't interfere with the user." + :args (list '(:name "file" + :type string + :description "Path to file..")) + :category "filesystem") + +(defun gptel-org-tools-org-extract-tags (buffer) + (interactive "bBuffer: ") + (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<)))) + +(gptel-make-tool + :function #'gptel-org-tools-org-extract-tags + :name "org-extract-tags" + :description "Extract all unique tags from an org-mode buffer" + :args (list '(:name "buffer" + :type string + :description "The Org buffer to extract tags from.")) + :category "org-mode") + +(defun gptel-org-tools-org-extract-headings (buffer) + (interactive "bBuffer: ") + (with-current-buffer buffer + (org-map-entries + #'(buffer-substring-no-properties + (line-beginning-position) + (line-end-position)) + t + 'file))) + +(gptel-make-tool + :function #'gptel-org-tools-org-extract-headings + :name "org-extract-headings" + :description "Extract all headings from an org-mode buffer" + :args (list '(:name "buffer" + :type string + :description "The Org buffer to extract headings from.")) + :category "org-mode") + +(defun gptel-org-tools-org-ql-select (buf query) + (org-ql-select + (get-buffer buf) + (if (stringp query) + (read query) + query) + :action #'(lambda () + (concat + (buffer-substring-no-properties + (line-beginning-position) + (progn + (outline-next-heading) + (line-beginning-position))))))) + +(gptel-make-tool + :function #'gptel-org-tools-org-ql-select + :name "org-ql-select" + :description "Run org-ql-select against buffer with query. Using filename fails." + :args (list '(:name "buffer" + :type string + :description "The name of the buffer. Can be multiple buffers. See the NAME column in `emacs-list-buffers`.") + '(: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") + +(defun gptel-org-tools-org-ql-select-dates (buf date) + (interactive "fBuffer: \nsDate (YYYY or YYYY-MM): ") + (org-ql-select + (get-buffer buf) + `(heading ,date) + :action #'(lambda () + (buffer-substring-no-properties + (line-beginning-position) + (org-end-of-subtree))))) + + +(gptel-make-tool + :function #'gptel-org-tools-org-ql-select-dates + :name "org-ql-select-dates" + :description "Extract org subtree by date in YYYY or YYYY-MM format" + :args (list '(:name "buffer" + :type string + :description "Buffer name.") + '(:name "date" + :type string + :description "Date in YYYY or YYYY-MM format. Can add multiple like so: \"YYYY-MM\" \"YYYY-MM\"")) + :category "org") + +(gptel-make-tool + :function (lambda (days) + (with-temp-buffer + (org-agenda-list (or days 14)) + (let ((content (buffer-string))) + (kill-buffer (current-buffer)) + content))) + :name "org-agenda-fortnight" + :description "Get the next 14 days of user's org-agenda." + :args (list '(:name "days" + :type integer + :description "The number of days to look ahead. Default: 14")) + :category "org") + +(defun gptel-org-tools-org-ql-select-headings (buf query) + (org-ql-select + (get-buffer buf) + `(heading ,query) + :action #'(lambda () + (concat + (buffer-substring-no-properties + (line-beginning-position) + (line-end-position)))))) + + +(gptel-make-tool + :function #'gptel-org-tools-org-ql-select-headings + :name "org-ql-select-headings" + :description "Retreive matching headings from buffer using org-ql-select. Matches only against heading. Using filename fails." + :args (list '(:name "buffer" + :type string + :description "The name of the buffer. See the NAME column in ~emacs-list-buffers~.") + '(:name "query" + :type string + :description "The string to pass into org-ql-select-headings. This is a bare string. Example: \"searchterm\"")) + :category "org-ql") + +(defun gptel-org-tools-org-ql-select-headings-rifle (buf query) + (org-ql-select + (get-buffer buf) + `(rifle ,query) + :action :action #'(lambda () + (concat + (buffer-substring-no-properties + (line-beginning-position) + (line-end-position)))))) + + + (gptel-make-tool + :function #'gptel-org-tools-org-ql-select-headings-rifle + :name "org-ql-select-headings-rifle" + :description "Retreive headings from buffer using org-ql-select. Matches against both heading and content. Using filename fails." + :args (list '(:name "buffer" + :type string + :description "The name of the buffer. See the NAME column in ~emacs-list-buffers~.") + '(:name "query" + :type string + :description "The string to pass into org-ql-select-headings-rifle. This is a bare string. Example: \"searchterm\"")) + :category "org-ql") + +(defun gptel-org-tools-org-ql-select-tags-local (buf query) + (org-ql-select + (get-buffer buf) + `(tags-local ,query) + :action #'(lambda () + (concat + (buffer-substring-no-properties + (line-beginning-position) + (progn + (outline-next-heading) + (line-beginning-position))))))) + + +(gptel-make-tool + :function #'gptel-org-tools-org-ql-select-tags-local + :name "org-ql-select-tags-local" + :description "Run org-ql-select-tags-local against buffer with query. No tag inheritance." + :args (list '(:name "buffer" + :type string + :description "The name of the buffer. See the NAME column in `emacs-list-buffers`. Using filename fails.") + '(:name "query" + :type string + :description "The tags to match entry headings against. Example: \"tag1\" \"tag2\"")) + :category "org-ql") + +(defun gptel-org-tools-org-ql-select-tags (buf query) + (org-ql-select + (get-buffer buf) + `(tags ,query) + :action #'(lambda () + (concat + (buffer-substring-no-properties + (line-beginning-position) + (progn + (outline-next-heading) + (line-beginning-position))))))) + + +(gptel-make-tool + :function #'gptel-org-tools-org-ql-select-tags + :name "org-ql-select-tags" + :description "Run org-ql-select-tags against buffer with query. Supports tag inheritance." + :args (list '(:name "buffer" + :type string + :description "The name of the buffer. See the NAME column in `emacs-list-buffers`. Using filename fails.") + '(:name "query" + :type string + :description "The tags to match entry headings against. Example: \"tag1\" \"tag2\"")) + :category "org-ql") + +(defun gptel-org-tools-org-ql-select-rifle (buf query) + (org-ql-select + (get-buffer buf) + `(rifle ,query) + :action #'(lambda () + (concat + (buffer-substring-no-properties + (line-beginning-position) + (progn + (outline-next-heading) + (line-beginning-position))))))) + +(gptel-make-tool + :function #'gptel-org-tools-org-ql-select-rifle + :name "org-ql-select-rifle" + :description "Run org-ql-select-rifle against buffer with query." + :args (list '(:name "buffer" + :type string + :description "The name of the buffer. See the NAME column in `emacs-list-buffers`. Using filename fails.") + '(:name "query" + :type string + :description "The strings to match entry headings and content against. Example: \"tag1\" \"tag2\"")) + :category "org-ql") + +(provide 'gptel-org-tools) +;;; gptel-org-tools.el ends here