aboutsummaryrefslogtreecommitdiff
path: root/archive/gptel/gptel-tools
diff options
context:
space:
mode:
authorCraig Jennings <c@cjennings.net>2026-06-23 20:12:58 -0400
committerCraig Jennings <c@cjennings.net>2026-06-23 20:12:58 -0400
commit10fa6f4e2e7150ad99827721ada1ae4badcc5e90 (patch)
tree960065c8e69f1e7a4150ecf522e2f813c239b5ee /archive/gptel/gptel-tools
parentf4cc70c69e7707dd4a686637e14885f5443fcca6 (diff)
downloaddotemacs-10fa6f4e2e7150ad99827721ada1ae4badcc5e90.tar.gz
dotemacs-10fa6f4e2e7150ad99827721ada1ae4badcc5e90.zip
chore(ai): archive gptel and remove it from the live config
I archived gptel to archive/gptel/ since I rarely use it. Moved there: the six gptel modules (ai-config, ai-conversations, ai-conversations-browser, ai-mcp, ai-quick-ask, ai-rewrite), the gptel-tools/ directory, custom/gptel-prompts.el, their test files and utilities, and the four gptel-only specs. Scrubbed from the live config: the ai-config require in init.el, which also drops the whole C-; a keymap; the gptel-mode emojify hook in font-config.el; the gptel-tools entries in the Makefile clean target and the coverage runner; and the gptel feature notes in README. Cancelled the open gptel tasks in todo.org (the AI Open Work issues, the feature-extension brainstorm, the velox gptel-magit bug). ai-term stays. It is the ghostel Claude launcher, independent of gptel. Verified: every module loads, a batch init launch reaches completion clean, and the full test suite shows only pre-existing coverage failures unrelated to this change.
Diffstat (limited to 'archive/gptel/gptel-tools')
-rw-r--r--archive/gptel/gptel-tools/git_diff.el110
-rw-r--r--archive/gptel/gptel-tools/git_log.el100
-rw-r--r--archive/gptel/gptel-tools/git_status.el85
-rw-r--r--archive/gptel/gptel-tools/list_directory_files.el200
-rw-r--r--archive/gptel/gptel-tools/move_to_trash.el149
-rw-r--r--archive/gptel/gptel-tools/read_buffer.el33
-rw-r--r--archive/gptel/gptel-tools/read_text_file.el146
-rw-r--r--archive/gptel/gptel-tools/update_text_file.el235
-rw-r--r--archive/gptel/gptel-tools/web_fetch.el150
-rw-r--r--archive/gptel/gptel-tools/write_text_file.el107
10 files changed, 1315 insertions, 0 deletions
diff --git a/archive/gptel/gptel-tools/git_diff.el b/archive/gptel/gptel-tools/git_diff.el
new file mode 100644
index 00000000..47db8dae
--- /dev/null
+++ b/archive/gptel/gptel-tools/git_diff.el
@@ -0,0 +1,110 @@
+;;; git_diff.el --- Read-only git diff tool for gptel -*- coding: utf-8; lexical-binding: t; -*-
+
+;; Author: Craig Jennings <c@cjennings.net>
+;; Keywords: convenience, tools, git
+
+;; This file is not part of GNU Emacs.
+
+;;; Commentary:
+
+;; Gptel tool returning `git diff' output for a path under the user's
+;; home directory. Read-only. Output is capped at ~500KB so a
+;; runaway diff can't blow up the model's context budget; truncation
+;; is reported in the output when it triggers.
+
+;;; Code:
+
+(require 'gptel)
+
+(defconst cj/gptel-git-diff--max-output-bytes (* 500 1024)
+ "Cap on diff output size. Larger diffs are truncated with a note.")
+
+(defun cj/gptel-git-diff--validate-path (path)
+ "Validate PATH for a git diff call. Return the expanded path on success.
+Same contract as the other git_* validators: under HOME, a directory,
+inside a git working tree."
+ (let* ((home (file-name-as-directory (file-truename (expand-file-name "~"))))
+ (full (expand-file-name (or path "~") "~")))
+ (unless (string-prefix-p (expand-file-name "~") full)
+ (error "Path must be within home directory: %s" path))
+ (unless (file-directory-p full)
+ (error "Not a directory: %s" full))
+ (let ((resolved (file-truename full)))
+ (unless (or (string= resolved (directory-file-name home))
+ (string-prefix-p home resolved))
+ (error "Resolved path must be within home directory: %s" path))
+ (setq full resolved))
+ (let ((default-directory full))
+ (unless (zerop (process-file "git" nil nil nil
+ "rev-parse" "--is-inside-work-tree"))
+ (error "Not a git working tree: %s" full)))
+ full))
+
+(defun cj/gptel-git-diff--truncate (text)
+ "Truncate TEXT to `cj/gptel-git-diff--max-output-bytes' bytes.
+Returns TEXT unchanged when it's under the cap, otherwise returns the
+prefix plus a one-line truncation marker."
+ (if (<= (length text) cj/gptel-git-diff--max-output-bytes)
+ text
+ (concat (substring text 0 cj/gptel-git-diff--max-output-bytes)
+ (format
+ "\n\n[truncated: output exceeded %d bytes; %d bytes total]"
+ cj/gptel-git-diff--max-output-bytes
+ (length text)))))
+
+(defun cj/gptel-git-diff--build-args (ref1 ref2 file)
+ "Build the `git' argv from optional REF1, REF2, FILE.
+Uses `-c color.ui=false' at the git level so output is plain across
+git subcommands."
+ (let ((args (list "-c" "color.ui=false" "diff")))
+ (when (and (stringp ref1) (not (string-empty-p ref1)))
+ (setq args (append args (list ref1))))
+ (when (and (stringp ref2) (not (string-empty-p ref2)))
+ (setq args (append args (list ref2))))
+ (when (and (stringp file) (not (string-empty-p file)))
+ (setq args (append args (list "--" file))))
+ args))
+
+(defun cj/gptel-git-diff--run (path &optional ref1 ref2 file)
+ "Run `git diff [REF1 [REF2]] [-- FILE]' in PATH. Return the output."
+ (let* ((dir (cj/gptel-git-diff--validate-path path))
+ (args (cj/gptel-git-diff--build-args ref1 ref2 file))
+ (default-directory dir))
+ (with-temp-buffer
+ (let ((exit (apply #'process-file "git" nil t nil args)))
+ (unless (or (zerop exit) (= exit 1))
+ (error "git diff exited with %d: %s" exit (buffer-string)))
+ (let ((out (buffer-string)))
+ (if (string-empty-p out)
+ (format "No diff in %s for the given refs/file" dir)
+ (cj/gptel-git-diff--truncate out)))))))
+
+(with-eval-after-load 'gptel
+ (gptel-make-tool
+ :name "git_diff"
+ :function (lambda (path &optional ref1 ref2 file)
+ (cj/gptel-git-diff--run path ref1 ref2 file))
+ :description "Return the output of `git diff' for a directory in the user's home tree. Read-only. REF1 and REF2 are optional git revisions (commit SHA, branch, tag, or expressions like HEAD~3); when both are present the diff is between them, when only REF1 is present the diff is between REF1 and the working tree, when neither is present the diff is unstaged-vs-HEAD. FILE optionally narrows the diff to one path. Output is capped at ~500KB."
+ :args (list '(:name "path"
+ :type string
+ :description "Directory inside a git working tree. Either an absolute path under the user's home directory or a path relative to it (e.g. 'code/myproject').")
+ '(:name "ref1"
+ :type string
+ :description "Optional first git revision (commit, branch, tag, or expression like HEAD~3)."
+ :optional t)
+ '(:name "ref2"
+ :type string
+ :description "Optional second git revision; pair with REF1 to diff between two refs."
+ :optional t)
+ '(:name "file"
+ :type string
+ :description "Optional path inside the working tree to narrow the diff to."
+ :optional t))
+ :category "git"
+ :confirm nil
+ :include t)
+
+ (add-to-list 'gptel-tools (gptel-get-tool '("git" "git_diff"))))
+
+(provide 'git_diff)
+;;; git_diff.el ends here
diff --git a/archive/gptel/gptel-tools/git_log.el b/archive/gptel/gptel-tools/git_log.el
new file mode 100644
index 00000000..324435dc
--- /dev/null
+++ b/archive/gptel/gptel-tools/git_log.el
@@ -0,0 +1,100 @@
+;;; git_log.el --- Read-only git log tool for gptel -*- coding: utf-8; lexical-binding: t; -*-
+
+;; Author: Craig Jennings <c@cjennings.net>
+;; Keywords: convenience, tools, git
+
+;; This file is not part of GNU Emacs.
+
+;;; Commentary:
+
+;; Gptel tool returning `git log --oneline -n N' for a path under the
+;; user's home directory. Read-only. N is capped to keep the model's
+;; context budget predictable.
+
+;;; Code:
+
+(require 'gptel)
+
+(defconst cj/gptel-git-log--max-count 100
+ "Hard cap on the number of commits `git_log' will return.")
+
+(defconst cj/gptel-git-log--default-count 20
+ "Default commit count when the caller doesn't specify one.")
+
+(defun cj/gptel-git-log--validate-path (path)
+ "Validate PATH for a git log call. Return the expanded path on success.
+Same contract as the git_status validator: must be under HOME, must
+be a directory, must be inside a git working tree."
+ (let* ((home (file-name-as-directory (file-truename (expand-file-name "~"))))
+ (full (expand-file-name (or path "~") "~")))
+ (unless (string-prefix-p (expand-file-name "~") full)
+ (error "Path must be within home directory: %s" path))
+ (unless (file-directory-p full)
+ (error "Not a directory: %s" full))
+ (let ((resolved (file-truename full)))
+ (unless (or (string= resolved (directory-file-name home))
+ (string-prefix-p home resolved))
+ (error "Resolved path must be within home directory: %s" path))
+ (setq full resolved))
+ (let ((default-directory full))
+ (unless (zerop (process-file "git" nil nil nil
+ "rev-parse" "--is-inside-work-tree"))
+ (error "Not a git working tree: %s" full)))
+ full))
+
+(defun cj/gptel-git-log--effective-count (n)
+ "Return the commit count to use given caller-supplied N.
+Nil / non-integer N → `cj/gptel-git-log--default-count'.
+Values above `cj/gptel-git-log--max-count' get capped."
+ (cond
+ ((not (integerp n)) cj/gptel-git-log--default-count)
+ ((< n 1) cj/gptel-git-log--default-count)
+ ((> n cj/gptel-git-log--max-count) cj/gptel-git-log--max-count)
+ (t n)))
+
+(defun cj/gptel-git-log--run (path &optional n since)
+ "Run `git log --oneline -n N' in PATH. Return the output as a string.
+SINCE, if a non-empty string, is passed as `--since=SINCE'."
+ (let* ((dir (cj/gptel-git-log--validate-path path))
+ (count (cj/gptel-git-log--effective-count n))
+ (args (list "-c" "color.ui=false"
+ "log" "--oneline"
+ (format "-n%d" count)))
+ (args (if (and (stringp since) (not (string-empty-p since)))
+ (append args (list (format "--since=%s" since)))
+ args))
+ (default-directory dir))
+ (with-temp-buffer
+ (let ((exit (apply #'process-file "git" nil t nil args)))
+ (unless (zerop exit)
+ (error "git log exited with %d: %s" exit (buffer-string)))
+ (let ((out (buffer-string)))
+ (if (string-empty-p out)
+ (format "No commits in %s matching the filter" dir)
+ out))))))
+
+(with-eval-after-load 'gptel
+ (gptel-make-tool
+ :name "git_log"
+ :function (lambda (path &optional n since)
+ (cj/gptel-git-log--run path n since))
+ :description "Return the output of `git log --oneline -n N' for a directory in the user's home tree. Read-only. N defaults to 20 and is capped at 100. Use SINCE to filter commits more recent than a date expression git understands (e.g. '2 weeks ago', '2026-05-01')."
+ :args (list '(:name "path"
+ :type string
+ :description "Directory inside a git working tree. Either an absolute path under the user's home directory or a path relative to it (e.g. 'code/myproject').")
+ '(:name "n"
+ :type integer
+ :description "Number of commits to return. Defaults to 20; capped at 100."
+ :optional t)
+ '(:name "since"
+ :type string
+ :description "Optional date expression for `git log --since='; e.g. '2 weeks ago' or '2026-05-01'."
+ :optional t))
+ :category "git"
+ :confirm nil
+ :include t)
+
+ (add-to-list 'gptel-tools (gptel-get-tool '("git" "git_log"))))
+
+(provide 'git_log)
+;;; git_log.el ends here
diff --git a/archive/gptel/gptel-tools/git_status.el b/archive/gptel/gptel-tools/git_status.el
new file mode 100644
index 00000000..de76a985
--- /dev/null
+++ b/archive/gptel/gptel-tools/git_status.el
@@ -0,0 +1,85 @@
+;;; git_status.el --- Read-only git status tool for gptel -*- coding: utf-8; lexical-binding: t; -*-
+
+;; Author: Craig Jennings <c@cjennings.net>
+;; Keywords: convenience, tools, git
+
+;; This file is not part of GNU Emacs.
+
+;;; Commentary:
+
+;; Gptel tool returning `git status --short --branch' for a path under
+;; the user's home directory. Read-only: never writes to the repo,
+;; never runs anything that could mutate state. Path validation
+;; rejects anything outside HOME and any path that doesn't resolve to
+;; a directory inside a git working tree.
+
+;;; Code:
+
+(require 'gptel)
+(require 'cl-lib)
+
+(defun cj/gptel-git-status--validate-path (path)
+ "Validate PATH as a usable working directory for a git status call.
+PATH must resolve under the user's home directory, must be an
+existing directory, and must be inside a git working tree. Returns
+the expanded path string on success; signals `error' otherwise."
+ (let* ((home (file-name-as-directory (file-truename (expand-file-name "~"))))
+ (full (expand-file-name (or path "~") "~")))
+ (unless (string-prefix-p (expand-file-name "~") full)
+ (error "Path must be within home directory: %s" path))
+ (unless (file-directory-p full)
+ (error "Not a directory: %s" full))
+ (let ((resolved (file-truename full)))
+ (unless (or (string= resolved (directory-file-name home))
+ (string-prefix-p home resolved))
+ (error "Resolved path must be within home directory: %s" path))
+ (setq full resolved))
+ (let ((default-directory full))
+ (unless (zerop (process-file "git" nil nil nil
+ "rev-parse" "--is-inside-work-tree"))
+ (error "Not a git working tree: %s" full)))
+ full))
+
+(defun cj/gptel-git-status--run (path)
+ "Run `git status --short --branch' in PATH. Return the output.
+Color is disabled via `-c color.ui=false' at the git level (`git status'
+itself doesn't accept `--no-color' like `git log' / `git diff' do)."
+ (let* ((dir (cj/gptel-git-status--validate-path path))
+ (default-directory dir))
+ (with-temp-buffer
+ (let ((exit (process-file "git" nil t nil
+ "-c" "color.ui=false"
+ "status" "--short" "--branch")))
+ (unless (zerop exit)
+ (error "git status exited with %d: %s" exit (buffer-string)))
+ ;; `--branch' always prints a `## <branch>' header, so empty
+ ;; output is unreachable. Detect a clean tree by counting the
+ ;; non-branch lines: if only the header is present, no files
+ ;; are modified / staged / untracked.
+ (let* ((out (buffer-string))
+ (non-branch-lines
+ (cl-count-if
+ (lambda (l)
+ (and (not (string-empty-p l))
+ (not (string-prefix-p "## " l))))
+ (split-string out "\n"))))
+ (if (zerop non-branch-lines)
+ (format "Clean working tree in %s\n%s" dir (string-trim out))
+ out))))))
+
+(with-eval-after-load 'gptel
+ (gptel-make-tool
+ :name "git_status"
+ :function (lambda (path) (cj/gptel-git-status--run path))
+ :description "Return the output of `git status --short --branch' for a directory in the user's home tree. Read-only. Useful for seeing which files are modified, staged, or untracked, and how the current branch compares to its upstream."
+ :args (list '(:name "path"
+ :type string
+ :description "Directory inside a git working tree. Either an absolute path under the user's home directory or a path relative to it (e.g. 'code/myproject')."))
+ :category "git"
+ :confirm nil
+ :include t)
+
+ (add-to-list 'gptel-tools (gptel-get-tool '("git" "git_status"))))
+
+(provide 'git_status)
+;;; git_status.el ends here
diff --git a/archive/gptel/gptel-tools/list_directory_files.el b/archive/gptel/gptel-tools/list_directory_files.el
new file mode 100644
index 00000000..8da9ba28
--- /dev/null
+++ b/archive/gptel/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/archive/gptel/gptel-tools/move_to_trash.el b/archive/gptel/gptel-tools/move_to_trash.el
new file mode 100644
index 00000000..923da790
--- /dev/null
+++ b/archive/gptel/gptel-tools/move_to_trash.el
@@ -0,0 +1,149 @@
+;;; 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 (and extension (not (string= 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))
+ (resolved-path (and (file-exists-p expanded-path)
+ (file-truename expanded-path)))
+ (home-dir (file-name-as-directory (file-truename (expand-file-name "~"))))
+ (tmp-dir (file-name-as-directory (file-truename "/tmp")))
+ (critical-dirs (list (directory-file-name home-dir)
+ (file-truename (expand-file-name "~/.emacs.d"))
+ (file-truename (expand-file-name "~/.config"))
+ (directory-file-name tmp-dir))))
+ ;; Security check: must be within allowed directories
+ (unless (or (string-prefix-p home-dir expanded-path)
+ (string-prefix-p tmp-dir 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))
+
+ (unless (or (string-prefix-p home-dir resolved-path)
+ (string-prefix-p tmp-dir resolved-path))
+ (error "Resolved path must be within home directory or /tmp: %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/archive/gptel/gptel-tools/read_buffer.el b/archive/gptel/gptel-tools/read_buffer.el
new file mode 100644
index 00000000..c9136e3c
--- /dev/null
+++ b/archive/gptel/gptel-tools/read_buffer.el
@@ -0,0 +1,33 @@
+;;; read_buffer.el --- Read buffer tool for GPTel -*- coding: utf-8; lexical-binding: t; -*-
+
+;;; Commentary:
+;; Gptel tool that returns the contents of an Emacs buffer by name.
+
+;;; Code:
+
+(require 'gptel)
+
+(defun cj/read-buffer--get-content (buffer)
+ "Return the substring of BUFFER from `point-min' to `point-max'.
+BUFFER may be a buffer object or a buffer name string. Signal an
+error when no live buffer matches."
+ (unless (buffer-live-p (get-buffer buffer))
+ (error "Buffer %s is not live" buffer))
+ (with-current-buffer buffer
+ (save-restriction
+ (widen)
+ (buffer-substring-no-properties (point-min) (point-max)))))
+
+(gptel-make-tool
+ :name "read_buffer"
+ :function (lambda (buffer) (cj/read-buffer--get-content buffer))
+ :description "return the contents of an emacs buffer"
+ :args (list '(:name "buffer"
+ :type string
+ :description "the name of the buffer whose contents are to be retrieved"))
+ :category "emacs")
+
+(add-to-list 'gptel-tools (gptel-get-tool '("emacs" "read_buffer")))
+
+(provide 'read_buffer)
+;;; read_buffer.el ends here
diff --git a/archive/gptel/gptel-tools/read_text_file.el b/archive/gptel/gptel-tools/read_text_file.el
new file mode 100644
index 00000000..f35c9494
--- /dev/null
+++ b/archive/gptel/gptel-tools/read_text_file.el
@@ -0,0 +1,146 @@
+;;; 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* ((home (file-name-as-directory (file-truename (expand-file-name "~"))))
+ (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))
+ (let ((resolved (file-truename full-path)))
+ (unless (or (string= resolved (directory-file-name home))
+ (string-prefix-p home resolved))
+ (error "Resolved path must be within home directory: %s" path))
+ (when (file-directory-p resolved)
+ (error "Path is a directory, not a file: %s" resolved))
+ (unless (file-readable-p resolved)
+ (error "No read permission for file: %s" resolved))
+ resolved)))
+
+(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/archive/gptel/gptel-tools/update_text_file.el b/archive/gptel/gptel-tools/update_text_file.el
new file mode 100644
index 00000000..f8b58025
--- /dev/null
+++ b/archive/gptel/gptel-tools/update_text_file.el
@@ -0,0 +1,235 @@
+;;; update_text_file.el --- Update text files for gptel -*- lexical-binding: t; -*-
+
+;; Author: Craig Jennings <c@cjennings.net>
+;; Keywords: convenience, tools
+
+;; This file is not part of GNU Emacs.
+
+;;; Commentary:
+
+;; Gptel tool for updating an existing text file with one of five
+;; operations:
+;;
+;; replace Replace all occurrences of PATTERN with REPLACEMENT.
+;; append Add TEXT at the end of the file.
+;; prepend Add TEXT at the beginning of the file.
+;; insert-at-line Insert TEXT at LINE-NUM (1-indexed).
+;; delete-lines Delete every line containing PATTERN.
+;;
+;; The operations are pure-string transforms — file I/O happens only at
+;; the outer wrapper, which validates the path, takes a timestamped
+;; backup, and writes the new content atomically. The tool uses gptel's
+;; `:confirm t' meta-flag for the user-facing prompt, mirroring how
+;; `write_text_file' handles confirmation.
+;;
+;; PATTERN is a literal substring for `replace' and `delete-lines'. No
+;; regex. The model can build literal multi-line patterns and we don't
+;; want it to discover regex metacharacter gotchas through trial and
+;; error.
+
+;;; Code:
+
+(require 'gptel)
+(require 'subr-x)
+(require 'cl-lib)
+
+;; ---------------------------------------------------------------- helpers
+
+(defun cj/update-text-file--validate-path (path)
+ "Validate PATH for update. Return the truename on success.
+
+PATH must resolve inside the user's home directory, must exist, must
+be a regular file, and must be readable and writable."
+ (let* ((home (file-name-as-directory (file-truename (expand-file-name "~"))))
+ (full (expand-file-name path "~")))
+ (unless (string-prefix-p (expand-file-name "~") full)
+ (error "Path must be within home directory: %s" path))
+ (unless (file-exists-p full)
+ (error "File not found: %s" full))
+ (let ((resolved (file-truename full)))
+ (unless (or (string= resolved (directory-file-name home))
+ (string-prefix-p home resolved))
+ (error "Resolved path must be within home directory: %s" path))
+ (when (file-directory-p resolved)
+ (error "Path is a directory, not a file: %s" resolved))
+ (unless (file-readable-p resolved)
+ (error "No read permission for file: %s" resolved))
+ (unless (file-writable-p resolved)
+ (error "No write permission for file: %s" resolved))
+ resolved)))
+
+(defun cj/update-text-file--backup-name (path)
+ "Return a backup filename for PATH timestamped to the current second."
+ (format "%s-%s.bak" path (format-time-string "%Y-%m-%d-%H%M%S")))
+
+(defconst cj/update-text-file--size-limit (* 10 1024 1024)
+ "Reject files larger than 10MB so a runaway operation can't churn the disk.")
+
+;; ----------------------------------------------------- string transforms
+;;
+;; Each transform takes the file contents as a string plus operation
+;; parameters and returns the new contents. Pure functions — no I/O.
+
+(defun cj/update-text-file--replace (content pattern replacement)
+ "Return CONTENT with every occurrence of PATTERN replaced by REPLACEMENT.
+PATTERN is treated as a literal substring. Signal an error if PATTERN is
+empty or nil."
+ (unless (and (stringp pattern) (> (length pattern) 0))
+ (error "Replace operation requires a non-empty pattern"))
+ (unless (stringp replacement)
+ (error "Replace operation requires a replacement string"))
+ (replace-regexp-in-string (regexp-quote pattern) replacement content t t))
+
+(defun cj/update-text-file--append (content text)
+ "Return CONTENT with TEXT added at the end, separated by a newline.
+A trailing newline is guaranteed. Signal if TEXT is nil or empty."
+ (unless (and (stringp text) (> (length text) 0))
+ (error "Append operation requires non-empty text"))
+ (let ((base (if (or (string-empty-p content)
+ (string-suffix-p "\n" content))
+ content
+ (concat content "\n"))))
+ (if (string-suffix-p "\n" text)
+ (concat base text)
+ (concat base text "\n"))))
+
+(defun cj/update-text-file--prepend (content text)
+ "Return CONTENT with TEXT added at the beginning.
+TEXT is separated from CONTENT by a newline. Signal if TEXT is nil
+or empty."
+ (unless (and (stringp text) (> (length text) 0))
+ (error "Prepend operation requires non-empty text"))
+ (if (string-suffix-p "\n" text)
+ (concat text content)
+ (concat text "\n" content)))
+
+(defun cj/update-text-file--insert-at-line (content line-num text)
+ "Return CONTENT with TEXT inserted before LINE-NUM (1-indexed).
+LINE-NUM 1 prepends. LINE-NUM one past the last line appends. Signal
+on out-of-range LINE-NUM or empty TEXT."
+ (unless (and (integerp line-num) (> line-num 0))
+ (error "Insert-at-line requires a positive integer line number"))
+ (unless (and (stringp text) (> (length text) 0))
+ (error "Insert-at-line requires non-empty text"))
+ (let* ((lines (split-string content "\n"))
+ ;; `split-string' on a newline-terminated string returns an
+ ;; extra empty element at the end. Trim it so the line count
+ ;; matches what a human would say.
+ (trailing-newline (string-suffix-p "\n" content))
+ (line-count (cond
+ ((string-empty-p content) 0)
+ (trailing-newline (1- (length lines)))
+ (t (length lines)))))
+ (when (> line-num (1+ line-count))
+ (error "Line %d out of range (file has %d lines)" line-num line-count))
+ (let* ((to-insert (if (string-suffix-p "\n" text)
+ (substring text 0 (1- (length text)))
+ text))
+ (idx (1- line-num))
+ (head (cl-subseq lines 0 idx))
+ (tail (cl-subseq lines idx)))
+ (mapconcat #'identity
+ (append head (list to-insert) tail)
+ "\n"))))
+
+(defun cj/update-text-file--delete-lines (content pattern)
+ "Return CONTENT with every line containing PATTERN removed.
+PATTERN is a literal substring. Trailing-newline state is preserved
+when at least one line survives; an empty result is returned as the
+empty string."
+ (unless (and (stringp pattern) (> (length pattern) 0))
+ (error "Delete-lines requires a non-empty pattern"))
+ (let* ((trailing-newline (string-suffix-p "\n" content))
+ (raw-lines (split-string content "\n"))
+ ;; Drop the trailing empty element split-string produces when
+ ;; the input ends in a newline.
+ (lines (if trailing-newline
+ (butlast raw-lines)
+ raw-lines))
+ (kept (cl-remove-if (lambda (line)
+ (string-match-p (regexp-quote pattern) line))
+ lines)))
+ (cond
+ ((null kept) "")
+ (trailing-newline (concat (mapconcat #'identity kept "\n") "\n"))
+ (t (mapconcat #'identity kept "\n")))))
+
+(defun cj/update-text-file--apply-operation
+ (content operation pattern replacement line-num)
+ "Dispatch OPERATION on CONTENT. Return the transformed string.
+
+OPERATION is one of \"replace\", \"append\", \"prepend\",
+\"insert-at-line\", or \"delete-lines\". PATTERN, REPLACEMENT, and
+LINE-NUM are used per operation; unused arguments are ignored."
+ (pcase operation
+ ("replace" (cj/update-text-file--replace content pattern replacement))
+ ("append" (cj/update-text-file--append content pattern))
+ ("prepend" (cj/update-text-file--prepend content pattern))
+ ("insert-at-line" (cj/update-text-file--insert-at-line content line-num pattern))
+ ("delete-lines" (cj/update-text-file--delete-lines content pattern))
+ (_ (error "Unknown operation: %s" operation))))
+
+;; ----------------------------------------------------- file-level wrapper
+
+(defun cj/update-text-file--run (path operation pattern replacement line-num)
+ "Update PATH with OPERATION and return a status string.
+
+PATTERN, REPLACEMENT, and LINE-NUM are passed through per operation.
+A timestamped backup is created next to the file before writing. If
+the operation produces no change the backup is removed and the file
+is left untouched."
+ (let* ((full (cj/update-text-file--validate-path path))
+ (size (file-attribute-size (file-attributes full))))
+ (when (> size cj/update-text-file--size-limit)
+ (error "File too large (%s): exceeds 10MB limit"
+ (file-size-human-readable size)))
+ (let* ((before (with-temp-buffer
+ (insert-file-contents full)
+ (buffer-string)))
+ (after (cj/update-text-file--apply-operation
+ before operation pattern replacement line-num)))
+ (cond
+ ((string= before after)
+ (format "No changes made to %s" full))
+ (t
+ (let ((backup (cj/update-text-file--backup-name full)))
+ (copy-file full backup t)
+ (with-temp-file full (insert after))
+ (format "Updated %s (backup: %s)"
+ full (file-name-nondirectory backup))))))))
+
+;; ----------------------------------------------------- tool registration
+
+(with-eval-after-load 'gptel
+ (gptel-make-tool
+ :name "update_text_file"
+ :function (lambda (path operation &optional pattern replacement line-num)
+ (cj/update-text-file--run path operation pattern replacement line-num))
+ :description "Update an existing text file with one of: replace, append, prepend, insert-at-line, delete-lines. Creates a timestamped backup before writing. Patterns are literal substrings, not regex."
+ :args (list '(:name "path"
+ :type string
+ :description "File path relative to home directory, e.g. 'documents/foo.txt' or '~/documents/foo.txt'")
+ '(:name "operation"
+ :type string
+ :enum ["replace" "append" "prepend" "insert-at-line" "delete-lines"]
+ :description "Which update operation to perform")
+ '(:name "pattern"
+ :type string
+ :description "For replace/delete-lines: the literal substring to match. For append/prepend/insert-at-line: the text to add. Required for every operation."
+ :optional t)
+ '(:name "replacement"
+ :type string
+ :description "For replace: the literal replacement text. Ignored by other operations."
+ :optional t)
+ '(:name "line_num"
+ :type integer
+ :description "For insert-at-line: 1-indexed line number to insert before. Ignored by other operations."
+ :optional t))
+ :category "filesystem"
+ :confirm t
+ :include t)
+
+ (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/archive/gptel/gptel-tools/web_fetch.el b/archive/gptel/gptel-tools/web_fetch.el
new file mode 100644
index 00000000..b2f80c5f
--- /dev/null
+++ b/archive/gptel/gptel-tools/web_fetch.el
@@ -0,0 +1,150 @@
+;;; web_fetch.el --- Web fetch tool for gptel -*- coding: utf-8; lexical-binding: t; -*-
+
+;; Author: Craig Jennings <c@cjennings.net>
+;; Keywords: convenience, tools, web
+
+;; This file is not part of GNU Emacs.
+
+;;; Commentary:
+
+;; Gptel tool that fetches an HTTP/HTTPS URL and returns its body.
+;; HTML is piped through `pandoc -f html -t plain' (falling back to
+;; `w3m -dump -T text/html') so the model gets a reading shape that
+;; isn't full of markup; pass RAW=t to skip stripping and get the
+;; verbatim response. Output is capped at 200KB by default (hard cap
+;; 1MB) and the cap is reported inline when triggered.
+;;
+;; This tool is `:confirm t' because it makes outbound network
+;; requests -- the user sees every URL before the fetch happens. The
+;; URL goes wherever the user-agent points it, including internal
+;; networks if the URL names one; consider the network posture before
+;; approving sensitive endpoints.
+
+;;; Code:
+
+(require 'gptel)
+(require 'url)
+
+(defconst cj/gptel-web-fetch--default-max-bytes (* 200 1024)
+ "Default cap on returned body size. ~200KB.")
+
+(defconst cj/gptel-web-fetch--hard-max-bytes (* 1024 1024)
+ "Hard upper bound on the user-controllable byte cap. 1MB.")
+
+(defun cj/gptel-web-fetch--validate-url (url)
+ "Validate URL as an http or https request target. Return URL on success.
+Signals `user-error' for non-string, empty, or non-http/https URLs."
+ (unless (and (stringp url) (not (string-empty-p url)))
+ (user-error "web_fetch: expected non-empty URL string, got %S" url))
+ (unless (string-match-p "\\`https?://[^[:space:]]+\\'" url)
+ (user-error "web_fetch: URL must be http:// or https://, got %S" url))
+ url)
+
+(defun cj/gptel-web-fetch--effective-max-bytes (n)
+ "Return the byte cap to use given caller-supplied N.
+Nil / non-integer / out-of-range → default. Above hard cap → hard cap."
+ (cond
+ ((not (integerp n)) cj/gptel-web-fetch--default-max-bytes)
+ ((< n 1) cj/gptel-web-fetch--default-max-bytes)
+ ((> n cj/gptel-web-fetch--hard-max-bytes) cj/gptel-web-fetch--hard-max-bytes)
+ (t n)))
+
+(defun cj/gptel-web-fetch--retrieve (url)
+ "Synchronously GET URL. Return a cons (STATUS-CODE . BODY).
+Signals on network failure. STATUS-CODE is an integer when parseable
+from the response status line, or nil when the line is unrecognized."
+ (let ((buf (url-retrieve-synchronously url t t 30)))
+ (unless buf
+ (error "web_fetch: no response from %s" url))
+ (unwind-protect
+ (with-current-buffer buf
+ (goto-char (point-min))
+ (let* ((status (when (re-search-forward
+ "^HTTP/[0-9.]+ \\([0-9]+\\)" (point-max) t)
+ (string-to-number (match-string 1))))
+ (body-start (when (re-search-forward "\r?\n\r?\n" nil t)
+ (point))))
+ (cons status
+ (if body-start
+ (buffer-substring-no-properties body-start (point-max))
+ (buffer-substring-no-properties (point-min) (point-max))))))
+ (kill-buffer buf))))
+
+(defun cj/gptel-web-fetch--html-to-text (html)
+ "Strip HTML to plain text. Returns the stripped string.
+Tries `pandoc -f html -t plain' first, falls back to
+`w3m -dump -T text/html'. Signals `user-error' if neither is
+on PATH."
+ (let* ((coding-system-for-write 'utf-8)
+ (coding-system-for-read 'utf-8)
+ (tool (cond
+ ((executable-find "pandoc")
+ (list "pandoc" "-f" "html" "-t" "plain"))
+ ((executable-find "w3m")
+ (list "w3m" "-dump" "-T" "text/html"))
+ (t nil))))
+ (unless tool
+ (user-error
+ "web_fetch: HTML stripping needs pandoc or w3m on PATH; pass raw=t to bypass"))
+ ;; `call-process-region' with DELETE=t and OUTPUT=t replaces the
+ ;; input range with the tool's output, so `buffer-string' returns
+ ;; the stripped text.
+ (with-temp-buffer
+ (insert html)
+ (let ((exit (apply #'call-process-region
+ (point-min) (point-max) (car tool)
+ t t nil (cdr tool))))
+ (if (zerop exit)
+ (buffer-string)
+ (error "web_fetch: %s exited with %d" (car tool) exit))))))
+
+(defun cj/gptel-web-fetch--truncate (text max-bytes)
+ "Truncate TEXT to MAX-BYTES. Returns TEXT unchanged when under the cap."
+ (if (<= (length text) max-bytes)
+ text
+ (concat (substring text 0 max-bytes)
+ (format
+ "\n\n[truncated: response exceeded %d bytes; %d bytes total]"
+ max-bytes (length text)))))
+
+(defun cj/gptel-web-fetch--run (url &optional raw max-bytes)
+ "Fetch URL and return its body.
+When RAW is nil (the default) HTML responses are stripped to plain
+text via pandoc or w3m. MAX-BYTES caps the returned size; nil /
+out-of-range falls back to the default 200KB cap."
+ (let* ((validated (cj/gptel-web-fetch--validate-url url))
+ (cap (cj/gptel-web-fetch--effective-max-bytes max-bytes))
+ (response (cj/gptel-web-fetch--retrieve validated))
+ (status (car response))
+ (body (cdr response)))
+ (when (and status (>= status 400))
+ (error "web_fetch: HTTP %d from %s" status validated))
+ (let ((text (if raw body
+ (cj/gptel-web-fetch--html-to-text body))))
+ (cj/gptel-web-fetch--truncate text cap))))
+
+(with-eval-after-load 'gptel
+ (gptel-make-tool
+ :name "web_fetch"
+ :function (lambda (url &optional raw max_bytes)
+ (cj/gptel-web-fetch--run url raw max_bytes))
+ :description "Fetch an http:// or https:// URL and return its body. HTML responses are stripped to plain text via pandoc (or w3m as a fallback); pass raw=true to skip stripping. Output is capped at 200KB by default (max 1MB); the cap is reported inline when triggered. Network call: the URL goes wherever the user-agent points, including internal networks if specified."
+ :args (list '(:name "url"
+ :type string
+ :description "HTTP or HTTPS URL to fetch. Non-http schemes are rejected.")
+ '(:name "raw"
+ :type boolean
+ :description "When true, return the response body verbatim without HTML stripping. Default false."
+ :optional t)
+ '(:name "max_bytes"
+ :type integer
+ :description "Output size cap in bytes. Defaults to 200000; hard-capped at 1048576."
+ :optional t))
+ :category "web"
+ :confirm t
+ :include t)
+
+ (add-to-list 'gptel-tools (gptel-get-tool '("web" "web_fetch"))))
+
+(provide 'web_fetch)
+;;; web_fetch.el ends here
diff --git a/archive/gptel/gptel-tools/write_text_file.el b/archive/gptel/gptel-tools/write_text_file.el
new file mode 100644
index 00000000..1bda5446
--- /dev/null
+++ b/archive/gptel/gptel-tools/write_text_file.el
@@ -0,0 +1,107 @@
+;;; write_text_file.el --- Write text files for gptel -*- lexical-binding: t; -*-
+
+;; Author: Craig Jennings <c@cjennings.net>
+;; Keywords: convenience, tools
+
+;; This file is not part of GNU Emacs.
+
+;;; Commentary:
+
+;; Gptel tool for writing a text file under the user's home directory.
+;; Creates parent directories as needed, optionally overwrites an
+;; existing file (with a timestamped backup), and rejects writes
+;; larger than 1 GB unless the user confirms.
+
+;;; Code:
+
+(require 'gptel)
+
+(defconst cj/write-text-file--size-limit (* 1024 1024 1024)
+ "Soft cap for new-file writes (1 GB). Above this size a confirm is required.")
+
+(defun cj/write-text-file--validate-path (path)
+ "Validate PATH for write. Return the expanded path on success.
+PATH must resolve inside the user's home directory."
+ (let* ((home (file-name-as-directory (file-truename (expand-file-name "~"))))
+ (full (expand-file-name path "~"))
+ (existing (and (file-exists-p full) (file-truename full)))
+ (parent (file-name-directory full))
+ (resolved-parent (and parent
+ (file-exists-p parent)
+ (file-truename parent))))
+ (unless (string-prefix-p (expand-file-name "~") full)
+ (error "Path must be within home directory: %s" path))
+ (when (and existing
+ (not (string-prefix-p home existing)))
+ (error "Resolved path must be within home directory: %s" path))
+ (when (and resolved-parent
+ (not (or (string= resolved-parent (directory-file-name home))
+ (string-prefix-p home resolved-parent))))
+ (error "Resolved parent must be within home directory: %s" path))
+ full))
+
+(defun cj/write-text-file--backup-name (path)
+ "Return a timestamped backup filename for PATH."
+ (format "%s-%s.bak"
+ path
+ (format-time-string "%Y-%m-%d-%H%M%S")))
+
+(defun cj/write-text-file--ensure-parent (path)
+ "Ensure the parent directory of PATH exists and is writable.
+Create missing parents. Signal on failure."
+ (let ((parent (file-name-directory path)))
+ (when parent
+ (unless (file-exists-p parent)
+ (condition-case err
+ (make-directory parent t)
+ (error (error "Cannot create directory %s: %s"
+ parent (error-message-string err)))))
+ (unless (file-writable-p parent)
+ (error "No write permission for directory %s" parent)))))
+
+(defun cj/write-text-file--run (path content &optional overwrite)
+ "Write CONTENT to PATH. Return a status string.
+PATH must be inside the user's home directory. If the file exists
+and OVERWRITE is non-nil, make a timestamped backup before writing;
+otherwise signal."
+ (let* ((full (cj/write-text-file--validate-path path))
+ (content (or content ""))
+ (size (length content)))
+ (when (> size cj/write-text-file--size-limit)
+ (unless (y-or-n-p (format "File is %s. Write anyway? "
+ (file-size-human-readable size)))
+ (error "File write cancelled: size exceeds 1GB limit")))
+ (cj/write-text-file--ensure-parent full)
+ (when (file-exists-p full)
+ (if overwrite
+ (let ((backup (cj/write-text-file--backup-name full)))
+ (copy-file full backup t)
+ (message "Backed up existing file to %s" backup))
+ (error "File %s already exists. Set overwrite to true to replace it" full)))
+ (with-temp-file full (insert content))
+ (format "Successfully wrote %d bytes to %s" size full)))
+
+(with-eval-after-load 'gptel
+ (gptel-make-tool
+ :name "write_text_file"
+ :function (lambda (path content &optional overwrite)
+ (cj/write-text-file--run path content overwrite))
+ :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)
+
+ (add-to-list 'gptel-tools (gptel-get-tool '("filesystem" "write_text_file"))))
+
+(provide 'write_text_file)
+;;; write_text_file.el ends here