;;; doty-tools.el --- Define Doty's tools for gptel -*- lexical-binding: t; -*- ;; Copyright (C) 2025 John Doty ;; Author: John Doty ;; Package-Version: 20250512.0000 ;; Package-Revision: ;; Package-Requires: ((gptel "20250512.0000")) ;; Keywords: convenience, tools ;; URL: ;; SPDX-License-Identifier: GPL-3.0-or-later ;; This program is free software; you can redistribute it and/or modify ;; it under the terms of the GNU General Public License as published by ;; the Free Software Foundation, either version 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 General Public License for more details. ;; You should have received a copy of the GNU General Public License ;; along with this program. If not, see . ;; This file is NOT part of GNU Emacs. ;;; Commentary: ;; This defines a set of tools that gptel can use to edit buffers and whatnot. ;; ;;; Code: (require 'gptel) (require 'treesit) ;; === Emacs tools (defun doty-tools--describe-function (func-name) "Return the help text for function FUNC-NAME." (save-window-excursion (describe-function (intern func-name)) (with-current-buffer "*Help*" (buffer-substring-no-properties (point-min) (point-max))))) (gptel-make-tool :name "emacs_describe_function" :function #'doty-tools--describe-function :description "Get help documentation for an Emacs function. Returns the complete help text including the function signature, description, and any additional information available." :args (list '(:name "func-name" :type string :description "The name of the Emacs function to get help for, e.g., 'find-file' or 'buffer-string'")) :category "emacs" :confirm nil :include t) (defun doty-tools--describe-variable (variable-name) "Return the help text for function VARIABLE-NAME." (save-window-excursion (describe-variable (intern variable-name)) (with-current-buffer "*Help*" (buffer-substring-no-properties (point-min) (point-max))))) (gptel-make-tool :name "emacs_describe_variable" :function #'doty-tools--describe-variable :description "Get documentation for an Emacs variable. Returns the complete help text for the specified variable." :args '((:name "variable-name" :type string :description "Name of the Emacs variable to get documentation for")) :category "emacs" :confirm nil :include t) (defun doty-tools--apropos (pattern) "Invoke the help apropos function for PATTERN and return the results as a string." (save-window-excursion (apropos pattern) (with-current-buffer "*Apropos*" (buffer-substring-no-properties (point-min) (point-max))))) (gptel-make-tool :name "emacs_help_apropos" :function #'doty-tools--describe-variable :description "Search for appropriate emacs function and variable documentation. Further information about functions and variables can be retrieved with the `emacs_describe_function` and `emacs_describe_variable` tools." :args '((:name "pattern" :type string :description "The pattern to search for. It can be a word, a list of words (separated by spaces), or a regexp (using some regular expression characters). If it is a word, search for matches for that word as a substring. If it is a list of words, search for matches for any two (or more) of those words.")) :category "emacs" :confirm nil :include t) ;; === File reading (defun doty-tools--buffer-or-file (buffer-or-file) "Return a buffer for BUFFER-OR-FILE for the well-behaved LLM tool. If it is a buffer object, just return it. If it names a file, visit the file. If there is exactly one buffer that matches that name, return that buffer. Otherwise return nil." (cond ((bufferp buffer-or-file) buffer-or-file) ((file-exists-p (expand-file-name buffer-or-file)) (find-file-noselect (expand-file-name buffer-or-file))) ((length= (match-buffers buffer-or-file) 1) (car (match-buffers buffer-or-file))) (t (error "file %s doesn't exist and does not name an open buffer" buffer-or-file)))) (defun doty-tools--open-file (filename &optional max-chars) "Visit FILENAME and return up to MAX-CHARS of its contents as a string. If MAX-CHARS is not provided then the entire buffer is returned." (with-current-buffer (doty-tools--buffer-or-file filename) (buffer-substring-no-properties (point-min) (min (point-max) (or max-chars 4096))))) (gptel-make-tool :name "emacs_open_file" :function #'doty-tools--open-file :description "Opens a file and reads content from a specified file path or displays directory information. This tool accepts relative file paths and returns different outputs based on the path type: - For files: Returns the complete file contents - For directories: Returns directory listings in Unix long format with permissions, link count, owner, group, size (bytes), modification date, and filename. First character in permissions indicates file type ('d'=directory, '-'=file). Example: ``` drwxr-x--- 2 john.doty ubuntu 4096 May 13 17:08 . -rw-r----- 1 john.doty ubuntu 6290 Jan 9 23:20 50-arc.el ``` " :args '((:name "filename" :type string :description "The path of the file to read.") (:name "max-chars" :type integer :optional t :description "The maximum number of characters to return. If this is not specified then at most 4096 characters are returned.")) :category "reading" :confirm nil :include t) (defun doty-tools--read-lines (buffer-or-file start-line &optional end-line include-line-numbers) "Get content from specified line range in BUFFER-OR-FILE. START-LINE is the beginning line number. Optional END-LINE is the ending line number. If nil, only START-LINE is returned. Optional INCLUDE-LINE-NUMBERS, if non-nil, adds line numbers to the output." (with-current-buffer (doty-tools--buffer-or-file buffer-or-file) (save-excursion (save-restriction (widen) (goto-char (point-min)) (forward-line (1- start-line)) (let ((end-line (or end-line start-line)) (result "")) (dotimes (i (- end-line start-line -1)) (let ((line-num (+ start-line i)) (line-content (buffer-substring-no-properties (line-beginning-position) (line-end-position)))) (setq result (concat result (if include-line-numbers (format "%d: %s\n" line-num line-content) (format "%s\n" line-content))))) (forward-line 1)) result))))) (gptel-make-tool :name "emacs_read_lines" :function #'doty-tools--read-lines :description "Get content from specified line range in a file." :args '((:name "buffer_or_file" :type string :description "Buffer name or file path") (:name "start_line" :type integer :description "Starting line number") (:name "end_line" :type integer :optional t :description "Ending line number (optional)") (:name "include_line_numbers" :type boolean :optional t :description "Whether to include line numbers in output (optional)")) :category "reading" :confirm nil :include t) (defun doty-tools--convert-regex (regex) "Convert the REGEX in standard syntax to Emacs regex syntax. Handles common differences like | vs \\|, () vs \\(\\), etc. Also converts special character classes like \\d to [[:digit:]]." (let ((case-fold-search nil) (i 0) (result "") (len (length regex)) (escaped nil)) (while (< i len) (let ((char (aref regex i))) (cond ;; Handle escaped characters and special character classes (escaped (setq escaped nil) (cond ;; Convert \d (digits) to [[:digit:]] ((= char ?d) (setq result (concat result "[[:digit:]]"))) ;; Convert \D (non-digits) to [^[:digit:]] ((= char ?D) (setq result (concat result "[^[:digit:]]"))) ;; Convert \w (word chars) to [[:alnum:]_] ((= char ?w) (setq result (concat result "[[:alnum:]_]"))) ;; Convert \W (non-word chars) to [^[:alnum:]_] ((= char ?W) (setq result (concat result "[^[:alnum:]_]"))) ;; Convert \s (whitespace) to [[:space:]] ((= char ?s) (setq result (concat result "[[:space:]]"))) ;; Convert \S (non-whitespace) to [^[:space:]] ((= char ?S) (setq result (concat result "[^[:space:]]"))) ;; Pass through other escaped characters (t (setq result (concat result "\\" (string char)))))) ;; Handle escape character ((= char ?\\) (setq escaped t)) ;; Convert | to \| ((= char ?|) (setq result (concat result "\\|"))) ;; Convert ( to \( and ) to \) ((= char ?\() (setq result (concat result "\\("))) ((= char ?\)) (setq result (concat result "\\)"))) ;; Convert { to \{ and } to \} ((= char ?{) (setq result (concat result "\\{"))) ((= char ?}) (setq result (concat result "\\}"))) ;; Pass other characters through (t (setq result (concat result (string char)))))) (setq i (1+ i))) result)) (defun doty-tools--search-text (buffer-or-file pattern context-lines max-matches use-regex) "Search for PATTERN in BUFFER-OR-FILE and return matches with context. CONTEXT-LINES is the number of lines before and after each match to include. MAX-MATCHES is the maximum number of matches to return. If USE-REGEX is non-nil, treat PATTERN as a regular expression, in standard syntax. It will be converted into Emacs syntax before being run." (with-current-buffer (doty-tools--buffer-or-file buffer-or-file) (save-excursion (save-restriction (widen) (goto-char (point-min)) (let ((count 0) (matches "") (search-pattern (if use-regex (doty-tools--convert-regex pattern) pattern)) (search-fn (if use-regex 're-search-forward 'search-forward))) (while (and (funcall search-fn search-pattern nil t) (< count max-matches)) (setq count (1+ count)) (let* ((match-line (line-number-at-pos)) (start-line (max 1 (- match-line context-lines))) (end-line (+ match-line context-lines)) (context (doty-tools--read-lines (current-buffer) start-line end-line t))) (setq matches (concat matches (format "Match %d (line %d):\n" count match-line) context "\n")))) matches))))) (gptel-make-tool :name "emacs_search_text" :function #'doty-tools--search-text :description "Find text matching a pattern and return with context. Returns formatted matches with line numbers and surrounding context." :args '((:name "buffer_or_file" :type string :description "Buffer name or file path") (:name "pattern" :type string :description "Text or regex to search for") (:name "context_lines" :type integer :description "Number of lines before/after to include") (:name "max_matches" :type integer :description "Maximum number of matches to return") (:name "use_regex" :type boolean :description "Whether to use regex matching")) :category "reading" :confirm nil :include t) (defun doty-tools--buffer-info (buffer-or-file) "Get metadata about BUFFER-OR-FILE. Returns file path, modified status, major mode, size, line count, and more." (with-current-buffer (doty-tools--buffer-or-file buffer-or-file) (let ((file-path (buffer-file-name)) (modified (buffer-modified-p)) (mode major-mode) (size (buffer-size)) (line-count (count-lines (point-min) (point-max))) (read-only buffer-read-only) (coding-system buffer-file-coding-system)) (format "File path: %s\nModified: %s\nMajor mode: %s\nSize: %d bytes\nLine count: %d\nRead-only: %s\nEncoding: %s%s" (or file-path "Buffer has no file") (if modified "Yes" "No") mode size line-count (if read-only "Yes" "No") (or coding-system "default") (if file-path (format "\nDirectory: %s" (file-name-directory file-path)) ""))))) (gptel-make-tool :name "emacs_buffer_info" :function #'doty-tools--buffer-info :description "Get metadata about a buffer or file including path, modified status, major mode, size, line count, read only status, and encoding." :args '((:name "buffer_or_file" :type string :description "Buffer name or file path")) :category "reading" :confirm nil :include t) ;; === Code Indexing (defvar doty-tools--treesit-queries nil "Tree-sitter queries that we've registered for various languages.") (defun doty-tools--register-treesit-mapper (lang query) "Register a mapper based on tree-sitter for LANG (a symbol) that uses QUERY." (let ((query (seq-concatenate 'list '((ERROR) @err (MISSING) @err) query))) (treesit-query-validate lang query) (setq doty-tools--treesit-queries (assq-delete-all lang doty-tools--treesit-queries)) (push (cons lang (treesit-query-compile lang query)) doty-tools--treesit-queries))) (doty-tools--register-treesit-mapper 'python `((module (expression_statement (assignment left: (identifier) @loc))) (class_definition name: (_) @loc superclasses: (_) @loc body: (block :anchor (expression_statement :anchor (string _ :*) @loc)) :?) (function_definition name: (_) @loc parameters: (_) @loc return_type: (_) :? @loc body: (block :anchor (expression_statement :anchor (string _ :*) @loc)) :?))) (doty-tools--register-treesit-mapper 'scala `((trait_definition name: (_) :? @loc extend: (_) :? @loc) (val_definition (modifiers (_)) :? @loc pattern: (_) @loc) (function_definition name: (_) :? @loc parameters: (_) :? @loc return_type: (_) :? @loc) (class_definition name: (_) :? @loc class_parameters: (_) :? @loc extend: (_) :? @loc))) (defun doty-tools--node-is-error (node) "Return t if NODE is some kind of error." (or (treesit-node-check node 'has-error) (treesit-node-check node 'missing))) (defun doty-tools--map-buffer (file-or-buffer) "Generate a map for FILE-OR-BUFFER." (with-current-buffer (doty-tools--buffer-or-file file-or-buffer) (let* ((registration (or (assoc (treesit-language-at (point-min)) doty-tools--treesit-queries) (error "Language '%s' not registered as a tree-sitter mapper" (treesit-language-at (point-min))))) (loc-queries (cdr registration)) (decls (treesit-query-capture (treesit-buffer-root-node) loc-queries nil nil t)) ;; Count errors. (error-nodes (seq-filter #'doty-tools--node-is-error decls)) (error-count (length error-nodes)) ;; Remove errors, don't care anymore. (decls (seq-filter (lambda (node) (not (doty-tools--node-is-error node))) decls)) (ranges (mapcar (lambda (node) (cons (treesit-node-start node) (treesit-node-end node))) decls)) ;; Sort the result (ranges (sort ranges :key #'car :in-place t))) (save-excursion (let* ((line-count (count-lines (point-min) (point-max))) (width (1+ (floor (log line-count 10)))) (line-format (format "%%%dd: %%s" width)) (result-lines nil) (line-number 1)) (if (> error-count 0) (push (format "[STATUS: ERRORS] File contained %d parse errors" error-count) result-lines) (push "[STATUS: SUCCESS] File parsed successfully" result-lines)) (push "" result-lines) (widen) (goto-char (point-min)) (while (and ranges (not (eobp))) (let ((line-start (line-beginning-position)) (line-end (line-end-position))) ;; Remove the head of the ranges while the head is ;; before the current line. (while (and ranges (< (cdar ranges) line-start)) (setq ranges (cdr ranges))) ;; When the range intersects this line, append this line. (when (and ranges (>= (cdar ranges) line-start) (<= (caar ranges) line-end)) (push (format line-format line-number (buffer-substring-no-properties line-start line-end)) result-lines)) (setq line-number (1+ line-number)) (forward-line 1))) (mapconcat 'identity (nreverse result-lines) "\n")))))) (gptel-make-tool :name "emacs_get_code_map" :function #'doty-tools--map-buffer :description "Returns structural outline of code files with declarations and their line numbers. Includes parse status. Cheaper than reading the entire file when supported. Supports python and scala code. Example: [STATUS: ERRORS] File contained 2 parse errors 10: LOG_ROOT = pathlib.Path.home() / \".local\" / \"share\" / \"goose\" / \"sessions\" 12: class MyClass(object): 15: def method(self) -> int: 19: def export_session(session: str): 80: SESSION = \"20250505_222637_59fcedc5\"" :args '((:name "buffer_or_file" :type string :description "Buffer name or file path")) :category "reading" :confirm nil :include t) ;; === Editing tools (defun doty-tools--insert-at-line (buffer-or-file line-number text &optional at-end) "Insert TEXT at LINE-NUMBER in BUFFER-OR-FILE. If AT-END is non-nil, insert at end of line, otherwise at beginning." (with-current-buffer (doty-tools--buffer-or-file buffer-or-file) (save-excursion (save-restriction (widen) (goto-char (point-min)) (forward-line (1- line-number)) (if at-end (end-of-line) (beginning-of-line)) (insert text) (format "Inserted text at %s of line %d in %s" (if at-end "end" "beginning") line-number (if (bufferp buffer-or-file) (buffer-name buffer-or-file) buffer-or-file)))))) (gptel-make-tool :name "emacs_insert_at_line" :function #'doty-tools--insert-at-line :description "Insert text at the beginning or end of specified line. Be sure to carefully consider the context of the insertion point when modifying files!" :args '((:name "buffer_or_file" :type string :description "Buffer name or file path") (:name "line_number" :type integer :description "Line to insert at") (:name "text" :type string :description "Text to insert") (:name "at_end" :type boolean :optional t :description "If true, insert at end of line; otherwise at beginning (optional)")) :category "editing" :confirm t :include t) (defun doty-tools--replace-text (buffer-or-file from-text to-text use-regex replace-all) "Replace occurrences of FROM-TEXT with TO-TEXT in BUFFER-OR-FILE. If USE-REGEX is non-nil, treat FROM-TEXT as a regular expression. If REPLACE-ALL is non-nil, replace all occurrences, otherwise just the first one." (with-current-buffer (doty-tools--buffer-or-file buffer-or-file) (save-excursion (save-restriction (widen) (goto-char (point-min)) (let ((count 0) (search-pattern (if use-regex (doty-tools--convert-regex from-text) from-text)) (search-fn (if use-regex 're-search-forward 'search-forward)) (case-fold-search nil)) (while (and (funcall search-fn search-pattern nil t) (or replace-all (= count 0))) (setq count (1+ count)) (replace-match to-text t nil)) (format "Replaced %d occurrence%s of %s with %s in %s" count (if (= count 1) "" "s") from-text to-text (if (bufferp buffer-or-file) (buffer-name buffer-or-file) buffer-or-file))))))) (gptel-make-tool :name "emacs_replace_text" :function #'doty-tools--replace-text :description "Replace occurrences of text in a buffer or file. Can use regex patterns and supports replacing single or all occurrences." :args '((:name "buffer_or_file" :type string :description "Buffer name or file path") (:name "from_text" :type string :description "Text to replace") (:name "to_text" :type string :description "Replacement text") (:name "use_regex" :type boolean :description "Whether from_text is a regex") (:name "replace_all" :type boolean :description "Replace all occurrences if true")) :category "editing" :confirm t :include t) (defun doty-tools--delete-lines (buffer-or-file start-line &optional end-line) "Delete lines from START-LINE to END-LINE in BUFFER-OR-FILE. If END-LINE is not provided, only delete START-LINE." (let ((buffer (doty-tools--buffer-or-file buffer-or-file)) (end (or end-line start-line))) (with-current-buffer buffer (save-excursion (goto-char (point-min)) (forward-line (1- start-line)) (let ((beg (point))) (forward-line (1+ (- end start-line))) (delete-region beg (point))))) (format "Deleted lines %d to %d in %s" start-line end (if (bufferp buffer-or-file) (buffer-name buffer-or-file) buffer-or-file)))) (gptel-make-tool :name "emacs_delete_lines" :function #'doty-tools--delete-lines :description "Delete specified line range." :args (list '(:name "buffer_or_file" :type string :description "Buffer name or file path") '(:name "start_line" :type integer :description "First line to delete") '(:name "end_line" :type integer :optional t :description "Last line to delete (optional - single line if omitted)")) :category "editing" :confirm t :include t) ;; === System tools (defun doty-tools--run-async-command (callback command) "Run COMMAND asynchronously and call CALLBACK with the results as a string." (let ((output-buffer (generate-new-buffer " *async-command-output*"))) (with-current-buffer output-buffer ;; Make buffer lightweight - disable undo, make read-only (buffer-disable-undo) (setq-local inhibit-modification-hooks t) (setq-local inhibit-read-only t)) ; Temporarily allow writing by the process (set-process-sentinel (start-process "gptel-async-command" output-buffer shell-file-name shell-command-switch command) (lambda (process event) (when (string-match "finished" event) (with-current-buffer output-buffer (funcall callback (buffer-string))) (kill-buffer output-buffer)))))) (gptel-make-tool :name "shell_command" :function #'doty-tools--run-async-command :description "Run a shell command asynchronously and return its output as a string. The command is executed in a subprocess and the standard output and error are captured." :args (list '(:name "command" :type string :description "The shell command to execute")) :async t :category "system" :confirm t ;; For security, prompt the user before running any shell command :include t) ;; Include the command output in the conversation (provide 'doty-tools) ;;; doty-tools.el ends here