;;; macher.el --- LLM implementation toolset -*- lexical-binding: t -*-

;; Copyright (C) 2025 Kevin Montag

;; Author: Kevin Montag
;; Package-Version: 20260505.2003
;; Package-Revision: 293384813e4d
;; Package-Requires: ((emacs "30.1") (gptel "0.9.9.3"))
;; Keywords: convenience, gptel, llm
;; URL: https://github.com/kmontag/macher

;; SPDX-License-Identifier: GPL-3.0-or-later

;; This program is free software; you can redistribute it and/or modify
;; it under the terms of the GNU General Public License as published by
;; the Free Software Foundation, either version 3 of the License, or
;; (at your option) any later version.

;; This program is distributed in the hope that it will be useful,
;; but WITHOUT ANY WARRANTY; without even the implied warranty of
;; MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
;; GNU General Public License for more details.

;; You should have received a copy of the GNU General Public License
;; along with this program.  If not, see <https://www.gnu.org/licenses/>.

;; This file is NOT part of GNU Emacs.

;;; Commentary:
;;
;; macher is a project-aware LLM editing toolset built on gptel.
;;
;; Key features:
;; - gptel presets that add read/edit tools and workspace context to requests
;; - View proposed changes as reviewable patches (potentially spanning multiple
;;   files)
;; - Quick workflow via `macher-implement', `macher-revise', and
;;   `macher-discuss'
;;
;; Conceptually, when you send a macher request, the LLM receives tools to
;; read/search/edit files in your "workspace" (typically the current project).
;; Changes are captured in memory and displayed as patches, never written
;; directly to disk.
;;
;; Setup: Call `macher-install' to register presets and tools with gptel.
;; Optionally call `macher-enable' to apply base infrastructure globally,
;; allowing use of macher tools and dynamic context in any gptel buffer.
;;
;; Usage: The action commands (`macher-implement', `macher-revise',
;; `macher-discuss') provide a quick workflow in dedicated buffers.
;; Alternatively, use macher presets in any gptel request:
;;
;;   @macher set up eslint with sensible defaults
;;
;; Available presets: `@macher' (full editing), `@macher-ro' (read-only),
;; `@macher-system' (context only), and others - see `macher-presets-alist'.

;;; Code:

