| .gitignore | ||
| gptel-got.el | ||
| LICENSE | ||
| README.org | ||
gptel-got
- gptel-got
- Tools
- End
gptel-got
Notes
- This repository previously lived on my personal forge. I have since moved it to codeberg. My personal forge will remain reasonably up-to-date, however codeberg is the reference repository.
- The
gptel.orgfile exists for tracking issues in my set-up, and is fundamentally a way for me to maintain some semblance of sanity in testing, as GitHub/ forges aren't really easy to work with for this. - [2025-05-25 Sun] Package renamed to gptel-got, since typing
-toolsis a mess. Would have been gptel-org but it's a conflict with gptel's package (gptel-org). - [2025-10-26 Sun] Changed license to AGPLv3, as the package can be used server-side as a RAG solution.
- [2026-04-10 Fri] Major update to tooling and functionality. This package is no longer 'light'.
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
Summary:
- An explanation of each tool is below.
- The tools are tangled into
gptel-got.elfrom 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-errorisn'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.
Tools
Installation:
I only use Doom Emacs, so here's how I load:
packages.el:
(package! gptel-got
:recipe (:host nil
:repo "https://codeberg.org/bajsicki/gptel-got"))
config.el:
(require 'gptel-got)
(setq gptel-tools gptel-got)
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-got)
(mapcar (lambda (tool) (cl-pushnew tool gptel-tools)) gptel-got)
Configuring .llm/ directory path
To point to a custom .llm/ directory location:
(setq gptel-got-llm-base-dir "~/.llm") ; or any path
Without this, tools will look for .llm/ in the current default directory.
Preamble
Stuff, headers, etc.
;;; -*- lexical-binding: t -*-
;;; gptel-got.el --- LLM Tools for org-mode interaction
;; Copyright (C) 2025 Phil Bajsicki
;; Author: Phil Bajsicki <phil@bajsicki.com>
;; Keywords: extensions, comm, tools, matching, convenience,
;; Version: 0.1.0
;; Package-Requires: ((emacs "30.1") (gptel "0.9.8") (org-ql "0.9") (dash "2.x") (cl-lib))
;; URL: https://codeberg.org/bajsicki/gptel-got
;; SPDX-License-Identifier: AGPL-3.0-only
;; This program is free software: you can redistribute it and/or modify
;; it under the terms of the GNU Affero General Public License as published by
;; the Free Software Foundation, either version 3 of the License, 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 Affero General Public License for more details.
;; You should have received a copy of the GNU Affero General Public License
;; along with this program. If not, see <https://www.gnu.org/licenses/>.
;; This file is NOT part of GNU Emacs.
;;; Commentary:
;; Source code tangled from README.org.
;; Documentation included in README.org.
;; Repository: https://codeberg.org/bajsicki/gptel-got
;;; Code:
Requirements
(require 'cl-lib)
Configuration:
Variables
| Variable name | Type | Default | Description |
| gptel-got-skip-heading-extraction | list | '() | org-extract-headings will skip buffer names matching strings in this list |
| gptel-got-result-limit | number | 40000 | [Required!] If tool output is larger than result-limit, instructs the LLM to try something else. |
| gptel-got-timestamp-toggle | bool | t | If t, string #+current_time: append current date to it. |
Variables
gptel-got
Collects into gptel-got list, distinct from gptel-tools
(defvar gptel-got '())
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-got-skip-heading-extraction '())
Use setq in your configuration, with a list of buffer names whose headings you never want to extract. E.g. (if you want it to be scoped to org-directory)
(setf gptel-got-skip-heading-extraction
(mapcar (lambda (f) (expand-file-name f org-directory))
'("journal.org" "secret.org")))
default-search-directory
Default directory for org-search when scope is 'dir. Falls back to `org-directory' if set.
(defvar gptel-got-default-search-directory nil
"Default directory for org-search when scope is 'dir.
If nil, falls back to `org-directory'.")
result-limit
This stop-gap solution puts a hard limit on tool result length to prevent context overflow. Applied to a number of tools.
(defvar gptel-got-result-limit 40000)
(defun gptel-got--result-limit (result)
"Check if RESULT exceeds character limit. Truncate and warn if it does."
(let ((str-result (if (listp result) (string-join result "\n") (format "%s" result))))
(if (>= (length str-result) gptel-got-result-limit)
(format "Error: Results over %s characters. Stop. Use a more specific query.\n\nTruncated preview:\n%s..."
gptel-got-result-limit
(substring str-result 0 1000))
str-result)))
subagent-context-strategy
How should sub-agent execution traces be handled in the parent LLM context?
(defvar gptel-got-subagent-context-strategy 'monolithic
"Subagent context strategy (preset symbol or custom plist).
Presets:
- `monolithic' (Default): Hide everything.
- `granular': Hide reasoning, persist tool results (Google-compliant).
- `minimal': Hide everything except final completion.
- `transparent': Full visibility; keep everything.
Custom Plist:
- (:instructions <type> :reasoning <type> :tool <type> :return <type>)
Where <type> is either `ignore' (hidden) or `response' (visible).")
(defun gptel-got--get-strategy-plist ()
"Resolve `gptel-got-subagent-context-strategy' into a full config plist.
Honors native `gptel' flags as parity baseline."
(let* ((val gptel-got-subagent-context-strategy)
(native-reasoning (if (and (boundp 'gptel-include-reasoning)
(bound-and-true-p gptel-include-reasoning))
'response 'ignore))
(native-tools (if (and (boundp 'gptel-include-tools)
(bound-and-true-p gptel-include-tools))
'response 'ignore))
(defaults (list :instructions 'ignore
:reasoning native-reasoning
:tool native-tools
:return 'response)))
(pcase val
('monolithic '(:instructions ignore :reasoning ignore :tool ignore :return ignore))
('granular '(:instructions ignore :reasoning ignore :tool response :return response))
('minimal '(:instructions ignore :reasoning ignore :tool ignore :return response))
('transparent '(:instructions response :reasoning response :tool response :return response))
((and (pred listp) l)
(progn
(cl-loop for key in '(:instructions :reasoning :tool :return)
unless (plist-member l key)
do (progn
(message "WARNING: gptel-got-subagent-context-strategy is a partial plist. Missing key `%s' will default to `%s' (following native gptel flags)."
key (plist-get defaults key))
(cl-return)))
(append l defaults)))
(_ defaults))))
parent-context-strategy
How should the main conversation context be handled? This applies to the parent AI's own responses (e.g. stripping reasoning from DeepSeek or Gemma).
(defvar gptel-got-parent-context-strategy '(:reasoning default :tool default)
"Strategy for managing context in the main gptel buffer.
- :reasoning: How to handle <think>, <|thinking|>, etc.
- :tool: How to handle standard gptel tool use entries.
The value `default' inherits visibility from native gptel flags
(`gptel-include-reasoning' and `gptel-include-tools').")
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-got--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-got--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-got--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))))
Collect org files from directory
(defun gptel-got--org-files-in-dir (dir)
"Return list of .org file paths in DIR.
Uses `directory-files-recursively' like `org-agenda-files'."
(directory-files-recursively dir "\\.org$"))
Hooks and Utilities
Cleanup functions
Functions for marking/ unmarking text in buffers as either an LLM response or not.
(defun gptel-got--prop-add (a b)
"Add gptel property while preserving existing response tracking."
(let ((existing (get-text-property a 'gptel)))
(unless (eq existing 'response)
(add-text-properties a b '(gptel response)))))
(defun gptel-got--prop-rem (a b)
(remove-text-properties a b '(gptel)))
(defun gptel-got-prop-add ()
(interactive)
(gptel-got--prop-add (region-beginning) (region-end)))
(defun gptel-got-prop-rem ()
(interactive)
(gptel-got--prop-rem (region-beginning) (region-end)))
And automatic cleanup. This is customizable to some extent.
(defun gptel-got--cleanup (a b)
"Clean up model-specific artifacts from responses non-destructively."
(interactive)
(gptel-got--cleanup-parent-context a b))
(defun gptel-got--strip-preamble (&rest _)
"In the current prompt buffer, delete everything before the first Org heading.
Leaves the first heading (a line starting with one or more '*') in place."
(when (save-excursion
(goto-char (point-min))
(re-search-forward "^#\\+gptel_headsonly:\\s-*\\(t\\|true\\|yes\\|on\\|1\\)" nil t))
(save-excursion
(save-restriction
(widen)
(goto-char (point-min))
(when (re-search-forward "^\\*+\\(?:[ \t]+\\|$\\)" nil t)
(delete-region (point-min) (match-beginning 0))
(while (looking-at "^[ \t]*\n")
(replace-match "" nil nil)))))))
(unless (member #'gptel-got--strip-preamble gptel-prompt-transform-functions)
(add-to-list 'gptel-prompt-transform-functions #'gptel-got--strip-preamble))
(defun gptel-got--cleanup-parent-context (beg end)
"Cleanup parent AI context according to gptel-got-parent-context-strategy.
Called by gptel-post-response-functions for the range BEG to END."
(when (buffer-live-p (current-buffer))
(let* ((strategy gptel-got-parent-context-strategy)
(reasoning-type (let ((val (plist-get strategy :reasoning)))
(if (eq val 'default)
(if (bound-and-true-p gptel-include-reasoning) 'response 'ignore)
val)))
(tool-type (let ((val (plist-get strategy :tool)))
(if (eq val 'default)
(if (bound-and-true-p gptel-include-tools) 'response 'ignore)
val))))
(save-excursion
(goto-char beg)
(let ((case-fold-search t))
(let ((patterns '("<think>\\([[:ascii:][:nonascii:]]*?\\)\\(</think>\\|\\'\\)"
"\\[THINK\\]\\([[:ascii:][:nonascii:]]*?\\)\\(\\[/THINK\\]\\|\\'\\)"
"<|?thinking|?>\\([[:ascii:][:nonascii:]]*?\\)\\(<|/thinking|?>\\|\\'\\)")))
(dolist (pat patterns)
(goto-char beg)
(while (re-search-forward pat end t)
(gptel-got--mark-subagent (match-beginning 0) (match-end 0) reasoning-type))))
(goto-char beg)
(while (re-search-forward "</s>" end t)
(delete-region (match-beginning 0) (match-end 0)))
(goto-char beg)
(while (re-search-forward "\\('\\/\\|''\\)" end t)
(replace-match "'"))
(goto-char beg)
(while (re-search-forward "\n[ \t]*\n[ \t]*\n+" end t)
(replace-match "\n\n"))
(when (eq tool-type 'ignore)
(goto-char beg)
(while (re-search-forward "^#\\+begin_tool\\([[:ascii:][:nonascii:]]*?\\)^#\\+end_tool" end t)
(gptel-got--mark-subagent (match-beginning 0) (match-end 0) 'ignore))))))))
(setq gptel-got-parent-context-strategy '(:reasoning default :tool default))
(unless (member #'gptel-got--cleanup-parent-context gptel-post-response-functions)
(add-to-list 'gptel-post-response-functions #'gptel-got--cleanup-parent-context))
Emacs
eval-emacs-lisp
(defun gptel-got--eval-emacs-lisp (elisp)
"Evaluate ELISP string and return the result.
Captured in a condition-case to provide feedback on failure."
(condition-case err
(let ((result (eval (read elisp) t)))
(gptel-got--result-limit (prin1-to-string result)))
(error (format "Error: %s" (error-message-string err)))))
(add-to-list 'gptel-got
(gptel-make-tool
:function #'gptel-got--eval-emacs-lisp
:name "eval-emacs-lisp"
:category "gptel-got"
:confirm t
:description "Evaluates Emacs Lisp code. Use for internal introspection or complex logic not covered by other tools."
:args '((:name "elisp" :type string :description "The s-expression to evaluate"))))
eval-shell-command
(defun gptel-got--eval-shell-command (command)
"Execute a shell COMMAND in the directory of the current buffer."
(condition-case err
(let ((output (with-current-buffer (current-buffer)
(let ((default-directory (or (buffer-file-name) default-directory)))
(shell-command-to-string command)))))
(if (string-empty-p output)
"Command executed successfully (no output)."
(gptel-got--result-limit output)))
(error (format "Shell Error: %s" (error-message-string err)))))
(add-to-list 'gptel-got
(gptel-make-tool
:function #'gptel-got--eval-shell-command
:name "eval-shell-command"
:category "gptel-got"
:confirm t
:description "Executes shell commands. STRATEGY: Append '2>/dev/null' to suppress error noise."
:args '((:name "command" :type string :description "The shell command to run"))))
emacs-list-buffers
Not using ibuffer to avoid customization differences between users.
(defun gptel-got--emacs-list-buffers ()
"Return list of buffers natively."
(with-current-buffer (list-buffers-noselect)
(buffer-string)))
(add-to-list 'gptel-got
(gptel-make-tool
:function #'gptel-got--emacs-list-buffers
:name "emacs-list-buffers"
:category "gptel-got"
:description "Lists all open Emacs buffers with file names and full paths."))
emacs-describe-symbol
Unified tool for Emacs introspection. Replaces describe-variable and describe-function.
(defun gptel-got--emacs-describe-symbol (symbol type)
"Describe SYMBOL of TYPE: 'var or 'fun."
(let ((sym (intern symbol)))
(cond
((string= type "var")
(if (boundp sym)
(prin1-to-string (symbol-value sym))
(format "Variable %s is not bound." symbol)))
((string= type "fun")
(if (fboundp sym)
(prin1-to-string (documentation sym 'function))
(format "Function %s is not defined." symbol)))
(t "Type must be 'var or 'fun"))))
(add-to-list 'gptel-got
(gptel-make-tool
:function #'gptel-got--emacs-describe-symbol
:name "emacs-describe"
:args (list '(:name "symbol"
:type string
:description "Symbol name")
'(:name "type"
:type string
:description "Type: 'var for variable, 'fun for function"))
:category "gptel-got"
:description "Returns variable value or function documentation for introspection."))
emacs-env-manage
(defun gptel-got--emacs-env-manage (action variable &optional value)
"Get or set Emacs environment variables."
(pcase action
("get"
(let ((val (getenv variable)))
(if val (format "%s=%s" variable val)
(format "Environment variable %s is not set." variable))))
("set"
(unless value (error "Action 'set' requires a value."))
(setenv variable value)
(format "Success: Set environment variable %s" variable))
(_ (error "Invalid action. Use 'get' or 'set'."))))
(add-to-list 'gptel-got
(gptel-make-tool
:name "env-manage"
:function #'gptel-got--emacs-env-manage
:description "Manage environment variables for the current session (e.g., API keys, flags)."
:args '((:name "action" :type string :enum ["get" "set"])
(:name "variable" :type string :description "Variable name (e.g., 'OPENAI_API_KEY')")
(:name "value" :type string :optional t :description "Required if action is 'set'"))
:category "gptel-got"))
Misc
system-notify
(defun gptel-got--system-notify (title message)
"Push an OS-level desktop notification to the user."
(cond
((eq system-type 'darwin)
(shell-command (format "osascript -e 'display notification \"%s\" with title \"%s\"'"
(replace-regexp-in-string "\"" "\\\\\"" message)
(replace-regexp-in-string "\"" "\\\\\"" title)))
"Success: Notification pushed to macOS Notification Center.")
((fboundp 'notifications-notify)
(notifications-notify :title title :body message)
"Success: Notification pushed to Linux desktop.")
(t
(message "gptel-got ALERT: [%s] %s" title message)
"Success: Notification pushed to Emacs echo area (OS unsupported).")))
(add-to-list 'gptel-got
(gptel-make-tool
:name "system-notify"
:function #'gptel-got--system-notify
:description "Send a desktop notification to the user. Useful for alerting them when a long-running background task finishes."
:args '((:name "title" :type string :description "Short summary")
(:name "message" :type string :description "Body of the notification"))
:category "gptel-got"))
Conversion
pdf-to-text
(defun gptel-got--pdf-to-text (path &optional start_page end_page)
"Extract text from a PDF file."
(let ((expanded-path (expand-file-name path)))
(unless (file-readable-p expanded-path)
(error "File not readable: %s" expanded-path))
(unless (executable-find "pdftotext")
(error "The 'pdftotext' command-line utility is not installed on this system."))
(let* ((f-page (if start_page (format "-f %d " start_page) ""))
(l-page (if end_page (format "-l %d " end_page) ""))
(cmd (format "pdftotext -layout %s%s%s -"
f-page l-page (shell-quote-argument expanded-path))))
(condition-case err
(let ((output (shell-command-to-string cmd)))
(if (string-empty-p output)
"PDF extracted successfully but contains no readable text (might be scanned images)."
(gptel-got--result-limit output)))
(error (format "PDF Extraction Error: %s" (error-message-string err)))))))
(add-to-list 'gptel-got
(gptel-make-tool
:name "pdf-to-text"
:function #'gptel-got--pdf-to-text
:description "Extract readable text from a PDF document. Use start/end pages to avoid context bloat."
:args '((:name "path" :type string)
(:name "start_page" :type integer :optional t)
(:name "end_page" :type integer :optional t))
:category "gptel-got"))
Agents
Context Management
Agents and subagents generate lots of garbage, here's a somewhat granular context tagging system.
gptel-got-subagent-context-strategy and gptel-got-parent-context-strategy change which parts of the generated garbage will be visible to the LLM in future turns (i.e. after it finishes generating and you prompt it again.)
gptel-got hooks into gptel-post-response-functions to find these blocks and mark them with gptel 'ignore so they are invisible to the LLM while remaining visible in the current buffer.
- :reasoning (default
ignore) strips <think>, <|thinking|>, and other common CoT tags. - :tool (default
response) controls whether standard gptel tool-use entries are hidden.
For ultimate control, you can provide plists:
(setq gptel-got-subagent-context-strategy
'(:instructions ignore :reasoning ignore :tool response :return response))
(setq gptel-got-parent-context-strategy
'(:reasoning ignore :tool response))
:instructions: The header block containing the task and privilege level.:reasoning: The internal "thinking" blocks of the sub-agent.:tool: The individual tool-call and result blocks.:return: The final completion message returned to the parent.
Using 'ignore hides the segment from the AI (but keeps it visible to you), while 'response makes it part of the AI's permanent conversation context.
Additionally there are a number of presets that you can use like so:
(setq gptel-got-subagent-context-strategy 'monolithic)
(setq gptel-got-parent-context-strategy 'granular)
- monolithic (Default): Hides the entire sub-agent block from the parent. Best for simple, one-shot tasks where only the final outcome matters.
- granular: Strips internal reasoning but persists tool results. This is the Google-compliant mode for Gemma 4 models… if I didn't screw up.
- minimal: Strips instructions, reasoning, and tools. The parent AI only sees the final status report returned by the subagent.
- transparent: Persists everything. Full context, but expensive in terms of tokens.
Subagent Management
(defvar-local gptel-got--waiting-subagents nil
"An alist of sleeping subagents. Format: ((ID . (:task TASK ...)) ...)")
(defvar gptel-got-max-subagents 1
"The absolute maximum number of background subagents allowed to run simultaneously.")
(defvar gptel-got--active-subagents 0
"Internal tracker for currently running subagents.")
(defun gptel-got-reset-subagents ()
"Emergency reset for the active subagent counter if a fatal desync occurs."
(interactive)
(setq gptel-got--active-subagents 0)
(message "gptel-got: Active subagent count forcefully reset to 0."))
(defun gptel-got--mark-subagent (start end type)
"Tag region START to END with 'gptel property TYPE."
(put-text-property start end 'gptel type))
(defconst gptel-got--ro-tools
'("emacs-list-buffers" "system-dir" "system-search" "emacs-describe"
"file-system-search" "project-map" "system-notify" "pdf-to-text"
"web-render" "org-extract-tags" "org-extract-headings" "org-search"
"org-agenda-seek" "system-open-file" "system-read-file" "file-diff"
"check-syntax" "file-tail" "subagent"))
(defconst gptel-got--rw-tools
(append gptel-got--ro-tools
'("git-helper" "file-system" "http-request" "env-manage"
"org-mutate" "org-capture" "org-export" "file-edit"
"bulk-search-replace")))
(defconst gptel-got--rwx-tools
(append gptel-got--rw-tools
'("eval-emacs-lisp" "eval-shell-command" "org-babel-execute" "sqlite-query")))
(defun gptel-got--intercept-unknown-tools (orig-fn fsm)
"Intercept hallucinated tools to prevent FSM lockups."
(let* ((info (gptel-fsm-info fsm))
(tool-use (plist-get info :tool-use))
(schema-tools (plist-get info :tools)))
(dolist (tc tool-use)
(unless (plist-get tc :result)
(let ((name (or (plist-get tc :name) "UNKNOWN_TOOL")))
(unless (or (equal name (bound-and-true-p gptel--ersatz-json-tool))
(cl-find-if (lambda (ts) (equal (gptel-tool-name ts) name)) schema-tools))
(let ((dummy-tool (gptel-make-tool :name name :description "Virtual Trap" :category "gptel-got" :function #'ignore)))
(gptel--process-tool-call fsm dummy-tool tc
(format "CRITICAL ERROR: You hallucinated tool '%s' which does not fundamentally exist in your schema payload. NEVER output unlisted tools! Ensure proper snake_case and use `request_upgrade` if you require broad execution abilities." name)))))))
(when (cl-remove-if (lambda (tc) (plist-get tc :result)) tool-use)
(funcall orig-fn fsm))))
(advice-add 'gptel--handle-tool-use :around #'gptel-got--intercept-unknown-tools)
Subagent
(defun gptel-got--subagent (callback task instructions privilege &optional max_steps id)
"Autonomous subagent with strictly isolated FSM and Org markers."
(if (>= gptel-got--active-subagents gptel-got-max-subagents)
(when callback
(funcall callback (format "SYSTEM ERROR: Cannot start subagent for '%s'. Max subagents reached." task)))
(setq gptel-got--active-subagents (1+ gptel-got--active-subagents))
(message "--- gptel-got: Spawning Subagent [%s] ---" task)
(let* ((subagent-id (or (and id (not (string-empty-p id)) id)
(substring (md5 (number-to-string (float-time))) 0 4)))
(resuming-p (and id (not (string-empty-p id))))
(steps-remaining (if (and max_steps (integerp max_steps) (> max_steps 0)) max_steps 10))
(parent-buf (current-buffer))
(parent-backend gptel-backend)
(parent-model gptel-model)
(in-reasoning nil)
(in-tool nil)
(partial-result "")
(allowed-names (pcase privilege
("ro" gptel-got--ro-tools)
("rw" gptel-got--rw-tools)
(_ gptel-got--rwx-tools)))
(subagent-tools
(mapcar (lambda (t-obj)
(let ((name (gptel-tool-name t-obj)))
(cond
;; 1. INTERCEPT ASYNC CALLS TO SUSPEND THE AGENT
((string= name "lisp-eval-async")
(gptel-make-tool
:name name
:description "Evaluates Lisp code in the background and SUSPENDS this subagent until a signal is received."
:args (gptel-tool-args t-obj)
:category "gptel-got"
:function (lambda (code &optional package)
;; Pass the subagent-id silently
(gptel-got--lisp-eval-async code package subagent-id)
;; Kill FSM state
(setf (gptel-fsm-state fsm) nil)
;; Register in the ALIST
(setf (alist-get subagent-id gptel-got--waiting-subagents nil nil #'string=)
(list :task task :privilege privilege :callback wrapped-callback :steps steps-remaining))
(with-current-buffer parent-buf
(save-excursion
(goto-char pos-marker)
(funcall close-reasoning)
(unless (bolp) (insert "\n"))
(set-marker tool-start (point) parent-buf)
(insert (format "#+begin_subagent_tool (lisp-eval-async)\n(:name \"lisp-eval-async\" :args (:code %S))\n: SUBAGENT SUSPENDED: Waiting for Common Lisp async signal.\n#+end_subagent_tool\n" code))
(funcall mark-block (marker-position tool-start) (point) (plist-get strategy :tool))))
nil)))
;; 2. ALLOWED TOOLS PASS THROUGH
((member name allowed-names) t-obj)
;; 3. RESTRICTED TOOLS GET THE SECURITY BOUNCE
(t
(gptel-make-tool
:name name
:description (concat "[RESTRICTED] " (gptel-tool-description t-obj))
:args (gptel-tool-args t-obj)
:category "gptel-got"
:function (lambda (&rest _)
(format "SECURITY ERROR: Tool '%s' is restricted. Privilege level is '%s'. Use `request_upgrade` to escalate."
name privilege)))))))
gptel-got))
(strategy (gptel-got--get-strategy-plist))
(reasoning-start (make-marker))
(tool-start (make-marker))
(prompt (cond
(resuming-p
(concat "OBJECTIVE: " task
"\n\nYou are being RESUMED with upgraded privilege: " privilege "."
(if instructions (concat "\nINSTRUCTIONS: " instructions) "")
"\n\nCRITICAL DIRECTIVE: Continue executing. Do NOT call request_upgrade again for this privilege level."
(format "\nLIFESPAN: You have %d tool calls remaining." steps-remaining)))
(t
(concat "OBJECTIVE: " task
"\nINSTRUCTIONS: " (or instructions "No specific instructions provided.")
(format "\n\nCRITICAL DIRECTIVE: Execute tools until objective is complete. You have a maximum of %d tool calls allowed. At 1 step remaining, you MUST synthesize and return." steps-remaining))))))
(with-current-buffer parent-buf
(save-excursion
(goto-char (point-max))
(unless (bolp) (insert "\n"))
(let* ((log-start (point))
(_ (insert (format "#+begin_subagent :task %S :privilege %S\n: INSTRUCTIONS: %s\n"
task privilege
(or instructions (if resuming-p "(resuming previous session)" "N/A")))))
(header-end (point))
(_ (insert "#+end_subagent\n"))
(end-marker (let ((m (make-marker)))
(set-marker m (point) parent-buf)
m))
(pos-marker (let ((m (make-marker)))
(set-marker m header-end parent-buf)
(set-marker-insertion-type m t)
m))
(fsm (gptel-make-fsm :table gptel-request--transitions
:handlers gptel-request--handlers))
(mark-block (lambda (start end type)
(gptel-got--mark-subagent start end (or type 'ignore))))
(close-reasoning
(lambda ()
(when in-reasoning
(unless (bolp) (insert "\n"))
(let ((beg (marker-position reasoning-start)))
(insert "#+end_subagent_reasoning\n")
(funcall mark-block beg (point) (plist-get strategy :reasoning)))
(setq in-reasoning nil))))
(close-tool
(lambda ()
(when in-tool
(unless (bolp) (insert "\n"))
(let ((beg (marker-position tool-start)))
(insert "#+end_subagent_tool\n")
(funcall mark-block beg (point) (plist-get strategy :tool)))
(setq in-tool nil))))
(wrapped-callback
(lambda (res)
(setq gptel-got--active-subagents (max 0 (1- gptel-got--active-subagents)))
(message "--- gptel-got: Subagent [%s] completed ---" task)
(when (buffer-live-p parent-buf)
(with-current-buffer parent-buf
(save-excursion
(goto-char pos-marker)
(funcall close-reasoning)
(funcall close-tool)
(unless (bolp) (insert "\n"))
(let ((ret-start (point)))
(insert "#+begin_subagent_return\n"
(string-trim (if (stringp res) res (prin1-to-string res)))
"\n#+end_subagent_return\n")
(funcall mark-block ret-start (point) (plist-get strategy :return)))
(let ((first-line-end (save-excursion (goto-char log-start) (line-end-position))))
(funcall mark-block log-start (min header-end (1+ first-line-end)) 'ignore)
(when (> header-end (1+ first-line-end))
(funcall mark-block (1+ first-line-end) header-end (plist-get strategy :instructions))))
(funcall mark-block (marker-position end-marker)
(save-excursion (goto-char (marker-position end-marker)) (line-end-position))
'ignore))))
(when callback
(funcall callback (string-trim (if (stringp res) res (prin1-to-string res))))))))
(condition-case err
(let ((gptel-backend parent-backend)
(gptel-model parent-model)
(gptel-tools
(append (unless (equal privilege "rwx")
(list (gptel-make-tool
:name "request_upgrade" :async t
:function (lambda (_p-res target justification)
(setf (gptel-fsm-state fsm) nil)
(when (buffer-live-p parent-buf)
(with-current-buffer parent-buf
(save-excursion
(goto-char pos-marker)
(funcall close-reasoning)
(unless (bolp) (insert "\n"))
(set-marker tool-start (point) parent-buf)
(insert (format "#+begin_subagent_tool (request_upgrade :target_level %S :justification %S)\n(:name \"request_upgrade\" :args (:target_level %S :justification %S))\n: SUBAGENT SUSPENDED: Waiting for %s escalation.\n#+end_subagent_tool\n"
target justification target justification target))
(funcall mark-block (marker-position tool-start) (point) (plist-get strategy :tool)))))
(funcall wrapped-callback
(format ": SUBAGENT SUSPENDED [-ID: %s-]\n: Requests escalation to '%s'. Justification: '%s'."
subagent-id target justification)))
:description "Request privilege escalation."
:args '((:name "target_level" :type string :enum ["rw" "rwx"])
(:name "justification" :type string))
:category "gptel-got")))
subagent-tools)))
(gptel-request prompt
:buffer parent-buf :position pos-marker
:system "You are an autonomous execution worker. Adhere to your privileges."
:fsm fsm
:callback
(lambda (resp info)
(when (buffer-live-p parent-buf)
(pcase resp
('nil
(with-current-buffer parent-buf
(save-excursion
(goto-char pos-marker)
(funcall close-reasoning)
(funcall close-tool)
(unless (bolp) (insert "\n"))
(insert ": SYSTEM ERROR: Task failed.\n")))
(funcall wrapped-callback "Error: Task failed to complete."))
(`(tool-call . ,calls)
(if (<= steps-remaining 0)
(progn
(setf (gptel-fsm-state fsm) nil)
(with-current-buffer parent-buf
(save-excursion
(goto-char pos-marker)
(funcall close-reasoning)
(funcall close-tool)
(unless (bolp) (insert "\n"))
(insert ": SYSTEM ERROR: Subagent forcefully killed. Step count reached 0.\n")))
(funcall wrapped-callback "Error: Subagent forcefully killed. It exceeded the maximum number of tool calls without returning a final response."))
(cl-decf steps-remaining)
(with-current-buffer parent-buf
(save-excursion
(goto-char pos-marker)
(funcall close-reasoning)
(dolist (c calls)
(let* ((tool (car c))
(name (gptel-tool-name tool))
(arg-plist (cadr c)))
(unless (bolp) (insert "\n"))
(set-marker tool-start (point) parent-buf)
(insert (format "#+begin_subagent_tool (%s)\n%S\n"
name (list :name name :args arg-plist)))
(setq in-tool t)))))
(unless (plist-get info :tracking-marker)
(plist-put info :tracking-marker pos-marker))
(gptel--display-tool-calls calls info)))
(`(tool-result . ,results)
(with-current-buffer parent-buf
(save-excursion
(goto-char pos-marker)
(dolist (r results)
(let* ((tool (car r))
(name (gptel-tool-name tool))
(res (caddr r))
(raw-res-str (if (stringp res) res (prin1-to-string res)))
(res-str (cond
((= steps-remaining 1)
(concat raw-res-str "\n\n[SYSTEM OVERRIDE: 1 STEP REMAINING. YOU MUST NOT CALL ANY MORE TOOLS. SYNTHESIZE YOUR FINDINGS AND RETURN YOUR FINAL RESPONSE IMMEDIATELY.]"))
((= steps-remaining 0)
(concat raw-res-str "\n\n[CRITICAL SYSTEM OVERRIDE: 0 STEPS REMAINING. IF YOU GENERATE ANOTHER TOOL CALL, YOUR PROCESS WILL BE FORCEFULLY KILLED. RETURN FINAL STRING NOW.]"))
(t raw-res-str))))
(unless (bolp) (insert "\n"))
(if in-tool
(progn
(insert (format "%s\n#+end_subagent_tool\n" res-str))
(funcall mark-block (marker-position tool-start) (point) (plist-get strategy :tool))
(setq in-tool nil))
(let ((beg (point)))
(insert (format "\n#+begin_subagent_tool (%s)\n%S\n%s\n#+end_subagent_tool\n"
name (list :name name :args arg-plist) res-str))
(funcall mark-block beg (point) (plist-get strategy :tool)))))))))
(`(reasoning . ,text)
(when text
(with-current-buffer parent-buf
(save-excursion
(goto-char pos-marker)
(funcall close-tool)
(unless in-reasoning
(unless (bolp) (insert "\n"))
(insert "#+begin_subagent_reasoning\n")
(set-marker reasoning-start (point) parent-buf)
(setq in-reasoning t))
(insert (propertize text 'face 'font-lock-comment-face))))))
((pred stringp)
(with-current-buffer parent-buf
(save-excursion
(goto-char pos-marker)
(funcall close-tool)
(funcall close-reasoning)
(insert resp)))
(setq partial-result (concat partial-result resp))
(unless (plist-get info :tool-use)
(funcall wrapped-callback (string-trim partial-result))))
('abort
(with-current-buffer parent-buf
(save-excursion
(goto-char pos-marker)
(funcall close-reasoning)
(funcall close-tool)
(unless (bolp) (insert "\n"))
(insert ": ABORTED.\n")))
(funcall wrapped-callback "Error: Task aborted.")))))))
(error (funcall wrapped-callback (format "SYSTEM ERROR: Subagent initialization failed: %s" (error-message-string err)))))))))))
(add-to-list 'gptel-got
(gptel-make-tool
:name "subagent"
:description "Forks a background task to process information with granular privilege controls. It can suspend and request higher privileges."
:function #'gptel-got--subagent
:args '((:name "task" :type string :description "Short title of the job")
(:name "instructions" :type string :description "Specific instructions outlining what the subagent must do.")
(:name "privilege" :type string :enum ["ro" "rw" "rwx"] :description "Security privilege of the subagent tools. ro: read-only. rw: allows file writing. rwx: allows full shell/elisp execution.")
(:name "max_steps" :type integer :optional t :description "Maximum number of tool calls this subagent is allowed to execute. Estimate this based on task complexity. Default is 10.")
(:name "id" :type string :optional t :description "Optional ID to resume a previously suspended subagent to approve a privilege upgrade request."))
:async t
:category "gptel-got"))
Version Control
git-action
(defun gptel-got--git-action (command &optional repo_path argument)
"Execute a structured Git COMMAND in REPO_PATH."
(let* ((dir (or repo_path
(if (fboundp 'projectile-project-root) (projectile-project-root))
(vc-root-dir)
default-directory))
(git-cmd (pcase command
("status" "git status --short")
("diff" "git diff")
("diff-stat" "git diff --stat")
("log" "git log -n 5 --oneline")
("commit" (unless argument (error "Commit requires a message argument"))
(format "git commit -m %s" (shell-quote-argument argument)))
("checkout" (unless argument (error "Checkout requires a branch name argument"))
(format "git checkout %s" (shell-quote-argument argument)))
("branch" "git branch -a")
("pull" "git pull")
("push" "git push")
("stash" "git stash")
("stash-pop" "git stash pop")
("cherry-pick" (unless argument (error "Cherry-pick requires a commit hash"))
(format "git cherry-pick %s" (shell-quote-argument argument)))
("rebase" (unless argument (error "Rebase requires a target branch/hash"))
(format "git rebase %s" (shell-quote-argument argument)))
(_ (error "Unsupported git command: %s" command)))))
(unless dir (error "Could not determine repository root."))
(let ((default-directory dir))
(condition-case err
(let ((output (shell-command-to-string git-cmd)))
(if (string-empty-p output)
(format "Git '%s' executed successfully (no output)." command)
(gptel-got--result-limit output)))
(error (format "Git Error: %s" (error-message-string err)))))))
(add-to-list 'gptel-got
(gptel-make-tool
:name "git-helper"
:function #'gptel-got--git-action
:description "Performs standard and advanced Git operations safely."
:args '((:name "command" :type string :enum ["status" "diff" "diff-stat" "log" "commit" "checkout" "branch" "pull" "push" "stash" "stash-pop" "cherry-pick" "rebase"])
(:name "repo_path" :type string :optional t :description "Overrides default projectile/vc root")
(:name "argument" :type string :optional t :description "Required for 'commit', 'checkout', 'cherry-pick', and 'rebase'"))
:category "gptel-got"
:confirm t))
backup-or-commit
(defun gptel-got--backup-or-commit (path mode dry-run)
"Commit PATH to Git or create a backup file. Skip if DRY-RUN."
(if dry-run
""
(let* ((expanded (expand-file-name path))
(dir (file-name-directory expanded))
(file (file-name-nondirectory expanded))
(default-directory dir)
(in-git (and (executable-find "git")
(zerop (call-process "git" nil nil nil "rev-parse" "--is-inside-work-tree")))))
(if in-git
(progn
(call-process "git" nil nil nil "add" file)
(call-process "git" nil nil nil "commit" "-m"
(format "🤖 gptel-got auto-commit: %s via %s" file mode))
" [Auto-committed to Git]")
(let ((backup-name (format "%s.%s.bak" expanded (format-time-string "%Y%m%d%H%M%S"))))
(copy-file expanded backup-name t)
(format " [Auto-backed up to %s]" (file-name-nondirectory backup-name)))))))
Lisp
Common Lisp integration.
(require 'sly nil t)
buffer locking
(defvar-local gptel-got-llm-busy nil
"Tracks if the LLM is currently streaming a response.")
(defvar-local gptel-got-signal-queue nil
"Queue for async signals that arrive while the LLM is busy.")
(defun gptel-got--mark-busy (&rest _)
(setq-local gptel-got-llm-busy t))
(advice-add 'gptel-send :after #'gptel-got--mark-busy)
(defun gptel-got--clear-busy (beg end)
(setq-local gptel-got-llm-busy nil)
(when gptel-got-signal-queue
(let ((signals (reverse gptel-got-signal-queue)))
(setq-local gptel-got-signal-queue nil)
(run-with-timer 0.5 nil
(lambda (buf sigs)
(when (buffer-live-p buf)
(with-current-buffer buf
(save-excursion
(goto-char (point-max))
(unless (bolp) (insert "\n"))
;; 1. Insert all queued signals and combine them
(let ((combined-signal ""))
(dolist (sig sigs)
(insert sig)
(setq combined-signal (concat combined-signal sig)))
;; 2. THE ROUTER: Wake Subagent OR Wake Parent
(if gptel-got--waiting-subagent
(let* ((state gptel-got--waiting-subagent)
(id (plist-get state :id))
(task (plist-get state :task))
(priv (plist-get state :privilege))
(cb (plist-get state :callback))
(steps (plist-get state :steps)))
;; Wipe registry to prevent loops
(setq-local gptel-got--waiting-subagent nil)
(message "gptel-got: Resuming queued subagent [%s] with delayed Lisp signal." id)
;; Wake the subagent with the queued data!
(gptel-got--subagent cb task combined-signal priv steps id))
;; 3. Parent AI fallback
(gptel-send)))))))
(current-buffer) signals))))
(add-hook 'gptel-post-response-functions #'gptel-got--clear-busy)
reset active subagents
(defun gptel-got--reset-turn-state (&rest _)
"Wipe all ephemeral agent state clean at the start of a new user turn.
Prevents zombie subagents from locking up the queue after a crash or keyboard quit."
(when (> gptel-got--active-subagents 0)
(message "gptel-got: reset: %d orphaned subagents from the previous turn." gptel-got--active-subagents))
(setq gptel-got--active-subagents 0))
(advice-add 'gptel-send :before #'gptel-got--reset-turn-state)
lisp lifecycle
(defun gptel-got--lisp-start (implementation)
"Start a Lisp process using IMPLEMENTATION (e.g., 'sbcl')."
;; Arm the one-shot hook
(add-hook 'sly-connected-hook #'gptel-got--inject-lisp-agent)
(let ((sly-default-lisp implementation))
(sly)
(format "Starting SLY with implementation: %s. The Lisp image is booting. Wait a few moments before sending evaluation commands." implementation)))
(add-to-list 'gptel-got
(gptel-make-tool
:name "lisp-start"
:function #'gptel-got--lisp-start
:description "Start a new local Lisp process and connect SLY. Use this if lisp-status reports disconnected."
:args '((:name "implementation" :type string :description "e.g., sbcl, ccl, ecl" :enum ["sbcl" "ccl" "ecl" "abcl"]))
:category "gptel-got"
:confirm t))
(defun gptel-got--lisp-connect (host port)
"Attach SLY to an existing Slynk server at HOST and PORT."
;; Arm the one-shot hook
(add-hook 'sly-connected-hook #'gptel-got--inject-lisp-agent)
(sly-connect host port)
(format "Attempting to connect SLY to %s:%s..." host port))
(add-to-list 'gptel-got
(gptel-make-tool
:name "lisp-connect"
:function #'gptel-got--lisp-connect
:description "Attach to a running external Slynk server."
:args '((:name "host" :type string :description "Target host, e.g., 'localhost'")
(:name "port" :type integer :description "Target port, e.g., 4005"))
:category "gptel-got"
:confirm t))
(defun gptel-got--lisp-quit ()
"Quit the current SLY connection."
(if (and (featurep 'sly) (sly-connected-p))
(progn
(sly-quit-lisp)
"SLY connection terminated and Lisp process killed.")
"SLY is not currently connected."))
(add-to-list 'gptel-got
(gptel-make-tool
:name "lisp-quit"
:function #'gptel-got--lisp-quit
:description "Kill the running Lisp process and disconnect SLY."
:category "gptel-got"
:confirm t))
Status
(defun gptel-got--lisp-status ()
"Check if SLY is connected and return basic image info."
(if (and (featurep 'sly) (sly-connected-p))
(format "SLY is CONNECTED.\nImplementation: %s\nPackage: %s"
(sly-connection-name)
(sly-current-package))
"SLY is NOT connected. The user must manually start Sly (e.g., M-x sly)."))
(add-to-list 'gptel-got
(gptel-make-tool
:name "lisp-status"
:function #'gptel-got--lisp-status
:description "Checks if the Common Lisp environment is running and connected."
:category "gptel-got"))
lisp-eval
(defun gptel-got--lisp-eval (code &optional package timeout)
"Evaluate CODE via SLY with strict separation of Lisp vs RPC errors."
(unless (and (featurep 'sly) (sly-connected-p))
(error "SLY is not connected. Use lisp-start."))
(gptel-got--ensure-lisp-agent)
(let* ((pkg (or package (sly-current-package)))
(time-limit (or timeout 10))
(safe-code (format "(handler-case (multiple-value-list (eval (read-from-string %S)))
(error (c) (format nil \"<<LISP-RUNTIME-ERROR>>: ~~a\" c)))" code)))
(condition-case err
(with-timeout (time-limit
(progn
(sly-interrupt)
(format "RPC/TIMEOUT ERROR: Evaluation exceeded %d seconds. A sly-interrupt was sent. Do NOT rewrite the code yet; check for infinite loops or blocking I/O." time-limit)))
(let* ((response (sly-eval `(slynk:eval-and-grab-output ,safe-code) pkg))
(output (car response))
(value (cadr response)))
(gptel-got--result-limit
(concat (when (and output (not (string-empty-p output)))
(format "STDOUT:\n%s\n---\n" output))
(format "RETURN: %s" value)))))
(error (format "RPC/COMMUNICATION ERROR: %s\n(This is a connection failure, NOT a syntax error in your code. Check lisp-status.)" (error-message-string err))))))
(add-to-list 'gptel-got
(gptel-make-tool
:name "lisp-eval"
:function #'gptel-got--lisp-eval
:description "Evaluates Lisp code safely. Separates connection errors from Lisp syntax errors."
:args '((:name "code" :type string)
(:name "package" :type string :optional t)
(:name "timeout" :type integer :optional t))
:category "gptel-got"
:confirm t))
(defun gptel-got--lisp-quickload (system)
"Attempt to load a system via Quicklisp."
(unless (sly-connected-p) (error "SLY is not connected."))
(gptel-got--lisp-eval (format "(ql:quickload :%s)" system)))
(add-to-list 'gptel-got
(gptel-make-tool
:name "lisp-quickload"
:function #'gptel-got--lisp-quickload
:description "Verifies and loads a library into the image using Quicklisp (ql:quickload)."
:args '((:name "system" :type string :description "The system name, e.g., 'alexandria' or 'hunchentoot'"))
:category "gptel-got"
:confirm t))
lisp-eval-async
(defun gptel-got--lisp-eval-async (code &optional package subagent-id)
"Send CODE to SLY asynchronously. Returns control immediately."
(unless (and (featurep 'sly) (sly-connected-p))
(error "SLY is not connected."))
(gptel-got--ensure-lisp-agent)
;; Here we automatically wrap the code in your Common Lisp macro so errors are caught and tagged!
(let* ((pkg (or package (sly-current-package)))
(raw-code (if subagent-id
(format "(gptel-got:with-ai-handler (:agent-id %S) (multiple-value-list (eval (read-from-string %S))))" subagent-id code)
(format "(multiple-value-list (eval (read-from-string %S)))" code))))
(sly-eval-async `(slynk:eval-and-grab-output ,raw-code)
(lambda (response)
(let ((output (car response))
(value (cadr response)))
(gptel-got--receive-lisp-signal
"EVAL COMPLETE"
(gptel-got--result-limit
(concat (when (and output (not (string-empty-p output)))
(format "STDOUT:\n%s\n---\n" output))
(format "RETURN: %s" value)))
subagent-id))) ;; Pass the ID to the receiver
pkg)
"Evaluation submitted to the Lisp process asynchronously."))
(add-to-list 'gptel-got
(gptel-make-tool
:name "lisp-eval-async"
:function #'gptel-got--lisp-eval-async
:description "Evaluates Lisp code in the background. CRITICAL: Use this instead of lisp-eval for risky code. Results or debugger prompts will be sent to you asynchronously."
:args '((:name "code" :type string)
(:name "package" :type string :optional t))
:category "gptel-got"
:confirm t))
lisp-sly-db
(defun gptel-got--sly-db-alert ()
"Scrapes the SLY-DB buffer and sends the state to the AI."
(run-with-timer 0.5 nil
(lambda (db-buf)
(when (buffer-live-p db-buf)
(with-current-buffer db-buf
(let* ((content (buffer-substring-no-properties (point-min) (point-max)))
(lines (split-string content "\n"))
(summary (string-join (cl-subseq lines 0 (min 50 (length lines))) "\n")))
(gptel-got--receive-lisp-signal
"DEBUGGER ENTERED"
"The Lisp process has paused execution and is waiting in the SLY-DB debugger."
nil
(format "SLY-DB STATE:\n%s\n\nACTION REQUIRED: Use the `lisp-invoke-restart` tool. Provide the integer ID of the restart you wish to invoke (e.g., 0 to abort, 1 to retry)." summary))))))
(current-buffer)))
(add-hook 'sly-db-mode-hook #'gptel-got--sly-db-alert)
lisp-restart
(defun gptel-got--lisp-invoke-restart (restart-number)
"Programmatically invoke a restart in the active SLY-DB buffer."
(let ((db-buffer (cl-find-if (lambda (b) (with-current-buffer b (derived-mode-p 'sly-db-mode)))
(buffer-list))))
(if (not db-buffer)
"Error: No active SLY-DB buffer found. The debugger might have already closed or aborted."
(with-current-buffer db-buffer
(sly-eval-async `(slynk:invoke-nth-restart-for-emacs ,sly-db-level ,restart-number))
(sly-db-quit)
(format "Dispatched instruction to invoke restart %d. If this resolves the issue, the original eval will finish and send an 'EVAL COMPLETE' signal." restart-number)))))
(add-to-list 'gptel-got
(gptel-make-tool
:name "lisp-invoke-restart"
:function #'gptel-got--lisp-invoke-restart
:description "Invokes a specific restart ID to recover from the SLY-DB debugger. Only use this if you have received a 'DEBUGGER ENTERED' signal."
:args '((:name "restart_number" :type integer :description "The ID of the restart to invoke (e.g., 0, 1, 2)."))
:category "gptel-got"
:confirm t))
lisp dev
(defun gptel-got--lisp-get-source (symbol)
"Locate and return the source code for SYMBOL using Slynk."
(unless (sly-connected-p) (error "SLY is not connected."))
(condition-case err
(let* ((definitions (sly-eval `(slynk:find-definitions-for-emacs ,symbol)))
(first-def (car definitions)))
(if (not first-def)
(format "Source for '%s' not found in the live image." symbol)
(let* ((loc (cadr first-def))
(file-info (cadr (assoc :file (cdr loc))))
(pos-info (cadr (assoc :position (cdr loc))))
(snippet (cadr (assoc :snippet (cdr loc)))))
(if snippet
(format "SOURCE LOCATED: %s\nFILE: %s (Position: %s)\n---\n%s"
symbol file-info pos-info snippet)
"Definition found, but source snippet is unavailable (might be compiled C code or built-in)."))))
(error (format "Source Retrieval Error: %s" (error-message-string err)))))
(add-to-list 'gptel-got
(gptel-make-tool
:name "lisp-get-source"
:function #'gptel-got--lisp-get-source
:description "Retrieves the actual source code definition for a given Lisp symbol from the live environment."
:args '((:name "symbol" :type string))
:category "gptel-got"))
(defun gptel-got--lisp-list-packages (&optional search-term)
"List available packages, optionally filtered by SEARCH-TERM."
(unless (sly-connected-p) (error "SLY is not connected."))
(let ((code (if search-term
(format "(remove-if-not (lambda (p) (search \"%s\" (package-name p) :test #'string-equal)) (list-all-packages))" (upcase search-term))
"(list-all-packages)")))
(gptel-got--lisp-eval code)))
(add-to-list 'gptel-got
(gptel-make-tool
:name "lisp-list-packages"
:function #'gptel-got--lisp-list-packages
:description "Lists loaded packages in the Lisp image. Use search_term to filter."
:args '((:name "search_term" :type string :optional t))
:category "gptel-got"))
lisp-meta
(defun gptel-got--lisp-macroexpand (form &optional package)
"Macroexpand FORM via SLY."
(unless (sly-connected-p) (error "SLY is not connected."))
(condition-case err
(let* ((pkg (or package (sly-current-package)))
(result (sly-eval `(slynk:swank-macroexpand-1 ,form) pkg)))
(gptel-got--result-limit result))
(error (format "Lisp Macroexpand Error: %s" (error-message-string err)))))
(add-to-list 'gptel-got
(gptel-make-tool
:name "lisp-macroexpand"
:function #'gptel-got--lisp-macroexpand
:description "Expands a Common Lisp macro to reveal its underlying structure."
:args '((:name "form" :type string :description "The macro form to expand (e.g., '(defmacro ...)')")
(:name "package" :type string :optional t))
:category "gptel-got"))
(defun gptel-got--lisp-describe (symbol &optional package)
"Describe SYMBOL via SLY."
(unless (sly-connected-p) (error "SLY is not connected."))
(condition-case err
(let* ((pkg (or package (sly-current-package)))
(result (sly-eval `(slynk:describe-symbol ,symbol) pkg)))
(gptel-got--result-limit result))
(error (format "Lisp Describe Error: %s" (error-message-string err)))))
(add-to-list 'gptel-got
(gptel-make-tool
:name "lisp-describe"
:function #'gptel-got--lisp-describe
:description "Get deep metadata, documentation, and arglists for a Lisp symbol directly from the running image."
:args '((:name "symbol" :type string :description "The symbol to describe")
(:name "package" :type string :optional t))
:category "gptel-got"))
lisp-load
(defun gptel-got--lisp-load (file)
"Load a Lisp FILE asynchronously into the running SLY image.
Allows the AI to handle debugger states without locking Emacs."
(unless (sly-connected-p) (error "SLY is not connected."))
(let ((expanded (expand-file-name file)))
(unless (file-readable-p expanded)
(error "File not readable: %s" expanded))
(setq gptel-got-active-signal-buffer (current-buffer))
(condition-case err
(progn
(sly-eval-async `(slynk:load-file ,expanded)
`(lambda (result)
(gptel-got--receive-lisp-signal
"LOAD COMPLETE"
(format "File %s loaded successfully.\nReturn: %s"
,(file-name-nondirectory expanded)
(prin1-to-string result)))))
(format "Asynchronous load instruction sent for %s. The system will signal you when complete, or if it enters the debugger."
(file-name-nondirectory expanded)))
(error (format "Lisp Load Dispatch Error: %s" (error-message-string err))))))
(add-to-list 'gptel-got
(gptel-make-tool
:name "lisp-load"
:function #'gptel-got--lisp-load
:description "Compiles and loads a Lisp source file into the live running environment."
:args '((:name "file" :type string :description "Path to the .lisp or .asd file"))
:category "gptel-got"
:confirm t))
lisp-receiver
(defvar gptel-got-active-signal-buffer nil
"The buffer designated to receive async signals from the Lisp process.")
(defun gptel-got--receive-lisp-signal (level message agent-id &optional backtrace context-data)
(if (not (and (boundp 'gptel-got-active-signal-buffer)
gptel-got-active-signal-buffer
(buffer-live-p gptel-got-active-signal-buffer)))
(message "gptel-got SIGNAL DROPPED: [%s] %s" level message)
(with-current-buffer gptel-got-active-signal-buffer
(let ((signal-text (format "\n*** [ASYNC LISP SIGNAL : %s] ***\nMESSAGE: %s\n%s%s***********************************\n@ai\n"
level message
(if context-data (format "CONTEXT: %s\n" context-data) "")
(if backtrace (format "BACKTRACE:\n%s\n" backtrace) ""))))
(if gptel-got-llm-busy
(progn
(push (cons agent-id signal-text) gptel-got-signal-queue)
(message "gptel-got: Signal queued for agent %s (LLM is busy)." (or agent-id "Parent")))
(save-excursion
(goto-char (point-max))
(unless (bolp) (insert "\n"))
(insert signal-text)
(let ((target-state (when agent-id (alist-get agent-id gptel-got--waiting-subagents nil nil #'string=))))
(if target-state
(let ((task (plist-get target-state :task))
(priv (plist-get target-state :privilege))
(cb (plist-get target-state :callback))
(steps (plist-get target-state :steps)))
(setf (alist-get agent-id gptel-got--waiting-subagents nil 'remove #'string=) nil)
(message "gptel-got: Resuming Subagent [%s]." agent-id)
(gptel-got--subagent cb task signal-text priv steps agent-id))
(gptel-send)))))))))
(defun gptel-got--lisp-run-tests (system-name)
"Run the ASDF test suite for SYSTEM-NAME."
(unless (sly-connected-p) (error "SLY is not connected."))
(gptel-got--lisp-eval (format "(asdf:test-system :%s)" system-name)))
(add-to-list 'gptel-got
(gptel-make-tool
:name "lisp-run-tests"
:function #'gptel-got--lisp-run-tests
:description "Executes the ASDF test suite for a given system."
:args '((:name "system_name" :type string))
:category "gptel-got"
:confirm t))
cl-lisp-sender (Common Lisp)
This system automatically triggers an API call (gptel-send).
Do not the (alert-ai ...) too much.
It can lead to many simultaneous requests to the LLM API (loop -> Emacs -> LLM API ), which will very quickly lock everything up.
(defconst gptel-got--lisp-agent-code
"(progn
(unless (find-package :gptel-got)
(defpackage :gptel-got
(:use :cl)
(:export :alert-ai :with-ai-handler)))
(in-package :gptel-got)
(defun alert-ai (message &key (level \"INFO\") backtrace context agent-id)
\"Send a message directly to the AI agent in Emacs.\"
(ignore-errors
(slynk:eval-in-emacs
`(gptel-got--receive-lisp-signal ,level ,message ,agent-id ,backtrace ,context))))
(defmacro with-ai-handler ((&key context agent-id) &body body)
\"Execute BODY. If an error occurs, capture the backtrace and signal the AI.\"
`(handler-bind
((error #'(lambda (c)
(let* ((err-msg (format nil \"~~A\" c))
(bt (with-output-to-string (s)
(uiop:print-backtrace :stream s :condition c))))
(alert-ai err-msg
:level \"ERROR\"
:backtrace bt
:context ,context
:agent-id ,agent-id)
(invoke-restart 'abort)))))
,@body)))"
"The core Common Lisp agent code as a string, to be injected dynamically.")
(defun gptel-got--inject-lisp-agent ()
"Inject the gptel-got AI handler code directly into the connected Lisp image.
Removes itself from the hook to prevent polluting manual user sessions."
(when (and (featurep 'sly) (sly-connected-p))
(sly-eval `(slynk:eval-and-grab-output ,gptel-got--lisp-agent-code))
(message "gptel-got: Asynchronous Lisp agent handler injected into the live image.")
(remove-hook 'sly-connected-hook #'gptel-got--inject-lisp-agent)))
(defun gptel-got--ensure-lisp-agent ()
"Ensure the AI handler is injected into the current Lisp image.
Acts as a lazy-load fallback if the user started the REPL manually."
(when (and (featurep 'sly) (sly-connected-p))
;; Ask SLY if our package exists in the current image
(let ((pkg-exists (sly-eval '(cl:if (cl:find-package :gptel-got) cl:t cl:nil))))
(unless pkg-exists
(gptel-got--inject-lisp-agent)))))
File System
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-got--dir (dir)
"Return directory listing."
(let ((buf (dired-noselect (or dir "~"))))
(with-current-buffer buf
(let ((content (buffer-string)))
(kill-buffer (current-buffer))
content))))
(add-to-list 'gptel-got
(gptel-make-tool
:function #'gptel-got--dir
:name "system-dir"
:description "Lists contents of a directory using dired."
:args (list '(:name "dir"
:type string
:description "Directory path (defaults to ~)"
:optional t))
:category "gptel-got"
))
file-system-cd
(defun gptel-got--cd (path)
"Change the buffer-local default-directory to PATH."
(let ((expanded-path (expand-file-name path default-directory)))
(if (file-directory-p expanded-path)
(progn
(setq-local default-directory (file-name-as-directory expanded-path))
(format "Successfully changed directory. New working directory: %s" default-directory))
(error "Directory does not exist or is not accessible: %s" expanded-path))))
(add-to-list 'gptel-got
(gptel-make-tool
:name "system-cd"
:function #'gptel-got--cd
:description "Changes the current working directory (cd) for subsequent tool executions. Accepts relative or absolute paths."
:args '((:name "path" :type string :description "The target directory path"))
:category "gptel-got"
:confirm t))
file-system-pwd
(defun gptel-got--pwd ()
"Return the current working directory of the active buffer."
(format "Current working directory: %s" (expand-file-name default-directory)))
(add-to-list 'gptel-got
(gptel-make-tool
:name "system-pwd"
:function #'gptel-got--pwd
:description "Returns the current working directory (pwd) of the active session."
:category "gptel-got"))
file-system
(defun gptel-got--file-system (action source &optional destination)
"Perform native file system ACTION on SOURCE."
(let ((expanded-source (expand-file-name source))
(expanded-dest (when destination (expand-file-name destination))))
(pcase action
("mkdir"
(make-directory expanded-source t)
(format "Success: Created directory %s" expanded-source))
("rm"
(if (file-directory-p expanded-source)
(delete-directory expanded-source t)
(delete-file expanded-source t))
(format "Success: Deleted %s" expanded-source))
("mv"
(unless destination (error "Action 'mv' requires a destination"))
(rename-file expanded-source expanded-dest t)
(format "Success: Moved %s to %s" expanded-source expanded-dest))
("cp"
(unless destination (error "Action 'cp' requires a destination"))
(if (file-directory-p expanded-source)
(copy-directory expanded-source expanded-dest t t t)
(copy-file expanded-source expanded-dest t))
(format "Success: Copied %s to %s" expanded-source expanded-dest))
(_ (error "Invalid action. Use mkdir, rm, mv, or cp.")))))
(add-to-list 'gptel-got
(gptel-make-tool
:name "file-system"
:function #'gptel-got--file-system
:description "Native file system management. Safely create, delete, move, or copy files/directories."
:args '((:name "action" :type string :enum ["mkdir" "rm" "mv" "cp"])
(:name "source" :type string :description "Target file or directory path")
(:name "destination" :type string :optional t :description "Required for 'mv' and 'cp'"))
:category "gptel-got"
:confirm t))
file-system-find
(defun gptel-got--file-system-find (dir &optional name_pattern mtime_days)
"Search DIR for files matching NAME_PATTERN and/or MTIME_DAYS using `find`."
(let* ((expanded-dir (expand-file-name dir))
(name-arg (if name_pattern (format "-name %s" (shell-quote-argument name_pattern)) ""))
(mtime-arg (if mtime_days (format "-mtime -%d" mtime_days) ""))
(cmd (format "find %s -type f %s %s -print | head -n 100"
(shell-quote-argument expanded-dir) name-arg mtime-arg)))
(condition-case err
(let ((output (shell-command-to-string cmd)))
(if (string-empty-p output)
(format "No files found matching criteria in %s" expanded-dir)
(gptel-got--result-limit output)))
(error (format "Search Error: %s" (error-message-string err))))))
(add-to-list 'gptel-got
(gptel-make-tool
:name "file-system-search"
:function #'gptel-got--file-system-find
:description "Find files by metadata (name/extension or recently modified) rather than text content."
:args '((:name "dir" :type string :description "Directory to search in")
(:name "name_pattern" :type string :optional t :description "E.g., '*.org' or 'config.*'")
(:name "mtime_days" :type integer :optional t :description "Find files modified in the last X days"))
:category "gptel-got"))
file-system-rg
(defun gptel-got--system-rg (query &optional directory)
"Search for QUERY in DIRECTORY.
Suppresses file-access errors but preserves syntax errors."
(let* ((dir (or directory default-directory))
(has-rg (executable-find "rg"))
(command (if has-rg
(format "rg --line-number --column --no-heading --no-messages --color never %s %s"
(shell-quote-argument query) (shell-quote-argument dir))
(format "grep -rns %s %s"
(shell-quote-argument query) (shell-quote-argument dir)))))
(condition-case err
(let ((output (shell-command-to-string command)))
(if (string-empty-p output)
(format "No matches found for '%s' in %s" query dir)
(gptel-got--result-limit output)))
(error (format "Search Error: %s" (error-message-string err))))))
(add-to-list 'gptel-got
(gptel-make-tool
:function #'gptel-got--system-rg
:name "system-search"
:description "Global text search. Ignores permission errors but reports bad syntax."
:args '((:name "query" :type string :description "Search pattern")
(:name "directory" :type string :optional t :description "Target directory"))
:category "gptel-got"
:confirm t))
file-system-tree
(defun gptel-got--file-system-tree (dir &optional depth)
"Generate a structural map of the directory tree."
(let* ((expanded-dir (expand-file-name dir))
(d (or depth 2))
(cmd (if (executable-find "tree")
(format "tree -L %d %s" d (shell-quote-argument expanded-dir))
(format "find %s -maxdepth %d" (shell-quote-argument expanded-dir) d))))
(condition-case err
(let ((output (shell-command-to-string cmd)))
(gptel-got--result-limit (concat "Project Map:\n" output)))
(error (format "Error generating map: %s" (error-message-string err))))))
(add-to-list 'gptel-got
(gptel-make-tool
:name "project-map"
:function #'gptel-got--file-system-tree
:description "Generates a fast, high-level structural map of a project directory."
:args '((:name "dir" :type string :description "Root directory to map")
(:name "depth" :type integer :optional t :description "Traversal depth (default: 2)"))
:category "gptel-got"))
Network
http-request
(defun gptel-got--http-request (url &optional method headers body)
"Make a structured HTTP request using curl, returning only the body."
(let* ((method-str (upcase (or method "GET")))
(valid-methods '("GET" "POST" "PUT" "DELETE" "PATCH"))
(_ (unless (member method-str valid-methods)
(error "Invalid HTTP method: %s" method-str)))
(header-args (if headers
(mapconcat (lambda (h) (format "-H %s" (shell-quote-argument h))) headers " ")
""))
(body-arg (if body (format "-d %s" (shell-quote-argument body)) ""))
(cmd (format "curl -sS -L -X %s %s %s %s"
method-str header-args body-arg (shell-quote-argument url))))
(condition-case err
(let ((output (shell-command-to-string cmd)))
(if (string-empty-p output)
"Request successful but returned empty body."
(gptel-got--result-limit output)))
(error (format "HTTP Request Error: %s" (error-message-string err))))))
(add-to-list 'gptel-got
(gptel-make-tool
:name "http-request"
:function #'gptel-got--http-request
:description "Make network/API requests securely. Returns the response body."
:args '((:name "url" :type string :description "Target URL")
(:name "method" :type string :optional t :description "GET, POST, PUT, DELETE, PATCH (defaults to GET)")
(:name "headers" :type array :items (:type string) :optional t :description "List of header strings e.g. 'Content-Type: application/json'")
(:name "body" :type string :optional t :description "Request payload"))
:category "gptel-got"
:confirm t))
web-render
(defun gptel-got--web-render (url)
"Dump the rendered DOM of a URL using a headless browser."
(let ((chrome-bin (or (executable-find "google-chrome")
(executable-find "chromium")
(executable-find "chromium-browser")
(executable-find "Microsoft Edge"))))
(unless chrome-bin
(error "No Chromium-based browser found on the system path for headless rendering."))
(let ((cmd (format "%s --headless --disable-gpu --dump-dom %s"
(shell-quote-argument chrome-bin)
(shell-quote-argument url))))
(condition-case err
(let ((output (shell-command-to-string cmd)))
(if (string-empty-p output)
"Render completed but DOM is empty."
(gptel-got--result-limit output)))
(error (format "Headless Render Error: %s" (error-message-string err)))))))
(add-to-list 'gptel-got
(gptel-make-tool
:name "web-render"
:function #'gptel-got--web-render
:description "Fetch a URL using a headless browser to execute JavaScript and return the rendered HTML DOM."
:args '((:name "url" :type string :description "Target URL"))
:category "gptel-got"
:confirm t))
Org-mode
Given the complexity of org-mode and org-ql queries, the philosophy here is:
- One unified tool for all org queries (
org-search). - Different modes handle different predicates (
tags,tags-local,rifle,headings,date). - The
actionargument controls return format (heading, heading-body, subtree).
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-got--org-extract-tags (buffer)
"Return all tags from BUFFER."
(let ((buf (get-buffer buffer)))
(if (not buf)
(user-error "Buffer '%s' not found" buffer)
(with-current-buffer buf
(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-got
(gptel-make-tool
:function #'gptel-got--org-extract-tags
:name "org-extract-tags"
:args (list '(:name "buffer"
:type string
:description "Org buffer to scan"))
:category "gptel-got"
:description "Extracts all tags from an org-mode buffer."))
org-extract-headings
(defun gptel-got--org-extract-headings (buffer)
"Return all headings from BUFFER, checking against blocked absolute paths."
(let* ((buf (get-buffer buffer))
(file-path (and buf (buffer-file-name buf)
(expand-file-name (buffer-file-name buf)))))
(cond
((not buf)
(user-error "Buffer '%s' not found" buffer))
((and file-path (member file-path gptel-got-skip-heading-extraction))
(error "Buffer '%s' (%s) is blocked from heading extraction." buffer file-path))
(t
(with-current-buffer buf
(org-map-entries #'gptel-got--heading t 'file))))))
(add-to-list 'gptel-got
(gptel-make-tool
:function #'gptel-got--org-extract-headings
:name "org-extract-headings"
:description "Extracts all headings from an org-mode buffer."
:args (list '(:name "buffer"
:type string
:description "Org buffer to scan"))
:category "gptel-got"))
org-search
Unified tool for org-mode queries. Handles all search modes in a single tool:
tags- search with tag inheritancetags-local- search without tag inheritancerifle- search heading and body textheadings- search only headingsdate- search by date (YYYY, YYYY-MM, YYYY-MM-DD)
The action argument controls what gets returned: heading only, heading+body, or full subtree.
The scope argument controls where to search:
file- search a specific open buffer (target = buffer name)agenda- search all files in org-agenda-files (target ignored)dir- search all .org files in a directory (target = directory path, optional if gptel-got-default-search-directory or org-directory is set)
(defun gptel-got--org-search (scope target mode query &optional action)
"Search with SCOPE, TARGET, MODE and QUERY.
Provides guided feedback if no results are found."
(let* ((buffers
(pcase scope
("file"
(let ((buf (get-buffer target)))
(if (and buf (eq (buffer-local-value 'major-mode buf) 'org-mode))
buf
(format "Error: Buffer '%s' not found or is not in org-mode." target))))
("agenda"
(let ((files (org-agenda-files)))
(if files files "Error: org-agenda-files is empty.")))
("dir"
(let* ((directory (or target
gptel-got-default-search-directory
(bound-and-true-p org-directory)))
(files (when directory (gptel-got--org-files-in-dir directory))))
(cond
((not directory) "Error: Scope 'dir' requires a target directory path.")
((not (file-directory-p directory)) (format "Error: Directory '%s' does not exist." directory))
((not files) (format "Error: No .org files found in '%s'." directory))
(t files))))
(_ (format "Error: Invalid scope '%s'. Use 'file', 'agenda', or 'dir'." scope))))
(action-str (or action "heading-body"))
(action-fn (pcase action-str
("heading" #'gptel-got--heading)
("heading-body" #'gptel-got--heading-body)
("subtree" #'gptel-got--heading-subtree)
(_ #'gptel-got--heading-body))))
(if (stringp buffers)
buffers
(condition-case err
(let* ((raw-results (org-ql-select buffers
(pcase mode
("tags" `(tags ,query))
("tags-local" `(tags-local ,query))
("rifle" `(rifle ,query))
("headings" `(heading ,query))
("date" `(date ,query))
(_ `(rifle ,query)))
:action action-fn))
(count (if (listp raw-results) (length raw-results) 0)))
(if (or (null raw-results) (equal raw-results ""))
(cond
((equal mode "tags") (format "No results found for tag '%s'." query))
((equal mode "date") (format "No entries found for date '%s'." query))
(t (format "No results found for %s query '%s'." mode query)))
(let ((combined-text (if (listp raw-results) (string-join raw-results "\n") raw-results)))
(format "MATCH COUNT: %d\n---\n%s" count (gptel-got--result-limit combined-text)))))
(error
(format "Org-QL Error: %s\nTip: If searching a directory, a background file-load hook might be crashing. Pivot to 'system-search' instead."
(error-message-string err)))))))
(add-to-list 'gptel-got
(gptel-make-tool
:function #'gptel-got--org-search
:name "org-search"
:category "gptel-got"
:description "Primary tool for searching Org files.
GUIDELINES:
- Use 'file' scope for specific open buffers.
- Use 'agenda' scope for your primary tasks/notes.
- Use 'dir' scope for broad discovery in a folder.
- Use 'rifle' mode for full-text search (headings + body).
- Use 'tags' for categorized search (recommended)."
:args (list '(:name "scope"
:type string
:description "file, agenda, or dir"
:enum ["file" "agenda" "dir"])
'(:name "target"
:type string
:description "Buffer name (for 'file') or Directory path (for 'dir').")
'(:name "mode"
:type string
:description "Search mode"
:enum ["tags" "tags-local" "rifle" "headings" "date"])
'(:name "query"
:type string
:description "The string/tag/date to search for.")
'(:name "action"
:type string
:description "Return format (Default: heading-body)"
:enum ["heading" "heading-body" "subtree"]
:optional t))))
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-got--org-agenda-seek (days)
"Return the results of org-agenda-list spanning now to DAYS days."
(condition-case err
(save-window-excursion
(org-agenda-list nil nil (or days 14))
(let ((content (with-current-buffer "*Org Agenda*"
(buffer-substring-no-properties (point-min) (point-max)))))
(kill-buffer "*Org Agenda*")
(gptel-got--result-limit content)))
(error
(format "Org Agenda Error: %s\nTip: You likely have a malformed timestamp (e.g., missing a bracket) in one of your agenda files causing the parser to crash."
(error-message-string err)))))
(add-to-list 'gptel-got
(gptel-make-tool
:function #'gptel-got--org-agenda-seek
:name "org-agenda-seek"
:args (list '(:name "days"
:type integer
:description "Days. Positive = future. Negative = past. Default: 14"))
:category "gptel-got"
:description "Returns user's agenda spanning X days from current moment."))
org-log-entry
(defvar gptel-got-journal-file "journal.org"
"Path to the personal journal file.")
(defvar gptel-got-agent-log-file "agent-log.org"
"Path to the agent activity log.")
(defun gptel-got--chron-append (path tags content &optional sec-tag-p timestamp)
"Append CONTENT to PATH with Year/Month/Day hierarchy.
Optional TIMESTAMP (YYYY-MM-DD) ensures the entry is placed in the correct subtree.
Automatically adds @sec tag to the END of the tags if SEC-TAG-P is non-nil."
(let* ((filename (expand-file-name path))
(time-val (if (and timestamp (not (string-empty-p timestamp)))
(apply #'encode-time (org-read-date-analyze timestamp nil nil))
(current-time)))
(y-title (format "[%s]" (format-time-string "%Y" time-val)))
(m-title (format "[%s]" (format-time-string "%Y-%m" time-val)))
(d-prefix (format "[%s" (format-time-string "%Y-%m-%d" time-val)))
(ts-full (format-time-string "[%Y-%m-%d %a %H:%M]" time-val))
(raw-tags (if (vectorp tags) (append tags nil) (append tags nil)))
(clean-tags (delete "@sec" raw-tags))
(final-tags (append clean-tags (when sec-tag-p '("@sec"))))
(tag-str (if final-tags (concat ":" (string-join final-tags ":") ":") ""))
(entry-title (concat ts-full " " tag-str)))
(with-current-buffer (find-file-noselect filename)
(unless (derived-mode-p 'org-mode) (org-mode))
(save-excursion
(save-restriction
(widen)
(goto-char (point-min))
(let ((y-pos (org-find-exact-headline-in-buffer y-title)))
(if y-pos (goto-char y-pos)
(goto-char (point-max))
(unless (bolp) (insert "\n"))
(insert (format "* %s\n" y-title))
(org-back-to-heading t)))
(org-narrow-to-subtree)
(let ((m-pos (org-find-exact-headline-in-buffer m-title)))
(if m-pos (goto-char m-pos)
(org-end-of-subtree t t)
(insert (format "** %s\n" m-title))
(org-back-to-heading t)))
(org-narrow-to-subtree)
(let ((day-exists nil))
(goto-char (point-min))
(while (re-search-forward (concat "^\\*\\*\\* " (regexp-quote d-prefix)) nil t)
(setq day-exists t))
(org-end-of-subtree t t)
(let* ((level (if day-exists 4 3))
(stars (make-string level ?*))
(search-string (format "*%s*%s*%s" y-title m-title entry-title)))
(insert (format "%s %s\n%s\n" stars entry-title content))
(save-buffer)
(format "[[file:%s::%s][%s entry: %s]]"
filename search-string
(if day-exists "Subsequent" "Daily") ts-full))))))))
(add-to-list 'gptel-got
(gptel-make-tool
:function (lambda (tags content &optional timestamp)
(gptel-got--chron-append gptel-got-journal-file tags content t timestamp))
:name "journal-add-entry"
:category "gptel-got"
:description "Records a personal entry. Use 'timestamp' (YYYY-MM-DD) to backdate if needed."
:args '((:name "tags" :type array :items (:type string) :description "Descriptive tags")
(:name "content" :type string :description "Journal entry text")
(:name "timestamp" :type string :optional t :description "Optional: YYYY-MM-DD HH:MM"))
:confirm t))
(add-to-list 'gptel-got
(gptel-make-tool
:function (lambda (tags content &optional timestamp)
(gptel-got--chron-append gptel-got-agent-log-file tags content nil timestamp))
:name "agent-log-action"
:category "gptel-got"
:description "Logs agent activity. Use 'timestamp' (YYYY-MM-DD) to backdate if needed."
:args '((:name "tags" :type array :items (:type string) :description "Action categories")
(:name "content" :type string :description "Summary of actions performed")
(:name "timestamp" :type string :optional t :description "Optional: YYYY-MM-DD"))
:confirm t))
org-mutate
(defun gptel-got--org-mutate (file line &optional todo checkbox prop_name prop_val tags scheduled deadline)
"Mutate an Org element at FILE and LINE.
Handles TODO states, checkboxes, properties, tags, and dates natively."
(let ((expanded-path (expand-file-name file)))
(unless (file-readable-p expanded-path)
(error "File not readable: %s" expanded-path))
(let ((buf (find-file-noselect expanded-path)))
(with-current-buffer buf
(save-excursion
(unless (derived-mode-p 'org-mode) (org-mode))
(widen)
(goto-char (point-min))
(forward-line (1- line))
(when checkbox
(if (re-search-forward "\\(^[ \t]*\\(?:[-+*]\\|[0-9]+[.)]\\)[ \t]+\\)\\[[ Xx-]\\]" (line-end-position) t)
(replace-match (concat "\\1[" (if (string= (downcase checkbox) "on") "X" " ") "]"))
(error "No checkbox found on line %d" line)))
(when (or todo prop_name tags scheduled deadline)
(condition-case nil
(org-back-to-heading t)
(error (error "Line %d is not a heading and cannot accept todo/tags/properties." line)))
(when todo (org-todo todo))
(when (and prop_name prop_val) (org-entry-put nil prop_name prop_val))
(when tags (org-set-tags (append tags nil)))
(when scheduled
(let ((time-val (org-read-date nil t scheduled)))
(org-schedule nil time-val)))
(when deadline
(let ((time-val (org-read-date nil t deadline)))
(org-deadline nil time-val))))
(save-buffer)
(format "Successfully mutated %s at line %d." (file-name-nondirectory file) line))))))
(add-to-list 'gptel-got
(gptel-make-tool
:name "org-mutate"
:function #'gptel-got--org-mutate
:description "Modifies an Org heading or checkbox at a specific line. Use natural language for dates (e.g., 'may 1st')."
:args '((:name "file" :type string :description "Target file path")
(:name "line" :type integer :description "Exact line number of the heading or checkbox")
(:name "todo" :type string :optional t :description "New TODO state (e.g., DONE, WAIT)")
(:name "checkbox" :type string :enum ["on" "off"] :optional t :description "Toggle checkbox on the exact line")
(:name "prop_name" :type string :optional t :description "Property name to set")
(:name "prop_val" :type string :optional t :description "Property value")
(:name "tags" :type array :items (:type string) :optional t)
(:name "scheduled" :type string :optional t :description "Natural language date string")
(:name "deadline" :type string :optional t :description "Natural language date string"))
:category "gptel-got"
:confirm t))
org-capture
(defun gptel-got--org-capture (path target_heading content &optional title todo scheduled deadline tags)
"Construct and insert a new Org entry under TARGET_HEADING in PATH."
(let ((expanded-path (expand-file-name path)))
(unless (file-readable-p expanded-path)
(error "File not readable: %s" expanded-path))
(let ((buf (find-file-noselect expanded-path)))
(with-current-buffer buf
(save-excursion
(unless (derived-mode-p 'org-mode) (org-mode))
(widen)
(goto-char (point-min))
(let ((found nil)
(search-pattern (concat "^\\*+\\s-+" (regexp-quote target_heading))))
(when (re-search-forward search-pattern nil t)
(setq found t)
(org-end-of-subtree t t))
(unless found (error "Target heading '%s' not found" target_heading)))
(let* ((parent-level (save-excursion (org-back-to-heading t) (org-outline-level)))
(new-level (1+ parent-level))
(stars (make-string new-level ?*))
(todo-str (if todo (concat " " todo) ""))
(title-str (if title (concat " " title) " Note"))
(raw-tags (if (vectorp tags) (append tags nil) (append tags nil)))
(clean-tags (delete "@sec" raw-tags))
(final-tags (append clean-tags '("@sec")))
(tag-str (concat " :" (string-join final-tags ":") ":"))
(sched-str (when scheduled
(format "SCHEDULED: <%s>\n" (format-time-string "%Y-%m-%d %a" (org-read-date nil t scheduled)))))
(dead-str (when deadline
(format "DEADLINE: <%s>\n" (format-time-string "%Y-%m-%d %a" (org-read-date nil t deadline)))))
(planning-info (concat (or sched-str "") (or dead-str ""))))
(unless (bolp) (insert "\n"))
(insert (format "%s%s%s%s\n%s%s\n"
stars todo-str title-str tag-str
planning-info (or content "")))
(save-buffer)
(format "Successfully captured entry under '%s' in %s." target_heading (file-name-nondirectory path))))))))
(add-to-list 'gptel-got
(gptel-make-tool
:name "org-capture"
:function #'gptel-got--org-capture
:description "Creates a new Org entry as a sub-heading."
:args '((:name "path" :type string :description "File path")
(:name "target_heading" :type string :description "The existing heading to insert under")
(:name "content" :type string :description "Body content")
(:name "title" :type string :optional t :description "Title of the new entry")
(:name "todo" :type string :optional t)
(:name "scheduled" :type string :optional t :description "Natural language date")
(:name "deadline" :type string :optional t :description "Natural language date")
(:name "tags" :type array :items (:type string) :optional t))
:category "gptel-got"
:confirm t))
org-export
(require 'ox-md)
(require 'ox-html)
(require 'ox-latex)
(defun gptel-got--org-export (file format)
"Export an Org FILE to FORMAT natively."
(let ((expanded-path (expand-file-name file)))
(unless (file-readable-p expanded-path)
(error "File not readable: %s" expanded-path))
(let* ((buf (find-file-noselect expanded-path))
(out-file (concat (file-name-sans-extension expanded-path) "." format)))
(with-current-buffer buf
(unless (derived-mode-p 'org-mode) (org-mode))
(pcase format
("md" (org-md-export-to-markdown))
("html" (org-html-export-to-html))
("pdf" (org-latex-export-to-pdf))
(_ (error "Unsupported format. Use md, html, or pdf.")))
(format "Successfully exported to %s" out-file)))))
(add-to-list 'gptel-got
(gptel-make-tool
:name "org-export"
:function #'gptel-got--org-export
:description "Exports an Org file to Markdown, HTML, or PDF."
:args '((:name "file" :type string :description "Target .org file")
(:name "format" :type string :enum ["md" "html" "pdf"]))
:category "gptel-got"
:confirm t))
org-babel-execute
(defun gptel-got--babel-execute (lang code)
"Execute CODE in LANG using org-babel."
(with-temp-buffer
(org-mode)
(insert (format "#+begin_src %s\n%s\n#+end_src" lang code))
(goto-char (point-min))
(condition-case err
(let ((result (org-babel-execute-src-block)))
(if result
(gptel-got--result-limit (prin1-to-string result))
"Execution completed successfully (no output returned)."))
(error (format "Babel Execution Error: %s. (Ensure org-babel-load-languages is configured for %s)"
(error-message-string err) lang)))))
(add-to-list 'gptel-got
(gptel-make-tool
:name "org-babel-execute"
:function #'gptel-got--babel-execute
:description "Executes code natively via org-babel. Use for running Python, JS, shell scripts, or Lisp."
:args '((:name "lang" :type string :description "Language identifier (e.g., 'python', 'sh', 'emacs-lisp')")
(:name "code" :type string :description "The script to evaluate"))
:category "gptel-got"
:confirm t))
File Editing
A unified tool for precise file modifications with multiple modes.
open-file-inactive
Opens a file into an inactive (background) buffer for processing.
(defun gptel-got--open-file-inactive (file)
"Open FILE in a background buffer without modifying its contents."
(let ((expanded (expand-file-name file)))
(unless (file-readable-p expanded)
(error "File not readable: %s" expanded))
(find-file-noselect expanded)
(set-buffer-modified-p nil)))
(add-to-list 'gptel-got
(gptel-make-tool
:function #'gptel-got--open-file-inactive
:name "system-open-file"
:description "Opens a file in a background buffer without modifying it. Does NOT return contents."
:args (list '(:name "file"
:type string
:description "Path to file."))
:category "gptel-got"))
read-file
(defun gptel-got--read-file (file &optional start_line end_line)
"Read FILE from START_LINE to END_LINE with navigation metadata."
(let ((expanded (expand-file-name file)))
(unless (file-readable-p expanded)
(error "File not readable: %s" expanded))
(with-temp-buffer
(insert-file-contents expanded)
(let* ((total-lines (count-lines (point-min) (point-max)))
(s-line (max 1 (or start_line 1)))
(e-line (min total-lines
(or end_line (+ s-line 199))
(+ s-line 399)))
(content
(save-restriction
(widen)
(goto-char (point-min))
(forward-line (1- s-line))
(let ((start (point)))
(forward-line (1+ (- e-line s-line)))
(buffer-substring-no-properties start (point))))))
(let* ((next-start (1+ e-line))
(next-end (min total-lines (+ e-line 200)))
(status-message
(if (< e-line total-lines)
(format "STATUS: INCOMPLETE (More content available).\nACTION REQUIRED: You have not finished reading this file. You MUST call this tool again using arguments: { \"start_line\": %d, \"end_line\": %d }" next-start next-end)
"STATUS: END OF FILE REACHED. You have read the entire file.")))
(format "FILE: %s\nLINES: %d to %d of %d\n---\n%s\n---\n%s"
(file-name-nondirectory file)
s-line e-line total-lines
(gptel-got--result-limit content)
status-message))))))
(add-to-list 'gptel-got
(gptel-make-tool
:function #'gptel-got--read-file
:name "system-read-file"
:description "Reads a file. Use start/end_line to paginate through large files and stay under context limits."
:args '((:name "file" :type string :description "Path to file")
(:name "start_line" :type integer :optional t :description "First line to read (1-indexed)")
(:name "end_line" :type integer :optional t :description "Last line to read"))
:category "gptel-got"
:confirm t))
file-edit
Note: this tool does not ask for user confirmation. This is intentional.
(defun gptel-got--file-edit (path mode content &optional line_number regexp_pattern heading_path dry_run search_block)
"Edit FILE at PATH using MODE, with automatic Git commits or backups."
(let* ((expanded-path (expand-file-name path)))
(unless (file-readable-p expanded-path)
(error "File not readable: %s" expanded-path))
(let ((result
(pcase mode
("diff" (gptel-got--edit-diff expanded-path content dry_run))
("line"
(unless line_number (error "Mode 'line' requires line_number"))
(gptel-got--edit-line expanded-path line_number content dry_run))
("regexp"
(unless regexp_pattern (error "Mode 'regexp' requires regexp_pattern"))
(gptel-got--edit-regexp expanded-path regexp_pattern content dry_run))
("insert"
(unless heading_path (error "Mode 'insert' requires heading_path"))
(gptel-got--edit-insert expanded-path heading_path content dry_run))
("append" (gptel-got--edit-append expanded-path content dry_run))
("search-replace"
(unless search_block (error "Mode 'search-replace' requires search_block"))
(gptel-got--edit-search-replace expanded-path search_block content dry_run))
(_ (error "Invalid mode: %s. Use: diff, line, regexp, insert, append, or search-replace" mode)))))
(concat result (gptel-got--backup-or-commit path mode dry_run)))))
(defun gptel-got--edit-line (path line-number content dry-run)
"Replace LINE-NUMBER in PATH with CONTENT."
(let ((buf (find-file-noselect path)))
(with-current-buffer buf
(save-excursion
(goto-char (point-min))
(if (zerop (forward-line (1- line-number)))
(let* ((start (line-beginning-position))
(end (line-end-position))
(old-text (buffer-substring-no-properties start end)))
(if dry-run
(format "[DRY RUN] Would replace line %d in %s\n- %s\n+ %s"
line-number (file-name-nondirectory path) old-text content)
(delete-region start end)
(insert content)
(save-buffer)
(format "Success: Replaced line %d in %s\n- %s\n+ %s"
line-number (file-name-nondirectory path) old-text content)))
(error "Line %d is out of bounds for %s" line-number (file-name-nondirectory path)))))))
(defun gptel-got--edit-diff (path diff-content dry-run)
"Apply a unified diff (DIFF-CONTENT) to PATH using the system 'patch' command.
Ignores whitespace variations to accommodate LLM generation quirks."
(let ((expanded (expand-file-name path)))
(unless (executable-find "patch")
(error "The 'patch' command-line utility is not installed."))
(if dry-run
(format "[DRY RUN] Would apply the following diff to %s:\n%s" (file-name-nondirectory path) diff-content)
(with-temp-buffer
(insert diff-content)
(unless (bolp) (insert "\n"))
(let ((status (call-process-region
(point-min) (point-max)
"patch" nil '(t nil) nil
"--quiet" "--force" "--ignore-whitespace" expanded)))
(if (eq status 0)
(progn
(when-let ((buf (get-file-buffer expanded)))
(with-current-buffer buf (revert-buffer t t t)))
(format "Success: Diff successfully applied to %s." (file-name-nondirectory path)))
(error "Failed to apply diff. Ensure your diff contains standard @@ hunk headers and matches the original file reasonably well. Tip: use 'search-replace' instead for simpler multi-line changes.")))))))
(defun gptel-got--edit-search-replace (path search-block replace-block dry-run)
"Reliably replaces SEARCH-BLOCK with REPLACE-BLOCK in PATH and returns a preview."
(let ((buf (find-file-noselect path)))
(with-current-buffer buf
(save-excursion
(goto-char (point-min))
(let* ((search-regex (replace-regexp-in-string "[ \t\n\r]+" "[ \t\n\r]+" (regexp-quote search-block))))
(if (re-search-forward search-regex nil t)
(let ((start (match-beginning 0))
(end (match-end 0)))
(if dry-run
(format "[DRY RUN] Would replace found block in %s" (file-name-nondirectory path))
(progn
(replace-match replace-block t t)
(save-buffer)
(let* ((preview-start (save-excursion (goto-char start) (forward-line -2) (point)))
(preview-end (save-excursion (goto-char (point)) (forward-line 2) (point)))
(preview-text (buffer-substring-no-properties
(max (point-min) preview-start)
(min (point-max) preview-end))))
(format "Success: Replaced block in %s.\nPreview of change:\n---\n%s\n---"
(file-name-nondirectory path) preview-text)))))
(error "Search block NOT found. Tip: Use 'system-read-file' to copy the EXACT text (including whitespace) before retrying.")))))))
(defun gptel-got--edit-regexp (path pattern replacement dry-run)
"Replace all matches of PATTERN in PATH with REPLACEMENT with improved feedback."
(let ((buf (find-file-noselect path)))
(with-current-buffer buf
(save-excursion
(let ((matches 0))
(goto-char (point-min))
(while (re-search-forward pattern nil t)
(cl-incf matches))
(cond
((= matches 0)
(error "Regex '%s' found 0 matches in %s. Check your escape characters."
pattern (file-name-nondirectory path)))
(dry-run
(format "[DRY RUN] Pattern matches %d times in %s."
matches (file-name-nondirectory path)))
(t
(goto-char (point-min))
(while (re-search-forward pattern nil t)
(replace-match replacement t t))
(save-buffer)
(format "Success: Replaced %d occurrences in %s."
matches (file-name-nondirectory path)))))))))
(defun gptel-got--edit-insert (path heading-path content dry-run)
"Insert CONTENT as child of HEADING-PATH in PATH."
(let ((buf (find-file-noselect path)))
(with-current-buffer buf
(save-excursion
(unless (derived-mode-p 'org-mode) (org-mode))
(goto-char (point-min))
(let ((found nil)
(parts (split-string heading-path "\\s-*\\*\\s-*"))
(current-level 0))
(dolist (part parts)
(unless (string= part "")
(let ((search-pattern (concat "^\\*+\\s-" (regexp-quote part)))
(part-found nil)) ;; <-- Locally scoped
(when (re-search-forward search-pattern nil t)
(setq part-found t)
(beginning-of-line 2))
(unless part-found
(error "Heading not found: %s" part)))))
(if dry-run
(format "[DRY RUN] Would insert under heading '%s' in %s\nCONTENT:\n%s"
heading-path (file-name-nondirectory path) content)
(insert (concat "\n" content))
(save-buffer)
(format "Successfully inserted under heading '%s' in %s" heading-path (file-name-nondirectory path))))))))
(defun gptel-got--edit-append (path content dry-run)
"Append CONTENT to the end of PATH."
(let ((buf (find-file-noselect path)))
(with-current-buffer buf
(save-excursion
(if dry-run
(format "[DRY RUN] Would append to %s\nCONTENT:\n%s"
(file-name-nondirectory path) content)
(goto-char (point-max))
(insert (concat "\n\n" content))
(save-buffer)
(format "Successfully appended to %s" (file-name-nondirectory path)))))))
(add-to-list 'gptel-got
(gptel-make-tool
:name "file-edit"
:description "Edits a file. STRATEGY:\n1. Always 'system-read-file' first to see the current state.\n2. Use 'search-replace' for multi-line changes.\n3. After editing, verify the returned preview text to ensure the vibe is maintained."
:function #'gptel-got--file-edit
:args '((:name "path" :type string :description "File path")
(:name "mode" :type string
:enum ["diff" "line" "regexp" "insert" "append" "search-replace"]
:description "Edit mode")
(:name "content" :type string :description "New content")
(:name "line_number" :type integer :optional t)
(:name "regexp_pattern" :type string :optional t)
(:name "search_block" :type string :optional t :description "Exact block to replace")
(:name "heading_path" :type string :optional t)
(:name "dry_run" :type boolean :optional t))
:category "gptel-got"
:confirm nil))
file-diff
(defun gptel-got--file-diff (file1 file2)
"Compare two arbitrary files safely."
(let ((f1 (expand-file-name file1))
(f2 (expand-file-name file2)))
(unless (and (file-readable-p f1) (file-readable-p f2))
(error "One or both files are not readable."))
(let ((output (shell-command-to-string
(format "diff -u %s %s" (shell-quote-argument f1) (shell-quote-argument f2)))))
(if (string-empty-p output)
"Files are identical."
(gptel-got--result-limit output)))))
(add-to-list 'gptel-got
(gptel-make-tool
:name "file-diff"
:function #'gptel-got--file-diff
:description "Generates a unified diff between two files outside of version control."
:args '((:name "file1" :type string)
(:name "file2" :type string))
:category "gptel-got"))
dir-replace
(defun gptel-got--bulk-replace (dir file_pattern search_regex replacement &optional dry_run)
"Find and replace SEARCH_REGEX with REPLACEMENT across files in DIR."
(let* ((expanded-dir (expand-file-name dir))
(files (directory-files-recursively expanded-dir (or file_pattern "")))
(changed-files 0)
(total-replacements 0))
(dolist (file files)
(when (file-readable-p file)
(with-temp-buffer
(insert-file-contents file)
(goto-char (point-min))
(let ((matches 0))
(while (re-search-forward search_regex nil t)
(cl-incf matches)
(unless dry_run (replace-match replacement t nil)))
(when (> matches 0)
(cl-incf changed-files)
(cl-incf total-replacements matches)
(unless dry_run (write-region (point-min) (point-max) file)))))))
(if dry_run
(format "[DRY RUN] Would make %d replacements across %d files." total-replacements changed-files)
(let* ((default-directory expanded-dir)
(in-git (and (executable-find "git")
(zerop (call-process "git" nil nil nil "rev-parse" "--is-inside-work-tree")))))
(if in-git
(progn
(call-process "git" nil nil nil "commit" "-am"
(format "🤖 gptel-got bulk replace: '%s'" search_regex))
(format "Success: %d replacements across %d files. [Auto-committed to Git]"
total-replacements changed-files))
(format "Success: %d replacements across %d files. [WARNING: Not in Git, no backups made!]"
total-replacements changed-files))))))
(add-to-list 'gptel-got
(gptel-make-tool
:name "bulk-search-replace"
:function #'gptel-got--bulk-replace
:description "Perform project-wide regex replacements safely. Always use dry_run first!"
:args '((:name "dir" :type string :description "Root directory")
(:name "file_pattern" :type string :optional t :description "E.g., '\\.el$' or '\\.py$'")
(:name "search_regex" :type string :description "Emacs regular expression")
(:name "replacement" :type string :description "Replacement string")
(:name "dry_run" :type boolean :optional t :description "If true, only counts matches without saving"))
:category "gptel-got"
:confirm t))
check-syntax
(defun gptel-got--check-syntax (path &optional check_pairs)
"Check PATH for syntax errors using Tree-sitter and native pair validation."
(let* ((expanded-path (expand-file-name path))
(buf (find-file-noselect expanded-path))
(results '()))
(with-current-buffer buf
(when check_pairs
(save-excursion
(goto-char (point-min))
(condition-case err
(progn
(check-parens)
(push "Pair Check: SUCCESS (All brackets, braces, and quotes are perfectly balanced)." results))
(error
(push (format "Pair Check FAILED: %s (Look closely around line %d)"
(error-message-string err)
(line-number-at-pos (point)))
results)))))
(if (not (fboundp 'treesit-parser-list))
(push "Tree-sitter Check: Skipped (Not available in this Emacs build)." results)
(let ((parsers (treesit-parser-list))
(ts-errors '()))
(if (not parsers)
(push (format "Tree-sitter Check: Skipped (No active parsers for %s)." (file-name-nondirectory path)) results)
(dolist (parser parsers)
(let ((root (treesit-buffer-root-node parser)))
(when (treesit-node-has-error-p root)
(let ((error-nodes (treesit-filter-child root (lambda (n) (treesit-node-has-error-p n)) t)))
(dolist (node error-nodes)
(push (format "Line %d" (line-number-at-pos (treesit-node-start node))) ts-errors))))))
(if ts-errors
(push (format "Tree-sitter ERRORS found near: %s" (string-join (reverse ts-errors) ", ")) results)
(push "Tree-sitter Check: SUCCESS (No AST errors detected)." results)))))
(string-join (reverse results) "\n\n"))))
(add-to-list 'gptel-got
(gptel-make-tool
:name "check-syntax"
:function #'gptel-got--check-syntax
:description "Synchronously checks a file for syntax errors. Enable check_pairs to strictly verify brackets, braces, and quotes."
:args '((:name "path" :type string :description "File path to check")
(:name "check_pairs" :type boolean :optional t :description "Set to true to verify all pairs are balanced"))
:category "gptel-got"))
file-tail
(defun gptel-got--file-tail (path &optional lines)
"Return the last N lines of a file for log sampling."
(let ((expanded-path (expand-file-name path))
(n (or lines 50)))
(unless (file-readable-p expanded-path)
(error "File not readable: %s" expanded-path))
(condition-case err
(let ((output (shell-command-to-string
(format "tail -n %d %s" n (shell-quote-argument expanded-path)))))
(if (string-empty-p output)
"File is empty."
(gptel-got--result-limit output)))
(error (format "Tail Error: %s" (error-message-string err))))))
(add-to-list 'gptel-got
(gptel-make-tool
:name "file-tail"
:function #'gptel-got--file-tail
:description "Sample the end of a log file or live output stream without reading the whole file."
:args '((:name "path" :type string :description "Path to the file")
(:name "lines" :type integer :optional t :description "Number of lines to return (default: 50)"))
:category "gptel-got"))
Databases
sqlite-query
(defun gptel-got--sqlite-query (db_path query)
"Execute a SQL QUERY against an SQLite database at DB_PATH natively."
(let ((expanded-path (expand-file-name db_path)))
(unless (file-readable-p expanded-path)
(error "Database file not readable: %s" expanded-path))
(unless (fboundp 'sqlite-select)
(error "Native SQLite support is missing from this Emacs build."))
(condition-case err
(let* ((db (sqlite-open expanded-path))
(result (sqlite-select db query))
(formatted (prin1-to-string result)))
(gptel-got--result-limit formatted))
(error (format "SQLite Error: %s" (error-message-string err))))))
(add-to-list 'gptel-got
(gptel-make-tool
:name "sqlite-query"
:function #'gptel-got--sqlite-query
:description "Executes a SELECT query against a local SQLite database natively."
:args '((:name "db_path" :type string :description "Path to the .sqlite or .db file")
(:name "query" :type string :description "Valid SQL query string"))
:category "gptel-got"
:confirm t))
Daemon
This is very rough… I need to work on this sometime. Probably move to file based.
(defvar gptel-got-daemon-queue '()
"A list of pre-programmed prompts for the AI to execute automatically.")
(defvar gptel-got-daemon-active nil
"Flag to control if the 24/7 daemon is running.")
(defun gptel-got-daemon--trigger-heartbeat (beg end)
"Triggered post-response. If daemon is active, schedule the next heartbeat."
(when gptel-got-daemon-active
(run-with-timer 2 nil #'gptel-got-daemon-heartbeat)))
(unless (member #'gptel-got-daemon--trigger-heartbeat gptel-post-response-functions)
(add-to-list 'gptel-post-response-functions #'gptel-got-daemon--trigger-heartbeat))
(defun gptel-got-daemon-start ()
"Boot the autonomous 24/7 agent loop."
(interactive)
(setq gptel-got-daemon-active t)
(message ": gptel-got daemon: Initializing 24/7 execution...")
(gptel-got-daemon-heartbeat))
(defun gptel-got-daemon-stop ()
"Halt the autonomous agent."
(interactive)
(setq gptel-got-daemon-active nil)
(message ": gptel-got daemon: halted."))
(defun gptel-got-daemon-heartbeat ()
"The continuous loop that pops the next objective and sends it."
(when (and gptel-got-daemon-active gptel-got-daemon-queue)
(let ((next-task (pop gptel-got-daemon-queue))
(buffer (get-buffer-create "*gptel-daemon*")))
(with-current-buffer buffer
(unless (derived-mode-p 'gptel-mode)
(gptel-mode))
(goto-char (point-max))
(insert (format "\n\n: daemon directive:\n: %s\n" next-task))
(gptel-send)))))
Preset
This is an example of what I use.
Make sure you edit it to your system/ needs if you use this.
(gptel-make-preset 'gptel-got-ai
:description "AI (gptel-got)"
:system "You are AI, an elite, autonomous AI orchestrator deeply integrated into the user's Emacs and system environment. Your primary goal is to assist with knowledge management, software engineering, and system orchestration while STRICTLY minimizing context bloat.
### CORE DIRECTORIES
- ~/org - base org-mode
- ~/common-lisp - base Common Lisp projects
### CORE PHILOSOPHY
1. Limit Garbage Data: Never guess. If you don't know the exact syntax or file structure, use discovery tools first (`project-map`, `system-search`, `system-read-file`).
2. Be Atomic: Make precise, targeted changes rather than rewriting entire files.
3. Fail Gracefully: If a tool fails, analyze the error output. Do not blindly repeat the same failed command.
4. Delegate Heavy Lifting: Do not clog your own context with massive logs or repetitive tasks. Fork subagents.
### TOOL SELECTION HIERARCHY
1. Native Discovery: `project-map`, `file-system-search` (metadata), `system-search` (rg), `emacs-list-buffers`.
2. Native Org-Mode: `org-search` (use 'tags' or 'rifle'), `org-mutate`, `org-capture`, `journal-add-entry`.
3. Native File System: `file-system` (mkdir/rm/mv/cp), `git-helper`, `sqlite-query`.
4. Escape Hatches (Use sparingly): `eval-shell-command`, `eval-emacs-lisp`, `org-babel-execute`.
### STANDARD OPERATING PROCEDURES (SOP)
- File Editing: ALWAYS call `system-read-file` before editing to get the exact strings. For `file-edit`, prefer 'search-replace' mode for multi-line changes. ALWAYS call `check-syntax` immediately after modifying source code.
- Bulk Edits: ALWAYS set `dry_run=true` on your first call to `bulk-search-replace` to verify your regex.
- Common Lisp (SLY): For risky code, use `lisp-eval-async` and wait for the signal. If you receive a 'DEBUGGER ENTERED' signal, read the trace and use `lisp-invoke-restart` to recover. Use `lisp-get-source` and `lisp-describe` to explore the live image.
- Autonomous Subagents: Use the `subagent` tool to fork complex, multi-step, or noisy tasks. Assign them strict `privilege` levels (ro, rw, rwx) and explicit `instructions`.
- Web: Use `http-request` for raw APIs. Use `web-render` for reading human-facing websites and SPAs.
### BEHAVIORAL DIRECTIVES
- ALWAYS execute tool calls immediately. Do not ask for permission unless explicit confirmation is required.
- Do not assume a tool has executed until you receive the actual system response.
- Do not apologize or act overly conversational. Be direct, precise, and execute."
:tools gptel-got)
End
(provide 'gptel-got)