diff options
| author | Craig Jennings <c@cjennings.net> | 2025-10-12 11:47:26 -0500 |
|---|---|---|
| committer | Craig Jennings <c@cjennings.net> | 2025-10-12 11:47:26 -0500 |
| commit | 092304d9e0ccc37cc0ddaa9b136457e56a1cac20 (patch) | |
| tree | ea81999b8442246c978b364dd90e8c752af50db5 /gptel-tools | |
changing repositories
Diffstat (limited to 'gptel-tools')
| -rw-r--r-- | gptel-tools/fetch_page.el | 399 | ||||
| -rw-r--r-- | gptel-tools/list_directory_files.el | 200 | ||||
| -rw-r--r-- | gptel-tools/move_to_trash.el | 142 | ||||
| -rw-r--r-- | gptel-tools/read_buffer.el | 27 | ||||
| -rw-r--r-- | gptel-tools/read_text_file.el | 144 | ||||
| -rw-r--r-- | gptel-tools/update_text_file.el | 149 | ||||
| -rw-r--r-- | gptel-tools/write_text_file.el | 94 |
7 files changed, 1155 insertions, 0 deletions
diff --git a/gptel-tools/fetch_page.el b/gptel-tools/fetch_page.el new file mode 100644 index 00000000..deaae1f9 --- /dev/null +++ b/gptel-tools/fetch_page.el @@ -0,0 +1,399 @@ +;;; fetch_page.el --- Web fetch tool for gptel (HTML/text/links) -*- lexical-binding: t; -*- + +;; Copyright (C) 2025 cjennings +;; Author: cjennings and AI assistant +;; Keywords: convenience, tools, web +;; Package-Requires: ((emacs "27.1")) +;; SPDX-License-Identifier: GPL-3.0-or-later + +;;; Commentary: +;; +;; fetch_page: In-memory web fetch-and-parse tool for gptel tools. +;; +;; Features +;; - Fetch a URL (http/https), follow redirects. +;; - Return one of: rendered text (DOM+shr), raw HTML, or extracted links. +;; - Size/time guards: max_bytes, max_chars, timeout. +;; - User-Agent presets; Accept/Accept-Language overrides. +;; - Predictable, LLM-friendly outputs and explicit error suggestions. +;; +;; Notes +;; - This tool uses url.el (asynchronous url-retrieve) and libxml/shr for HTML. +;; - For non-text content (e.g., PDFs), it returns an explicit error and suggests +;; using a download tool (e.g., download_url) instead. +;; +;; Installation +;; - Load after gptel and registers a gptel tool named "fetch_page" (category "web"). +;; +;;; Code: + +(require 'url) +(require 'subr-x) +(eval-when-compile (require 'cl-lib)) +(require 'dom) +(require 'shr) + +;; Internal helpers are prefixed gptel-fetch-page-- + +(defconst gptel-fetch-page--default-max-bytes (* 16 1024 1024)) +(defconst gptel-fetch-page--max-allowed-bytes (* 32 1024 1024)) +(defconst gptel-fetch-page--default-max-chars 300000) +(defconst gptel-fetch-page--default-timeout 30) + +(defconst gptel-fetch-page--ua-presets + '(("desktop-chrome" . "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36") + ("desktop-firefox" . "Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:121.0) Gecko/20100101 Firefox/121.0") + ("desktop-safari" . "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.0 Safari/605.1.15") + ("edge-windows" . "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36 Edg/120.0.0.0") + ("mobile-iphone" . "Mozilla/5.0 (iPhone; CPU iPhone OS 17_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.0 Mobile/15E148 Safari/604.1") + ("mobile-android" . "Mozilla/5.0 (Linux; Android 13; Pixel 6) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Mobile Safari/537.36") + ("emacs-default" . "Emacs gptel/fetch_page (+https://github.com/karthink/gptel)") ) + "Preset User-Agent strings.") + +(defun gptel-fetch-page--coerce-bool (v) + (cond + ((eq v :json-false) nil) + ((stringp v) (not (string-blank-p v))) + (t (and v t)))) + +(defun gptel-fetch-page--coerce-int (v &optional default min max) + (let ((n (cond + ((numberp v) v) + ((stringp v) (string-to-number v)) + (t nil)))) + (setq n (or n default)) + (when (and min n (< n min)) (setq n min)) + (when (and max n (> n max)) (setq n max)) + n)) + +(defun gptel-fetch-page--ua (ua) + "Return a User-Agent string from UA preset or use UA directly." + (cond + ((null ua) (cdr (assoc "desktop-chrome" gptel-fetch-page--ua-presets))) + ((stringp ua) + (or (cdr (assoc ua gptel-fetch-page--ua-presets)) ua)) + (t (cdr (assoc "desktop-chrome" gptel-fetch-page--ua-presets))))) + +(defun gptel-fetch-page--ok-scheme-p (url) + (let ((u (ignore-errors (url-generic-parse-url url)))) + (and u (member (url-type u) '("http" "https"))))) + +(defun gptel-fetch-page--parse-headers () + "Parse headers in current buffer up to `url-http-end-of-headers'." + (let (hdrs) + (save-excursion + (goto-char (point-min)) + (while (re-search-forward "^\([^:]+\):[ \t]*\(.*\)$" url-http-end-of-headers t) + (push (cons (downcase (match-string 1)) (string-trim (match-string 2))) hdrs))) + (nreverse hdrs))) + +(defun gptel-fetch-page--content-type (headers) + "Return (mime charset) from HEADERS alist." + (let* ((ct (cdr (assoc "content-type" headers))) + (mime (and ct (car (split-string ct ";" t "[ \t]*")))) + (charset (and ct (when (string-match "charset=\\([^;]+\\)" ct) + (downcase (match-string 1 ct)))))) + (list (or mime "unknown") charset))) + +(defun gptel-fetch-page--header-bytes (headers) + (let ((cl (cdr (assoc "content-length" headers)))) + (and cl (ignore-errors (string-to-number cl))))) + +(defun gptel-fetch-page--region-string (beg end) + (save-excursion + (goto-char beg) + (buffer-substring-no-properties beg end))) + +(defun gptel-fetch-page--html-to-dom (html) + (with-temp-buffer + (insert html) + (condition-case err + (if (fboundp 'libxml-parse-html-region) + (libxml-parse-html-region (point-min) (point-max)) + (signal 'error '(libxml-missing))) + (error (signal (car err) (cdr err)))))) + +(defun gptel-fetch-page--dom-to-text (dom) + (with-temp-buffer + ;; Use shr to render + (let ((shr-use-fonts nil) + (shr-width (min 120 (window-body-width)))) + (shr-insert-document dom)) + (buffer-string))) + +(defun gptel-fetch-page--dom-base (dom final-url) + (or (when-let* ((node (car (dom-by-tag dom 'base))) + (href (dom-attr node 'href))) href) + final-url)) + +(defun gptel-fetch-page--resolve (href base) + (condition-case _ + (if (and href (not (string-empty-p href))) + (url-expand-file-name href base) + nil) + (error nil))) + +(defun gptel-fetch-page--collect-links (dom final-url include-titles) + (let* ((base (gptel-fetch-page--dom-base dom final-url)) + (seen (make-hash-table :test 'equal)) + (out '())) + (cl-labels ((push-link (u ttitle) + (when (and u (not (gethash u seen))) + (puthash u t seen) + (push (if include-titles + (format "%s\t%s" u (or (and ttitle (string-trim ttitle)) "")) + u) + out)))) + ;; <a> and <area> + (dolist (tag '(a area)) + (dolist (n (dom-by-tag dom tag)) + (let ((href (dom-attr n 'href))) + (push-link (gptel-fetch-page--resolve href base) + (ignore-errors (string-trim (dom-texts n))))))) + ;; <link rel="stylesheet"> included by request + (dolist (n (dom-by-tag dom 'link)) + (let* ((rel (downcase (or (dom-attr n 'rel) ""))) + (is-style (or (string-match-p "stylesheet" rel) + (string-match-p "\bstyle\b" rel))) + (href (dom-attr n 'href))) + (when is-style + (push-link (gptel-fetch-page--resolve href base) + (or (dom-attr n 'title) rel)))))) + (mapconcat #'identity (nreverse out) "\n"))) + +(defun gptel-fetch-page--truncate (s max-chars) + (if (and max-chars (> (length s) max-chars)) + (concat (substring s 0 max-chars) "\nnote: output truncated at " (number-to-string max-chars) " chars") + s)) + +(defun gptel-fetch-page--format-error (&rest kv) + (let ((summary (plist-get kv :summary)) + (status (or (plist-get kv :status) "unknown")) + (url (or (plist-get kv :url) "unknown")) + (type (or (plist-get kv :type) "unknown")) + (limit (plist-get kv :limit)) + (suggest (plist-get kv :suggest))) + (string-join + (delq nil + (list (format "ERROR: %s" summary) + (format "details: status=%s url=%s type=%s" status url type) + (and limit (format "limit: %s" limit)) + (and suggest (format "suggest: %s" suggest)))) + "\n"))) + +(defun gptel-fetch-page--format-header (status final-url type size) + (format "[%s] final-url: %s | type: %s | size: %s B" + status final-url type (or size "unknown"))) + +(defun gptel-fetch-page--run (callback url format timeout max-bytes max-chars include-titles + user-agent accept accept-language debug) + (let* ((format (or format "text")) + (timeout (gptel-fetch-page--coerce-int timeout gptel-fetch-page--default-timeout 1 nil)) + (max-bytes (gptel-fetch-page--coerce-int max-bytes gptel-fetch-page--default-max-bytes 1 gptel-fetch-page--max-allowed-bytes)) + (max-chars (gptel-fetch-page--coerce-int max-chars gptel-fetch-page--default-max-chars 1000 nil)) + (include-titles (gptel-fetch-page--coerce-bool include-titles)) + (debug (gptel-fetch-page--coerce-bool debug)) + (ua (gptel-fetch-page--ua user-agent)) + (accept (or accept "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8")) + (accept-language (or accept-language "en-US,en;q=0.9"))) + (unless (gptel-fetch-page--ok-scheme-p url) + (funcall callback (gptel-fetch-page--format-error + :summary "Only http/https URLs are supported" + :url url + :suggest "use an http/https URL") ) + (cl-return-from gptel-fetch-page--run)) + + (let* ((url-request-method "GET") + (url-request-extra-headers + `(("User-Agent" . ,ua) + ("Accept" . ,accept) + ("Accept-Language" . ,accept-language) + ;; Byte range to hint the server to cap size + ,@(and max-bytes (list (cons "Range" (format "bytes=0-%d" (1- max-bytes))))))) + (done nil) + (timer nil) + (orig-url url)) + (setq timer (run-at-time timeout nil + (lambda () + (unless done + (setq done t) + (funcall callback (gptel-fetch-page--format-error + :summary (format "Request timed out after %ss" timeout) + :url orig-url + :suggest "retry with a larger timeout or probe connectivity")))) ) + (url-retrieve + url + (lambda (status) + (let ((inhibit-read-only t)) + (unwind-protect + (progn + (when (and (not done)) (setq done t) (when timer (cancel-timer timer))) + (let ((bail nil)) + (when (plist-get status :error) + (let ((err (plist-get status :error))) + (funcall callback (gptel-fetch-page--format-error + :summary (error-message-string err) + :url orig-url + :suggest "check URL or try again"))) + (setq bail t)) + ;; Parse status line & headers + (goto-char (point-min)) + (let* ((status-line (buffer-substring (line-beginning-position) (line-end-position))) + (http-status (or (and (boundp 'url-http-response-status) url-http-response-status) + (and (string-match "HTTP/[0-9.]+ +\([0-9]+\)" status-line) + (string-to-number (match-string 1 status-line))))) + (status-msg (or (and (boundp 'url-http-response-status) url-http-response-status) + http-status)) + (_ (search-forward "\n\n" nil t)) + (headers (gptel-fetch-page--parse-headers)) + (final-url (or (and (boundp 'url-current-object) + (ignore-errors (url-recreate-url url-current-object))) orig-url)) + (ctype (gptel-fetch-page--content-type headers)) + (mime (car ctype)) + (_charset (cadr ctype)) + (declared-bytes (gptel-fetch-page--header-bytes headers)) + (body (gptel-fetch-page--region-string url-http-end-of-headers (point-max))) + (actual-bytes (string-bytes body))) + ;; Enforce size limits + (when (and (not bail) declared-bytes max-bytes (> declared-bytes max-bytes)) + (funcall callback (gptel-fetch-page--format-error + :summary (format "Response exceeds max_bytes (%d bytes) for in-memory fetch" max-bytes) + :status http-status + :url final-url + :type mime + :limit (format "max_bytes=%d max_chars=%d timeout=%ss" max-bytes max-chars timeout) + :suggest (format "call download_url to save the file, or retry fetch_page with a larger max_bytes (up to %d)" gptel-fetch-page--max-allowed-bytes))) + (setq bail t)) + (when (and (not bail) max-bytes (> actual-bytes max-bytes)) + (funcall callback (gptel-fetch-page--format-error + :summary (format "Response exceeds max_bytes (%d bytes) during retrieval" max-bytes) + :status http-status + :url final-url + :type mime + :limit (format "max_bytes=%d max_chars=%d timeout=%ss" max-bytes max-chars timeout) + :suggest (format "call download_url to save the file, or retry fetch_page with a larger max_bytes (up to %d)" gptel-fetch-page--max-allowed-bytes))) + (setq bail t)) + + (unless bail + ;; Route based on content-type & requested format + (pcase mime + ((or "text/html" "application/xhtml+xml") + (pcase format + ("html" + (let* ((content (gptel-fetch-page--truncate body max-chars)) + (out (if debug + (concat (gptel-fetch-page--format-header status-msg final-url mime actual-bytes) + "\n\n" content) + content))) + (funcall callback out))) + ("text" + (if (fboundp 'libxml-parse-html-region) + (condition-case err + (let* ((dom (gptel-fetch-page--html-to-dom body)) + (txt (gptel-fetch-page--truncate (gptel-fetch-page--dom-to-text dom) max-chars)) + (out (if debug + (concat (gptel-fetch-page--format-header status-msg final-url mime actual-bytes) + "\n\n" txt) + txt))) + (funcall callback out)) + (error + (funcall callback (gptel-fetch-page--format-error + :summary "HTML parsing failed" + :status http-status + :url final-url + :type mime + :suggest "return html format (raw), use download_url, or install Emacs with libxml2"))) + (funcall callback (gptel-fetch-page--format-error + :summary "HTML parsing requires Emacs built with libxml2" + :status http-status + :url final-url + :type mime + :suggest "return html format (raw), use download_url, or install Emacs with libxml2")))) + ("links" + (if (fboundp 'libxml-parse-html-region) + (condition-case err + (let* ((dom (gptel-fetch-page--html-to-dom body)) + (links (gptel-fetch-page--collect-links dom final-url include-titles)) + (content (gptel-fetch-page--truncate links max-chars)) + (out (if debug + (concat (gptel-fetch-page--format-header status-msg final-url mime actual-bytes) + "\n\n" content) + content))) + (funcall callback out)) + (error + (funcall callback (gptel-fetch-page--format-error + :summary "HTML parsing failed" + :status http-status + :url final-url + :type mime + :suggest "return html format (raw), use download_url, or install Emacs with libxml2"))) + (funcall callback (gptel-fetch-page--format-error + :summary "HTML parsing requires Emacs built with libxml2" + :status http-status + :url final-url + :type mime + :suggest "return html format (raw), use download_url, or install Emacs with libxml2")))))) + ("text/plain" + (let* ((content (gptel-fetch-page--truncate body max-chars)) + (out (if debug + (concat (gptel-fetch-page--format-header status-msg final-url mime actual-bytes) + "\n\n" content) + content))) + (funcall callback out))) + (_ + (funcall callback (gptel-fetch-page--format-error + :summary (format "Non-text content not supported for parsing (%s)" mime) + :status http-status + :url final-url + :type mime + :suggest "use download_url to save the file locally, then parse with a file tool"))))))) + (kill-buffer (current-buffer))))) + nil t nil)))))))) + +;; Public entry for the gptel tool function. +(defun gptel-fetch-page--tool (callback url &optional format timeout max_bytes max_chars include_titles user_agent accept accept_language debug) + (gptel-fetch-page--run callback url format timeout max_bytes max_chars include_titles user_agent accept accept_language debug)) + +(with-eval-after-load 'gptel + (gptel-make-tool + :name "fetch_page" + :function #'gptel-fetch-page--tool + :description (concat + "Fetch a web page and return rendered text, raw HTML, or links.\n" + "- format=text|html|links (default text)\n" + "- Guards: timeout (30s), max_bytes (default 16MiB, up to 32MiB), max_chars (300k)\n" + "- Includes stylesheets in links mode; relative URLs resolved with <base>/final URL\n" + "- Non-text types error with explicit suggestion to use a downloader.") + :category "web" + :async t + :include t + :args (list + '(:name "url" :type string + :description "The http/https URL to fetch") + '(:name "format" :type string + :enum ["text" "html" "links"] + :optional t + :description "Output format: text (rendered), html (raw), or links (absolute URLs)") + '(:name "timeout" :type integer :optional t + :description "Timeout in seconds (default 30)") + '(:name "max_bytes" :type integer :optional t + :description "Max response bytes (default 16MiB, up to 32MiB)") + '(:name "max_chars" :type integer :optional t + :description "Max output chars (default 300k)") + '(:name "include_titles" :type boolean :optional t + :description "In links mode, include titles as a tab-separated second column") + '(:name "user_agent" :type string :optional t + :description "User-Agent preset or full string. Presets: desktop-chrome, desktop-firefox, desktop-safari, edge-windows, mobile-iphone, mobile-android, emacs-default") + '(:name "accept" :type string :optional t + :description "Accept header override") + '(:name "accept_language" :type string :optional t + :description "Accept-Language header override (e.g., en-US,en;q=0.9)") + '(:name "debug" :type boolean :optional t + :description "If true, prepend a compact metadata header")))) + +;; Automatically add to gptel-tools on load +(add-to-list 'gptel-tools (gptel-get-tool '("web" "fetch_page"))) + + +(provide 'fetch_page) +;;; fetch_page.el ends here diff --git a/gptel-tools/list_directory_files.el b/gptel-tools/list_directory_files.el new file mode 100644 index 00000000..8da9ba28 --- /dev/null +++ b/gptel-tools/list_directory_files.el @@ -0,0 +1,200 @@ +;;; list_directory_files.el --- List directory files for GPTel -*- coding: utf-8; lexical-binding: t; -*- + +;; Copyright (C) 2025 + +;; Author: gptel-tool-writer +;; Keywords: convenience, tools +;; Version: 2.0.0 + +;; This file is not part of GNU Emacs. + +;; 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. + +;;; Commentary: + +;; This tool provides a comprehensive directory listing function for use within gptel. +;; It lists files and directories with detailed attributes such as size, last modification time, +;; permissions, and executable status, supporting optional recursive traversal and filtering +;; by file extension. +;; +;; Features: +;; - Lists files with Unix-style permissions, size, and modification date +;; - Optional recursive directory traversal +;; - Filter files by extension +;; - Graceful error handling and reporting +;; - Human-readable file sizes and dates + +;;; Code: + +(require 'cl-lib) +(require 'seq) +(require 'subr-x) + +;;; Helper Functions + +(defun list-directory-files--mode-to-permissions (mode) + "Convert numeric MODE to symbolic Unix style permissions string." + (concat + (if (eq (logand #o40000 mode) #o40000) "d" "-") + (mapconcat + (lambda (bit) + (cond ((eq bit ?r) (if (> (logand mode #o400) 0) "r" "-")) + ((eq bit ?w) (if (> (logand mode #o200) 0) "w" "-")) + ((eq bit ?x) (if (> (logand mode #o100) 0) "x" "-")))) + '(?r ?w ?x) "") + (mapconcat + (lambda (bit) + (cond ((eq bit ?r) (if (> (logand mode #o040) 0) "r" "-")) + ((eq bit ?w) (if (> (logand mode #o020) 0) "w" "-")) + ((eq bit ?x) (if (> (logand mode #o010) 0) "x" "-")))) + '(?r ?w ?x) "") + (mapconcat + (lambda (bit) + (cond ((eq bit ?r) (if (> (logand mode #o004) 0) "r" "-")) + ((eq bit ?w) (if (> (logand mode #o002) 0) "w" "-")) + ((eq bit ?x) (if (> (logand mode #o001) 0) "x" "-")))) + '(?r ?w ?x) ""))) + +(defun list-directory-files--get-file-info (filepath) + "Get file information for FILEPATH as a plist." + (condition-case err + (let* ((attrs (file-attributes filepath 'string)) + (size (file-attribute-size attrs)) + (last-mod (file-attribute-modification-time attrs)) + (dirp (eq t (file-attribute-type attrs))) + (mode (file-modes filepath)) + (perm (list-directory-files--mode-to-permissions mode)) + (execp (file-executable-p filepath))) + (list :success t + :path filepath + :size size + :last-modified last-mod + :is-directory dirp + :permissions perm + :executable execp)) + (error + (list :success nil + :path filepath + :error (error-message-string err))))) + +(defun list-directory-files--filter-by-extension (extension) + "Create a filter function for files with EXTENSION." + (when extension + (lambda (file-info) + (or (plist-get file-info :is-directory) ; Always include directories + (and (plist-get file-info :success) + (string-suffix-p (concat "." extension) + (file-name-nondirectory (plist-get file-info :path)) + t)))))) + +(defun list-directory-files--format-file-entry (file-info base-path) + "Format a single FILE-INFO entry relative to BASE-PATH." + (format " %s%s %10s %s %s" + (plist-get file-info :permissions) + (if (plist-get file-info :executable) "*" " ") + (file-size-human-readable (or (plist-get file-info :size) 0)) + (format-time-string "%Y-%m-%d %H:%M" (plist-get file-info :last-modified)) + (file-relative-name (plist-get file-info :path) base-path))) + +;;; Core Implementation + +(defun list-directory-files--list-directory (path &optional recursive filter max-depth current-depth) + "List files in PATH directory. +RECURSIVE enables subdirectory traversal. +FILTER is a predicate function for filtering files. +MAX-DEPTH limits recursion depth (nil for unlimited). +CURRENT-DEPTH tracks the current recursion level." + (let ((files '()) + (errors '()) + (current-depth (or current-depth 0)) + (expanded-path (expand-file-name (or path ".") "~"))) + + (if (not (file-directory-p expanded-path)) + ;; Return error if not a directory + (list :files nil + :errors (list (format "Not a directory: %s" expanded-path))) + ;; Process directory + (condition-case err + (dolist (entry (directory-files expanded-path t "^\\([^.]\\|\\.[^.]\\|\\.\\..\\)")) + (let ((info (list-directory-files--get-file-info entry))) + (if (plist-get info :success) + (progn + ;; Add file if it passes the filter + (when (or (not filter) (funcall filter info)) + (push info files)) + ;; Recurse into directories if needed + (when (and recursive + (plist-get info :is-directory) + (or (not max-depth) (< current-depth max-depth))) + (let ((subdir-result (list-directory-files--list-directory + entry recursive filter max-depth (1+ current-depth)))) + (setq files (nconc files (plist-get subdir-result :files))) + (setq errors (nconc errors (plist-get subdir-result :errors)))))) + ;; Handle file access error + (push (format "%s: %s" (plist-get info :path) (plist-get info :error)) errors)))) + (error + (push (format "Error accessing directory %s: %s" expanded-path (error-message-string err)) errors))) + + (list :files (nreverse files) :errors (nreverse errors))))) + +(defun list-directory-files--format-output (path result) + "Format the directory listing RESULT for PATH as a string." + (let ((files (plist-get result :files)) + (errors (plist-get result :errors)) + (base-path (expand-file-name "~"))) + (concat + (when files + (format "Found %d file%s in %s:\n%s" + (length files) + (if (= (length files) 1) "" "s") + path + (mapconcat (lambda (f) (list-directory-files--format-file-entry f base-path)) + files "\n"))) + (when (and files errors) "\n\n") + (when errors + (format "Errors encountered:\n%s" + (mapconcat (lambda (e) (format " - %s" e)) errors "\n"))) + ;; Handle case where there are no files and no errors + (when (and (not files) (not errors)) + (format "No files found in %s" path))))) + +;;; Tool Registration + +(gptel-make-tool + :name "list_directory_files" + :function (lambda (path &optional recursive filter-extension) + "List files in directory PATH. +RECURSIVE enables subdirectory listing. +FILTER-EXTENSION limits results to files with the specified extension." + (let* ((filter (list-directory-files--filter-by-extension filter-extension)) + (result (list-directory-files--list-directory path recursive filter))) + (list-directory-files--format-output (or path ".") result))) + :description "List files in a directory with detailed attributes. Returns formatted listing with permissions, size, modification time." + :args (list '(:name "path" + :type string + :description "Directory path to list (relative to home directory)") + '(:name "recursive" + :type boolean + :description "Recursively list subdirectories" + :optional t) + '(:name "filter-extension" + :type string + :description "Only include files with this extension" + :optional t)) + :category "filesystem" + :confirm nil + :include t) + +;; Automatically add to gptel-tools on load +(add-to-list 'gptel-tools (gptel-get-tool '("filesystem" "list_directory_files"))) + +(provide 'list_directory_files) +;;; list_directory_files.el ends here
\ No newline at end of file diff --git a/gptel-tools/move_to_trash.el b/gptel-tools/move_to_trash.el new file mode 100644 index 00000000..6ea97995 --- /dev/null +++ b/gptel-tools/move_to_trash.el @@ -0,0 +1,142 @@ +;;; move_to_trash.el --- Move files/directories to trash for gptel -*- lexical-binding: t; -*- + +;; Copyright (C) 2025 + +;; Author: gptel-tool-writer +;; Keywords: convenience, tools, files + +;; This file is not part of GNU Emacs. + +;; 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. + +;;; Commentary: + +;; This file provides a gptel tool for moving files and directories to the trash. +;; Files are moved to ~/.local/share/Trash/files with automatic timestamping for +;; name conflicts. The tool operates only within the home directory and /tmp +;; for security reasons. + +;;; Code: + +(require 'gptel) +(require 'subr-x) + +(defun gptel--move-to-trash-generate-unique-name (original-name trash-dir) + "Generate a unique name for ORIGINAL-NAME in TRASH-DIR. +If a file with the same name exists, append a timestamp in the format +YYYY-MM-DD-HH-MM-SS." + (let* ((base-name (file-name-nondirectory original-name)) + (target-path (expand-file-name base-name trash-dir))) + (if (not (file-exists-p target-path)) + target-path + ;; Name conflict: add timestamp + (let* ((extension (file-name-extension base-name t)) + (name-sans-ext (file-name-sans-extension base-name)) + (timestamp (format-time-string "%Y-%m-%d-%H-%M-%S")) + (new-name (if extension + (concat name-sans-ext "-" timestamp extension) + (concat base-name "-" timestamp)))) + (expand-file-name new-name trash-dir))))) + +(defun gptel--move-to-trash-validate-path (path) + "Validate that PATH is safe to trash. +Returns the expanded path if valid, signals an error otherwise. +Ensures path is within home directory or /tmp, and prevents +trashing of critical system directories." + (let ((expanded-path (expand-file-name path)) + (home-dir (expand-file-name "~")) + (critical-dirs (list (expand-file-name "~") + (expand-file-name "~/.emacs.d") + (expand-file-name "~/.config") + "/tmp"))) + ;; Security check: must be within allowed directories + (unless (or (string-prefix-p home-dir expanded-path) + (string-prefix-p "/tmp" expanded-path)) + (error "Path must be within home directory or /tmp: %s" path)) + + ;; Prevent trashing critical directories + (when (member expanded-path critical-dirs) + (error "Cannot trash critical directory: %s" path)) + + ;; Existence check + (unless (file-exists-p expanded-path) + (error "File or directory does not exist: %s" path)) + + expanded-path)) + +(defun gptel--move-to-trash-perform (expanded-path trash-dir) + "Move EXPANDED-PATH to TRASH-DIR with unique naming. +Returns a formatted message describing the operation." + (let* ((is-directory (file-directory-p expanded-path)) + (is-symlink (file-symlink-p expanded-path)) + (trash-path (gptel--move-to-trash-generate-unique-name + expanded-path trash-dir)) + (item-type (cond + (is-symlink "Symlink") + (is-directory "Directory") + (t "File")))) + + ;; Perform the move + (condition-case move-err + (progn + (rename-file expanded-path trash-path) + + ;; Verify success + (cond + ((file-exists-p expanded-path) + (error "Failed to move %s to trash - file still exists at original location" + expanded-path)) + ((not (file-exists-p trash-path)) + (error "Move operation failed - file not found in trash")) + (t + (format "%s moved to trash: %s → %s" + item-type + (abbreviate-file-name expanded-path) + (file-name-nondirectory trash-path))))) + (permission-denied + (error "Permission denied: cannot move %s to trash" expanded-path)) + (error + (error "Failed to move %s to trash: %s" + expanded-path (error-message-string move-err)))))) + +;; Main tool definition +(with-eval-after-load 'gptel + (gptel-make-tool + :name "move_to_trash" + :function (lambda (path) + "Move PATH to the trash directory. +Creates the trash directory if needed, handles naming conflicts, +and provides detailed error messages." + (condition-case err + (let* ((trash-dir (expand-file-name "~/.local/share/Trash/files")) + (expanded-path (gptel--move-to-trash-validate-path path))) + + ;; Ensure trash directory exists + (unless (file-exists-p trash-dir) + (make-directory trash-dir t)) + + ;; Move and return status message + (gptel--move-to-trash-perform expanded-path trash-dir)) + (error + (error "Tool error: %s" (error-message-string err))))) + :description "Move a file or directory to the trash (~/.local/share/Trash/files). Works recursively for directories. Handles name conflicts with timestamps. Operates only within home directory and /tmp. Does not follow symlinks. Synonyms: delete, remove, trash file/directory." + :args (list '(:name "path" + :type string + :description "Path to the file or directory to move to trash. Must be within home directory or /tmp.")) + :category "filesystem" + :confirm nil ; No confirmation needed + :include t)) + +;; Automatically add to gptel-tools on load +(add-to-list 'gptel-tools (gptel-get-tool '("filesystem" "move_to_trash"))) + +(provide 'move_to_trash) +;;; move_to_trash.el ends here diff --git a/gptel-tools/read_buffer.el b/gptel-tools/read_buffer.el new file mode 100644 index 00000000..d01cee71 --- /dev/null +++ b/gptel-tools/read_buffer.el @@ -0,0 +1,27 @@ +;;; read_buffer.el --- Read buffer tool for GPTel -*- coding: utf-8; lexical-binding: t; -*- + +;;; Commentary: +;; + +;;; Code: + +(require 'gptel) + +(gptel-make-tool + :name "read_buffer" + :function (lambda (buffer) + (unless (buffer-live-p (get-buffer buffer)) + (error "Error: buffer %s is not live" buffer)) + (with-current-buffer buffer + (buffer-substring-no-properties (point-min) (point-max)))) + :description "return the contents of an emacs buffer" + :args (list '(:name "buffer" + :type string ; :type value must be a symbol + :description "the name of the buffer whose contents are to be retrieved")) + :category "emacs") + +;; Automatically add to gptel-tools on load +(add-to-list 'gptel-tools (gptel-get-tool '("emacs" "read_buffer"))) + +(provide 'read_buffer) +;;; read_buffer.el ends here. diff --git a/gptel-tools/read_text_file.el b/gptel-tools/read_text_file.el new file mode 100644 index 00000000..8e0433a9 --- /dev/null +++ b/gptel-tools/read_text_file.el @@ -0,0 +1,144 @@ +;;; read_text_file.el --- Read text files for GPTel -*- coding: utf-8; lexical-binding: t; -*- + +;; Copyright (C) 2025 + +;; Author: gptel-tool-writer +;; Keywords: convenience, tools +;; Package-Requires: ((emacs "27.1") (gptel "0.9.0")) + +;; This file is not part of GNU Emacs. + +;; 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. + +;;; Commentary: + +;;; Code: + +;; Helper functions for read_text_file tool +(defun cj/validate-file-path (path) + "Validate PATH is within home directory and exists." + (let ((full-path (expand-file-name path "~"))) + (unless (string-prefix-p (expand-file-name "~") full-path) + (error "Path must be within home directory")) + (unless (file-exists-p full-path) + (error "File not found: %s" full-path)) + (when (file-directory-p full-path) + (error "Path is a directory, not a file: %s" full-path)) + (unless (file-readable-p full-path) + (error "No read permission for file: %s" full-path)) + ;; Follow symlinks + (if (file-symlink-p full-path) + (file-truename full-path) + full-path))) + +(defun cj/get-file-metadata (path) + "Return formatted metadata string for file at PATH." + (let* ((attributes (file-attributes path)) + (size (file-attribute-size attributes)) + (modes (file-attribute-modes attributes)) + (modtime (format-time-string "%Y-%m-%d" + (file-attribute-modification-time attributes)))) + (list :size size + :string (format "File: %s (%s, %s, modified %s)" + path modes + (file-size-human-readable size) + modtime)))) + +(defun cj/check-file-size-limits (size no-confirm) + "Check file SIZE against limits, prompting user unless NO-CONFIRM." + (let ((size-warning-limit (* 10 1024 1024)) ; 10MB + (size-hard-limit (* 100 1024 1024))) ; 100MB + (when (> size size-hard-limit) + (error "File too large (%s): exceeds 100MB limit" + (file-size-human-readable size))) + (when (and (> size size-warning-limit) + (not no-confirm)) + (unless (y-or-n-p (format "File is large (%s). Continue? " + (file-size-human-readable size))) + (error "File read cancelled: size exceeds 10MB"))))) + +(defun cj/detect-binary-file (path) + "Check if file at PATH appears to be binary." + (with-temp-buffer + (insert-file-contents path nil 0 1024) + (goto-char (point-min)) + (search-forward "\0" nil t))) + +(defun cj/handle-special-file-types (path no-confirm) + "Handle PDF, EPUB, and other binary files at PATH." + (cond + ((string-match-p "\\.pdf\\'" path) + (when (and (not no-confirm) + (not (y-or-n-p "This is a PDF file. Extract text for LLM (y) or cancel (n)? "))) + (error "PDF file read cancelled")) + ;; Extract text from PDF + (let ((text (shell-command-to-string + (format "pdftotext '%s' -" path)))) + (if (string-empty-p text) + (error "Could not extract text from PDF: %s" path) + text))) + ((string-match-p "\\.epub\\'" path) + (when (and (not no-confirm) + (not (y-or-n-p "This is an EPUB file. Extract text for LLM (y) or cancel (n)? "))) + (error "EPUB file read cancelled")) + (error "EPUB text extraction not yet implemented")) + (t + (when (and (not no-confirm) + (not (y-or-n-p "This appears to be a binary file. Read anyway? "))) + (error "Binary file read cancelled")) + nil))) ; Return nil to indicate normal read + +;; Main tool function using the helpers +(gptel-make-tool + :name "read_text_file" + :function (lambda (path &optional no-confirm) + (let* ((full-path (cj/validate-file-path path)) + (metadata (cj/get-file-metadata full-path)) + (size (plist-get metadata :size)) + (metadata-string (plist-get metadata :string))) + ;; Show metadata and confirm + (unless no-confirm + (unless (y-or-n-p (format "%s\nRead this file? " metadata-string)) + (error "File read cancelled by user"))) + ;; Check size limits + (cj/check-file-size-limits size no-confirm) + ;; Handle binary/special files + (let ((content + (if (cj/detect-binary-file full-path) + (or (cj/handle-special-file-types full-path no-confirm) + ;; If not a special type or user wants to read anyway + (with-temp-buffer + (insert-file-contents full-path) + (buffer-string))) + ;; Normal text file + (with-temp-buffer + (insert-file-contents full-path) + (buffer-string))))) + (format "Read %d bytes from %s\n\n%s" + (length content) full-path content)))) + :description "Read text content from a file within the user's home directory. Shows file metadata and requests confirmation before reading. Handles large files, binary detection, and PDF text extraction." + :args (list '(:name "path" + :type string + :description "File path relative to home directory, e.g., 'documents/myfile.txt' or '~/documents/myfile.txt'") + '(:name "no_confirm" + :type boolean + :description "If true, skip confirmation prompts and read immediately" + :optional t)) + :category "filesystem" + :confirm t + :include t) + +;; Automatically add to gptel-tools on load +(add-to-list 'gptel-tools (gptel-get-tool '("filesystem" "read_text_file"))) + + +(provide 'read_text_file) +;;; read_text_file.el ends here. diff --git a/gptel-tools/update_text_file.el b/gptel-tools/update_text_file.el new file mode 100644 index 00000000..0125e2ab --- /dev/null +++ b/gptel-tools/update_text_file.el @@ -0,0 +1,149 @@ +;;; update_text_file.el --- Update text files for gptel -*- lexical-binding: t; -*- + +;; Copyright (C) 2025 + +;; Author: gptel-tool-writer +;; Keywords: convenience, tools + +;; This file is not part of GNU Emacs. + +;; 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. + +;;; Commentary: + +;; This file provides a gptel tool for updating text files with various +;; operations including replace, append, prepend, insert-at-line, and +;; delete-lines. The tool creates timestamped backups and shows diffs +;; before applying changes. + +;;; Code: + +(require 'gptel) +(require 'subr-x) + +;; Helper function for building sed commands +(defun cj/build-sed-command (operation pattern replacement line-num temp-file) + "Build appropriate sed/shell command for OPERATION." + (pcase operation + ("replace" + (unless (and pattern replacement) + (error "Replace operation requires pattern and replacement")) + (format "sed -i 's|%s|%s|g' '%s'" + (replace-regexp-in-string "|" "\\\\\\\\|" pattern) + (replace-regexp-in-string "|" "\\\\\\\\|" replacement) + temp-file)) + ("append" + (unless pattern + (error "Append operation requires text to append")) + (format "printf '%%s\\\\n' %s >> '%s'" + (shell-quote-argument pattern) + temp-file)) + ("prepend" + (unless pattern + (error "Prepend operation requires text to prepend")) + (format "(printf '%%s\\\\n' %s; cat '%s') > '%s.new' && mv '%s.new' '%s'" + (shell-quote-argument pattern) + temp-file temp-file temp-file temp-file)) + ("insert-at-line" + (unless (and pattern line-num) + (error "Insert-at-line requires text and line number")) + (format "sed -i '%di\\\\%s' '%s'" + line-num + (replace-regexp-in-string "/" "\\\\\\\\/" pattern) + temp-file)) + ("delete-lines" + (unless pattern + (error "Delete-lines requires pattern")) + (format "sed -i '/%s/d' '%s'" + (replace-regexp-in-string "/" "\\\\\\\\/" pattern) + temp-file)) + (_ + (error "Unknown operation: %s" operation)))) + +;; Main tool definition +(with-eval-after-load 'gptel + (gptel-make-tool + :name "update_text_file" + :function (lambda (path operation &optional pattern replacement line-num) + (let* ((full-path (expand-file-name path "~")) + (temp-file (make-temp-file "gptel-update-" nil ".tmp")) + (backup-name (format "%s-%s.bak" + full-path + (format-time-string "%Y-%m-%d-%H%M%S")))) + (unwind-protect + (progn + ;; Validate path + (unless (string-prefix-p (expand-file-name "~") full-path) + (error "Path must be within home directory")) + (unless (file-exists-p full-path) + (error "File not found: %s" full-path)) + (unless (file-readable-p full-path) + (error "No read permission for file: %s" full-path)) + ;; Check file size + (let ((size (file-attribute-size (file-attributes full-path)))) + (when (> size (* 10 1024 1024)) + (error "File too large (%s): exceeds 10MB limit" + (file-size-human-readable size)))) + ;; Create backup + (copy-file full-path backup-name t) + ;; Copy to temp file for operations + (copy-file full-path temp-file t) + ;; Execute operation and check diff + (let* ((sed-cmd (cj/build-sed-command operation pattern replacement line-num temp-file)) + (result (shell-command-to-string sed-cmd)) + (diff-output (shell-command-to-string + (format "diff -u '%s' '%s' 2>/dev/null" full-path temp-file)))) + (if (string-empty-p diff-output) + (progn + (delete-file backup-name) + (format "No changes made to %s" full-path)) + (if (y-or-n-p (format "Apply these changes to %s?\\n\\n%s\\n" + full-path diff-output)) + (progn + (copy-file temp-file full-path t) + (format "Updated %s (backup: %s)" + full-path (file-name-nondirectory backup-name))) + (progn + (delete-file backup-name) + (error "Update cancelled by user")))))) + ;; Cleanup temp file + (when (file-exists-p temp-file) + (delete-file temp-file))))) + :description "Update a text file with various operations: replace, append, prepend, insert-at-line, or delete-lines. Shows diff before applying changes and creates timestamped backups." + :args (list '(:name "path" + :type string + :description "File path relative to home directory, e.g., 'documents/myfile.txt' or '~/documents/myfile.txt'") + '(:name "operation" + :type string + :enum ["replace" "append" "prepend" "insert-at-line" "delete-lines"] + :description "The type of update operation to perform") + '(:name "pattern" + :type string + :description "For replace/delete: pattern to match. For append/prepend/insert: text to add" + :optional t) + '(:name "replacement" + :type string + :description "For replace operation: the replacement text" + :optional t) + '(:name "line_num" + :type integer + :description "For insert-at-line operation: the line number where to insert" + :optional t)) + :category "filesystem" + :confirm t + :include t)) + +;; Automatically add to gptel-tools on load +(add-to-list 'gptel-tools (gptel-get-tool '("filesystem" "update_text_file"))) + + +(provide 'update_text_file) +;;; update_text_file.el ends here" diff --git a/gptel-tools/write_text_file.el b/gptel-tools/write_text_file.el new file mode 100644 index 00000000..03d64e57 --- /dev/null +++ b/gptel-tools/write_text_file.el @@ -0,0 +1,94 @@ +;;; write_text_file.el --- Write text files for gptel -*- lexical-binding: t; -*- + +;; Copyright (C) 2025 + +;; Author: gptel-tool-writer +;; Keywords: convenience, tools +;; Package-Requires: ((emacs "27.1") (gptel "0.9.0")) + +;; This file is not part of GNU Emacs. + +;; 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. + +;;; Commentary: + +;; This file provides a gptel tool for writing text files to the filesystem. +;; The tool includes safety features like backup creation, size limits, +;; and restriction to the user's home directory. + +;;; Code: + +(require 'gptel) + +(with-eval-after-load 'gptel + (gptel-make-tool + :name "write_text_file" + :function (lambda (path content &optional overwrite) + (let* ((full-path (expand-file-name path "~")) + (content (or content "")) + (content-size (length content)) + (size-limit (* 1024 1024 1024))) ; 1 GB + ;; Check if path is within home directory + (unless (string-prefix-p (expand-file-name "~") full-path) + (error "Path must be within home directory")) + ;; Check size limit + (when (> content-size size-limit) + (unless (y-or-n-p (format "File is %s. Write anyway? " + (file-size-human-readable content-size))) + (error "File write cancelled: size exceeds 1GB limit"))) + ;; Check write permission on parent directory + (let ((parent-dir (file-name-directory full-path))) + (when parent-dir + ;; Create parent directories if needed + (unless (file-exists-p parent-dir) + (condition-case err + (make-directory parent-dir t) + (error (error "Cannot create directory %s: %s" + parent-dir (error-message-string err))))) + ;; Check write permission + (unless (file-writable-p parent-dir) + (error "No write permission for directory %s" parent-dir)))) + ;; Handle existing file + (when (file-exists-p full-path) + (if overwrite + ;; Create backup with timestamp + (let* ((backup-name + (format "%s-%s.bak" + full-path + (format-time-string "%Y-%m-%d-%H%M%S")))) + (copy-file full-path backup-name t) + (message "Backed up existing file to %s" backup-name)) + (error "File %s already exists. Set overwrite to true to replace it" full-path))) + ;; Write the file atomically + (with-temp-file full-path + (insert content)) + (format "Successfully wrote %d bytes to %s" + content-size full-path))) + :description "Write text content to a file within the user's home directory. Creates parent directories if needed. Backs up existing files with timestamp when overwriting." + :args (list '(:name "path" + :type string + :description "File path relative to home directory, e.g., 'documents/myfile.txt' or '~/documents/myfile.txt'") + '(:name "content" + :type string + :description "The text content to write to the file") + '(:name "overwrite" + :type boolean + :description "If true, backup and overwrite existing file. If false or omitted, error if file exists" + :optional t)) + :category "filesystem" + :confirm t + :include t) + + ;; Automatically add to gptel-tools on load + (add-to-list 'gptel-tools (gptel-get-tool '("filesystem" "write_text_file")))) + +(provide 'write_text_file) +;;; write_text_file.el ends here
\ No newline at end of file |