(require 'gptel)
(require 'gptel-context)
(require 'cl-lib)

;; Declare external functions.  The associated libraries are explicitly required at runtime before
;; these functions are actually called.  Note that Eask's "declare" linter should flag any
;; declarations that don't actually exist.
(declare-function diff-fixup-modifs "diff-mode")
(declare-function diff-setup-buffer-type "diff-mode")
(declare-function org-escape-code-in-string "org-src")
(declare-function gptel-org-set-topic "gptel-org" (topic))
;; These xref functions are generated by cl-defstruct, so `check-declare' (run by the linter) won't
;; find them directly.  We need to specify an arguments list and t to tell `check-declare' to only
;; ensure that the associated file exists, not that it actually contains these functions.
(declare-function xref-item-location "xref" (item) t)
(declare-function xref-item-summary "xref" (item) t)
(declare-function xref-file-location-file "xref" (file-location) t)
(declare-function xref-file-location-line "xref" (file-location) t)

;;; Preliminary definitions needed for defcustom defaults

(defconst macher--preset-setup-tools
  '(:description
    "macher base: setup tools"
    :prompt-transform-functions
    (:function
     ;; Remove any existing instances of the base transform, and then append it at the end. This
     ;; explicit at-the-end ordering ensures that if we append this preset to the end of, for
     ;; example, a list of other @presets in a request, we'll capture the prompt text as it appears
     ;; _after_ any other prompt transforms.
     (lambda (prompt-transform-functions)
       (let ((transform #'macher--transform-setup-tools))
         (append
          (seq-remove (lambda (el) (equal el transform)) prompt-transform-functions)
          (list transform))))))
  "Helper preset for tool setup in macher-base.")

(defconst macher--preset-system-transform
  '(:description
    "macher base: setup system prompt context injection"
    :prompt-transform-functions
    (:function
     (lambda (prompt-transform-functions)
       (macher--append-if-missing
        prompt-transform-functions (list #'macher--transform-system-replace-placeholder))))))

(defconst macher-preset-base
  `(:description
    "Utility preset, must be applied to use macher tools and system prompts"
    :parents (,macher--preset-system-transform ,macher--preset-setup-tools))
  "Preset spec to set up macher behavior for outgoing gptel requests.

You can apply this preset globally by calling `macher-enable'.

Or, if you've run `macher-install' with default settings, you can apply
the \"@macher-base\" preset from the gptel menu.

Without this preset (or a preset inheriting from it), macher tools won't
work (they'll just respond with an error), and the
`macher-context-string-placeholder' won't be replaced in the system
prompt.

If you're making your own presets involving macher tools, the easiest
way to make them work robustly is to add this preset as a parent.

This preset should be safe to apply globally and/or repeatedly.  It
won't change anything about the content of your outgoing requests, other
than replacing the `macher-context-string-placeholder' in the system
prompt if present.")

(defconst macher-preset-system
  `(:description
    "Add macher workspace info to the system prompt (if not already present)"
    :parents (,macher--preset-system-transform)
    :system (:function macher--system-ensure-placeholder-or-context-string))
  "Preset spec to ensure the macher workspace context appears in the system prompt.

If the system prompt already contains the
`macher-context-string-placeholder' OR the
`macher-context-string-marker-start' (i.e. if the context has been
commiitted), this preset is a no-op.

Otherwise, this preset appends the `macher-context-string-placeholder'
to the end of the system prompt.

This preset also pulls in the prompt transform to replace the
placeholder in the system prompt at request-time.")

(defconst macher-preset-system-commit
  '(:description
    "Commit the current macher context string to the system prompt"
    :system (:function macher--system-replace-placeholder))
  "Preset spec to commit the current macher workspace context in the system prompt.

Replaces all instances of the `macher-context-string-placeholder' in the
system prompt with the current result of the
`macher-context-string-function'.

This preset is a no-op unless the system prompt contains the
`macher-context-string-placeholder'.

This might be useful, for example, if you want to avoid cache churn.

It probably only makes sense to apply this at the buffer-local level.")

(defconst macher--preset-clear-tools
  '(:description
    "Remove all macher tools"
    :tools
    (:function
     (lambda (tools)
       (seq-remove
        (lambda (tool) (equal (gptel-tool-category tool) macher-tool-category)) tools)))))

(defun macher--tools-preset (pred &rest keys)
  "Get a gptel preset spec which enables a selection of macher tools.

KEYS is a baseline spec plist, as would be passed to
`gptel-make-preset'.  It cannot include :tools or :use-tools.

The baseline spec will be augmented with:

- an appropriate :tools entry (which appends all matching tools to
  variable `gptel-tools', unless they already appear there).

- a :use-tools entry to ensure `gptel-use-tools' is non-nil.

- `macher-preset-base' as a parent.

When the preset is applied, the PRED receives each element of
`macher-tools', and should return non-nil for tools that should be
enabled.  Tools are then normalized using `macher-resolve-tool'.

Any matching tools already present in the variable
`gptel-tools' (according to their name/category) will be skipped."
  (when (plist-get keys :tools)
    (error "Cannot include :tools in a preset spec for `macher--tools-preset'"))
  (when (plist-get keys :use-tools)
    (error "Cannot include :use-tools in a preset spec for `macher--tools-preset'"))
  (let* ((parents
          (append (ensure-list (plist-get keys :parents)) (list macher--preset-setup-tools))))
    (plist-put
     (plist-put
      (plist-put
       keys
       ;; Make sure tools are enabled, but preserve the existing value of `gptel-use-tools' if
       ;; already non-nil (e.g. \\='force.
       :use-tools '(:function (lambda (use-tools) (or use-tools t))))
      ;; Add tools from `macher-tools' matching the predicate.
      :tools `(:function ,(apply-partially #'macher--preset-function-add-tools pred)))
     :parents parents)))

(defun macher--action-from-region-or-input (input-prompt transform preset &optional input)
  "Get a macher action plist from user input or the selected region.

First, gets user input from the selected region, or if no region is
selected, prompts interactively using `read-string' with the
INPUT-PROMPT (e.g. \"Your request: \").  If an INPUT argument is provided
explicitly, this step will be skipped and the argument value will be
used directly.

Then, generates a full prompt by calling the TRANSFORM function with two
arguments:

- INPUT - the user input string from the previous step

- IS-SELECTED - a boolean indicating whether the user input was pulled
  from the selected region (as opposed to from a prompt or an explicit
  INPUT argument).

The returned plist will contain the transformed :prompt, the
:preset (simply the PRESET argument), and a :summary (equal to the input
that was passed to the transformer).

This function is used by the default actions in the
`macher-actions-alist'."

  ;; Prompt to save any unsaved buffers.
  (save-some-buffers nil (lambda () (and (buffer-file-name) (buffer-modified-p))))

  (let* ((user-input
          (cond
           ;; If INPUT is explicitly provided, use it directly.
           (input
            input)
           ;; If there's an active region, use its contents.
           ((use-region-p)
            (buffer-substring-no-properties (region-beginning) (region-end)))
           ;; Otherwise, prompt the user.
           (t
            (read-string input-prompt))))
         (is-selected (and (not input) (use-region-p)))
         (transformed-prompt (funcall transform user-input is-selected)))
    `(:prompt ,transformed-prompt :preset ,preset :summary ,user-input)))

(define-obsolete-function-alias
  'macher-action-from-region-or-input
  #'macher--action-from-region-or-input
  "0.5.0"
  "Removed from public API.

You can use `macher--action-from-region-or-input' if you need to, but
behavior is subject to change in the future.")

(define-obsolete-function-alias 'macher--project-workspace #'macher-workspace-project "0.6.0"
  "Renamed to `macher-workspace-project' for a consistent public API.
All workspace detection functions now use the `macher-workspace-' prefix.")

(define-obsolete-function-alias 'macher--file-workspace #'macher-workspace-file "0.6.0"
  "Renamed to `macher-workspace-file' for a consistent public API.
All workspace detection functions now use the `macher-workspace-' prefix.")

(defconst macher--workspace-postfix
  (concat
   "\n\n"
   "WORKSPACE DEFINITION:\n"
   "The \"workspace\" is a purely in-memory editing environment containing a snapshot of files from "
   "the user's current project. When you use these tools:\n"
   "- You are reading from and modifying this in-memory snapshot, NOT the actual files on disk\n"
   "- Changes you make will NOT be saved directly to the filesystem\n"
   "- Instead, all changes are tracked and will be presented to the user as a patch for review\n"
   "- The user can then decide whether to apply your proposed changes to their actual files\n"
   "- Any CLI commands or file-reading operations you suggest to the user will NOT see your workspace changes\n"
   "- The contents of \"files\" in the workspace will be captured the first time you access them, so subsequent "
   "filesystem changes by other processes will NOT be reflected in the workspace\n"
   "- The workspace is cleared every time the user sends a request\n"
   "- Between requests, the user may apply all, some, or none of your changes, and may make additional edits")
  "Description of the workspace concept for tool descriptions.

This text is appended to tool descriptions to clarify that workspace
tools operate on an in-memory editing environment, not directly on the
filesystem.")

;;; Customization

(defgroup macher nil
  "Project-aware LLM implementation toolset."
  :group 'convenience
  :prefix "macher-")

(defgroup macher-actions nil
  "Customization for macher actions and action buffers."
  :group 'macher
  :prefix "macher-")

(defgroup macher-workspace nil
  "Customization for workspace detection and context generation."
  :group 'macher
  :prefix "macher-")

(defgroup macher-processing nil
  "Customization for processing completed requests and generating patches."
  :group 'macher
  :prefix "macher-")

(defgroup macher-tools nil
  "Customization for macher tools and presets."
  :group 'macher
  :prefix "macher-")

;;;; Actions

(defcustom macher-actions-alist
  `((implement
     .
     ,(apply-partially #'macher--action-from-region-or-input
                       "To implement: "
                       #'macher--implement-prompt
                       'macher))
    (revise
     .
     ,(apply-partially #'macher--action-from-region-or-input
                       "Revision instructions: "
                       #'macher--revise-prompt
                       'macher))
    (discuss
     .
     ,(apply-partially #'macher--action-from-region-or-input
                       "Discuss: "
                       #'macher--discuss-prompt
                       'macher-ro)))
  "Alist of actions that can be invoked within a macher workspace.

These definitions are used by `macher-action' to send macher requests
with a specific workflow, where a prompt is generated based on
contextual information and sent from a shared buffer for the current
workspace.  See `macher-action' for a more detailed description of the
workflow.

Each entry is of the form (ACTION . FUNCTION-OR-PLIST) where ACTION is a
symbol representing the action name and FUNCTION-OR-PLIST is (or
returns) a plist containing the following keys:

- :prompt - the full prompt string to send to the LLM.

- :preset - gptel preset to use for the request (optional, defaults to
  ='macher').  This can be a symbol or a plain preset spec plist (like
  the keys from `gptel-make-preset').  If it's a symbol, it will be
  resolved first by checking the global gptel registry for a preset of
  that name, or if not found, by checking for an entry with that name in
  the `macher-presets-alist'.

- :summary - a summarized version of the prompt (optional).  This won't
  affect the actual request content, but it will be included with the
  `macher-action-execution' struct provided to the before-/after-action
  hooks, for potential usage in the UI.  For the built-in actions, this
  will be the raw user input or selected-region text that was used to
  generate the prompt (as opposed to the full prompt including
  instructions and other contextual information).  The default action
  buffer UI uses this to render a heading above the full prompt text.

If FUNCTION-OR-PLIST is a function, it will be called in the context
where the action was initiated, so it can access buffer content,
region-selection status, etc.  The function must be callable with no
arguments, although additional (i.e. &rest) arguments passed to
`macher-action' will be forwarded to it if provided - this allows, for
example, input strings to be passed programmatically to the default
actions."
  :type
  '(alist
    :key-type symbol
    :value-type
    (choice
     (function :tag "Function that generates action plist")
     (plist
      :tag "Action plist"
      :options
      ((:prompt (string :tag "Full prompt string to send to the LLM"))
       (:preset
        (choice (symbol :tag "Preset symbol") (plist :tag "Preset spec plist"))
        :tag "Preset to use (optional, defaults to 'macher)")
       (:summary (string :tag "Summarized prompt (optional)"))))))
  :group 'macher-actions)

(defcustom macher-action-buffer-ui 'default
  "Specifies a base UI configuration to use for action buffers.

The action buffer for a workspace is the shared buffer where gptel
requests are made when using `macher-action' (or its wrappers like
`macher-implement').

This value controls the buffer-local setup that happens just after an
action buffer is created, and just before the
`macher-action-buffer-setup-hook' is fired.  It effectively sets up a
predefined \"pre\" function for the `macher-action-buffer-setup-hook'.

The choices are:

- ='basic' - sets up buffer-local hooks in
  `macher-before-action-functions' and `macher-after-action-functions'
  to display the action buffer and insert a nicely-formatted prompt.

- ='default' - like ='basic', but also enables `gptel-mode' and the
  global `gptel-default-mode' (e.g. markdown or org), and ensures tool
  calls are rendered.

- ='org' - like ='default', but instead of the `gptel-default-mode',
  enables `org-mode'.  If you're already using `org-mode' as your
  `gptel-default-mode', this will behave exactly the same as ='default'.

- nil - don't perform any action buffer setup.  The action buffer will
  just be a `fundamental-mode' buffer which receives the responses from
  gptel requests made through `macher-action'.  Use this if you want
  full control over the action buffer UI - you can customize the
  `macher-action-buffer-setup-hook' to perform your own initialization
  from scratch.

The exact behavior of the predefined UI configurations is subject to
change."
  :type
  '(choice
    (const :tag "Basic UI with hooks only" basic)
    (const :tag "Default UI with gptel-mode and formatting" default)
    (const :tag "Org-mode UI with gptel-org" org)
    (const :tag "No setup - full manual control" nil))
  :group 'macher-actions)

(defcustom macher-action-buffer-setup-hook nil
  "Hook run when creating new macher action buffers.

Functions in this hook are called with no arguments in the newly created
action buffer.  The buffer will already have its workspace set up (i.e.
`macher-workspace' can be used to get the associated workspace) and
`default-directory' set to the workspace root.

The base UI configuration is controlled by `macher-action-buffer-ui'
and is applied before this hook runs.  This hook can be used for
additional customization on top of the base configuration.

If you want full control over the action buffer UI, set
`macher-action-buffer-ui' to nil and use this hook to perform all
setup from scratch.  In that case, you'll probably also need to
customize (globally or buffer-locally) the
`macher-before-action-functions' to make sure the buffer actually gets
displayed."
  :type 'hook
  :group 'macher-actions)

(defcustom macher-action-dispatch-hook nil
  "Hook run when invoking a macher action.

This hook is run within the source buffer where the action is being
invoked, e.g. a file or directory buffer.  It runs before the prompt text
is captured/transformed - so you could use this, for example, to adjust
the selected region, or add the current file to the gptel context.

This runs prior to the `macher-before-action-functions', and potentially
prior to the creation of the action buffer."
  :type 'hook
  :group 'macher-actions)

(defcustom macher-before-action-functions nil
  "Abnormal hook run before sending the request for a macher action.

Each function is called in the workspace's action buffer, with a
`macher-action-execution' object containing details about the request
that's about to be sent.

Note this hook runs _after_ the `macher-action-dispatch-hook', after the
prompt has been captured and transformed but before the actual request
is sent.

Functions can modify the PROMPT or CONTEXT fields of the execution
object to change the prompt that will be sent to the LLM and/or add
context to the request's `gptel-fsm'.

Note that the default UI configurations (controlled by
`macher-action-buffer-ui') will modify this hook buffer-locally in the
action buffer.  This is what handles inserting the prompt and displaying
the action buffer when you make a request.  If you set
`macher-action-buffer-ui' to nil, you'll probably also want to add
something to this hook which at least displays the action buffer."
  :type 'hook
  :group 'macher-actions)

(defcustom macher-after-action-functions nil
  "Abnormal hook run after a macher action's request completes.

Each function is called with three arguments: ERROR (nil on success, or an
error description on failure), EXECUTION (the `macher-action-execution'
object for the completed action), and FSM (the `gptel-fsm' object for the
request).

The hook will be run in the action buffer where the request is being
executed, so it can be customized buffer-locally.  In the edge case where
the action buffer is killed during request execution, the hook will not
be run - though gptel will probably throw other errors first in this
case.

This hook runs after the callback provided to `macher-action' (if any)."
  :type 'hook
  :group 'macher-actions)

;;;; Context

(defcustom macher-focus-string #'macher--focus-string-default
  "Function to generate a description of your current location in the code.

This will be evaluated when generating prompts for built-in actions, or
when calling function `macher-focus-string' directly.  It can be a
sexp form or a function.

The result should be a string describing the user's focus - the file,
cursor location, selection, etc.  This is included in prompts for
built-in actions and can be yanked into any conversation to point the
LLM at a specific place in your code.

By default, this uses `macher--focus-string-default'.

Call function `macher-focus-string' interactively to yank this value."
  :type '(choice (function :tag "Function to call") (sexp :tag "Form to evaluate"))
  :group 'macher-actions)

(defcustom macher-context-string-placeholder "\n[macher_placeholder]\n"
  "A placeholder in the system prompt for the macher context string.

This gets inserted (if not already present) into the gptel system
message when applying the \"@macher\", \"@macher-ro\", and
\"@macher-system\" presets.  You can also use it in your own gptel
directives if you want finer control over placement.

At request time, as long as you've activated the \"@macher-base\"
preset (or are using one of the other built-in presets), this will be
replaced with the current result of the `macher-context-string-function'
in your system prompt.

Setting this to nil will disable request-time modification of the
outgoing system prompt.  Note, though, that in that case
\"@macher-system\" etc. will still insert the _current_ result of
`macher-context-string-function' (if not already present) directly into
the system prompt when applied.  If you want to disable macher's
interaction with the system prompt entirely, you can set both this and
`macher-context-string-function' to nil."
  :type '(choice (string :tag "Placeholder string") (const :tag "Disabled" nil))
  :group 'macher-workspace)

(defcustom macher-context-string-marker-start "\n<current-project>\n"
  "A marker for the start of the macher context string in the system prompt.

You should avoid using this exact string elsewhere in your system
prompt, otherwise the workspace context string might not get added to
macher requests."
  :type 'string
  :group 'macher-workspace)

(defcustom macher-context-string-marker-end "\n</current-project>\n"
  "A marker for the end of the macher context string in the system prompt.

For now this is purely cosmetic, but you should avoid using this exact
string elsewhere in your system prompt, as it may be used for additional
system transforms in the future."
  :type 'string
  :group 'macher-workspace)

(defcustom macher-context-string-function #'macher--context-string
  "Function for generating workspace information strings during macher requests.

This function will be called from the buffer where the request is being
sent (or a preset is being applied).  It receives no arguments.  You can
use `macher-workspace' to get the buffer's current workspace.

The function should return a workspace information string that can be
included in the system prompt.

As long as `macher-preset-base' is applied, any instances of the
`macher-context-string-placeholder' in the system prompt will be
replaced with the result of this function.

Set this to nil to disable workspace context injection.  Note that in
that case, \"@macher-system\" etc. will still insert the
`macher-context-string-placeholder' into the raw system prompt.  If you
want to disable macher's interaction with the system prompt entirely,
you can set both this and `macher-context-string-placeholder' to nil."
  :type '(function :tag "Workspace string function")
  :group 'macher-workspace)

(defcustom macher-context-string-max-files 50
  "Maximum number of files to include in the workspace context string.

When using the default `macher-context-string-function', only up to this
many files will be listed.

Files from the gptel context are always included in the workspace
context (even if their count exceeds this limit).  Additional workspace
files will be included up to the remaining limit.

Files are listed in the same order as returned by the workspace's files
function - for built-in workspace types, this means files are listed in
alphabetical order.

Set to nil to disable the limit entirely.

Note: This value is used within the default `macher--context-string'
function.  If you customize `macher-context-string-function' to use a
different function, this value will have no effect."
  :type '(choice (natnum :tag "Maximum number of files") (const :tag "No limit" nil))
  :group 'macher-workspace)

;;;; Result processing

(defcustom macher-process-request-function #'macher--process-request
  "Function to take action based on the current state of a macher request.

This function is called when requests where macher tools were used are
completed (with success or error), or manually via
`macher-process-request' and variants.

By default, this function prepares and displays the patch buffer if the
request contains any edits.

The function is called with three arguments:

- REASON: the reason that the processing function is being invoked.
  Currently this can be:

  - The symbol ='complete' - when invoked at the end of the request
    lifecycle.

  - The symbol ='interactive' - when `macher-process-request' is invoked
    interactively.

  - any custom value passed to `macher-process-request'
    programmatically.

- CONTEXT: the `macher-context' for the request.

- FSM: the `gptel-fsm' (state machine) for the request.

This function is only called for requests that actually create a
`macher-context', i.e. requests where macher tools are present and used.
The ='macher-system' preset will not cause the hook to be called.

The value of this variable will be stored at request-time and used
throughout a particular request.  This enables custom handling on a
per-request basis."
  :type 'function
  :group 'macher-processing)

(defcustom macher-patch-prepare-functions
  '(macher--patch-prepare-diff macher--patch-prepare-metadata)
  "Abnormal hook called to prepare the patch content when processing a request.

Functions in this hook will be called inside an initially-empty
temporary buffer (not the actual patch buffer), and should modify the
buffer to contain the patch content.  They will be called with three
arguments:

- CONTEXT: the `macher-context' associated with the request being
  processed.

- FSM: the `gptel-fsm' struct for the request.

- CALLBACK: a callback that must be called after the preparation step is
  finished.  This function can be called synchronously or asynchronously,
  but it must be called in all cases.

By default, the functions in this hook will populate the buffer with a
git-patch-style diff, and add metadata including the request prompt.

If you want to add to this hook but also preserve the default behavior,
make sure you call `add-hook' (or similar) _after_ requiring `macher'.
If you call it beforehand, the default hook value will simply be
ignored.  If you're using `use-package', this means you should avoid the
:hook keyword, and instead use `add-hook' in the :config section.

This hook is run within the default `macher-process-request-function'.
If you modify that function for a particular request, this hook won't be
run."
  :type 'hook
  :group 'macher-processing)

(defcustom macher-patch-buffer-ui 'diff
  "Specifies a base UI configuration to use for patch buffers.

The patch buffer for a workspace is the shared buffer where diff content
is displayed when using macher actions that generate patches.

This value controls the buffer-local setup that happens just after a
patch buffer is created, and just before the
`macher-patch-buffer-setup-hook' is fired.  It effectively sets up a
predefined \"pre\" function for the `macher-patch-buffer-setup-hook'.

The choices are:

- \\='diff - sets up `diff-mode', makes the buffer read-only, and adds
  buffer-local hooks to display the patch buffer when it's ready.

- nil - don't perform any patch buffer setup.  The patch buffer will
  just be a `fundamental-mode' buffer which receives the patch content.
  Use this if you want full control over the patch buffer UI - you can
  customize the `macher-patch-buffer-setup-hook' and
  `macher-patch-ready-hook' to perform your own initialization from
  scratch."
  :type
  '(choice
    (const :tag "Default diff UI with diff-mode" diff)
    (const :tag "No setup - full manual control" nil))
  :group 'macher-processing)

(defcustom macher-patch-buffer-setup-hook nil
  "Hook run when creating new macher patch buffers.

Functions in this hook are called with no arguments in the newly created
patch buffer.  The buffer will already have its workspace set up (i.e.
`macher-workspace' can be used to get the associated workspace) and
`default-directory' set to the workspace root.

The base UI configuration is controlled by `macher-patch-buffer-ui'
and is applied before this hook runs.  This hook can be used for
additional customization on top of the base configuration.

If you want full control over the patch buffer UI, set
`macher-patch-buffer-ui' to nil and use this hook to perform all
setup from scratch."
  :type 'hook
  :group 'macher-processing)

(defcustom macher-patch-ready-hook nil
  "Hook called after the patch buffer has been prepared.

Functions in this hook will be called inside the patch buffer, with its
contents initialized to the result of the
`macher-patch-prepare-functions' (i.e. the contents of the temporary
preparation buffer after running those functions).  They will be called
with no arguments.

The patch buffer's `default-directory' will be set to the workspace
root, and `macher-workspace' can be called to retrieve the associated
workspace.

The base UI configuration is controlled by `macher-patch-buffer-ui'
and will modify this hook buffer-locally in the patch buffer.  This is
what handles setting up `diff-mode' and displaying the patch buffer when
it's ready.  If you set `macher-patch-buffer-ui' to nil, you'll probably
also want to add something to this hook which at least displays the
patch buffer.

This hook is run within the default `macher-process-request-function'.
If you modify that function for a particular request, this hook won't be
run."
  :type 'hook
  :group 'macher-processing)

;;;; Tools, Presets, and Tool Settings

(defcustom macher-tool-category "macher"
  "The category to use when installing macher tools.

Tools in this category receive special handling to enable them to
interact with the current request's `macher-context'.  You should avoid
adding tools to this category directly; instead, customize
`macher-tools' as needed."
  :type 'string
  :group 'macher-tools)

(defcustom macher-tools
  `((:name
     "read_file_in_workspace"
     :category "read"
     :function macher--tool-read-file
     :description
     ,(concat
       "Read file contents from your workspace.\n"
       "\n"
       "Returns the file contents as a string. Supports optional line-based slicing "
       "(offset/limit) and line numbering.\n"
       "\n"
       "Returns file contents on success. Fails if the file is not found in the workspace "
       "or if the content exceeds the maximum read length."
       macher--workspace-postfix)
     :confirm nil
     :include nil
     :args
     ((:name "path" :type string :description "Path to the file, relative to workspace root")
      (:name
       "offset"
       :type number
       :optional t
       :description
       ,(concat
         "Line number to start reading from (1-based). For negative values, starts at that many "
         "lines before the end of the file. ONLY use for targeted re-reads - read entire file first!"))
      (:name
       "limit"
       :type number
       :optional t
       :description
       ,(concat
         "Number of lines to read from the start position. For negative values, the actual "
         "limit is computed as (total_lines + limit). For example, with a 100-line file: "
         "limit=10 reads 10 lines, limit=-10 reads 90 lines (100 + (-10)). "
         "ONLY use for targeted re-reads - read entire file first!"))
      (:name
       "show_line_numbers"
       :type boolean
       :optional t
       :description
       ,(concat
         "Include line numbers in output (cat -n style: each line prefixed with right-aligned "
         "line number and a tab character)"))))

    (:name
     "list_directory_in_workspace"
     :category "read"
     :function macher--tool-list-directory
     :description
     ,(concat
       "List directory contents, reflecting current workspace state including your changes. "
       "Shows files (file:), directories (dir:), and symlinks (link:) with optional file sizes and "
       "recursive traversal.\n"
       "\n"
       "Returns formatted directory listing on success. Fails if the directory is not found in "
       "the workspace."
       macher--workspace-postfix)
     :confirm nil
     :include nil
     :args
     ((:name "path" :type string :description "Path to the directory, relative to workspace root")
      (:name
       "recursive"
       :type boolean
       :optional t
       :description "If true, recursively list subdirectories")
      (:name
       "sizes"
       :type boolean
       :optional t
       :description "If true, include file sizes in the output")))

    (:name
     "search_in_workspace"
     :category "read"
     :function macher--tool-search
     :description
     ,(concat
       "Search for regex patterns across workspace files. Searches reflect your pending changes.\n"
       "\n"
       "Two modes:\n"
       "- 'files' (default): file paths + match counts\n"
       "- 'content': matching lines with optional context (lines_before/after)\n"
       "\n"
       "Returns formatted search results on success. Fails if pattern is empty or if parameters "
       "are invalid."
       macher--workspace-postfix)
     :confirm nil
     :include nil
     :args
     ((:name
       "pattern"
       :type string
       :description "Regular expression pattern to search for (required)")
      (:name
       "path"
       :type string
       :optional t
       :description "Directory or file to search, relative to workspace root (defaults to workspace root)")
      (:name
       "file_regexp"
       :type string
       :optional t
       :description
       ,(concat
         "Filter files by regular expression matched against file path relative to search path. "
         "Examples: '\\.py$' for Python files, 'src/.*\\.js$' for JS files in src/, "
         "'test.*\\.py$' for test files"))
      (:name
       "mode"
       :type string
       :optional t
       :description
       ,(concat
         "Output mode: ='files' (default, shows paths + counts) or "
         "='content' (shows grep-style matching lines)"))
      (:name
       "case_insensitive"
       :type boolean
       :optional t
       :description "If true, perform case-insensitive search (default: false)")
      (:name
       "lines_after"
       :type number
       :optional t
       :description "Number of lines to show after each match (content mode only). Like `grep -A`.")
      (:name
       "lines_before"
       :type number
       :optional t
       :description "Number of lines to show before each match (content mode only). Like `grep -B`.")
      (:name
       "show_line_numbers"
       :type boolean
       :optional t
       :description "Include line numbers in output (content mode only). Like `grep -n`.")
      (:name
       "head_limit"
       :type number
       :optional t
       :description "Limit output to first N lines (equivalent to piping through `head -N`)")))

    (:name
     "edit_file_in_workspace"
     :function macher--tool-edit-file
     :description
     ,(concat
       "Replace exact text in a file in your workspace. old_text must match precisely (whitespace, newlines, indentation). "
       "Use actual file content only - NO line numbers.\n"
       "\n"
       "If replace_all=false and multiple matches exist: ERROR. Add more context to old_text to make "
       "the match unique.\n"
       "\n"
       "Returns null on success. Fails if the file is not found, if the text to replace is not found, "
       "or if multiple matches exist when replace_all is false."
       macher--workspace-postfix)
     :confirm nil
     :include nil
     :args
     ((:name "path" :type string :description "Path to the file, relative to workspace root")
      (:name
       "old_text"
       :type string
       :description
       ,(concat
         "Exact text to find and replace. Must match precisely including whitespace and "
         "newlines. Do NOT include line numbers."))
      (:name "new_text" :type string :description "Text to replace the old_text with")
      (:name
       "replace_all"
       :type boolean
       :optional t
       :description "If true, replace all occurrences. If false (default), error if multiple matches exist")))

    (:name
     "multi_edit_file_in_workspace"
     :function macher--tool-multi-edit-file
     :description
     ,(concat
       "Apply multiple replacements to one file in your workspace sequentially. All edits use exact text matching "
       "(whitespace, newlines, indentation). Use actual content - NO line numbers.\n"
       "\n"
       "Edits apply in array order. If ANY edit fails, ALL changes are rolled back.\n"
       "\n"
       "Returns null on success. Fails if the file is not found or if any individual edit fails "
       "(text not found, multiple matches without replace_all, etc.)."
       macher--workspace-postfix)
     :confirm nil
     :include nil
     :args
     ((:name "path" :type string :description "Path to the file, relative to workspace root")
      (:name
       "edits"
       :type array
       :description "Array of edit operations to apply in sequence"
       :items
       (:type
        object
        :properties
        (:old_text
         (:type
          string
          :description
          ,(concat
            "Exact text to find and replace. Must match precisely including whitespace "
            "and newlines. Do NOT include line numbers."))
         :new_text (:type string :description "Text to replace the old_text with")
         :replace_all
         (:type
          boolean
          :description "If true, replace all occurrences. If false (default), error if multiple matches exist"))
        :required ["old_text" "new_text"]))))

    (:name
     "write_file_in_workspace"
     :function macher--tool-write-file
     :description
     ,(concat
       "Create new file or completely replace existing file content in your workspace.\n"
       "\n"
       "WARNING: Overwrites ALL existing content. Use edit_file_in_workspace for partial changes.\n"
       "\n"
       "Returns null on success. Fails if the path is invalid or outside the workspace."
       macher--workspace-postfix)
     :confirm nil
     :include nil
     :args
     ((:name "path" :type string :description "Path to the file, relative to workspace root")
      (:name
       "content"
       :type string
       :description "Complete new content that will replace the entire file")))

    (:name
     "move_file_in_workspace"
     :function macher--tool-move-file
     :description
     ,(concat
       "Move or rename a file in your workspace. Can change both location and name in one operation.\n"
       "\n"
       "Fails if destination already exists.\n"
       "\n"
       "Returns null on success."
       macher--workspace-postfix)
     :confirm nil
     :include nil
     :args
     ((:name
       "source_path"
       :type string
       :description "Current path of the file to move, relative to workspace root")
      (:name
       "destination_path"
       :type string
       :description "New path for the file, relative to workspace root")))

    (:name
     "delete_file_in_workspace"
     :function macher--tool-delete-file
     :description
     ,(concat
       "Delete a file in your workspace.\n"
       "\n"
       "Fails if file doesn't exist.\n"
       "\n"
       "Returns null on success."
       macher--workspace-postfix)
     :confirm nil
     :include nil
     :args
     ((:name
       "path"
       :type string
       :description "Path to the file to delete, relative to workspace root"))))
  "List of macher tool definitions.

Entries are plists of keyword arguments for `gptel-make-tool', except
that:

- FUNCTION (in both the sync and async cases) takes an additional first
  argument, the `macher-context' that the tool is interacting with.

- CATEGORY is ignored when creating the tool (all tools will live in the
  `macher-tool-category'), but can be used for internal organization -
  currently, the \"@macher-ro\" preset uses the value \"read\" to select
  tools, though the exact behavior here is subject to change.

These tools will be installed to gptel's global tool registry (but not
to the global variable `gptel-tools') when calling `macher-install'.

When using the \"@macher\" and \"@macher-ro\" presets, tool names will
be looked up in the global gptel registry and used if they exist;
otherwise, anonymous tools will be created using these definitions."
  :group 'macher
  :type
  '(repeat
    (plist
     :options
     ((:name string)
      (:function function)
      (:description string)
      (:args (vector plist))
      (:category string)
      (:async boolean)
      (:confirm (choice (const nil) (const t) (function)))
      (:include boolean)))))

(defcustom macher-match-max-columns 300
  "Maximum length in characters for individual lines in search matches.

Any matches or context lines from the search tool which exceed this
length will be replaced with an omission message, similar to ripgrep's
'--max-columns' option.

Set to nil to disable the limit entirely."
  :type '(choice (natnum :tag "Maximum number of characters") (const :tag "No limit" nil))
  :group 'macher-tools)

(defcustom macher-presets-alist
  `(
    ;; Enable all macher tools.
    (macher
     .
     ,(macher--tools-preset
       ;; Include all macher tools.
       (lambda (_) t)
       :description "Send macher workspace context + tools to read files and propose edits"
       :parents (list macher-preset-system)))
    ;; Enable read-only macher tools, and disable other macher tools.
    (macher-ro
     .
     ,(macher--tools-preset
       ;; Only include macher tools categorized as read tools.
       (lambda (tool-def) (equal (plist-get tool-def :category) "read"))
       :description "Send macher workspace context + tools to read files"
       ;; Clear other macher tools before applying - force read-only.
       :parents (list macher-preset-system macher--preset-clear-tools)))
    (macher-tools
     .
     ,(macher--tools-preset
       ;; Include all macher tools.
       (lambda (_) t)
       :description "Enable macher tools only"))
    ;; Add dynamic contextual info about the current workspace to the system prompt.
    (macher-system . ,macher-preset-system)
    ;; Commit current system prompt with contextual info (i.e. make it non-dynamic) - may be useful
    ;; to avoid cache thrashing.
    (macher-system-commit . ,macher-preset-system-commit)
    ;; Inject context into macher tools.
    (macher-base . ,macher-preset-base))
  "Alist of definitions for macher presets.

Entries have the form (NAME . KEYS).  NAME is the name to use when
installing presets globally with `macher-install'.  KEYS are the keys to
pass to `gptel-make-preset'.

This list will also be used to resolve preset symbols when using
`macher-action', in case a symbol isn't found in the global gptel
registry.

Otherwise there's nothing special about this list - you can also use
macher tools in your custom presets, etc."
  :type '(alist :key-type symbol :value-type plist)
  :group 'macher-tools)

;;;; Workspace selection and configuration

(defcustom macher-workspace-functions '(macher-workspace-project macher-workspace-file)
  "Functions to determine the workspace for the current buffer.

Each function in this list is called with no arguments in the current
buffer until one returns a non-nil workspace cons cell of the form
\(TYPE . ID).

Functions should return nil if they cannot determine a workspace for
the current buffer, allowing other functions in the list to try.

Built-in workspace functions:
- `macher-workspace-project': Uses project.el to find project workspaces
- `macher-workspace-file': Falls back to single-file workspaces
- `macher-workspace-directory': Uses `default-directory' as the
  workspace root (opt-in, not included in the default value)

The output and patch buffers will be shared among files in the same
workspace (i.e. same type and ID).

To add custom workspace detection, add functions to this list.

Custom functions should return a cons cell (TYPE . ID) where:
- TYPE is a symbol from the `macher-workspace-types-alist' (e.g.
  ='project', ='file', or a custom workspace type)
- ID is the workspace identifier (typically a root directory or file path,
  but for custom workspace types, it can be any string as long as you
  can use it to resolve a real root path)"
  :type 'hook
  :group 'macher-workspace)

(defcustom macher-workspace-types-alist
  '((project
     .
     (:get-root
      macher--project-root
      :get-name macher--project-name
      :get-files macher--project-files))
    (file . (:get-root file-name-directory :get-name file-name-nondirectory :get-files list))
    (directory
     .
     (:get-root
      macher--directory-root
      :get-name macher--directory-name
      :get-files macher--directory-files)))
  "Alist mapping workspace types to their defining functions.

Each entry is of the form (TYPE . PLIST) where TYPE is a symbol
representing the workspace type and PLIST contains the following keys:

- :get-root - Function to get the workspace root, i.e. the base
  directory for unified diffs and the \"root\" for relative filenames
  provided to tools.

- :get-name - Function to get a descriptive name for the workspace.

- :get-files - Function to get files in the workspace.  Returned paths
  can be absolute, or relative to the workspace root.

All functions receive the workspace ID as a string argument, i.e. the
cdr of a workspace cons cell whose type matches the associated alist key.

To add a new workspace type, add an entry to this alist and update
`macher-workspace-functions' to detect it."
  :type '(alist :key-type symbol :value-type (plist :key-type keyword :value-type function))
  :group 'macher-workspace)

;;; Constants

(defconst macher--max-read-length (* 1024 1024)
  "Max number of bytes to return from the read tool.")

;;; Variables

(defvar-local macher--workspace nil
  "Workspace information for the current macher session.
When nil (the default), uses `macher-workspace-functions' to
determine the workspace scope.

When set to a cons cell of the form (TYPE . ID) where:
- TYPE is a symbol identifying the workspace type
- ID is the workspace identifier (typically a root directory or file path)

The workspace represents the conceptual space where the LLM edits files.
From the LLM's perspective, when a request is made, the entire
workspace is loaded into in-memory editing buffers.")

;; Prevent the stored workspace from being cleared when changing major modes.  See
;; `kill-all-local-variables'.
(put 'macher--workspace 'permanent-local t)

(defvar-local macher--fsm-latest nil
  "The most recent macher FSM for the current buffer.

This is set at the beginning of each macher request and can be used
to access the FSM associated with the request even after it completes
or is aborted.")

;;; Context Structure

(cl-defstruct (macher-context (:constructor macher--make-context))
  "Structure holding context information for an implementation request.

- CONTENTS is an alist of (filename . (orig-content . new-content))
  pairs, where orig-content and new-content are strings.

- WORKSPACE is the workspace information (same format as `macher--workspace').

- PROMPT is the raw prompt text sent to the LLM (not including any
  conversation history/prior messages that might also have been
  included).

  Note the prompt is captured via a prompt transform that gets appended
  to 'gptel-prompt-transform-functions' when applying macher presets.  If
  the prompt is modified by other transforms that get added later in the
  chain (e.g. via other presets), these modifications won't be picked
  up.

- PROCESS-REQUEST-FUNCTION is the function to process the request
  throughout its lifecycle.  This will be set to the current value of
  `macher-process-request-function' at the time a request is made.

- DATA is an arbitrary user-defined data object that you can manipulate
  in custom tools.

- DIRTY-P is a boolean flag indicating whether any changes have been
  made to the workspace during the request.  This defaults to nil and is
  set to t by the edit tools when they make changes.  Custom tools that
  make changes should set this flag explicitly."
  (contents nil)
  (workspace)
  (prompt)
  (process-request-function)
  (data nil)
  (dirty-p nil))

(cl-defstruct (macher-action-execution (:constructor macher--make-action-execution))
  "Structure holding execution information for a macher action.

- ACTION is the action name symbol (e.g. \\='implement, \\='revise,
  \\='discuss).

- PROMPT is the transformed prompt string that will be sent to the LLM.
  Functions in `macher-before-action-functions' can modify this field to
  change the prompt before sending.

- PRESET is the preset string or plist that will be applied.  See
  `macher-actions-alist' for allowed formats.

- SUMMARY is a summary of the prompt being sent - for built-in actions,
  this is the actual user input string.

- BUFFER is the action buffer where the request will be sent.

- SOURCE is the source buffer where the action was initiated.

- CONTEXT will be passed as the :context key when calling
  `gptel-request'.  This is a user-defined object that can be read from
  the `gptel-fsm' (state machine) associated with the request.  Functions
  in `macher-before-action-functions' can modify this field."
  (action)
  (prompt)
  (preset)
  (summary)
  (buffer)
  (source)
  (context nil))

;;; Internal Functions

;; Built-in workspace detection functions
(defun macher-workspace-project ()
  "Detect project workspace for the current buffer.
Returns (project . ROOT) if the buffer is in a project, nil otherwise."
  (require 'project)
  (when-let ((project (project-current nil default-directory)))
    (cons 'project (project-root project))))

(defun macher-workspace-file ()
  "Detect file workspace for the current buffer.
Returns (file . FILENAME) if the buffer is visiting a file, nil otherwise."
  (when-let ((filename (buffer-file-name)))
    (cons 'file filename)))

(defun macher-workspace-directory ()
  "Detect directory workspace for the current buffer.
Returns (directory . DIR) using the buffer's `default-directory'.

All files under DIR are exposed to the LLM, including hidden files and
directories.  This function is NOT included in
`macher-workspace-functions' by default because it would expose all
files under `default-directory' to the LLM, which could be intrusive
for broad directories like the home directory.

To enable, add to `macher-workspace-functions':

  (setq macher-workspace-functions
        \\='(macher-workspace-project macher-workspace-directory))"
  (when-let ((dir
              (and default-directory
                   (file-directory-p default-directory)
                   (expand-file-name default-directory))))
    (cons 'directory dir)))

;; Built-in workspace type functions
(defun macher--project-root (project-id)
  "Get the project root for PROJECT-ID.

Validates only the shape of the ID (absolute path string).  A full
`project-current' check is deliberately avoided here: it triggers a
cascade of remote I/O (VC discovery, `.gitmodules' probing, etc.) that
becomes very expensive over TRAMP, and `project-files' will re-validate
the project when it's actually needed."
  (unless (and (stringp project-id) (file-name-absolute-p project-id))
    (error "Project ID '%s' is not a valid project root" project-id))
  project-id)

(defun macher--project-name (project-id)
  "Get the project name for PROJECT-ID using project.el."
  (require 'project)
  (if-let ((project (project-current nil project-id)))
      (project-name project)
    ;; Get the last directory name from the root (trailing slash removed).
    (file-name-nondirectory (directory-file-name project-id))))

(defun macher--project-files (project-id)
  "Get list of files in PROJECT-ID using project.el.
Returns a list of relative file paths."
  ;; project.el allows a nil ID (in which case it just looks at the current buffer), but this
  ;; would give inconsistent results and introduce a security risk that files from unexpected
  ;; projects could be exposed to the workspace.
  (unless (stringp project-id)
    (error "Project ID must be a string, got %s (%s)" (type-of project-id) project-id))
  (require 'project)
  (when-let* ((proj (project-current nil project-id))
              (files (project-files proj)))
    ;; Return files relative to project root.
    (mapcar (lambda (f) (file-relative-name f project-id)) files)))

(defun macher--directory-root (directory-id)
  "Get the root for DIRECTORY-ID, validating it's a real directory."
  (unless (and (stringp directory-id)
               (file-name-absolute-p directory-id)
               (file-directory-p directory-id))
    (error "Directory ID '%s' is not a valid directory" directory-id))
  directory-id)

(defun macher--directory-name (directory-id)
  "Get a descriptive name for DIRECTORY-ID."
  (file-name-nondirectory (directory-file-name directory-id)))

(defun macher--directory-files (directory-id)
  "Get list of files in DIRECTORY-ID recursively.
Returns a list of relative file paths."
  (mapcar
   (lambda (f) (file-relative-name f directory-id)) (directory-files-recursively directory-id "")))

(defun macher-workspace (&optional buffer)
  "Get the workspace information for BUFFER.
Returns a cons cell representing the workspace scope.

The return value is always a cons cell of the form (TYPE . ID) where:
- TYPE is a symbol identifying the workspace type
- ID is the workspace identifier (typically a root directory or file path)

If the buffer-local variable `macher--workspace` is nil, this function
runs the functions in `macher-workspace-functions' until one returns a
non-nil workspace cons cell.  If no function returns a workspace,
it returns nil."
  (with-current-buffer (or buffer (current-buffer))
    (or macher--workspace (cl-some #'funcall macher-workspace-functions))))

(defun macher--workspace-root (workspace)
  "Get the workspace root for WORKSPACE.

WORKSPACE is a cons cell (TYPE . ID) where TYPE is a workspace type.
Returns the root directory path for the workspace.

Uses the appropriate root function as configured in the
`macher-workspace-types-alist', and validates that the result is an
absolute path to a real directory."
  (let* ((workspace-type (car workspace))
         (workspace-id (cdr workspace))
         (type-config (alist-get workspace-type macher-workspace-types-alist))
         (root-fn (plist-get type-config :get-root))
         (root
          (if root-fn
              (or (funcall root-fn workspace-id)
                  (error
                   "Root function for workspace type %s failed to return a root" workspace-type))
            (error "No root function configured for workspace type %s" workspace-type))))
    ;; Only validate the shape of the root.  A `file-directory-p' check would be a remote
    ;; round-trip over TRAMP on every call; any real file operation downstream will fail with a
    ;; reasonable error anyway if the root doesn't exist.
    (unless (and root (file-name-absolute-p root))
      (error "Workspace root '%s' is not an absolute path" root))
    root))

(defun macher--workspace-name (workspace)
  "Get a descriptive name for WORKSPACE.
WORKSPACE is a cons cell (TYPE . ID) where TYPE is a workspace type.
Returns a string containing the workspace name."
  (let* ((workspace-type (car workspace))
         (workspace-id (cdr workspace))
         (type-config (alist-get workspace-type macher-workspace-types-alist))
         (name-fn (plist-get type-config :get-name)))
    (if name-fn
        (funcall name-fn workspace-id)
      (error "No name function configured for workspace type %s" workspace-type))))

(defun macher--workspace-files (workspace)
  "Get list of files in WORKSPACE.
WORKSPACE is a cons cell (TYPE . ID) where TYPE is a workspace type.
Returns a list of absolute file paths."
  (let* ((workspace-type (car workspace))
         (workspace-id (cdr workspace))
         (type-config (alist-get workspace-type macher-workspace-types-alist))
         (files-fn (plist-get type-config :get-files))
         (root-path (macher--workspace-root workspace)))
    (when files-fn
      (let ((files (funcall files-fn workspace-id)))
        ;; Ensure all paths are absolute.
        (when files
          (mapcar
           (lambda (f)
             (if (file-name-absolute-p f)
                 f
               (expand-file-name f root-path)))
           files))))))

(defun macher--context-string ()
  "Generate workspace information string for the current workspace.

Returns a workspace information string to be added to the request."
  (when-let ((workspace (macher-workspace)))
    (let* ((workspace-name (macher--workspace-name workspace))
           (workspace-files (macher--workspace-files workspace))
           ;; This might get trimmed if there are too many files.
           (files-for-listing workspace-files))

      ;; Trim the files list if there's a limit.
      (when macher-context-string-max-files
        (when (> (length files-for-listing) macher-context-string-max-files)
          (setq files-for-listing (seq-take files-for-listing macher-context-string-max-files))))

      (with-temp-buffer
        (insert
         (format "The user is currently working on a project named: \"%s\"\n" workspace-name))
        (if workspace-files
            (progn
              (insert (format "Files in the \"%s\" project:" workspace-name))
              (dolist (file-path files-for-listing)
                (let ((rel-path (file-relative-name file-path (macher--workspace-root workspace))))
                  (insert (format "\n    %s" rel-path))))
              ;; Add a note if files were truncated due to the limit.
              (when macher-context-string-max-files
                (let* ((total-files (length workspace-files))
                       (listed-files (length files-for-listing))
                       (truncated-files (- total-files listed-files)))
                  (when (> truncated-files 0)
                    (insert (format "\n    ... and %d more files" truncated-files))))))
          (insert (format "There are no files in the \"%s\" project." workspace-name)))
        (buffer-string)))))

(defun macher--workspace-hash (workspace &optional length)
  "Generate a unique hash for WORKSPACE.

LENGTH specifies the number of characters in the hash (default 16).  This
can be used, for example, to ensure unique buffer names per workspace,
without needing to put the full path in the buffer name."
  (let* ((workspace-type (car workspace))
         (workspace-id (cdr workspace))
         (hash-input (secure-hash 'sha256 (concat (format "%s" workspace-type) workspace-id)))
         (chars "abcdefghijklmnopqrstuvwxyz0123456789")
         (hash-length (or length 16))
         (result ""))
    ;; Take the starting characters and map them to our character set.
    (dotimes (i hash-length)
      (let* ((hex-char (aref hash-input i))
             (idx
              (mod
               (if (>= hex-char ?a)
                   (- hex-char ?a -10)
                 (- hex-char ?0))
               (length chars))))
        (setq result (concat result (substring chars idx (1+ idx))))))
    result))

(defun macher--action-buffer-setup-basic ()
  "Set up basic common behavior for action buffers.

This adds local hooks to `macher-before-action-functions' to
format/insert prompts sent by the user, and to display the action
buffer when actions are performed."
  ;; Set up the buffer-local hook to insert prompts and headings.
  (add-hook 'macher-before-action-functions #'macher--before-action-insert-prompt nil t)
  ;; Display the action buffer.
  (add-hook 'macher-before-action-functions #'macher--before-action-display-buffer nil t))

(defun macher--action-buffer-setup-ui ()
  "Set up a slightly more opinionated action buffer UI.

This setup is shared among the symbol `default' and symbol `org' UI
configurations.  The function enables `gptel-mode', ensures tool results
are included in output, and sets up a hook to apply the action's preset
buffer-locally."
  ;; Include tool calls in output.
  (setq-local gptel-include-tool-results t)
  ;; Enable gptel-mode for a nice header and LLM interaction feedback.
  (gptel-mode 1)
  ;; Apply the action's preset buffer-locally before each request.
  (add-hook 'macher-before-action-functions #'macher--before-action-apply-preset nil t)
  ;; Scroll the action buffer window to the current cursor position.
  (add-hook 'macher-before-action-functions #'macher--before-action-scroll nil t))

(defun macher--before-action-apply-preset (execution)
  "Apply the current action's preset buffer-locally.

EXECUTION is a `macher-action-execution' struct.  This is part of the
default action buffer UI: it is added buffer-locally to
`macher-before-action-functions' by `macher--action-buffer-setup-ui'."
  (macher--apply-preset-locally (macher-action-execution-preset execution)))

(defun macher--before-action-scroll (_execution)
  "Scroll the action buffer window to show point.

When `macher--before-action-insert-prompt' runs, point is left at the
end of the just-inserted prompt.  This function updates the window to
show that position.

This is added buffer-locally to `macher-before-action-functions' by
`macher--action-buffer-setup-ui'."
  (when-let ((win (get-buffer-window (current-buffer))))
    (set-window-point win (point))))

(defun macher--action-buffer-setup ()
  "Apply the base UI configuration based on `macher-action-buffer-ui'.

This function is called automatically when creating action buffers,
before running the `macher-action-buffer-setup-hook'."
  (pcase macher-action-buffer-ui
    ('basic
     ;; Basic UI: just hooks.
     (macher--action-buffer-setup-basic))
    ('default
     ;; Use the global gptel default mode (e.g., markdown-mode)
     (funcall (or gptel-default-mode #'text-mode))
     (macher--action-buffer-setup-basic)
     (macher--action-buffer-setup-ui))
    ('org
     ;; Use org as the major mode.  Tool output blocks will be auto-folded.
     (org-mode)
     (macher--action-buffer-setup-basic)
     (macher--action-buffer-setup-ui))
    ;; nil: no automatic setup
    ((pred null))
    (_ (user-error "Unrecognized action buffer UI configuration: %s" macher-action-buffer-ui))))

(defun macher--transform-backticks-to-org-src (text)
  "Transform backtick code blocks in TEXT to org src blocks.

Finds all ```language...``` blocks and converts them to
#+begin_src language...#+end_src blocks, properly escaping
the content with `org-escape-code-in-string'."
  (require 'org-src)
  (with-temp-buffer
    (insert text)
    (goto-char (point-min))
    (while (re-search-forward "^```\\([[:alnum:]]*\\)\n" nil t)
      (let* ((lang (match-string 1))
             (block-start (match-beginning 0))
             (content-start (match-end 0)))
        (when (re-search-forward "^```$" nil t)
          (let* ((content-end (match-beginning 0))
                 (block-end (match-end 0))
                 (content (buffer-substring-no-properties content-start content-end)))
            (delete-region block-start block-end)
            (goto-char block-start)
            (insert "#+begin_src " lang "\n" (org-escape-code-in-string content) "#+end_src")))))
    (buffer-string)))

(defun macher--before-action-insert-prompt (execution)
  "Insert the action prompt into the current buffer.

EXECUTION is a `macher-action-execution' struct.  This function
inserts the prompt text at the end of the buffer, adapting formatting
based on the major mode.  In `org-mode', it also adds a topic heading
and sets the GPTEL_TOPIC property so the conversation can be continued
via `gptel-send'.

After this function returns, point is at the end of the inserted
prompt text.  The buffer is not displayed or scrolled; use
`macher--before-action-display-buffer' and
`macher--before-action-scroll' for that.

This is added buffer-locally to `macher-before-action-functions' by
`macher--action-buffer-setup-basic'."
  (let* ((preset (macher-action-execution-preset execution))
         (prompt (macher-action-execution-prompt execution))
         (action (macher-action-execution-action execution)))

    (goto-char (point-max))

    ;; Add a topic heading when in org mode. This allows continuing the conversation for a single
    ;; action via normal gptel interactions.
    (when (derived-mode-p 'org-mode)
      ;; Make sure the topic heading is on its own line.
      (unless (bolp)
        (insert "\n"))
      ;; Add another newline if we're not at the beginning of the buffer, for visual clarity.
      (unless (bobp)
        (insert "\n"))
      (let* ((header-prefix "* ")
             (header-postfix (format " :%s:" action))
             (summary (macher-action-execution-summary execution))
             ;; Extract the first non-whitespace line from the summary and truncate to fill-column.
             (truncated-summary
              (let* ((lines (split-string summary "[\r\n]+" t "[[:space:]]*"))
                     (first-line (or (car lines) ""))
                     ;; Calculate available space: total fill-column minus prefix and postfix.
                     (used-length (+ (length header-prefix) (length header-postfix)))
                     (available-length (max 10 (- (or fill-column 70) used-length))))
                (truncate-string-to-width first-line available-length nil nil "...")))
             (topic
              (format "macher-%s-%s-%s"
                      action
                      (format-time-string "%Y%m%d%H%M%S" (current-time))
                      (replace-regexp-in-string "[[:space:]]+" "-" (downcase truncated-summary)))))
        ;; Use the 'ignore property to omit the header from requests sent via normal gptel
        ;; conversational methods.
        (insert
         (propertize (concat header-prefix truncated-summary header-postfix "\n") 'gptel 'ignore))
        (condition-case err
            (gptel-org-set-topic topic)
          (error
           (warn "Failed to set org topic: %S" err)))
        (goto-char (point-max))))

    ;; Insert the prefix if point isn't immediately preceded by it.
    (when-let ((prefix (alist-get major-mode gptel-prompt-prefix-alist)))
      (let ((prefix-length (length prefix)))
        (unless (and (>= (point) (+ (point-min) prefix-length))
                     (string=
                      (buffer-substring-no-properties (- (point) prefix-length) (point)) prefix))
          ;; Ensure prefix starts on its own line.
          (unless (bolp)
            (insert "\n"))
          (insert prefix))))

    ;; Insert presets via @-syntax if they exist in the global registry.
    (when (gptel-get-preset preset)
      (insert (format "@%s " preset)))

    ;; Insert the prompt text, transforming backtick blocks to org src blocks in org-mode.
    (let ((prompt-start (point))
          (final-prompt
           (if (derived-mode-p 'org-mode)
               (progn
                 (require 'org-src)
                 (macher--transform-backticks-to-org-src prompt))
             prompt)))
      (insert final-prompt)
      ;; Fold org src blocks after insertion.
      (when (derived-mode-p 'org-mode)
        (save-excursion
          (goto-char prompt-start)
          (while (re-search-forward "^#\\+begin_src" (point-max) t)
            (org-cycle)))))))

(defun macher--before-action-display-buffer (_execution)
  "Display the action buffer.

This is added buffer-locally to `macher-before-action-functions' by
`macher--action-buffer-setup-basic'."
  ;; Use a no-op buffer context so `display-buffer' doesn't change the selected buffer while we're
  ;; in the middle of running the before-action hooks.
  (with-current-buffer (current-buffer)
    (display-buffer (current-buffer))))

(defun macher--patch-buffer-setup-diff ()
  "Set up `diff-mode' and related settings for patch buffers.

This performs setup related to displaying patch content as diffs,
including enabling `diff-mode' and setting up buffer-local hooks for
displaying the patch buffer when it's ready."
  (diff-mode)
  ;; Diffs are generally easier to interact with (e.g. press RET to jump to a change) in read-only
  ;; mode, so this seems like a reasonable default.
  (read-only-mode 1)
  ;; Diffs are visually confusing if continuation lines are displayed.
  (setq-local truncate-lines t)
  ;; Set up the buffer-local hook to display the patch buffer when ready.
  (add-hook 'macher-patch-ready-hook #'macher--patch-ready nil t))

(defun macher--patch-buffer-setup ()
  "Apply the base UI configuration based on `macher-patch-buffer-ui'.

This function is called automatically when creating patch buffers,
before running the `macher-patch-buffer-setup-hook'."
  (pcase macher-patch-buffer-ui
    ('diff
     ;; Diff UI: diff-mode + display behavior.
     (macher--patch-buffer-setup-diff))
    ;; nil: no automatic setup.
    ((pred null))
    (_ (user-error "Unrecognized patch buffer UI configuration: %s" macher-patch-buffer-ui))))

(defun macher--patch-ready ()
  "Set up the patch buffer with appropriate modes and settings.

This is added buffer-locally (in the patch buffer) to the
`macher-patch-ready-hook' when using the ='diff' UI configuration.
If you set `macher-patch-buffer-ui' to nil, this function will
never be called.

The function syncs `diff-mode' settings based on the current content and
displays the patch buffer."
  (when (derived-mode-p 'diff-mode)
    ;; Re-detect patch type (i.e. ='git') now that the buffer has been populated.
    (diff-setup-buffer-type)
    ;; Fix hunk headers to have line counts as well as starting lines even for one-line changes,
    ;; e.g.`=@@ -1 +1 @@=` becomes `=@@ -1,1 +1,1 @@=`. This allows `diff-apply-buffer' to work in a
    ;; wider range of cases, particularly when content is being replaced at the end of the
    ;; file. `diff-mode' normally runs this automatically when the buffer is modified interactively.
    ;;
    ;; Note the unified diff explicitly uses the more compact format when the line count is exactly
    ;; 1, so there's not really a good way to get this format out of the diff command directly.
    (diff-fixup-modifs (point-min) (point-max)))

  ;; Display the buffer.  Do this in a no-op buffer context to avoid changing the actual current
  ;; buffer.
  (with-current-buffer (current-buffer)
    (display-buffer (current-buffer))))

(defun macher--process-request (_reason context fsm)
  "Process the macher CONTEXT throughout the request lifecycle.

This is the default implementation of `macher-process-request-function'.
It uses the `macher-patch-prepare-functions' and the
`macher-patch-ready-hook' to create/handle a patch based on the changes
made during the request.

CONTEXT is the `macher-context' for the request being processed.

FSM is the `gptel-fsm' (state machine) for the request being processed."
  (when context
    ;; Check if any changes were made during the request using the dirty-p flag.
    (when (macher-context-dirty-p context)
      (macher--build-patch context fsm))))

(defun macher--context-for-fsm (fsm)
  "Extract the macher context from the FSM, if any.

FSM is a `gptel-fsm' (state machine) for a request.

Returns the request's `macher-context' object if macher tools have been
invoked during the FSM lifecycle, otherwise returns nil."
  (when fsm
    (when-let* ((info (gptel-fsm-info fsm)))
      (plist-get info :macher--context))))

(defun macher--build-patch (context fsm)
  "Run the `macher-patch-prepare-functions' and the `macher-patch-ready-hook'.

CONTEXT and FSM are the same as passed to the
`macher-process-request-function'."
  ;; Prepare patch content in a temporary buffer.
  (with-temp-buffer
    (let* ((temp-buffer (current-buffer))
           (remaining-functions (copy-sequence macher-patch-prepare-functions))
           ;; Forward declaration so that the function can be called recursively.
           (recursive-process-next-function)
           (process-next-function
            (lambda ()
              "Process the next function in the preparation chain."
              (if remaining-functions
                  (let ((func (pop remaining-functions)))
                    (funcall func context fsm recursive-process-next-function))
                ;; All functions processed, finalize the patch.
                (let ((patch-content
                       (with-current-buffer temp-buffer
                         (buffer-substring-no-properties (point-min) (point-max)))))
                  ;; Populate the actual patch buffer.
                  (with-current-buffer (macher-patch-buffer (macher-context-workspace context) t)
                    ;; Save read-only status to restore later.
                    (let ((was-read-only buffer-read-only))
                      ;; Temporarily disable read-only mode to update the buffer.
                      (when was-read-only
                        (read-only-mode -1))
                      (erase-buffer)
                      (insert patch-content)
                      (when was-read-only
                        (read-only-mode 1)))

                    ;; Move to the beginning of the buffer.
                    (goto-char (point-min))

                    ;; Run the patch-ready hooks.
                    (run-hooks 'macher-patch-ready-hook)))))))
      (setq recursive-process-next-function process-next-function)
      ;; Start the preparation chain.
      (funcall process-next-function))))

(defun macher--get-buffer (buffer-type &optional workspace create)
  "Get a macher buffer with BUFFER-TYPE associated with WORKSPACE.

BUFFER-TYPE can be any string or nil.  The return value for a given
BUFFER-TYPE is shared across all files in the same workspace.

If CREATE is non-nil, create the buffer if it doesn't already exist.
Otherwise, return nil if the buffer doesn't already exist.

When WORKSPACE is nil, use the current buffer's workspace as determined
by `macher-workspace'.

Returns a cons cell (BUFFER . CREATED-P) where BUFFER is the target
buffer and CREATED-P is t if the buffer was newly created, nil
otherwise."
  (let* ((workspace (or workspace (macher-workspace))))

    (unless (consp workspace)
      (user-error
       (concat
        "No macher workspace found for the current buffer; " "see `macher-workspace-functions'")))

    (let* ((workspace-type (car workspace))
           (workspace-name (macher--workspace-name workspace))
           (buffer-type-segment
            (if buffer-type
                (format "-%s" buffer-type)
              ""))
           (buffer-name
            (format "*macher%s:%s@%s<%s>*"
                    buffer-type-segment workspace-type workspace-name
                    ;; Use a shorter hash in buffer names.
                    (macher--workspace-hash workspace 4)))
           target-buffer
           created-p)

      (setq target-buffer (and buffer-name (get-buffer buffer-name)))
      (when (and (not target-buffer) create buffer-name)
        (setq target-buffer (get-buffer-create buffer-name))
        (setq created-p t)
        (with-current-buffer target-buffer
          ;; Track workspace for this buffer.
          (setq-local macher--workspace workspace)
          ;; Set the context directory for operations like diff application or 'project.el' lookups.
          (let ((base-dir (macher--workspace-root workspace)))
            (setq-local default-directory base-dir))))
      (when target-buffer
        (cons target-buffer created-p)))))

(defun macher-action-buffer (&optional workspace create)
  "Get the macher action buffer associated with WORKSPACE.

The action buffer is used for requests sent via `macher-action', and is
shared across all files in the same workspace.

If CREATE is non-nil, create the buffer if it doesn't already exist.
Otherwise, return nil if the buffer doesn't already exist.

When WORKSPACE is nil, use the current buffer's workspace as determined
by `macher-workspace'."
  (let ((result (macher--get-buffer nil workspace create)))
    (when result
      (let ((target-buffer (car result))
            (created-p (cdr result)))
        (when created-p
          (with-current-buffer target-buffer
            ;; Apply base UI configuration first.
            (macher--action-buffer-setup)
            ;; Then run user hooks.
            (run-hooks 'macher-action-buffer-setup-hook)))
        target-buffer))))

(defun macher-patch-buffer (&optional workspace create)
  "Get the macher patch buffer associated with WORKSPACE.

The patch buffer is used for displaying proposed changes as diffs and is
shared across all files in the same workspace.

If CREATE is non-nil, create the buffer if it doesn't already exist.
Otherwise, return nil if the buffer doesn't already exist.

When WORKSPACE is nil, use the current buffer's workspace as determined
by `macher-workspace'."
  (let ((result (macher--get-buffer "patch" workspace create)))
    (when result
      (let ((target-buffer (car result))
            (created-p (cdr result)))
        (when created-p
          (with-current-buffer target-buffer
            ;; Apply base UI configuration first.
            (macher--patch-buffer-setup)
            ;; Then run user hooks.
            (run-hooks 'macher-patch-buffer-setup-hook)))
        target-buffer))))

(defun macher--normalize-path (path)
  "Normalize PATH to an absolute path suitable for context lookups.
Ensures paths are consistently handled throughout the codebase."
  (if (file-name-absolute-p path)
      (expand-file-name path)
    (error "PATH must be an absolute file path")))

(defun macher--resolve-workspace-path (workspace rel-path &optional workspace-files)
  "Get the full path for REL-PATH within the WORKSPACE.

The path will be resolved relative to the workspace root.  '.' and '..'
are handled as standard relative path segments.

Raises an error if the LLM doesn't have permission to interact with the
path.  Specifically, an error is thrown if:

- The path resolves somewhere outside the workspace root, and isn't
  present in the workspace's files list.

- The path resolves to a file which exists on the filesystem, but isn't
  present in the workspace's files list (directories beneath the
  workspace root are allowed).

- The path contains an existing symbolic link as a non-final component
  below the workspace root - that is, we don't follow allow following
  directory symlinks within the workspace.

- The path contains an existing file as a non-final component below the
  workspace root - that is, we don't allow resolving paths that would
  require treating an existing file as a directory.

Absolute paths are allowed, though the rules above apply to the
filename, and in practice it would be unexpected for the LLM to pass
them in.

Note also that paths outside the workspace root are allowed _if_ they
appear in the workspace's files list.  This won't be the case for the
built-in workspace types, but might be relevant for custom workspace
types.

If WORKSPACE-FILES is provided, it is used as the workspace files list
instead of calling `macher--workspace-files'.  This lets callers that
already have the list avoid a redundant computation, which can be
expensive for remote workspaces."
  (let* (
         ;; We don't really want to deal with the `file-truename', as this would resolve symlinks
         ;; and might mess up the path structure when dealing with relative paths like
         ;; "../sibling-dir-in-custom-workspace/".  However, we will be using `expand-file-name' to
         ;; resolve the path, which expands some path components like "~" into real directories -
         ;; so, in order to more easily check whether the resolved path is inside the workspace
         ;; root, we expand these components immediately.
         (workspace-root (expand-file-name (macher--workspace-root workspace)))
         ;; Resolve the path relative to workspace root, handling . and ..
         (full-path (expand-file-name rel-path workspace-root)))

    ;; Check for files or symlinks in non-final path components below the workspace root
    ;; Only perform this check for paths that are within the workspace directory - other files
    (when (string-prefix-p workspace-root full-path)
      (let* ((relative-path (file-relative-name full-path workspace-root))
             ;; Use file-name-split for OS-independent path component splitting.
             (path-components (file-name-split relative-path))
             (current-path workspace-root))
        ;; Check each component except the last one for files or symlinks (only below workspace
        ;; root).  For performance (especially over remote connections), use a single
        ;; file-attributes call per component instead of separate
        ;; file-exists-p/file-symlink-p/file-directory-p calls.
        (when (> (length path-components) 1)
          (dolist (component (butlast path-components))
            (unless
                ;; Skip empty components.
                (string-empty-p component)
              (setq current-path (expand-file-name component current-path))
              (let ((attrs (file-attributes current-path)))
                ;; attrs is nil when the path doesn't exist.
                (when attrs
                  (let ((type (file-attribute-type attrs)))
                    (cond
                     ;; type is a string when the path is a symlink (the string is the target).
                     ((stringp type)
                      (error
                       "Path '%s' contains a symbolic link in a non-final component" rel-path))
                     ;; type is t for directories, nil for regular files.
                     ((not (eq t type))
                      (error
                       "Path '%s' contains a file in a non-final component" rel-path)))))))))))

    ;; Validate access permissions.  If WORKSPACE-FILES was passed in, use it instead of calling
    ;; `macher--workspace-files' (which can trigger `project-current' over TRAMP).
    (let* ((raw-workspace-files (or workspace-files (macher--workspace-files workspace)))
           ;; Process workspace files by expanding them relative to workspace root
           (workspace-files
            (when raw-workspace-files
              (mapcar
               (lambda (file-path)
                 (if (file-name-absolute-p file-path)
                     file-path
                   (expand-file-name file-path workspace-root)))
               raw-workspace-files)))
           (is-outside-workspace
            (not
             (or
              ;; Check whether the path is equal to the workspace root, i.e. '.'.
              (string= (directory-file-name workspace-root) full-path)
              ;; Check whether the path begins with the workspace root + the path separator.
              (string-prefix-p (file-name-as-directory workspace-root) full-path))))
           ;; Use file-attributes once instead of separate file-exists-p and
           ;; file-directory-p calls, since each is a TRAMP round-trip.
           (path-attrs (file-attributes full-path))
           (file-exists path-attrs)
           (is-directory (eq t (file-attribute-type path-attrs))))

      (when (and is-outside-workspace (not (member full-path workspace-files)))
        (error "Path '%s' resolves outside the workspace" rel-path))

      (when (and file-exists
                 ;; Allow directories, but only within the workspace.
                 (or is-outside-workspace (not is-directory))
                 (not (member full-path workspace-files)))
        (error "File '%s' is not in the workspace files list" rel-path)))

    ;; Return the resolved path
    full-path))

(defun macher--format-size (bytes)
  "Format BYTES as a human-readable size string."
  (let ((units '("B" "KB" "MB" "GB" "TB"))
        (size (float bytes))
        (unit-index 0))
    (while (and (>= size 1024) (< unit-index (1- (length units))))
      (setq size (/ size 1024.0))
      (setq unit-index (1+ unit-index)))
    (if (= unit-index 0)
        (format "%d %s" (truncate size) (nth unit-index units))
      (format "%.1f %s" size (nth unit-index units)))))

(defun macher--read-string (content &optional offset limit show-line-numbers)
  "Read string CONTENT with optional line-number OFFSET and LIMIT.

- CONTENT is the full string content to read from.

- OFFSET, if provided, specifies the line number to start reading
  from (1-based).  For negative values, starts at that many lines before
  the end of the file.

- LIMIT, if provided, specifies the number of lines to read from the
  start position.  For negative values, the actual limit is computed as
  (total_lines + limit).  For example, with a 100-line file: limit=10
  reads 10 lines, limit=-10 reads 90 lines (100 + (-10)).

- SHOW-LINE-NUMBERS, if non-nil, formats output in cat -n style with
  line numbers.  Lines are formatted as \"[spaces for alignment][line
  number][tab][line content]\".

If neither OFFSET nor LIMIT is provided, returns the full content.

If only OFFSET is provided, returns content from that line to the end.

If only LIMIT is provided, returns the first LIMIT lines.

If both are provided, returns LIMIT lines starting from OFFSET.

Returns the processed content as a string."
  (if (and (not offset) (not limit) (not show-line-numbers))
      ;; Return full content if no parameters provided.
      content
    (let* ((lines (split-string content "\n"))
           (num-lines (length lines))
           (start-idx
            (if offset
                (cond
                 ;; Negative offset: start at that many lines before the end.
                 ((< offset 0)
                  (max 0 (+ num-lines offset)))
                 ;; Zero or positive offset: 1-based indexing (treat 0 as 1).
                 (t
                  (max 0 (min (1- (max 1 offset)) num-lines))))
              0))
           (actual-limit
            (when limit
              (cond
               ;; Negative limit: equivalent to total lines - (negative limit value).
               ((< limit 0)
                (max 0 (+ num-lines limit)))
               ;; Zero or positive limit: use as-is.
               (t
                limit))))
           (end-idx
            (if actual-limit
                (min num-lines (+ start-idx actual-limit))
              num-lines))
           (selected-lines (seq-subseq lines start-idx end-idx)))
      (cond
       (show-line-numbers
        ;; Format with line numbers (cat -n style).
        (let* ((actual-start-line (1+ start-idx))
               ;; `cat -n` includes the trailing newline if present, but doesn't number it.  We
               ;; handle this as a special case.  If:
               ;;
               ;; - the last line is empty
               ;; - we're looking at the actual last line of the content (i.e. not a blank line in
               ;;   the middle)
               ;; - there's more than one line (since `cat -n` on a completely blank file does show
               ;;   line number 1
               ;;
               ;; then exclude the last line from the list of lines to be numbered, and add it back
               ;; at the end.
               (should-exclude-final-empty
                (and (= end-idx num-lines) ; processing to end.
                     (> (length selected-lines) 1) ; we have more than one line.
                     (string-empty-p (car (last selected-lines))))) ; last line is empty.
               (lines-to-number
                (if should-exclude-final-empty
                    (butlast selected-lines)
                  selected-lines))
               (max-line-num (+ actual-start-line (length lines-to-number) -1))
               (line-num-width
                (if (> (length lines-to-number) 0)
                    (length (number-to-string max-line-num))
                  1))
               (formatted-lines
                (cl-loop
                 for line in lines-to-number for line-num from actual-start-line collect
                 (format "%s%d\t%s"
                         (make-string
                          (- line-num-width (length (number-to-string line-num))) ?\s)
                         line-num line)))
               (result (string-join formatted-lines "\n")))
          ;; Add the final trailing newline if we excluded the final empty line
          (if should-exclude-final-empty
              (concat result "\n")
            result)))
       ;; Regular format without line numbers.
       (t
        (string-join selected-lines "\n"))))))

(defun macher--with-workspace-file (context path callback &optional set-dirty-p workspace-files)
  "Helper function to execute CALLBACK with workspace file content.

CONTEXT is a `macher-context' struct containing workspace information.

PATH is the path to the file, relative to the workspace root.

CALLBACK is called with arguments (full-path new-content) where:
- full-path is the absolute path to the file
- new-content is the current content string of the file

If SET-DIRTY-P is non-nil, sets the dirty-p flag on the context.

WORKSPACE-FILES, if provided, is passed through to
`macher--resolve-workspace-path' instead of having it compute the
list itself.  Useful for callers that already have the list, to avoid
a redundant computation over a remote connection."
  (let* ((workspace (macher-context-workspace context))
         (full-path (macher--resolve-workspace-path workspace path workspace-files))
         (contents (macher-context--contents-for-file full-path context))
         (new-content (cdr contents)))
    ;; Check if the file exists for editing.
    (if (not new-content)
        (error "File '%s' not found in workspace" path)
      ;; Set the dirty flag to indicate changes are being made if requested.
      (when set-dirty-p
        (setf (macher-context-dirty-p context) t))
      ;; Call the callback with the file content.
      (funcall callback full-path new-content))))

;; The workspace tools are somewhat inspired by the reference filesystem MCP.  See
;; https://github.com/modelcontextprotocol/servers/blob/main/src/filesystem/README.md.

(defun macher--tool-read-file (context path &optional offset limit show-line-numbers)
  "Read the contents of a file specified by PATH within the workspace.

CONTEXT is a `macher-context' struct containing workspace information.

PATH is the path to the file, relative to the workspace root.

OFFSET, if provided, specifies the line number to start reading
from (1-based).

LIMIT, if provided, specifies the number of lines to read.

SHOW-LINE-NUMBERS, if non-nil, formats output in cat -n style with line numbers.

Returns the file contents as a string, with optional
offset/limit/show-line-numbers processing.  For symlinks, returns the target
path instead of following the link.  Signals an error if the file is not
found in the workspace."
  (let* ((workspace (macher-context-workspace context))
         (full-path (macher--resolve-workspace-path workspace path)))

    ;; Check if this is a symlink first (only for existing files).
    (let ((symlink-target (file-symlink-p full-path)))
      (if symlink-target
          ;; For symlinks, return the target path instead of following the link.
          (format "Symlink target: %s" symlink-target)

        ;; Fetch file contents directly using the already-resolved path,
        ;; rather than going through macher--with-workspace-file which
        ;; would resolve the path a second time.
        (let* ((contents (macher-context--contents-for-file full-path context))
               (new-content (cdr contents)))
          (if (not new-content)
              (error "File '%s' not found in workspace" path)
            ;; Some LLMs (for example qwen3-coder at time of writing) seem to have trouble
            ;; invoking tools with integer inputs - they'll always pass e.g. '1.0' instead of
            ;; '1'.  Therefore we need to support float inputs, which in general we handle by
            ;; rounding to the nearest integer.
            (let* ((parsed-offset
                    (when offset
                      (round offset)))
                   (parsed-limit
                    (when limit
                      (round limit)))
                   (processed-content
                    (macher--read-string new-content parsed-offset parsed-limit show-line-numbers)))
              ;; Check if the processed content exceeds the maximum read length.
              (when (> (length processed-content) macher--max-read-length)
                (error
                 "File content too large: %d bytes exceeds maximum read length of %d bytes"
                 (length processed-content)
                 macher--max-read-length))
              processed-content)))))))

(defun macher--tool-list-directory (context path &optional recursive sizes)
  "List directory contents at PATH within the workspace.

CONTEXT is a `macher-context' struct containing workspace information.

PATH is the path to the directory, relative to the workspace root.

RECURSIVE, if non-nil, recursively lists subdirectories.

SIZES, if non-nil, includes file sizes in the output.

Returns a formatted string listing the directory contents.  Files are
prefixed with \"file:\" and directories with \"dir:\" to be clear to
LLMs.

Signals an error if the directory is not found in the workspace."
  (let* ((workspace (macher-context-workspace context))
         (workspace-root (macher--workspace-root workspace))
         ;; Compute workspace-files once and reuse it for path resolution and entry collection
         ;; below.  Each call transitively triggers `project-current', which is expensive over
         ;; TRAMP (walks the directory tree probing for a VC root).
         (workspace-files (macher--workspace-files workspace))
         (resolve-workspace-path
          (lambda (rel-path) (macher--resolve-workspace-path workspace rel-path workspace-files)))
         (full-path (funcall resolve-workspace-path path))
         (results '())
         (context-contents (macher-context-contents context)))

    ;; Check if this path exists as a file in our context (would indicate it's not a directory).
    (when-let ((existing-entry (assoc (macher--normalize-path full-path) context-contents)))
      (let ((contents (cdr existing-entry)))
        ;; Has new-content, so it's a file.
        (when (cdr contents)
          (error "Path '%s' is a file, not a directory" path))))

    ;; Check if the directory exists on disk OR has files in the context.
    ;; A directory is considered to exist if:
    ;; 1. It exists on disk as a directory, OR
    ;; 2. There are files in the context that have this directory as a parent
    (let ((directory-has-context-files-p
           (cl-some
            (lambda (entry)
              (let* ((file-path (car entry))
                     (contents (cdr entry))
                     (new-content (cdr contents)))
                ;; Only consider files that have new content (not deleted files)
                (when new-content
                  (let ((file-dir (file-name-directory file-path)))
                    ;; Check if this file's directory is or is a subdirectory of the requested path
                    (string-prefix-p
                     (file-name-as-directory full-path) (file-name-as-directory file-dir))))))
            context-contents)))
      (unless (or (file-directory-p full-path) directory-has-context-files-p)
        (error "Directory '%s' not found in workspace" path)))

    ;; Helper function to check if a file is deleted in the context.
    (cl-labels
        ((file-deleted-in-context-p
          (file-path) "Check if FILE-PATH is marked as deleted in the context."
          (when-let ((entry (assoc (macher--normalize-path file-path) context-contents)))
            (let ((contents (cdr entry)))
              (and
               ;; Has original content...
               (car contents)
               ;; ...but no new content.
               (not (cdr contents))))))

         (get-file-content-size
          (file-path) "Get the size of FILE-PATH, considering context modifications."
          (if-let ((entry (assoc (macher--normalize-path file-path) context-contents)))
              (let* ((contents (cdr entry))
                     (new-content (cdr contents)))
                (if new-content
                    (length new-content)
                  ;; File is deleted in context, so size is 0.
                  0))
            ;; Not in context, get size from disk.
            (if (file-exists-p file-path)
                (file-attribute-size (file-attributes file-path))
              0)))

         (collect-new-context-files
          (current-path)
          "Collect files that exist only in context (newly created) under CURRENT-PATH."
          (let ((new-files '()))
            (dolist (entry context-contents)
              (let* ((file-path (car entry))
                     (contents (cdr entry))
                     (orig-content (car contents))
                     (new-content (cdr contents)))
                ;; This is a newly created file if it has new-content but no orig-content.
                (when (and new-content (not orig-content))
                  ;; Check if this file is a direct child of the current directory.
                  (let ((file-dir (file-name-directory file-path))
                        (normalized-current-path (file-name-as-directory current-path)))
                    (when (string= (file-name-as-directory file-dir) normalized-current-path)
                      (push (file-name-nondirectory file-path) new-files))))))
            new-files))

         (collect-new-context-directories
          (current-path)
          "Collect directory names that are implied by context files under CURRENT-PATH."
          (let ((new-dirs '()))
            (dolist (entry context-contents)
              (let* ((file-path (car entry))
                     (contents (cdr entry))
                     (new-content (cdr contents)))
                ;; Only consider files that have new content (not deleted files).
                (when new-content
                  ;; Check if this file creates any intermediate directories under current-path.
                  (let* ((relative-path
                          (file-relative-name file-path (file-name-as-directory current-path))))
                    ;; Only process if the file is actually under the current path (not ..).
                    (when (not (string-prefix-p ".." relative-path))
                      ;; Split the path to find the first directory component
                      (when (string-match "/" relative-path)
                        (let ((first-component
                               (substring relative-path 0 (match-beginning 0))))
                          (unless (or (string-empty-p first-component)
                                      (member first-component new-dirs))
                            (push first-component new-dirs)))))))))
            new-dirs))

         (collect-entries
          (current-path current-rel-path depth)
          ;; Build entries list by iterating through workspace files.  Uses the
          ;; already-computed `workspace-files' from the enclosing let to avoid another remote
          ;; `project-current' walk.
          (let* ((current-path-as-dir (file-name-as-directory current-path))
                 ;; Hash table to collect unique entries (both files and directories).
                 (entries-hash (make-hash-table :test 'equal))

                 ;; Process workspace files to find immediate children.
                 (_
                  (dolist (workspace-file workspace-files)
                    (let* ((normalized-file
                            (if (file-name-absolute-p workspace-file)
                                workspace-file
                              (expand-file-name workspace-file workspace-root)))
                           ;; Check if this file is under current-path.
                           (relative-to-current
                            (when (string-prefix-p current-path-as-dir normalized-file)
                              (substring normalized-file (length current-path-as-dir)))))
                      ;; If the file is under current path, extract the immediate child.
                      (when relative-to-current
                        (let ((first-component
                               (if (string-match "/" relative-to-current)
                                   ;; Has subdirectories - take the first directory component.
                                   (substring relative-to-current 0 (match-beginning 0))
                                 ;; Direct child file.
                                 relative-to-current)))
                          (unless (string-empty-p first-component)
                            (puthash first-component t entries-hash)))))))

                 ;; Add any newly created files from the context.
                 (context-new-files (collect-new-context-files current-path))
                 (_
                  (dolist (file context-new-files)
                    (puthash file t entries-hash)))

                 ;; Add any directories implied by context files.
                 (context-new-directories (collect-new-context-directories current-path))
                 (_
                  (dolist (dir context-new-directories)
                    (puthash dir t entries-hash)))

                 ;; Convert hash table to list.
                 (all-entries
                  (let ((entries '()))
                    (maphash (lambda (key _value) (push key entries)) entries-hash)
                    entries)))
            ;; Process entries if we have any entries to process.
            (when (> (length all-entries) 0)
              (dolist (entry all-entries)
                (let* ((entry-full-path (expand-file-name entry current-path))
                       (entry-rel-path
                        (if (string-empty-p current-rel-path)
                            entry
                          (concat current-rel-path "/" entry)))
                       (entry-deleted-p (file-deleted-in-context-p entry-full-path))
                       ;; Use a single file-attributes call per entry instead of
                       ;; separate file-exists-p / file-symlink-p / file-directory-p,
                       ;; since each is a TRAMP round-trip.
                       (entry-attrs (file-attributes entry-full-path))
                       (entry-exists-on-disk-p entry-attrs)
                       (entry-disk-type (and entry-attrs (file-attribute-type entry-attrs)))
                       (entry-is-symlink-p (and (not entry-deleted-p) (stringp entry-disk-type)))
                       (entry-exists-in-context-p
                        (when-let ((entry
                                    (assoc
                                     (macher--normalize-path entry-full-path) context-contents)))
                          (let ((contents (cdr entry)))
                            ;; Has new content.
                            (cdr contents))))
                       (entry-exists-p
                        (or entry-exists-on-disk-p
                            entry-exists-in-context-p entry-is-symlink-p
                            ;; Also exists if it's a directory implied by context files.
                            (member entry context-new-directories)))
                       (entry-is-dir-p
                        (and entry-exists-p
                             (not entry-deleted-p) (not entry-is-symlink-p)
                             ;; A path is a directory if it exists on disk as a directory OR if it's
                             ;; in our context-new-directories list.
                             (or (eq t entry-disk-type) (member entry context-new-directories))))
                       (size-info "")
                       (indent (make-string (* depth 2) ?\s)))

                  ;; Skip deleted files.
                  (unless entry-deleted-p
                    ;; Get size information if requested (not for directories or symlinks).
                    (when (and sizes (not entry-is-dir-p) (not entry-is-symlink-p))
                      (let ((file-size (get-file-content-size entry-full-path)))
                        (setq size-info (format " (%s)" (macher--format-size file-size)))))

                    ;; Add symlink target info if it's a symlink.
                    ;;
                    ;; entry-disk-type is the symlink target string.
                    (when entry-is-symlink-p
                      (setq size-info (format " -> %s" entry-disk-type)))

                    ;; Add entry to results.
                    (push (format "%s%s: %s%s"
                                  indent
                                  (cond
                                   (entry-is-symlink-p
                                    "link")
                                   (entry-is-dir-p
                                    "dir")
                                   (t
                                    "file"))
                                  entry-rel-path size-info)
                          results)

                    ;; Recurse into subdirectories if requested (but not into symlinked
                    ;; directories).
                    (when (and recursive entry-is-dir-p)
                      (collect-entries entry-full-path entry-rel-path (1+ depth))))))))))

      ;; Start collection.
      (collect-entries full-path "" 0))

    ;; Return formatted results.
    (if results
        (string-join (reverse results) "\n")
      "Directory is empty")))

(defun macher--tool-edit-file (context path old-text new-text &optional replace-all)
  "Edit file specified by PATH within the workspace.

CONTEXT is a `macher-context' struct containing workspace information.

PATH is the path to the file, relative to the workspace root.

OLD-TEXT is the exact text to replace.

NEW-TEXT is the replacement text.

REPLACE-ALL, if non-nil, replaces all occurrences; otherwise errors if
multiple matches exist.

Returns nil on success.  Signals an error if the file is not found or if
the edit operation fails.  Sets the dirty-p flag on the context to
indicate changes."
  ;; Handle :json-false inputs for replace-all parameter.
  (let ((replace-all (and replace-all (not (eq replace-all :json-false)))))
    ;; Validate required parameters
    (unless (and old-text new-text)
      (error "Both old_text and new_text are required"))
    ;; Use the helper function to perform the edit.
    (macher--with-workspace-file context path
                                 (lambda (full-path new-content)
                                   (let ((result
                                          (macher--edit-string new-content old-text new-text
                                                               replace-all)))
                                     ;; Update the content in the context.
                                     (macher-context--set-new-content-for-file
                                      full-path result context)
                                     ;; Return nil to indicate success.
                                     nil))
                                 t)))

(defun macher--tool-multi-edit-file (context path edits)
  "Make multiple edits to a file specified by PATH within the workspace.

CONTEXT is a `macher-context' struct containing workspace information.

PATH is the path to the file, relative to the workspace root.

EDITS is a vector of edit operations, each containing :old_string and
:new_string.  For compatibility with LLMs that don't support array
arguments, a JSON string representing an array is also accepted.

All edits are applied in sequence to the same file.  Each edit requires
exact whitespace matching.  If any edit fails, the entire operation
fails.

Returns nil on success.  Signals an error if the file is not found or if
any edit operation fails.  Sets the dirty-p flag on the context to
indicate changes."
  ;; Validate that edits is a vector, i.e. a JSON array.  Ideally the argument should
  ;; have been sent as an actual array, but some LLMs seem to have trouble with this,
  ;; and instead send JSON strings which decode to an array.  Allow both cases, as
  ;; although the string case technically violates the tool signature, we can still
  ;; handle it unambiguously.
  (unless (vectorp edits)
    (if (stringp edits)
        ;; Try to decode JSON string to vector.
        (condition-case nil
            (let ((decoded (json-parse-string edits :array-type 'vector :object-type 'plist)))
              (if (vectorp decoded)
                  (setq edits decoded)
                (error
                 "The 'edits' parameter must be an array, but the decoded JSON is not an array")))
          (error
           (error
            "The 'edits' parameter must be an array of objects, or a valid JSON string representing an array")))
      ;; Not a vector or string - invalid input.
      (error "The 'edits' parameter must be an array of objects, not %s" (type-of edits))))
  ;; Use the helper function to perform the edits.
  (macher--with-workspace-file context path
                               (lambda (full-path new-content)
                                 ;; Apply edits sequentially.
                                 (cl-loop
                                  for edit across edits do
                                  (let ((old-text (plist-get edit :old_text))
                                        (new-text (plist-get edit :new_text))
                                        (replace-all (plist-get edit :replace_all)))

                                    (unless (and old-text new-text)
                                      (error
                                       "Each edit must contain old_text and new_text properties"))
                                    ;; Handle :json-false inputs for replace-all parameter.
                                    (setq replace-all
                                          (and replace-all (not (eq replace-all :json-false))))
                                    (setq new-content
                                          (macher--edit-string new-content old-text new-text
                                                               replace-all))
                                    ;; Update the content in the context after each edit.
                                    (macher-context--set-new-content-for-file
                                     full-path new-content context)))
                                 ;; Return nil to indicate success.
                                 nil)
                               t))

(defun macher--tool-write-file (context path content)
  "Create a new file or completely overwrite an existing file with CONTENT.

CONTEXT is a `macher-context' struct containing workspace information.

PATH is the path to the file, relative to the workspace root.

CONTENT is the complete new content for the file.

Use with caution as it will overwrite existing files without warning.
Handles text content with proper encoding.

Returns nil on success.  Sets the dirty-p flag on the context to indicate
changes."
  (let* ((workspace (macher-context-workspace context))
         (resolve-workspace-path (apply-partially #'macher--resolve-workspace-path workspace))
         (full-path (funcall resolve-workspace-path path)))
    ;; Set the dirty flag to indicate changes are being made.
    (setf (macher-context-dirty-p context) t)
    ;; Set the new content in the context (this will create the entry if needed).
    (macher-context--set-new-content-for-file full-path content context)
    nil))

(defun macher--tool-move-file (context source-path destination-path)
  "Move or rename files within the workspace.

CONTEXT is a `macher-context' struct containing workspace information.

SOURCE-PATH is the source path relative to the workspace root.

DESTINATION-PATH is the destination path relative to the workspace root.

Can move files between directories and rename them in a single operation.
If the destination exists, the operation will fail.  Works across different
directories and can be used for simple renaming within the same directory.

Returns nil on success.  Signals an error if the source file is not found or
if the destination already exists.  Sets the dirty-p flag on the context to
indicate changes."
  (let* ((workspace (macher-context-workspace context))
         ;; Compute workspace-files once and share it with both resolve calls
         ;; below, to avoid a redundant `project-current' walk over TRAMP.
         (workspace-files (macher--workspace-files workspace))
         (dest-full-path
          (macher--resolve-workspace-path workspace destination-path workspace-files)))
    ;; Check if destination already exists.
    (let ((dest-contents (macher-context--contents-for-file dest-full-path context)))
      (when (cdr dest-contents)
        (error "Destination '%s' already exists" destination-path)))
    ;; Use the helper function to move the file.
    (macher--with-workspace-file context source-path
                                 (lambda (source-full-path source-new-content)
                                   ;; Copy content from source to destination.
                                   (macher-context--set-new-content-for-file
                                    dest-full-path source-new-content context)
                                   ;; Mark source for deletion by setting its content to nil.
                                   (macher-context--set-new-content-for-file
                                    source-full-path nil context)
                                   ;; Return nil to indicate success.
                                   nil)
                                 t workspace-files)))

(defun macher--tool-delete-file (context rel-path)
  "Delete a file specified by REL-PATH within the workspace.

CONTEXT is a `macher-context' struct containing workspace information.

REL-PATH is the path to the file, relative to the workspace root.

The file must exist and will be marked for deletion in the patch.
Permanently removes the file from the workspace.

Returns nil on success.  Signals an error if the file is not found.
Sets the dirty-p flag on the context to indicate changes."
  ;; Use the helper function to delete the file.
  (macher--with-workspace-file context rel-path
                               (lambda (full-path _editable-content)
                                 ;; For deletion, set the new content to nil to indicate deletion.
                                 (macher-context--set-new-content-for-file full-path nil context)
                                 ;; Return nil to indicate success.
                                 nil)
                               t))

(cl-defun macher--search-get-xref-matches (context pattern &key path file-regexp case-insensitive)
  "Get raw xref matches as an alist of ((filename . matches)).

CONTEXT is the macher-context, which will be taken into account when
searching for matches (including new and deleted files).

PATTERN is the regexp pattern to search for.

PATH is the directory or file to search, defaulting to the context's
workspace root.

FILE-REGEXP is a regexp to specify which files should be included - it
will be matched against each file's path relative to the search PATH.

CASE-INSENSITIVE, if non-nil, performs a case-insensitive search.

Returns an alist containing `xref-match-item' structs for each file,
with structure:

 ((rel-path . (xref-match-item1 xref-match-item2 ...)) ...)

The search will be performed using `xref-matches-in-files', which uses
the `xref-search-program' to perform the search."
  (require 'xref)
  (let* (
         ;; Set case-fold-search to enable case-insensitive search when needed.  Note: xref uses
         ;; `grep-expand-template' to generate the command, which performs a case-insensitive search
         ;; if/only if `case-fold-search' is truthy and `isearch-no-upper-case-p' is true for the
         ;; regexp in question.
         (case-fold-search case-insensitive)
         (workspace (macher-context-workspace context))
         (workspace-root (macher--workspace-root workspace))
         ;; Compute workspace-files once and reuse it for path resolution below.  Each call
         ;; triggers `project-current' which is expensive over TRAMP (walks the directory tree
         ;; looking for a VC root).
         (workspace-files (macher--workspace-files workspace))
         (resolve-workspace-path
          (lambda (rel-path) (macher--resolve-workspace-path workspace rel-path workspace-files)))
         (search-path (funcall resolve-workspace-path (or path ".")))
         (context-contents (macher-context-contents context))
         (path
          (when path
            (expand-file-name path workspace-root)))
         ;; When case-insensitive is true, downcase the pattern so that `isearch-no-upper-case-p'
         ;; returns true, which is required for grep's case-insensitive mode to activate.
         (search-pattern
          (if case-insensitive
              (downcase pattern)
            pattern))
         (temp-files-alist '())
         (results '()))

    (let* (
           ;; Create a list of files to search, filtering by path and glob.
           (files-to-search
            (cl-remove-if-not
             (lambda (file-path)
               (let ((full-path (expand-file-name file-path workspace-root)))
                 ;; Note - we don't check explicitly that `file-exists-p', since this is expensive
                 ;; for remote files or large projects.  The search backend (grep/rg) should simply
                 ;; be able to skip files that don't exist.
                 (and
                  ;; File is under the search path.
                  (string-prefix-p search-path full-path)
                  ;; File matches regexp if specified.
                  (if file-regexp
                      (let ((rel-path
                             (if path
                                 (file-relative-name full-path search-path)
                               (file-relative-name full-path workspace-root))))
                        (string-match-p file-regexp rel-path))
                    t))))
             workspace-files))
           ;; Add any context-only files that match our criteria.
           (context-only-files
            (cl-remove-if-not
             (lambda (entry)
               (let* ((file-path (car entry))
                      (contents (cdr entry))
                      (new-content (cdr contents)))
                 (and
                  ;; File has new content (not deleted).
                  new-content
                  ;; File is not already in workspace-files.
                  (not
                   (cl-find
                    file-path files-to-search
                    :test (lambda (a b) (string= a (expand-file-name b workspace-root)))))
                  ;; File is under search path.
                  (string-prefix-p search-path file-path)
                  ;; File matches regexp if specified.
                  (if file-regexp
                      (let ((rel-path
                             (if path
                                 (file-relative-name file-path search-path)
                               (file-relative-name file-path workspace-root))))
                        (string-match-p file-regexp rel-path))
                    t))))
             context-contents))
           ;; Combine and convert to absolute paths
           (all-files-to-search
            (append
             (mapcar
              (lambda (f)
                (if (file-name-absolute-p f)
                    f
                  (expand-file-name f workspace-root)))
              files-to-search)
             (mapcar #'car context-only-files))))

      ;; Create temporary files for context modifications.
      (setq all-files-to-search
            (mapcar
             (lambda (file-path)
               (let ((entry (assoc (macher--normalize-path file-path) context-contents)))
                 (if entry
                     (let* ((contents (cdr entry))
                            (new-content (cdr contents)))
                       (if new-content
                           ;; Create temp file with modified content.
                           (let ((temp-file (make-temp-file "macher-search")))
                             (with-temp-buffer
                               (insert new-content)
                               (write-region (point-min) (point-max) temp-file nil 'silent))
                             (push (cons temp-file file-path) temp-files-alist)
                             temp-file)
                         ;; File is deleted in context, skip it.
                         nil))
                   ;; No context modification, use original file.
                   file-path)))
             all-files-to-search))

      ;; Remove nil entries (deleted files).
      (setq all-files-to-search (cl-remove-if #'null all-files-to-search))

      ;; Use xref-matches-in-files to search and clean up temp files afterwards.
      (unwind-protect
          (progn
            (when all-files-to-search
              (let* ((xref-matches (xref-matches-in-files search-pattern all-files-to-search)))
                (dolist (match xref-matches)
                  (let*
                      ((location (xref-item-location match))
                       (file-path (xref-file-location-file location))
                       ;; Map temp file back to original file.
                       (original-file (or (cdr (assoc file-path temp-files-alist)) file-path))
                       ;; Ensure matched files are loaded into the macher-context, so their contents
                       ;; won't change before they're accessed.
                       (_ (macher-context--contents-for-file original-file context))
                       ;; Always show results relative to workspace root (like grep relative to
                       ;; cwd).  Special case: if path points to a single file, show the original
                       ;; path parameter.
                       (rel-path
                        (if (and path
                                 (file-exists-p search-path)
                                 (not (file-directory-p search-path)))
                            ;; Path is a single file, use the original path parameter.
                            path
                          ;; Otherwise, always relative to workspace root.
                          ;; Strip any remote prefix from both paths so
                          ;; file-relative-name can compare them even when
                          ;; one has a TRAMP prefix and the other does not
                          ;; (e.g. xref may return local paths while
                          ;; workspace-root is remote).
                          (file-relative-name (or (file-remote-p original-file 'localname)
                                                  original-file)
                                              (or (file-remote-p workspace-root 'localname)
                                                  workspace-root)))))

                    ;; Group results by file for proper formatting.
                    (let ((file-entry (assoc rel-path results)))
                      (if file-entry
                          ;; Add to the existing file's match list - file-entry structure is
                          ;; (rel-path . xref-match-item-list).
                          (setcdr file-entry (append (cdr file-entry) (list match)))
                        ;; Create new file entry - structure is (rel-path . xref-match-item-list).
                        ;; Note this pushes to the beginning of the list.
                        (push (cons rel-path (list match)) results)))))))

            ;; Return the results - temp files are cleaned up in the unwind-protect.  Each entry was
            ;; prepended to the results array, so we need to use `reverse' to restore the original
            ;; order of the workspace's file list.
            (reverse results))

        ;; Cleanup: delete temporary files.
        (dolist (temp-entry temp-files-alist)
          (when (file-exists-p (car temp-entry))
            (delete-file (car temp-entry))))))))

(defun macher--search-format-files-mode (matches-alist)
  "Format search results for files mode output.

MATCHES-ALIST has structure:

  ((REL-PATH . (XREF-MATCH-ITEM1 XREF-MATCH-ITEM2 ...)) ...)"
  (let ((output "")
        (total-matches
         (apply #'+ (mapcar (lambda (file-entry) (length (cdr file-entry))) matches-alist))))
    ;; Files mode: show file paths with match counts
    (dolist (file-entry matches-alist)
      (let ((file-path (car file-entry))
            (matches (cdr file-entry)))
        (setq output
              (concat
               output
               (format "%s (%d %s)\n"
                       file-path (length matches)
                       (if (= (length matches) 1)
                           "match"
                         "matches"))))))
    (when matches-alist
      (setq output
            (concat
             output
             (format "\nTotal: %d %s in %d %s"
                     total-matches
                     (if (= total-matches 1)
                         "match"
                       "matches")
                     (length matches-alist)
                     (if (= (length matches-alist) 1)
                         "file"
                       "files")))))
    output))

(defun macher--search-format-content-mode
    (context matches-alist lines-before lines-after show-line-numbers)
  "Format search results for content mode output.

CONTEXT is the macher-context struct.

LINES-BEFORE and LINES-AFTER specify additional lines of context to
include.

SHOW-LINE-NUMBERS toggles inclusion of line numbers in the output.

MATCHES-ALIST is an alist of the form:

   ((rel-path . (xref-match-item1 xref-match-item2 ...)) ...)

The inner lists of the MATCHES-ALIST contain `xref-match-item' structs."
  (let* ((workspace (macher-context-workspace context))
         (workspace-root (macher--workspace-root workspace))
         (context-contents (macher-context-contents context))
         (output ""))

    ;; Content mode: show matching lines with grep-like format.
    (dolist (file-entry matches-alist)
      (let* ((file-path (car file-entry))
             (matches (cdr file-entry)))

        ;; For content mode with context lines, we need to read the file content.
        (let* ((original-file (expand-file-name file-path workspace-root))
               (entry (assoc (macher--normalize-path original-file) context-contents))
               (has-context (or lines-before lines-after)))

          ;; Create a hash table mapping line numbers to lists of matches.
          (let ((line-matches (make-hash-table :test 'eq)))
            (dolist (match matches)
              (let ((line-num (xref-file-location-line (xref-item-location match))))
                (push match (gethash line-num line-matches))))

            (if has-context
                ;; Context mode: merge overlapping ranges and show continuous output.
                (let*
                    ((file-content
                      (if entry
                          ;; File is in context, use the current content (new-content).  Note: deleted
                          ;; files (where new-content is nil) are filtered out during the search phase, so
                          ;; we should never encounter them here, but it doesn't really matter if we do -
                          ;; we'll just have nil file-content in that case.
                          (cdr (cdr entry))
                        ;; File not in context, read from disk.
                        (when (file-exists-p original-file)
                          (with-temp-buffer
                            (insert-file-contents original-file)
                            (buffer-substring-no-properties (point-min) (point-max))))))
                     (lines
                      (when file-content
                        (split-string file-content "\n")))

                     (line-ranges '()))

                  ;; Calculate line ranges for each match (including context)
                  (dolist (match matches)
                    (let* ((line-num (xref-file-location-line (xref-item-location match)))
                           (start-line (max 1 (- line-num (or lines-before 0))))
                           (end-line (min (length lines) (+ line-num (or lines-after 0)))))
                      (push (list start-line end-line) line-ranges)))

                  ;; Sort ranges by start line.
                  (setq line-ranges (sort line-ranges (lambda (a b) (< (car a) (car b)))))

                  ;; Merge overlapping or adjacent ranges.
                  (let ((merged-ranges '())
                        (current-start nil)
                        (current-end nil))
                    (dolist (range line-ranges)
                      (let ((start (car range))
                            (end (cadr range)))
                        (if (or (null current-start)
                                ;; ; Not overlapping/adjacent.
                                (> start (1+ current-end)))
                            (progn
                              (when current-start
                                (push (list current-start current-end) merged-ranges))
                              (setq
                               current-start start
                               current-end end))
                          ;; Overlapping or adjacent - merge.
                          (setq current-end (max current-end end)))))
                    (when current-start
                      (push (list current-start current-end) merged-ranges))
                    (setq merged-ranges (reverse merged-ranges))

                    ;; Output merged ranges with separators between non-adjacent ranges.
                    (let ((need-separator nil))
                      (dolist (range merged-ranges)
                        (let ((start-line (car range))
                              (end-line (cadr range)))
                          ;; Add separator between non-adjacent ranges.
                          (when need-separator
                            (setq output (concat output "--\n")))
                          (setq need-separator t)

                          ;; Output lines in this range.
                          (let ((i start-line))
                            (while (<= i end-line)
                              (let*
                                  ((line (nth (1- i) lines))
                                   (line-match-list (gethash i line-matches))
                                   (match-count (length line-match-list))
                                   (is-match (> match-count 0))
                                   ;; Replace line with ripgrep-style placeholder if it exceeds the
                                   ;; maximum length.
                                   (truncated-line
                                    (if (and macher-match-max-columns
                                             (> (length line) macher-match-max-columns))
                                        (if is-match
                                            (format "[Omitted long line with %d matches]"
                                                    match-count)
                                          "[Omitted long context line]")
                                      line)))
                                (let* ((separator
                                        (if is-match
                                            ":"
                                          "-"))
                                       (line-format
                                        (if show-line-numbers
                                            (format "%s%s%d%s%s\n"
                                                    file-path
                                                    separator
                                                    i
                                                    separator
                                                    truncated-line)
                                          (format "%s%s%s\n" file-path separator truncated-line))))
                                  (setq output (concat output line-format))
                                  (setq i (1+ i)))))))))))

              ;; Simple content mode without before/after lines.  In this case, xref has already
              ;; loaded enough information to render each line, so we don't need to read the entire
              ;; file/split it into lines.
              (let ((processed-lines (make-hash-table :test 'eq)))
                ;; Loop over unique line numbers and output concatenated summaries.
                (maphash
                 (lambda (line-num line-match-list)
                   (unless (gethash line-num processed-lines)
                     (puthash line-num t processed-lines)
                     (let* ((summaries
                             (mapcar
                              #'xref-item-summary
                              ;; We need to reverse the list, since entries were prepended in order.
                              (reverse line-match-list)))
                            (combined-summary (substring-no-properties (string-join summaries "")))
                            (match-count (length line-match-list))
                            ;; Replace summary with ripgrep-style placeholder if it exceeds the
                            ;; maximum length.
                            (truncated-summary
                             (if (and macher-match-max-columns
                                      (> (length combined-summary) macher-match-max-columns))
                                 (format "[Omitted long line with %d matches]" match-count)
                               combined-summary))
                            (line-format
                             (if show-line-numbers
                                 (format "%s:%d:%s\n" file-path line-num truncated-summary)
                               (format "%s:%s\n" file-path truncated-summary))))
                       (setq output (concat output line-format)))))
                 line-matches)))))))

    output))

(cl-defun macher--tool-search-helper
    (context
     pattern
     &key
     path
     file-regexp
     mode
     case-insensitive
     lines-after
     lines-before
     show-line-numbers
     head-limit)
  "Search for PATTERN within the workspace using grep-like functionality.

CONTEXT is a `macher-context' struct containing workspace information.

PATTERN is the regular expression to search for (required).

Keyword arguments:
- PATH: directory or file to search, relative to workspace root.
  Defaults to workspace root if not provided.

- GLOB: file pattern relative to PATH to filter results (e.g., \"*.js\",
  \"**/docker-compose*.yml\").

- MODE: output format - \"files\" (default) shows file paths with match
  counts, \"content\" shows grep-style matching lines with context.

- CASE-INSENSITIVE: if non-nil, performs a case-insensitive
  search (default: nil).

- LINES-AFTER: number of lines to show after each match (content mode only).

- LINES-BEFORE: number of lines to show before each match (content mode only).
- SHOW-LINE-NUMBERS: if non-nil, includes line numbers in output (content
  mode only).

- HEAD-LIMIT: limits output to first N lines (like `head -N`).

Returns formatted search results as a string.  Considers workspace context
including any pending changes, creations, or deletions.

HEAD-LIMIT applies to the final formatted output and works exactly like
piping the results through `head -N`."
  (let ((search-mode (or mode "files")))
    ;; Validate parameters.
    (unless (and pattern (not (string-empty-p pattern)))
      (error "Pattern is required and cannot be empty"))

    (when (and head-limit (< head-limit 0))
      (error "Head-limit cannot be negative"))

    ;; Handle float inputs by rounding to integers, like the read tool does.  Some LLMs seem to have
    ;; trouble invoking tools with integer inputs.
    (let* ((parsed-lines-after
            (when lines-after
              (round lines-after)))
           (parsed-lines-before
            (when lines-before
              (round lines-before)))
           (parsed-head-limit
            (when head-limit
              (round head-limit))))

      ;; Get raw xref matches.
      (let* ((matches-alist
              (macher--search-get-xref-matches
               context
               pattern
               :path path
               :file-regexp file-regexp
               :case-insensitive case-insensitive))
             (output "No matches found."))

        ;; Check if any matches were found.
        (when matches-alist
          ;; Format results based on mode.
          (cond
           ((string= search-mode "files")
            (setq output (macher--search-format-files-mode matches-alist)))

           ((string= search-mode "content")
            (setq output
                  (macher--search-format-content-mode
                   context matches-alist parsed-lines-before parsed-lines-after show-line-numbers)))

           (t
            (error "Mode must be either 'files' or 'content'"))))

        ;; Apply head-limit if specified.
        (when parsed-head-limit
          (let ((lines (split-string output "\n")))
            (when (> (length lines) parsed-head-limit)
              (setq output (string-join (seq-take lines parsed-head-limit) "\n")))))
        output))))

(defun macher--tool-search
    (context
     pattern
     &optional
     path
     file-regexp
     mode
     case-insensitive
     lines-after
     lines-before
     show-line-numbers
     head-limit)
  "Search for PATTERN within the workspace using grep-like functionality.

CONTEXT is a `macher-context' struct containing workspace information.

PATTERN is the regular expression to search for (required).

PATH is the directory or file to search, relative to workspace root.
Defaults to workspace root if not provided.

FILE-REGEXP is a regular expression to filter files by path relative to
search path.

MODE specifies output format:
- \"files\" (default) - Show file paths with match counts
- \"content\" - Show grep-style matching lines with context

CASE-INSENSITIVE, if non-nil, performs a case-insensitive search (default: nil).

LINES-AFTER specifies number of lines to show after each match (content
mode only).

LINES-BEFORE specifies number of lines to show before each
match (content mode only).

SHOW-LINE-NUMBERS, if non-nil, includes line numbers in output (content
mode only).

HEAD-LIMIT limits output to first N lines (equivalent to piping through
`head -N`).

Returns formatted search results as a string.  Considers workspace
context including any pending changes, creations, or deletions."
  (macher--tool-search-helper
   context
   pattern
   :path path
   :file-regexp file-regexp
   :mode mode
   :case-insensitive case-insensitive
   :lines-after lines-after
   :lines-before lines-before
   :show-line-numbers show-line-numbers
   :head-limit head-limit))

(defun macher--edit-string (content old-string new-string &optional replace-all)
  "In CONTENT string, replace OLD-STRING with NEW-STRING.

If REPLACE-ALL is non-nil, replace all occurrences.  Otherwise, error if
multiple matches exist and replace only single occurrences.

Return the new content string if the replacement was successful, or signal
an error if it was not."
  (let ((case-fold-search nil))
    ;; Error if old-string and new-string are identical
    (when (string-equal old-string new-string)
      (error "No changes to make: old_string and new_string are exactly the same"))
    ;; Handle empty old-string specially.
    (if (string-empty-p old-string)
        (if (string-empty-p content)
            ;; If content is empty, return the new string.
            new-string
          ;; If content is not empty and old-string is empty, throw an error.
          (error "Cannot replace empty string in non-empty content"))
      ;; Normal case: old-string is not empty.
      (let* ((start 0)
             (matches 0)
             (match-positions '()))
        ;; Count matches and collect positions.
        (while (setq start (string-search old-string content start))
          (setq matches (1+ matches))
          (push start match-positions)
          (setq start (+ start (length old-string))))

        (cond
         ((= matches 0)
          (error "String to replace not found in file"))
         ((and (> matches 1) (not replace-all))
          (error
           (concat
            "Found %d matches of the string to replace, but replace_all is false. "
            "To replace all occurrences, set replace_all to true.  To replace only one "
            "occurrence, please provide more context to uniquely identify the instance")
           matches))
         (t
          ;; Perform replacement(s)
          (if replace-all
              ;; Replace all occurrences (work backwards to preserve positions)
              (let ((result content))
                (dolist (pos (sort match-positions '>))
                  (setq result
                        (concat
                         (substring result 0 pos)
                         new-string
                         (substring result (+ pos (length old-string))))))
                result)
            ;; Replace single occurrence
            (let ((match-pos (car (reverse match-positions))))
              (concat
               (substring content 0 match-pos)
               new-string
               (substring content (+ match-pos (length old-string))))))))))))

(defun macher--generate-patch-diff (context)
  "Generate a raw diff to populate the patch buffer.
CONTEXT is the `macher-context' object.  Returns the generated diff text."
  (let* ((contents-alist (macher-context-contents context))
         (workspace (macher-context-workspace context))
         (base-dir (macher--workspace-root workspace))
         (result ""))

    ;; Use the system diff command to generate a unified diff for each file.
    ;; Sort the contents list by filename for consistent patch ordering.
    (dolist (entry (sort (copy-sequence contents-alist) (lambda (a b) (string< (car a) (car b)))))
      (let* ((filename (car entry))
             (contents (cdr entry))
             (orig-content (car contents))
             (new-content (cdr contents))
             ;; Get the path relative to the base directory.
             (rel-path (file-relative-name filename base-dir))
             ;; Check if file has actually changed.
             (file-changed-p (not (equal orig-content new-content))))

        ;; Only generate diff if the file has actually changed.
        (when file-changed-p
          (let ((temp-orig (make-temp-file "gptel-diff-orig"))
                (temp-new (make-temp-file "gptel-diff-new")))

            ;; Write original content (or empty file for new files).
            (with-temp-buffer
              (when orig-content
                (insert orig-content))
              (write-region (point-min) (point-max) temp-orig nil 'silent))

            ;; Write new content or create empty file for deletions.
            (with-temp-buffer
              (when new-content
                (insert new-content))
              (write-region (point-min) (point-max) temp-new nil 'silent))

            ;; Generate diff and append to result.
            (with-temp-buffer
              ;; Add the standard git diff header, which allows diff-mode to create new files.
              (insert (format "diff --git a/%s b/%s\n" rel-path rel-path))

              ;; Add file mode lines for new or deleted files.
              (cond
               ;; New file: orig-content is nil, new-content is non-nil.
               ((and (not orig-content) new-content)
                (insert "new file mode 100644\n"))
               ;; Deleted file: orig-content is non-nil, new-content is nil.
               ((and orig-content (not new-content))
                (insert "deleted file mode 100644\n")))

              ;; Use diff to generate a unified patch with the correct file path.
              (when (or orig-content new-content)
                (call-process "diff"
                              nil t nil "-u" "--label"
                              (if orig-content
                                  (concat "a/" rel-path)
                                ;; Use /dev/null to denote file creations.
                                "/dev/null")
                              "--label"
                              (if new-content
                                  (concat "b/" rel-path)
                                ;; Use /dev/null to denote file deletions.
                                "/dev/null")
                              temp-orig temp-new))

              ;; Append the diff to the result.
              (setq result (concat result (buffer-string))))

            ;; Clean up the temp files.
            (delete-file temp-orig)
            (delete-file temp-new)))))

    result))

;;; Context Content Management

(defun macher-context--set-new-content-for-file (path content context)
  "Set the new content for PATH to CONTENT in the given CONTEXT.

PATH is any absolute file path.  By default, macher will only call this
for paths within the CONTEXT's workspace..

CONTENT is the string content to use for the file, or nil if the file
is being deleted.

CONTEXT must be a `macher-context' struct.

Updates the CONTEXT's :contents alist with the new content mapping."
  (cl-assert (macher-context-p context) nil "CONTEXT must be a macher-context struct")

  (let* ((normalized-path (macher--normalize-path path))
         (context-contents (macher-context-contents context))
         (existing-entry (assoc normalized-path context-contents)))
    (if existing-entry
        ;; Update existing entry.
        (let ((contents (cdr existing-entry)))
          (setcdr contents content)
          contents)
      ;; Create new entry.  For new files, orig-content is nil.
      (let* ((orig-content
              (if (file-exists-p normalized-path)
                  (with-temp-buffer
                    (insert-file-contents normalized-path)
                    (buffer-substring-no-properties (point-min) (point-max)))
                nil))
             (content-pair (cons orig-content content)))
        ;; Add to context.
        (setf (macher-context-contents context)
              (cons
               (cons normalized-path content-pair)
               (assoc-delete-all normalized-path context-contents)))
        content-pair))))

(defun macher-context--contents-for-file (path context)
  "Get or create content strings for PATH in the macher CONTEXT.

Returns a cons cell \\=(orig-content new-content).

PATH can be any absolute file path.  By default, macher will only call
this for paths within the CONTEXT's workspace.

Returns a cons cell (orig-content . new-content) of strings for the file.
If the orig-content is nil, the file is being created; if the new-content
is nil, the file is being deleted.

Also updates the CONTEXT's :contents alist if the relevant entry was not
yet present.  In that case, loads file contents if the file exists;
otherwise returns (nil . nil)."
  (cl-assert (macher-context-p context) nil "CONTEXT must be a macher-context struct")
  ;; Normalize the path for consistent lookup.
  (let* ((normalized-path (macher--normalize-path path))
         ;; Check if we already have contents for this file.
         (existing-contents (assoc normalized-path (macher-context-contents context))))
    (if existing-contents
        ;; Return the existing contents.
        (cdr existing-contents)
      ;; Try to load the file content, treating a missing file as (nil . nil) rather than
      ;; doing a separate `file-exists-p' round-trip over TRAMP before the actual read.  Only
      ;; catch `file-missing' — broader `file-error' signals (permission denied, TRAMP
      ;; connection failure, etc.) should propagate so the caller sees the real problem.
      (let* ((file-content
              (condition-case nil
                  (with-temp-buffer
                    (insert-file-contents normalized-path)
                    (buffer-substring-no-properties (point-min) (point-max)))
                (file-missing
                 nil)))
             ;; For non-existent files, store (nil . nil); for existing files, the original and
             ;; new content start as the same.
             (content-pair
              (if file-content
                  (cons file-content file-content)
                (cons nil nil))))
        (push (cons normalized-path content-pair) (macher-context-contents context))
        content-pair))))

;;; Default Prompt Functions
(defun macher--focus-string-default ()
  "Default implementation of focus description.

Returns a string with information about the current buffer, including
file path, language, cursor position or selection, and relevant context
text."
  (let* ((workspace (macher-workspace))
         (workspace-root
          (if workspace
              (macher--workspace-root workspace)
            default-directory))
         (workspace-name
          (when workspace
            (macher--workspace-name workspace)))
         (filename (buffer-file-name))
         (dirname dired-directory)
         (parts nil))
    ;; Add project name if we have a workspace.
    (when workspace-name
      (push (format "project: %s" workspace-name) parts))
    (cond
     ;; Standard file-visiting buffer.
     (filename
      (let* ((lang
              (condition-case nil
                  (gptel--strip-mode-suffix major-mode)
                (error
                 (format-mode-line mode-name))))
             (has-selection (use-region-p)))
        ;; File and language.
        (push (format "file: %s" (file-relative-name filename workspace-root)) parts)
        (push (format "language: %s" lang) parts)

        (if has-selection
            ;; Selection case.
            (let* ((start (region-beginning))
                   (end (region-end))
                   (start-line (line-number-at-pos start))
                   (end-line (line-number-at-pos end))
                   (selected-text (buffer-substring-no-properties start end)))
              (if (= start-line end-line)
                  (push (format "line: %d" start-line) parts)
                (push (format "lines: %d-%d" start-line end-line) parts))
              (push (format "selection:\n%s" selected-text) parts))
          ;; No selection - show cursor position and current line.
          (let* ((pos (point))
                 (line (line-number-at-pos pos))
                 (col (current-column))
                 (line-start (line-beginning-position))
                 (text-before-cursor (buffer-substring-no-properties line-start pos)))
            (push (format "line: %d, column: %d" line col) parts)
            (when (not (string-empty-p text-before-cursor))
              ;; Truncate text-before-cursor to fill-column.
              (let* ((max-width (or fill-column 70))
                     (text-length (length text-before-cursor))
                     (truncated-text
                      (if (> text-length max-width)
                          ;; Truncate from the beginning, keeping the end.
                          (concat
                           "..." (substring text-before-cursor (- text-length (- max-width 3))))
                        text-before-cursor)))
                (push (format "text before cursor:\n%s" truncated-text) parts)))))))
     ;; Dired directory.
     (dirname
      (push (format "directory: %s" (file-relative-name dirname workspace-root)) parts))
     ;; Non-file buffer.
     (t
      (push (format "buffer: %s" (buffer-name)) parts)))
    (concat "```\n" (mapconcat #'identity (reverse parts) "\n") "\n```\n")))

(defconst macher--action-focus-prefix
  "Current editor context (may or may not be relevant to this request):\n"
  "Prefix for focus description in action prompts.")

(defun macher--implement-prompt (input is-selected)
  "Generate an implementation prompt for INPUT in the current buffer.

The prompt is slightly different depending on whether the content
IS-SELECTED (vs being entered manually)."
  (let ((focus-string (macher-focus-string)))
    (concat
     (when focus-string
       (concat
        macher--action-focus-prefix focus-string
        (unless (string-suffix-p "\n" focus-string)
          "\n")))
     (if is-selected
         "Implementation request (from user-selected text),"
       "Implementation request,")
     " use workspace tools to implement:\n\n" input)))

(defun macher--revise-prompt (input _is-selected &optional patch-buffer)
  "Generate a prompt for revising based on INPUT (revision instructions).

The contents of the PATCH-BUFFER (defaulting to the current workspace's
patch buffer) are included in the generated prompt."
  (let* ((patch-buffer (or patch-buffer (macher-patch-buffer)))
         (patch-content
          (if patch-buffer
              (with-current-buffer patch-buffer
                (buffer-substring-no-properties (point-min) (point-max)))
            (user-error "No patch buffer found for revision")))
         (focus-string (macher-focus-string)))
    (concat
     (when focus-string
       (concat
        macher--action-focus-prefix focus-string
        (unless (string-suffix-p "\n" focus-string)
          "\n")))
     "Your previous work:\n" "```diff\n" patch-content
     (unless (string-suffix-p "\n" patch-content)
       "\n")
     "```\n"
     (if (and input (not (string-empty-p input)))
         (concat "Revise your previous work based on these instructions:\n\n" input)
       "Revise your previous work."))))

(defun macher--discuss-prompt (input _is-selected)
  "Generate a prompt for discussion based on INPUT.

Includes the current focus description to provide context."
  (let ((focus-string (macher-focus-string)))
    (concat
     (when focus-string
       (concat
        macher--action-focus-prefix focus-string
        (unless (string-suffix-p "\n" focus-string)
          "\n")))
     input)))


(defun macher--patch-prepare-diff (context _fsm callback)
  "Generate and add a diff to the current buffer for CONTEXT.
CALLBACK must be called when preparation is complete."
  (let ((diff-text (macher--generate-patch-diff context)))
    (insert diff-text)
    (funcall callback)))

(defun macher--patch-prepare-metadata (context _fsm callback)
  "Add metadata to the current patch buffer content for CONTEXT.
CALLBACK must be called when preparation is complete."
  (let* ((workspace (macher-context-workspace context))
         (proj-name (macher--workspace-name workspace))
         (initial-text (buffer-string))
         ;; Generate a unique patch ID as a random 8-character alphanumeric string.  The result here
         ;; isn't really important - it's just used to help the LLM link the prompt with its
         ;; eventual generated patch.
         (patch-id
          (let ((chars "abcdefghijklmnopqrstuvwxyz0123456789")
                (result ""))
            (dotimes (_ 8 result)
              (let ((idx (random (length chars))))
                (setq result (concat result (substring chars idx (1+ idx))))))))
         (prompt (macher-context-prompt context))
         (header
          ;; Always start with the patch metadata header.
          (format "# Patch ID: %s\n# Project: %s\n" patch-id proj-name)))

    (goto-char (point-min))
    (insert header)

    ;; If the original patch text is empty, add a message.
    (when (string-empty-p initial-text)
      (insert "\n# No changes were made to any files.\n"))

    ;; Add the prompt as metadata if it exists.
    (goto-char (point-max))
    (when prompt
      (insert
       ;; Don't add any stray newlines, they can break the diff-mode parsing.
       "# -----------------------------\n"
       (format "# PROMPT for patch ID %s:\n" patch-id)
       "# -----------------------------\n"
       ;; Add comment prefix to each line of the prompt.
       (replace-regexp-in-string "^" "# " prompt)
       ;; A trailing newline at the end appears to be okay.
       "\n"))

    ;; Note: The original prompt is no longer stored in the context structure.
    ;; If prompt tracking is needed, it should be added to the context structure.

    (funcall callback)))

;;; Preset Definitions

(defun macher--append-if-missing (list new-items &optional testfn)
  "Get a copy of the LIST with NEW-ITEMS appended, unless already present.

Presence is determined by TESTFN, as defined in e.g. `seq-contains-p',
defaulting to `equal'."
  (let ((result (copy-sequence list)))
    (dolist (item new-items)
      (unless (seq-contains-p result item testfn)
        (setq result (append result (list item)))))
    result))

(defun macher--test-fn-tools (first second)
  "Return t if the FIRST gptel tool matches the SECOND.

Compares the tool names and categories."
  (and (string= (gptel-tool-category first) (gptel-tool-category second))
       (string= (gptel-tool-name first) (gptel-tool-name second))))

(defun macher--preset-function-add-tools (pred tools)
  "Augment TOOLS with macher tools matching PRED.

Use `apply-partially' to transform this into a suitable value for a
:function spec in a gptel preset's :tools value."
  (let* ((tool-defs (seq-filter pred macher-tools))
         (new-tools (mapcar #'macher-resolve-tool tool-defs)))
    (macher--append-if-missing tools new-tools #'macher--test-fn-tools)))

(defun macher-resolve-tool (tool)
  "Resolve a macher TOOL definition to a `gptel-tool'.

TOOL is a plist of the same form as elements of `macher-tools'.

First looks for an existing tool in the global gptel registry, under the
`macher-tool-category', with the same name as the provided TOOL
definition.  If found, returns it.

Otherwise, creates an anonymous tool based on the provided definition.

This might be useful, for example, in custom preset definitions if tools
have not have been installed globally."
  (let* ((tool-name (plist-get tool :name))
         ;; Look up the tool in the gptel registry using category and name.
         (registry-tool
          (condition-case nil
              (gptel-get-tool (list macher-tool-category tool-name))
            ;; `gptel-get-tool' throws an error if the tool was not found; we'll just create an
            ;; anonymous one in this case.
            (error
             nil))))
    (or registry-tool
        ;; Tool not found in registry, create an anonymous tool.
        (apply #'macher--make-tool t tool))))

(defun macher--resolve-preset (preset)
  "Resolve PRESET to a plist spec.

If PRESET is a symbol, look it up from the global gptel registry or from
`macher-presets-alist'.  Otherwise return PRESET as-is."
  (if (symbolp preset)
      (or (gptel-get-preset preset) (cdr (assq preset macher-presets-alist)))
    preset))

(defun macher--with-preset (preset callback)
  "Run the CALLBACK with the macher PRESET applied.

PRESET is a symbol or a raw preset spec plist as would be passed to
`gptel-make-preset', like \\='(:use-tools t).  If preset is a symbol, it
will be looked up from the global gptel registry, or if not found
there (e.g. if `macher-install' was never called), from the
`macher-presets-alist'.

CALLBACK takes no arguments."
  (let ((preset-for-gptel (macher--resolve-preset preset)))

    (unless preset-for-gptel
      (user-error
       "Macher preset \"%s\": Cannot find preset in gptel registry or macher presets list"
       preset))

    ;; WORKAROUND: gptel--preset-syms has a bug where (nconc syms ...) doesn't update syms when
    ;; it's nil, so parent-contributed symbols are silently dropped.  This means gptel-with-preset
    ;; won't save/restore variables set by parent presets.  We work around this by stripping
    ;; :parents and applying them via nested calls, so gptel-with-preset never sees a :parents key.
    ;; Once the nconc bug is fixed upstream, this can be simplified to just:
    ;;
    ;;   (gptel-with-preset preset-for-gptel (funcall callback))
    (if-let ((parents (plist-get preset-for-gptel :parents)))
        ;; If the current preset has any parents, apply them and call this function recursively with
        ;; one parent popped from the front of the list.
        (let* ((first-parent (car parents))
               (remaining-parents (cdr parents)))
          (macher--with-preset
           first-parent
           (lambda ()
             (macher--with-preset
              (plist-put (copy-sequence preset-for-gptel) :parents remaining-parents) callback))))
      (gptel-with-preset preset-for-gptel (funcall callback)))))

(defun macher--apply-preset-locally (preset)
  "Apply PRESET settings buffer-locally.

PRESET can be a symbol or a raw preset spec plist (as with
`macher--with-preset').  If PRESET is a symbol, it is looked up from the
global gptel registry, or if not found there, from `macher-presets-alist'."
  (when-let ((preset-spec (macher--resolve-preset preset)))
    (gptel--apply-preset preset-spec (lambda (sym val) (set (make-local-variable sym) val)))))

(defun macher--parse-directive (directive)
  "Normalize DIRECTIVE to a standard format.

DIRECTIVE is a gptel directive: it can be a string, a list or a function
that returns either, see `gptel-directives'.

Return a cons cell consisting of the system message (a string) and a
template consisting of alternating user/LLM records (a list of strings
or nil).

This is basically the same as `gptel--parse-directive' with a non-nil
RAW parameter, but we re-implemnt it to avoid depending on private gptel
methods."
  (and directive
       (cl-etypecase directive
         (string (list directive))
         (function (macher--parse-directive (funcall directive)))
         (cons directive))))

(defun macher--system-ensure-placeholder-or-context-string (system)
  "Ensure there's a macher placeholder or actual context string in SYSTEM prompt.

If the system prompt already contains the `macher-context-string-placeholder'
or the `macher-context-string-marker-start', this is a no-op.

Otherwise, appends the `macher-context-string-placeholder' to the end of
the system prompt.

SYSTEM can be a string, a function, or a list:

- String: The most common case, a static system message.  We check if
  the placeholder or marker is present, and append the placeholder if
  not.

- List: A conversation template, where the first element is the system
  message and subsequent elements are pre-filled user/assistant
  messages for few-shot prompting.  We check and modify only the first
  element (consing a new list to avoid mutation).

- Function: A dynamic directive that generates the system message at
  request time.  We return a wrapper that evaluates this directive and
  then handles the result as with other strings/lists.

If `macher-context-string-placeholder' is nil, simply appends the
current result of `macher-context-string-function' (if no start marker
already present), i.e. goes straight to the \"@macher-system-commit\"
state.

If both `macher-context-string-placeholder' AND
`macher-context-string-function' are nil, does nothing."
  ;; No placeholder to append or context function to call. Treat this as a special case, bail
  ;; immediately.
  (if (not (or macher-context-string-function macher-context-string-placeholder))
      system
    (cl-etypecase system
      (string
       (cond
        ;; Already has marker-start, nothing to do.
        ((string-match-p (regexp-quote macher-context-string-marker-start) system)
         system)
        ;; Placeholder is set and not already in string, append it.
        ((and macher-context-string-placeholder
              (not (string-match-p (regexp-quote macher-context-string-placeholder) system)))
         (concat system macher-context-string-placeholder))
        ;; Placeholder is nil, append demarcated context directly.
        ((not macher-context-string-placeholder)
         ;; Note the `macher-context-string-function' can't be nil at this point, due to the first
         ;; bail check.  Also note that even if the context string is nil, we render this block it
         ;; for parity with the `macher--system-replace-placeholder' result, i.e. treat this exactly
         ;; as if we appended an "anonymous" placeholder at the end and then committed it.  This seems
         ;; like the most consistent way to handle things.
         (let ((context-string (funcall macher-context-string-function)))
           (concat
            system
            macher-context-string-marker-start
            context-string
            macher-context-string-marker-end)))
        (t
         system)))
      (function
       ;; Note - it'd be nice if we didn't have to actually wrap the function if we already know it
       ;; contains the context string. As-is, this will overwrite functional user directives with a
       ;; lambda - this is functionally fine, but may look weird in gptel's directives UI.
       (lambda ()
         ;; By definition this lambda is being called in whatever buffer functional system prompts are
         ;; supposed to be run.  So it should be fine not to worry about changing buffers or anything
         ;; before resolving the system message.
         (macher--system-ensure-placeholder-or-context-string (macher--parse-directive system))))
      (list
       (cons (macher--system-ensure-placeholder-or-context-string (car system)) (cdr system))))))

(defun macher--system-replace-placeholder (system &optional buffer)
  "Inject macher context into the SYSTEM prompt.

Replaces any instances of the `macher-context-string-placeholder' with
the current result of `macher-context-string-function', surrounded by
`macher-context-string-marker-start' and
`macher-context-string-marker-end'.  Returns the result.

If BUFFER is non-nil, switch to it while generating the context string
and while evaluating function directives.  Otherwise, just use the
current buffer.

If `macher-context-string-placeholder' is nil, returns SYSTEM unchanged.

If the `macher-context-string-function' is or returns nil, replaces the
placeholder with empty markers (i.e. start marker immediately followed
by end marker).  This is for consistency on repeated applications of
things like \"@macher-system\" and \"@macher-system-commit\" - we don't
want to just remove the placeholder entirely, as it could end up getting
appended again later, which would be surprising.

SYSTEM can be a string, a function, or a list:

- String: The most common case.  We search for the placeholder and
  replace it with the demarcated context string.

- Function: A dynamic directive.  We call `macher--parse-directive' to
  evaluate it and get the actual string, then recursively process the
  result.  This is useful for system messages that need to adapt based
  on buffer state or other dynamic factors.

- List: A conversation template of the form (SYSTEM-MSG USER-MSG
  ASSISTANT-MSG ...).  We recursively process only the first element,
  consing a new list to avoid mutating the original.  This format is
  used for few-shot prompting, where you pre-fill a conversation with
  example exchanges to guide the LLM's behavior.

This function should be called in the prompt transform buffer for a
gptel request.  That's where dynamic directives are evaluated in
built-in contexts like `gptel-context--wrap-in-buffer' (though note
`macher-request' seems to evaluate them in the request buffer)."
  (if (not macher-context-string-placeholder)
      system
    (cl-etypecase system
      (string
       (let* ((context-string
               (with-current-buffer (or buffer (current-buffer))
                 (when macher-context-string-function
                   (funcall macher-context-string-function))))
              (demarcated-context-string
               (concat
                macher-context-string-marker-start
                (or context-string "")
                macher-context-string-marker-end)))
         (if (string-match-p (regexp-quote macher-context-string-placeholder) system)
             (replace-regexp-in-string
              (regexp-quote macher-context-string-placeholder) demarcated-context-string system
              t t)
           system)))
      (function
       ;; Should hopefully be ok to call this in the current buffer i.e. prompt buffer. Note though
       ;; that the `gptel--transform-add-context' ultimately calls `gptel--parse-directive' in the
       ;; :data buffer from the FSM's info struct - but it seems like that's the same as the prompt
       ;; buffer?
       ;;
       ;; This resolves functional directives to strings or lists.
       (let ((parsed (macher--parse-directive system)))
         (macher--system-replace-placeholder parsed buffer)))
      (list (cons (macher--system-replace-placeholder (car system) buffer) (cdr system))))))

(defun macher--transform-system-replace-placeholder (callback fsm)
  "A prompt transform to inject the macher context string in the system prompt.

Uses `macher--system-replace-placeholder' to perform the
replacement, invoked in the request buffer.

If the `macher-context-string-placeholder' is nil, or if it doesn't
appear in the system prompt for the current request, this is a no-op.

CALLBACK and FSM are as described in the
`gptel-prompt-transform-functions' documentation."
  (when-let* ((info (gptel-fsm-info fsm))
              (buffer (plist-get info :buffer))
              ((buffer-live-p buffer)))
    ;; The system message needs to be set in the temporary buffer where this prompt transform is
    ;; being invoked, but the context string needs to be generated in the buffer where the request
    ;; is actually being sent.  Pass the request buffer to the replace function.
    (setq gptel--system-message (macher--system-replace-placeholder gptel--system-message buffer)))
  (funcall callback))

(defun macher--setup-tools (fsm get-context)
  "Provide a `macher-context' to macher tools in the gptel FSM.

GET-CONTEXT should be a function that always returns the same
`macher-context' object - this allows for lazy initialization of the
object.  GET-CONTEXT may also return nil if no workspace could be
determined for the request.

This function updates the FSM's tools list by wrapping tools from the
`macher-tool-category' to inject the result of GET-CONTEXT as the first
argument.

This function is intended to be used as a transition handler at the
start of a gptel request, i.e. before any tools are invoked but after
they're accessible on the FSM."
  (let* ((info (gptel-fsm-info fsm))
         (tools (plist-get info :tools))
         (buffer (plist-get info :buffer))
         (processed-tools
          (mapcar
           (lambda (tool)
             (if (equal macher-tool-category (gptel-tool-category tool))
                 ;; If this is a macher tool, inject the current context as the first argument.
                 (let* ((orig-fn (gptel-tool-function tool))
                        (tool-name (gptel-tool-name tool))
                        ;; Wrapped function that injects context.
                        (wrapped-fn
                         (apply-partially `(lambda (get-context orig-fn &rest args)
                                             ,(format
                                               "Request-specific wrapper for macher tool \"%s\"."
                                               tool-name)
                                             (let ((context (funcall get-context)))
                                               (unless context
                                                 (warn
                                                  (concat
                                                   (format
                                                    "macher tool \"%s\" called, but no workspace "
                                                    ,tool-name)
                                                   (format
                                                    "could be determined for request buffer \"%s\" "
                                                    ,buffer)))
                                                 (error
                                                  "The tool \"%s\" is not available" ,tool-name))
                                               ;; Call original function with args plus the context.
                                               (apply orig-fn context args)))
                                          get-context orig-fn))
                        (tool-copy (gptel--copy-tool tool)))
                   ;; Set the function on the tool copy to the request-specific wrapper.
                   (setf (gptel-tool-function tool-copy) wrapped-fn)
                   ;; Replace the category so we don't try to modify this tool again if this
                   ;; function somehow gets run more than once.
                   (setf (gptel-tool-category tool-copy)
                         (format "%s__processed" macher-tool-category))
                   ;; Return the request-specific tool copy.
                   tool-copy)
               ;; For non-macher tools, just pass them through unmodified.
               tool))
           tools)))
    ;; Update the tools list that the FSM will actually use for tool calls.
    (setf (gptel-fsm-info fsm) (plist-put info :tools processed-tools))))

(defun macher--transform-setup-tools (callback fsm)
  "A gptel prompt transform to set up macher tools and behavior.

This transform does not affect the actual request content of outgoing
gptel requests; it's a no-op if there are no macher tools associated
with a request.  It just interacts with the gptel state machine to
provide request-specific context (e.g. the current workspace) to macher
tools, and to set up a termination handler so we can call the
`macher-process-request-function'.

This transform MUST be present in `gptel-prompt-transform-functions' in
order to use macher tools.  All of the built-in macher presets pull it
in automatically, but if you want to use macher tools in other
ways (e.g. directly from the gptel menu), you'll need to make sure this
transform is present.  You can do this by applying `macher-preset-base',
or using it as a parent in your own presets.

CALLBACK and FSM are as described in the
`gptel-prompt-transform-functions' documentation."
  (let* (
         ;; Capture information that needs to be included in the macher context if it gets created.
         (prompt (buffer-string))
         (process-request-function macher-process-request-function)
         ;; Shared context object for this request, with a lazy initializer.  The context will only
         ;; be initialized if macher tools are invoked during the request.  We use t as a flag that
         ;; we tried to load the context, but there was no macher workspace (that way we can use nil
         ;; to indicate that the context was never accessed).
         (context-or-t nil)
         (get-context
          (lambda ()
            (unless context-or-t
              (let* ((info (gptel-fsm-info fsm))
                     (buffer (plist-get info :buffer)))
                (when (plist-get info :macher--context)
                  (error "Macher context already present on FSM during setup"))
                ;; We always expect the request buffer to be non-nil.
                (unless buffer
                  (error "Trying to set up macher tools, but coudn't determine request buffer"))
                (setq context-or-t
                      (if (buffer-live-p buffer)
                          (if-let ((workspace (macher-workspace buffer)))
                              ;; If we found a workspace, perform context initialization.
                              (let ((context
                                     (macher--make-context
                                      :workspace workspace
                                      :prompt prompt
                                      :process-request-function process-request-function)))
                                ;; Store the context on the FSM, so we can look it up e.g. for
                                ;; processing later.
                                (setq info (plist-put info :macher--context context))
                                (setf (gptel-fsm-info fsm) info)
                                ;; Mark this FSM as the most recent macher request.
                                (with-current-buffer buffer
                                  (setq macher--fsm-latest fsm))
                                context)
                            ;; Request buffer live but no workspace found.
                            t)
                        ;; Request buffer not live - no workspace can be found.
                        t))))
            ;; Now return the cached context.
            (if (eq context-or-t t)
                ;; t actually means "no workspace found".
                nil
              context-or-t)))

         ;; Handler for the first state machine transition, i.e. as soon as the request is sent.
         (init-handler-invoked nil)
         (init-handler
          (lambda (fsm)
            "Inject context into macher tools (no-op on repeated calls)."
            (unless init-handler-invoked
              (setq init-handler-invoked t)
              ;; Replace tools with versions that have the current macher context injected. We can't
              ;; do this directly in the prompt transform because the :tools entry isn't available
              ;; at that point.
              (macher--setup-tools fsm get-context))))
         (termination-handler
          (lambda (fsm)
            "Process the `macher-context' (if any) on request termination."
            ;; When the context was accessed and successfully initialized, i.e. when a tool was
            ;; successfully called.
            (when (and context-or-t (funcall get-context))
              (macher-process-request 'complete fsm)))))
    (macher--add-transition-handler fsm init-handler)
    (macher--add-termination-handler fsm termination-handler))
  (funcall callback))

(defun macher--add-termination-handler (fsm handler)
  "Update FSM's state handlers to call HANDLER when the request terminates.

The HANDLER will receive one argument when the request terminates
successfully or otherwise:

- FSM: the `gptel-fsm' struct for the request (the same one passed to
  this function).  This can be used to extract a more specific
  termination reason - for example, standard gptel requests will end up
  in the the ='DONE' or ='ERRS' state, which can be extracted from the FSM.

The request is considered to have terminated when the FSM reaches a
state with no possible transitions to another state."
  (let* (
         ;; An alist of states mapped to potential next states.  See `gptel-request--transitions'.
         (transitions (gptel-fsm-table fsm))

         ;; An alist of states mapped to their handler functions.
         (handlers (gptel-fsm-handlers fsm))

         ;; Find all states that appear as a potential next state in one of the rules.
         (all-states
          (cl-remove-duplicates
           (append
            (mapcar #'car transitions)
            (cl-mapcan (lambda (entry) (mapcar #'cdr (cdr entry))) transitions))))

         ;; Filter down to states that either don't appear as keys, or appear as keys but have no
         ;; possible next states - that is, states which can't transition to any other states.
         ;;
         ;; In the case of 'gptel-request--transitions', this will be '(DONE ERRS).  This is
         ;; currently the only transitions list used by gptel, but this logic ensures we catch
         ;; termination even when using a custom list.
         (terminal-states
          (cl-remove-if-not
           (lambda (state)
             (let ((entry (assq state transitions)))
               ;; Terminal if: no entry exists, or entry exists but has no transitions.
               (or (null entry) (null (cdr entry)))))
           all-states))

         ;; Alist whose keys are the terminal states, and values are their new lists of handlers.
         (terminal-state-handlers
          (cl-loop
           for state in terminal-states for existing-entry = (assq state handlers) collect
           (if existing-entry
               (cons state (append (cdr existing-entry) (list handler)))
             (cons state (list handler)))))

         ;; Create a new handlers list for this FSM.
         (augmented-handlers
          (append
           ;; Copy existing non-terminal handlers.
           (cl-remove-if (lambda (entry) (member (car entry) terminal-states)) handlers)
           ;; Add our terminal state handlers.
           terminal-state-handlers)))

    ;; Update the handlers list.
    (setf (gptel-fsm-handlers fsm) augmented-handlers)))

(defun macher--add-transition-handler (fsm handler)
  "Update FSM's state handlers to call HANDLER on every state transition.

The HANDLER will receive one argument:

- FSM: the FSM for the request (the same one passed to this function).
  This can be used to extract the current state and other information
  about the transition.

The handler will be called once for each state transition throughout the
request lifecycle."
  (let* (
         ;; An alist of states mapped to potential next states.  See `gptel-request--transitions'.
         (transitions (gptel-fsm-table fsm))

         ;; An alist of states mapped to their handler functions.
         (handlers (gptel-fsm-handlers fsm))

         ;; Find all states that can possibly be transitioned to.
         (all-states
          (cl-remove-duplicates
           (cl-mapcan (lambda (entry) (mapcar #'cdr (cdr entry))) transitions)))

         ;; Alist whose keys are all available states, and values are their new lists of handlers.
         (all-state-handlers
          (cl-loop
           for state in all-states for existing-entry = (assq state handlers) collect
           (if existing-entry
               (cons state (append (cdr existing-entry) (list handler)))
             (cons state (list handler)))))

         ;; Create a new handlers list for this FSM.
         (augmented-handlers
          (append
           ;; Copy existing handlers for states not in all-states.
           (cl-remove-if (lambda (entry) (member (car entry) all-states)) handlers)
           ;; Add our state handlers for all states.
           all-state-handlers)))

    ;; Update the handlers list.
    (setf (gptel-fsm-handlers fsm) augmented-handlers)))

(defun macher--gptel-request (callback &optional prompt &rest keys)
  "Send PROMPT to the LLM and invoke the CALLBACK when the request terminates.

The CALLBACK will receive two arguments when the request terminates
successfully or otherwise:

- EXIT-CODE: nil if the request terminated via a normal FSM
  flow (including terminations due to an error response), or the symbol
  ='abort' if the request was aborted.

- FSM: the FSM for the request (the same one passed to this function).
  This can be used to extract a more specific termination reason - for
  example, standard gptel requests will end up in the the ='DONE' or ='ERRS'
  state, which can be extracted using `gptel-fsm-state'.

This is a thin wrapper around `gptel-request'.  The PROMPT and KEYS will
be passed directly to `gptel-request'.  Note that the CALLBACK parameter
here is different than the :callback key accepted by `gptel-request',
which might also be included."
  (let*
      (
       ;; Get the transforms that were already passed (possibly nil).  Note gptel generally expects
       ;; callers to pass `gptel-prompt-transform-functions' for this argument, but we don't make
       ;; any assumptions here.
       (transforms (plist-get keys :transforms))

       ;; Prompt transform which hooks into the lifecycle and causes the callback to be invoked at
       ;; the end of the request lifecycle.
       (prompt-transform
        (when callback
          (lambda (fsm)
            (macher--add-termination-handler
             fsm
             (lambda (fsm)
               "Invoke the user-provided callback after the FSM reached a terminal state"
               (funcall callback nil fsm))))))

       ;; Transforms list including our callback transform.
       (updated-transforms
        (append
         transforms
         (when prompt-transform
           (list prompt-transform)))))
    (setq keys (plist-put (copy-sequence keys) :transforms updated-transforms))

    (when-let* (
                ;; Send the request and get the state machine.
                (fsm (apply #'gptel-request prompt keys))

                ;; Extract the actual gptel callback for handling responses.  By default this will
                ;; generally be gptel--insert-response or gptel-curl--stream-insert-response.
                (info (gptel-fsm-info fsm))
                (fsm-callback (plist-get info :callback))

                ;; Wrap the callback to catch the abort signal.
                (wrapped-callback
                 (lambda (response &rest rest)
                   "Invoke the user-provided callback after the request is aborted.
Then pass arguments through to the original callback."
                   (when (eq response 'abort)
                     (funcall callback 'abort fsm))
                   (apply fsm-callback response rest))))
      (setf (gptel-fsm-info fsm) (plist-put info :callback wrapped-callback))
      fsm)))

(defun macher--make-tool (anon &rest slots)
  "Create a gptel tool with access to the macher context.

SLOTS are as described in the `gptel-make-tool' docstring, except that:

- FUNCTION takes an extra argument at the front, the `macher-context'
  which the tool is interacting with.

- CATEGORY is ignored, and the `macher-tool-category' is used.

If ANON is non-nil, don't register the tool with the gptel registry."
  ;; Extract the original function from slots.
  (let*
      ((name (plist-get slots :name))
       (orig-fn (plist-get slots :function))
       ;; Wrap the provided tool function with a sanity check to ensure that the macher context has
       ;; been provided as the first argument.  This happens via the
       ;; `macher--transform-setup-tools' transform, which injects the appropriate context
       ;; into tool functions for outgiong gptel requests.
       (wrapped-fn
        (apply-partially `(lambda (orig-fn &rest args)
                            ,(format "Wrapper for macher tool \"%s\"." name)
                            ;; The context should be the last argument if provided.
                            ;; If not provided, this will gracefully fail with an error.
                            (let ((context (car args)))
                              (unless (macher-context-p context)
                                (warn
                                 (concat
                                  (format "macher tool \"%s\" called without context.  " ,name)
                                  "If you're managing macher tools manually, make sure you "
                                  "apply the \"@macher-base\" preset before using them."))
                                (error "The tool \"%s\" is not available" ,name))
                              ;; Call original function with context first, then the tool args.
                              (apply orig-fn args)))
                         orig-fn))
       (slots (plist-put (copy-sequence slots) :category macher-tool-category))
       (slots (plist-put slots :function wrapped-fn)))
    (apply (if anon
               ;; Use the internal constructor to avoid interacting with the global registry at all.
               #'gptel--make-tool
             #'gptel-make-tool)
           slots)))

(defun macher--install-tools ()
  "Install all tools specified in `macher-tools'.

Tools will be available in the global gptel registry, but not
activated (i.e. not added to the variable `gptel-tools')."
  (dolist (tool-def macher-tools)
    ;; Create and register the tool.
    (apply #'macher--make-tool nil tool-def)))

(defun macher--install-presets ()
  "Install all presets specified in `macher-presets-alist'.

Presets will be available in the global gptel registry, with the keys of
the alist used as preset names."
  (dolist (preset-entry macher-presets-alist)
    (apply #'gptel-make-preset preset-entry)))

;;; Core Functions

;;;###autoload
(defun macher-install (&optional deprecated)
  "Register macher presets and tools with gptel.

Uses `macher-presets-alist' and `macher-tools' to determine the
presets/tools to install.

Once presets are registered, you can use macher functionality in any
gptel request using the \"@preset\" syntax, for example:

  @macher Add an eslint config to this project.

You can also enable/disable macher tools directly from the gptel tools
menu, or use them in restored gptel sessions.  In that case, you should
also make sure to call `macher-enable' in your init, or to apply
\"@macher-base\" (or a higher-level preset) anywhere that you're
managing tools manually.

A DEPRECATED parameter was previously used to control preset names; this
can now be done by customizing the `macher-presets-alist'."
  (when deprecated
    (warn
     (concat
      "The NAMES parameter to `macher-install' is no longer supported.  "
      "You can customize preset names by customizing `macher-presets-alist'.")))
  (macher--install-presets)
  (macher--install-tools))

;;;###autoload
(defun macher-enable ()
  "Install prompt transforms needed to support macher infrastructure.

This is equivalent to applying the \"@macher-base\" preset globally.

This adds entries to `gptel-prompt-transform-functions' which will do
the following for outgoing gptel requests:

- Provide a request's macher tools with a shared in-memory workspace,
  (lazily initialized when the tools are actually called), which is used
  to track changes throughout the request and generate a patch at the
  end.  This is a purely internal step and doesn't affect the request
  itself.

- Replace instances of the `macher-context-string-placeholder' in the
  system prompt with the current result of the
  `macher-context-string-function'.  This won't modify your system
  prompt in any other way; if the outgoing prompt doesn't already
  contain the `macher-context-string-placeholder', it won't be modified.
  You can also disable this transform entirely by setting
  `macher-context-string-placeholder' to nil.

These transforms will run on every gptel request, though they should
effectively be no-ops for requests that don't use macher tools or the
context-string placeholder.

In case you're wary of adding these transforms globally, you have some
alternatives:

- You don't need to call this if you're only using macher via actions or
  direct applications of built-in presets.

- You can also just make sure to apply \"@macher-base\" in places where
  you're using macher tools or context-string placeholders without
  explicitly applying a preset, e.g. in restored gptel buffers."
  (gptel--apply-preset macher-preset-base))

;;;###autoload
(defun macher-action (action &optional callback &rest action-args)
  "Send the prompt for a macher ACTION within a shared workspace buffer.

More specifically, this function implements the following workflow for
sending macher requests:

1. Generate a prompt based on contextual information, e.g. the cursor
   position or selected region.

2. Send the prompt from a shared workspace buffer (the
   `macher-action-buffer'), using a gptel preset that provides macher
   tools.

Lifecycle hooks are also run to allow modifying or otherwise handling
the prompt/response - see `macher-action-dispatch-hook',
`macher-before-action-functions', and `macher-after-action-functions'.
The default action buffer UI uses these hooks to render information
about outgoing action requests.

ACTION can be a symbol defined in `macher-actions-alist', or a list with
the same format as an entry in `macher-actions-alist' (e.g.
\='(my-action-name . my-action-function-or-plist)).

If CALLBACK is provided, it will be called when the action completes.
The callback will receive three arguments, the same as functions in
`macher-after-action-functions': ERROR (nil on success, or an error
description on failure), EXECUTION (the `macher-action-execution' object
for the action), and FSM (the `gptel-fsm' object for the request).

Any additional ACTION-ARGS will be forwarded to the action function, or
simply ignored if the action is defined as a plain plist.

When called interactively, prompts the user to select an ACTION from
those available in the `macher-actions-alist'.

Note that macher tools/presets can be used with any gptel request, and
you don't need to use this function to use macher.  This function simply
implements one possible workflow."
  (interactive (let* ((actions (mapcar #'car macher-actions-alist))
                      (action-names (mapcar #'symbol-name actions))
                      (selected-name (completing-read "Action: " action-names nil t))
                      (selected-action (intern selected-name)))
                 (list selected-action)))

  ;; Run the action dispatch hook in the source buffer.
  (run-hooks 'macher-action-dispatch-hook)

  (let ((action-config
         (if (symbolp action)
             (assoc action macher-actions-alist)
           action)))
    (unless action-config
      (user-error "Unrecognized action: %s" action))

    (let* ((action-function-or-plist (cdr action-config))
           (action-plist
            (if (functionp action-function-or-plist)
                (apply action-function-or-plist action-args)
              action-function-or-plist)))

      (unless action-config
        (error "Unknown action: %s" action))

      (let* ((prompt (plist-get action-plist :prompt))
             (preset (or (plist-get action-plist :preset) 'macher))
             (summary (plist-get action-plist :summary))
             ;; Get the action buffer for this workspace.
             (action-buffer (macher-action-buffer nil t))
             ;; Store the source buffer (where the action was initiated).
             (source-buffer (current-buffer))
             ;; Create execution object to pass to callbacks.
             (execution
              (macher--make-action-execution
               :action action
               :prompt prompt
               :preset preset
               :summary summary
               :buffer action-buffer
               :source source-buffer))
             ;; Create a callback wrapper that includes the action hooks.
             (request-callback
              (lambda (exit-code fsm)
                (let*
                    ((state (gptel-fsm-state fsm))
                     (error
                      (cond
                       ;; If we have a non-nil exit code (i.e. ='abort'), just use it as the error.
                       (exit-code)
                       ;; If the FSM is in an errored state, extract the error text.
                       ((eq state 'ERRS)
                        (let* ((info (gptel-fsm-info fsm))
                               (error (plist-get info :error))
                               (http-msg (plist-get info :status))
                               (error-type (plist-get error :type))
                               (error-msg (plist-get error :message)))
                          (or error-msg (format "%s: %s" error-type http-msg))))
                       ;; Otherwise, consider the request successful.  In practice the state should
                       ;; always be 'DONE here.
                       (t
                        nil))))

                  ;; Call the original callback if provided.  Don't change the buffer for this, to
                  ;; avoid any potential issues with killed buffers.
                  (when (functionp callback)
                    (funcall callback error execution fsm))

                  ;; Run the after-action hook if the action buffer is still live.
                  (when (buffer-live-p action-buffer)
                    (with-current-buffer action-buffer
                      (run-hook-with-args 'macher-after-action-functions error execution fsm)))))))

        ;; Run the before-action hook from the shared buffer.
        (with-current-buffer action-buffer
          (run-hook-with-args 'macher-before-action-functions execution))
        ;; It's possible for the before-action hook to change the current buffer, so re-enter the
        ;; shared buffer explicitly.
        (with-current-buffer action-buffer
          (macher--with-preset
           (macher-action-execution-preset execution)
           (lambda ()
             ;; Just like `gptel-send', but with a prompt specified directly, and with the callback on
             ;; termination.  Use the potentially modified prompt and context from the execution
             ;; object.
             (macher--gptel-request request-callback
                                    (macher-action-execution-prompt execution)
                                    :context (macher-action-execution-context execution)
                                    ;; Insert at the end of the buffer.
                                    :position (point-max)
                                    :stream gptel-stream
                                    :transforms gptel-prompt-transform-functions
                                    :fsm (gptel-make-fsm :handlers gptel-send--handlers)))))))))

;;;###autoload
(defun macher-abort (&optional buf)
  "Abort any active macher action associated with buffer BUF.

In other words, abort gptel requests running in the macher
action buffer associated with the `macher-workspace' for BUF.

If a callback was provided to the original request, it will be called
with the ='abort' symbol as the error parameter.

BUF defaults to the current buffer if not specified."
  (interactive)
  (with-current-buffer (or buf (current-buffer))
    (when-let* ((action-buffer (macher-action-buffer))
                ((buffer-live-p action-buffer)))
      (gptel-abort action-buffer))))

;;;###autoload
(defun macher-process-request (reason &optional fsm)
  "Process (e.g. display a patch for) the latest macher request.

Extracts the macher context from the FSM and calls the configured
`macher-process-request-function'.  You can use this, for example, to
show the patch associated with an in-progress or aborted request.

REASON is the reason that the processing function is being invoked.  This
will be forwarded to the `macher-process-request-function'.  Defaults to
='interactive' when called interactively, or any custom value can be
provided.

FSM is an optional `gptel-fsm' (state machine) for the request.  If not
provided, defaults the most recent macher request for the current
buffer (i.e. the local value of `macher--fsm-latest')."
  (interactive (list 'interactive))
  (when-let* ((fsm (or fsm macher--fsm-latest))
              (context (macher--context-for-fsm fsm))
              (process-request-fn (macher-context-process-request-function context)))
    (funcall process-request-fn reason context fsm)))

;;;###autoload
(defun macher-process-request-dwim (reason)
  "Process the latest macher request for the current buffer or the action buffer.

REASON is the reason that the processing function is being invoked.
Defaults to ='interactive' when called interactively, or any custom value
can be provided.

This function processes the most recent macher request in the current
buffer (i.e. the local value of `macher--fsm-latest') if present,
otherwise uses the most recent macher request in the action buffer
associated with the current workspace."
  (interactive (list 'interactive))
  (let ((fsm
         (or macher--fsm-latest
             (when-let ((action-buffer (macher-action-buffer)))
               (buffer-local-value 'macher--fsm-latest action-buffer)))))
    (macher-process-request reason fsm)))


;;; Convenience methods for built-in actions.

;;;###autoload
(defun macher-focus-string (&optional interactive)
  "Get a description of your current location in the code.

Evaluates the form (or calls the function) in variable
`macher-focus-string' and returns the result.

If called interactively (which sets INTERACTIVE), yanks the value to
the kill ring."
  (interactive (list "p"))
  (let ((result
         (if (functionp macher-focus-string)
             (funcall macher-focus-string)
           (eval macher-focus-string))))
    (when interactive
      (kill-new result)
      (message "Focus string copied to kill ring"))
    result))

;;;###autoload
(defun macher-implement (&optional instructions callback)
  "Propose a patch to implement INSTRUCTIONS.

When called interactively, get INSTRUCTIONS from the selected region or
prompt for them in the minibuffer.

When called non-interactively, INSTRUCTIONS can be a string or nil.
If nil, the user will be prompted.

If CALLBACK is provided, it will be called when the implementation
process completes.  The callback will receive three arguments: ERROR (nil
on success, a string error description on failure, or the symbol ='abort'
if the request was aborted), EXECUTION (the `macher-action-execution'
object for the action), and FSM (the `gptel-fsm' object for the request)."
  (interactive)
  (macher-action 'implement callback instructions))

;;;###autoload
(defun macher-revise (&optional instructions callback)
  "Propose a revision to a patch based on INSTRUCTIONS.

When called interactively, get instructions from the selected region or
prompt for them in the minibuffer.

When called non-interactively, INSTRUCTIONS can be a string or nil.
If nil, the user will be prompted.

The function identifies the patch buffer associated with the current buffer's
project and sends a revision request based on the provided instructions.

If CALLBACK is provided, it will be called when the revision process
completes.  The callback will receive three arguments: ERROR (nil on
success, or an error description on failure), EXECUTION (the
`macher-action-execution' object for the action), and FSM (the
`gptel-fsm' object for the request)."
  (interactive)
  (macher-action 'revise callback instructions))

;;;###autoload
(defun macher-discuss (&optional question callback)
  "Send a QUESTION about the current project to the LLM.

When called interactively, get the question from the selected region or
prompt for it in the minibuffer.

When called non-interactively, QUESTION can be a string or nil.  If nil,
the user will be prompted.

This creates a conversational request without proposing any changes
to the codebase.

If CALLBACK is provided, it will be called when the discussion
completes.  The callback will receive three arguments: ERROR (nil on
success, or an error description on failure), EXECUTION (the
`macher-action-execution' object for the action), and FSM (the
`gptel-fsm' object for the request)."
  (interactive)
  (macher-action 'discuss callback question))

(provide 'macher)
;;; macher.el ends here
