;;; ekg-agent.el --- Agentic actions for ekg -*- lexical-binding: t -*-

;; Copyright (c) 2026  Andrew Hyatt <ahyatt@gmail.com>

;; Author: Andrew Hyatt <ahyatt@gmail.com>
;; Homepage: https://github.com/ahyatt/ekg
;; Package-Requires: ((emacs "28.1") (ekg "0.8.0") (futur "1.2"))
;; Keywords: outlines, hypermedia
;; Package-Version: 20260518.21
;; Package-Revision: 616479afdf0e
;; 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 GNU Emacs.  If not, see <http://www.gnu.org/licenses/>.

;;; Commentary:
;; This module provides agentic actions for ekg, allowing an LLM to
;; interact with the ekg note-taking system to help the user.

;;; Code:

(require 'cl-lib)
(require 'llm)
(require 'llm-vertex)
(require 'ekg-llm)
(require 'ekg-embedding)
(require 'ekg-org)
(require 'seq)
(require 'json)
(require 'subr-x)
(require 'shr)
(require 'url)

(when (featurep 'ns)
  ;; Disable futur's background thread before loading: on macOS NS port,
  ;; `message' (or any redisplay) from a non-main thread causes an NSException →
  ;; GIL deadlock.  With this nil, futur dispatches all callbacks via
  ;; `run-with-timer' on the main thread instead.
  (defvar futur-use-threads)
  (setq futur-use-threads nil))
(require 'futur)

;; Forward declarations for variables defined later in this file.
(defvar ekg-agent-base-tools)
(defvar ekg-agent-extra-tools)

;; Forward declarations for optional external packages.
(declare-function flycheck-mode "flycheck")
(declare-function org-ql-search "org-ql")
(declare-function markdown-mode "markdown-mode")
(defvar eww-search-prefix)

(defgroup ekg-agent nil
  "Agentic actions for ekg."
  :group 'ekg)

(defcustom ekg-agent-author-tag "agent"
  "The tag used to identify notes created by the agent."
  :type 'string
  :group 'ekg-agent)

(defcustom ekg-agent-self-info-tag "agent/self-info"
  "The tag used to identify notes with agent information for itself."
  :type 'string
  :group 'ekg-agent)

(add-to-list 'ekg-hidden-tags ekg-agent-self-info-tag)

(defcustom ekg-agent-daily-time "09:00"
  "Time of day to run the daily agent evaluation, in HH:MM format (24-hour).
For example, \"09:00\" for 9:00 AM, \"14:30\" for 2:30 PM.
Changes to this variable will take effect the next time you call
`ekg-agent-schedule-daily`."
  :type 'string
  :group 'ekg-agent)

(defcustom ekg-agent-log-buffer-name-format "*ekg agent log: %s*"
  "Name of the buffer used for logging ekg agent progress.

The string var will be filled in with the name of the buffer, so
this MUST have a %s in it."
  :type 'string
  :group 'ekg-agent)

(defcustom ekg-agent-log-window-side 'bottom
  "Side of the frame where the agent log window is displayed."
  :type '(choice (const :tag "Bottom" bottom)
                 (const :tag "Right" right)
                 (const :tag "Left" left)
                 (const :tag "Top" top))
  :group 'ekg-agent)

(defcustom ekg-agent-log-window-size 12
  "Size of the agent log window (lines for top/bottom, columns for left/right)."
  :type 'integer
  :group 'ekg-agent)

(defcustom ekg-agent-timeout-seconds -1
  "Maximum time in seconds for an agent run before it is stopped.

If non-positive, no timeout is applied."
  :type 'integer
  :group 'ekg-agent)

(defcustom ekg-agent-status-reminder-seconds 50
  "Maximum quiet time before the agent is reminded to summarize state.

When positive, each new LLM turn checks how long it has been since the
last explicit `summarize_state' call.  If this threshold has been
exceeded, the prompt is augmented with a reminder to summarize progress
before doing more work."
  :type 'number
  :group 'ekg-agent)

(defcustom ekg-agent-llm-max-retries 3
  "Maximum number of retries for transient LLM errors.
Transient errors include overloaded servers, rate limiting, and
temporary network failures.  Set to 0 to disable retries."
  :type 'integer
  :group 'ekg-agent)

(defcustom ekg-agent-llm-retry-base-delay 2
  "Base delay in seconds between LLM retry attempts.
The actual delay uses exponential backoff: base * 2^attempt."
  :type 'number
  :group 'ekg-agent)

(defcustom ekg-agent-code-command nil
  "Command to run for the coding tool.

This should be a shell-style command string (for example, \"claude -p
--dangerously_skip_permissions\"). The prompt will be provided on stdin;
stdout will be returned."
  :type '(choice (const :tag "Unset" nil) string)
  :group 'ekg-agent)

(defvar-local ekg-agent--prompt nil
  "The prompt for the agent section in the current buffer.")

(defvar-local ekg-agent--end-tools nil
  "The end tools for the current agent run in this buffer.")

(defvar-local ekg-agent--running-p nil
  "Non-nil when the agent is actively running in this buffer.")

(defvar-local ekg-agent--cancelled-p nil
  "Non-nil when the user has requested cancellation of the agent.")

(defvar-local ekg-agent--current-request nil
  "The in-flight LLM request handle, for cancellation.")

(defvar-local ekg-agent--tool-processes nil
  "List of active tool subprocesses, for force cancellation.")

(defvar-local ekg-agent--status-callback nil
  "The status callback for the current agent run.
Stored so that `ekg-agent-force-cancel' can invoke it.")

(defvar-local ekg-agent--current-activity nil
  "Short description of what the agent is currently doing.
Shown in the header line; updated by tool wrappers and the iterate loop.")

(defvar-local ekg-agent--last-status-update-time nil
  "Float time when the last explicit `summarize_state' call was logged.")

(defvar-local ekg-agent--last-status-reminder-time nil
  "Float time when the agent was last reminded to summarize state.")

(defvar-local ekg-agent--origin-buffer nil
  "The buffer from which the agent was originally invoked.
Tool functions execute in this buffer so that `major-mode',
`default-directory', and other buffer-local state are correct.")

(defvar ekg-agent--current-log-buffer nil
  "Dynamically bound to the current agent log buffer.
Logging functions consult this variable instead of relying on
`current-buffer', so that logging always targets the log buffer
even when tool functions execute in the origin buffer.")

(defvar ekg-agent--daily-timer nil
  "Timer object for the daily agent evaluation.")

(defface ekg-agent-log-timestamp
  '((t :inherit shadow))
  "Face for timestamps in agent log."
  :group 'ekg-agent)

(defface ekg-agent-log-started
  '((((background light))
     :background "#fff3cd" :foreground "#664d03" :weight bold
     :box (:line-width (-1 . -1)))
    (((background dark))
     :background "#664d03" :foreground "#fff3cd" :weight bold
     :box (:line-width (-1 . -1)))
    (t :inherit warning :weight bold :box (:line-width (-1 . -1))))
  "Face for STARTED status in agent log tool lines."
  :group 'ekg-agent)

(defface ekg-agent-log-done
  '((((background light))
     :background "#d1e7dd" :foreground "#0a3622" :weight bold
     :box (:line-width (-1 . -1)))
    (((background dark))
     :background "#0a3622" :foreground "#d1e7dd" :weight bold
     :box (:line-width (-1 . -1)))
    (t :inherit success :weight bold :box (:line-width (-1 . -1))))
  "Face for DONE status in agent log tool lines."
  :group 'ekg-agent)

(defface ekg-agent-log-tool-name
  '((t :weight bold))
  "Face for tool names in agent log."
  :group 'ekg-agent)

(defface ekg-agent-log-header-running
  '((t :inherit warning :inverse-video t :weight bold))
  "Face for agent log header line when the agent is running."
  :group 'ekg-agent)

(defface ekg-agent-log-header-finished
  '((t :inherit success :inverse-video t :weight bold))
  "Face for agent log header line when the agent has finished."
  :group 'ekg-agent)

(defun ekg-agent--format-header-line ()
  "Return the formatted header line string for the agent log buffer."
  (let* ((running ekg-agent--running-p)
         (text (cond
                ((not running) " ✓ Agent Finished")
                (ekg-agent--current-activity
                 (format " ⟳ %s" ekg-agent--current-activity))
                (t " ⟳ Agent Running")))
         (face (if running 'ekg-agent-log-header-running
                 'ekg-agent-log-header-finished))
         (width (max (length text) (window-width))))
    (propertize (concat text (make-string (- width (length text)) ?\s))
                'face face)))

(defun ekg-agent--provider ()
  "Return the provider for the LLM.

If there is a list of providers, find the first one that has tool
calling, or if none have tool calling, just return the first provider."
  (if (listp ekg-llm-provider)
      (or (car (seq-filter (lambda (provider) (member 'tool-use (llm-capabilities provider)))
                           ekg-llm-provider))
          (car ekg-llm-provider))
    ekg-llm-provider))

(defun ekg-agent--transient-error-p (err-string)
  "Return non-nil if ERR-STRING indicates a transient LLM error.
Transient errors are those likely to succeed on retry, such as
overloaded servers, rate limits, and temporary network failures."
  (and (stringp err-string)
       (let ((lower (downcase err-string)))
         (or (string-match-p "overloaded" lower)
             (string-match-p "rate.limit" lower)
             (string-match-p "too many requests" lower)
             (string-match-p "429" lower)
             (string-match-p "529" lower)
             (string-match-p "503" lower)
             (string-match-p "server.*error" lower)
             (string-match-p "temporarily unavailable" lower)))))

(defun ekg-agent--llm-chat-async-with-retry (provider prompt on-success on-error
                                                      multi-output log-buf)
  "Call `llm-chat-async' with automatic retry on transient errors.
PROVIDER, PROMPT, ON-SUCCESS, ON-ERROR, and MULTI-OUTPUT are as
for `llm-chat-async'.  LOG-BUF is the agent log buffer, used for
logging retries and checking cancellation.  Returns the request
handle from the current attempt."
  (let ((attempt 0)
        (max-retries ekg-agent-llm-max-retries)
        (base-delay ekg-agent-llm-retry-base-delay))
    (cl-labels
        ((try ()
           (llm-chat-async
            provider prompt on-success
            (lambda (type err)
              (if (and (< attempt max-retries)
                       (ekg-agent--transient-error-p (format "%s" err))
                       (buffer-live-p log-buf)
                       (not (buffer-local-value
                             'ekg-agent--cancelled-p log-buf)))
                  (let* ((delay (* base-delay (expt 2 attempt)))
                         (ekg-agent--current-log-buffer log-buf))
                    (cl-incf attempt)
                    (ekg-agent--log
                     "Transient error (attempt %d/%d): %s — retrying in %ds…"
                     attempt (1+ max-retries) err delay)
                    (run-at-time delay nil
                                 (lambda ()
                                   (when (and (buffer-live-p log-buf)
                                              (not (buffer-local-value
                                                    'ekg-agent--cancelled-p
                                                    log-buf)))
                                     (let ((req (try)))
                                       (when (buffer-live-p log-buf)
                                         (with-current-buffer log-buf
                                           (setq ekg-agent--current-request
                                                 req))))))))
                (funcall on-error type err)))
            multi-output)))
      (try))))

(defmacro ekg-agent--with-error-as-text (&rest body)
  "Execute BODY, returning any error as a descriptive string.
If BODY signals an error, return \"Error: MESSAGE\" instead of
propagating the signal.  This is used in tool functions so that
the LLM sees the error and can react, rather than aborting the
tool call."
  (declare (indent 0) (debug t))
  `(condition-case err
       (progn ,@body)
     (error (format "Error: %s" (error-message-string err)))))

(defconst ekg-agent-tool-all-tags
  (make-llm-tool :function (lambda (tags num)
                             (ekg-agent--with-error-as-text
                               (let* ((ekg-llm-note-numwords 500)
                                      (tag-list (append tags nil))
                                      (notes (ekg-agent--get-notes
                                              :tags (or tag-list nil)
                                              :latest (null tag-list)
                                              :num num)))
                                 (if notes
                                     (mapconcat #'ekg-llm-note-to-text notes "\n\n")
                                   "No notes found."))))
                 :name "get_notes_with_all_tags"
                 :description "Retrieve notes that have all the specified tags.  Results are returned newest first."
                 :args '((:name "tags" :type array :items (:type string) :description "List of tags to filter notes by.  Each tag may have spaces or non-alphanumeric characters.  May be empty to retrieve the latest notes.")
                         (:name "num" :type integer :description "Maximum number of notes to retrieve."))))

(defconst ekg-agent-tool-any-tags
  (make-llm-tool :function (lambda (tags num)
                             (ekg-agent--with-error-as-text
                               (let* ((ekg-llm-note-numwords 500)
                                      (tag-list (append tags nil))
                                      (notes (ekg-agent--get-notes
                                              :any-tags (or tag-list nil)
                                              :latest (null tag-list)
                                              :num num)))
                                 (if notes
                                     (mapconcat #'ekg-llm-note-to-text notes "\n\n")
                                   "No notes found."))))
                 :name "get_notes_with_any_tags"
                 :description "Retrieve notes that have any of the specified tags.  Results are returned newest first."
                 :args '((:name "tags" :type array :items (:type string) :description "List of tags to filter notes by.  Each tag may have spaces or non-alphanumeric characters.  May be empty to retrieve the latest notes.")
                         (:name "num" :type integer :description "Maximum number of notes to retrieve."))))

(defconst ekg-agent-tool-get-note-by-id
  (make-llm-tool :function (lambda (id)
                             (ekg-agent--with-error-as-text
                               (let ((notes (ekg-agent--get-notes :note-id id)))
                                 (if notes
                                     (ekg-llm-note-to-text (car notes))
                                   (error "Note with ID %s not found" id)))))
                 :name "get_note_by_id"
                 :description "Retrieve a note by its unique identifier."
                 :args '((:name "id" :type string :description "The unique identifier of the note."))))

(defconst ekg-agent-tool-search-notes
  (make-llm-tool :function (lambda (query num)
                             (ekg-agent--with-error-as-text
                               (let ((ekg-llm-note-numwords 500)
                                     (notes (ekg-agent--get-notes :semantic-search query :num num)))
                                 (if notes
                                     (mapconcat #'ekg-llm-note-to-text notes "\n\n")
                                   "No notes found matching that search."))))
                 :name "search_notes"
                 :description "Search notes by a query string, retrieving by semantic similarity."
                 :args '((:name "query" :type string :description "The search query string.")
                         (:name "num" :type integer :description "Maximum number of notes to retrieve."))))

(defconst ekg-agent-tool-ask-user
  (make-llm-tool :function (lambda (question)
                             (ekg-agent--with-error-as-text
                               (read-string (format "Question from ekg-agent: %s\nResponse: " question))))
                 :name "ask_user"
                 :description "Ask the user a question and get their response."
                 :args '((:name "question" :type string :description "The question to ask the user."))))

(defconst ekg-agent-tool-list-tags
  (make-llm-tool :function (lambda (&optional regex-filter)
                             (ekg-agent--with-error-as-text
                               (let* ((tags (ekg-tags))
                                      (filtered (if regex-filter
                                                    (seq-filter (lambda (tag)
                                                                  (string-match-p regex-filter tag))
                                                                tags)
                                                  tags)))
                                 (if filtered
                                     (mapconcat #'identity filtered "\n")
                                   "No tags found."))))
                 :name "list_all_tags"
                 :description "List all existing tags in the ekg database."
                 :args '((:name "regex_filter" :type string :description "Optional regex to filter the tags by"))))

(defun ekg-agent--get-note-with-id (id)
  "Retrieve the note with string ID, handling different ID types.

This tries a few different things, since ekg ids can be of various
types, but we'll only get strings from the LLM."
  (or (ekg-get-note-with-id id)
      (let ((int-id (string-to-number id)))
        (when (> int-id 0)
          (ekg-get-note-with-id int-id)))
      (ekg-get-note-with-id (intern id))))

(defconst ekg-agent-tool-append-to-note
  (make-llm-tool :function (lambda (id content)
                             (ekg-agent--with-error-as-text
                               (let ((note (ekg-agent--get-note-with-id id)))
                                 (unless note
                                   (error "Note with ID %s not found" id))
                                 (let* ((enclosure (assoc-default (ekg-note-mode note) ekg-llm-format-output nil '("_BEGIN_" . "_END_")))
                                        (new-text (concat (ekg-note-text note) "\n"
                                                          (car enclosure) "\n"
                                                          content "\n"
                                                          (cdr enclosure))))
                                   (setf (ekg-note-text note) new-text)
                                   (ekg-save-note note)
                                   (format "Appended content to note ID %s" id)))))
                 :name "append_to_note"
                 :description "Append content to an existing note by its ID."
                 :args '((:name "id" :type string :description "The unique identifier of the note.")
                         (:name "content" :type string :description "The content to append to the note."))))

(defconst ekg-agent-tool-replace-note
  (make-llm-tool :function (lambda (id content)
                             (ekg-agent--with-error-as-text
                               (let ((note (ekg-agent--get-note-with-id id)))
                                 (unless note
                                   (error "Note with ID %s not found" id))
                                 (let* ((enclosure (assoc-default (ekg-note-mode note) ekg-llm-format-output nil '("_BEGIN_" . "_END_")))
                                        (new-text (concat (car enclosure) "\n"
                                                          content "\n"
                                                          (cdr enclosure))))
                                   (setf (ekg-note-text note) new-text)
                                   (ekg-save-note note)
                                   (format "Replaced content of note ID %s" id)))))
                 :name "replace_note"
                 :description "Replace the content of an existing note by its ID."
                 :args '((:name "id" :type string :description "The unique identifier of the note.")
                         (:name "content" :type string :description "The new content for the note."))))

(defconst ekg-agent-tool-create-note
  (make-llm-tool :function (lambda (tags content mode)
                             (ekg-agent--with-error-as-text
                               (unless (member mode '("org-mode" "markdown-mode" "text-mode"))
                                 (error "Unsupported mode: %s" mode))
                               ;; Use ekg-agent-add-note for consistency (includes auto-tags)
                               (format "Created note with ID %s" (ekg-agent-add-note content (append tags nil) mode))))
                 :name "create_note"
                 :description "Create a new note with specified tags and content."
                 :args `((:name "tags" :type array :items (:type string)
                                :description "List of tags to assign to the new note.  The tags should preferably be preexisting tags, but new tags can be created as well.")
                         (:name "content"
                                :type string
                                :description "The content of the new note.  This must be written in the format specified by the 'mode' parameter.  Do not include the tags in the content; they will be added automatically.")
                         (:name "mode" :type string :enum ["markdown-mode" "org-mode" "text-mode"]
                                :description
                                ,(concat "The emacs mode of the note content (e.g., 'org-mode', 'markdown-mode', 'text-mode'). "
                                         "Prefer org-mode to markdown-mode, and either to text-mode.  Use existing notes as "
                                         "a guide to what the user prefers.")))))

(defconst ekg-agent-tool-end
  (make-llm-tool :function (lambda () 'done)
                 :name "end"
                 :description "Indicate that no further actions need to be taken."
                 :args '()))

(defconst ekg-agent-tool-subagent-end
  (make-llm-tool :function #'identity
                 :name "subagent_end"
                 :description "Indicate that a sub-agent has completed its task and is returning control to the parent agent.  The return value will be passed back to the parent agent as the result of the sub-agent's `run_subagent` call."
                 :args '((:name "result" :type string :description "The result to return to the parent agent."))))

(defun ekg-agent--popup-result-in-buffer (result)
  "Display RESULT in a new buffer and pop up to it."
  (ekg-agent--with-error-as-text
    (let ((buf (get-buffer-create "*ekg agent result*")))
      (with-current-buffer buf
        (erase-buffer)
        (insert result)
        (when (featurep 'markdown-mode)
          (markdown-mode)
          (when (featurep 'flycheck)
            (flycheck-mode 0))
          (when (featurep 'flymake)
            (flymake-mode 0)))
        (goto-char (point-min)))
      (pop-to-buffer buf)
      (format "Popup displayed in buffer %s" (buffer-name)))))

(defconst ekg-agent-tool-popup-result
  (make-llm-tool :function #'ekg-agent--popup-result-in-buffer
                 :name "display_result_in_popup"
                 :description "Display the result of a query in a popup buffer.  Use markdown mode for formatting."
                 :args '((:name "result" :type string :description "The result to display."))))

(defun ekg-agent--run-code (callback prompt)
  "Run the configured `ekg-agent-code-command` asynchronously with PROMPT on stdin.
CALLBACK is called with the result string when the process finishes."
  (condition-case err
      (progn
        (unless (and (stringp ekg-agent-code-command)
                     (string-match-p "\\S-" ekg-agent-code-command))
          (error "Ekg-agent-code-command is not configured"))
        (let* ((args (split-string-and-unquote ekg-agent-code-command))
               (program (car args))
               (program-args (cdr args))
               (output-buf (generate-new-buffer " *ekg-agent-code*" t))
               (proc (make-process
                      :name "ekg-agent-code"
                      :buffer output-buf
                      :command (cons program program-args)
                      :sentinel (lambda (process _event)
                                  (when (memq (process-status process) '(exit signal))
                                    (let* ((exit-code (process-exit-status process))
                                           (output (with-current-buffer (process-buffer process)
                                                     (buffer-string))))
                                      (kill-buffer (process-buffer process))
                                      (funcall callback
                                               (if (zerop exit-code)
                                                   (string-trim-right output)
                                                 (format "Error: Command failed (%d): %s"
                                                         exit-code
                                                         (string-trim-right output))))))))))
          (process-send-string proc prompt)
          (process-send-eof proc)
          (push proc ekg-agent--tool-processes)))
    (error (funcall callback (format "Error: %s" (error-message-string err))))))

(defconst ekg-agent-tool-run-elisp
  (make-llm-tool :function (lambda (callback elisp return)
                             (condition-case err
                                 (let* ((output-buf (generate-new-buffer " *ekg-agent-elisp*" t))
                                        (eval-expr
                                         (prin1-to-string
                                          `(princ
                                            (let* (e
                                                   (result
                                                    (condition-case err
                                                        (eval (read ,elisp))
                                                      (error (setq e (format "%S" err))))))
                                              (or e
                                                  (if (equal ,return "result")
                                                      (format "%S" result)
                                                    (buffer-substring-no-properties
                                                     (point-min) (point-max))))))))
                                        (f (futur-process-call
                                            (expand-file-name invocation-name
                                                              invocation-directory)
                                            nil output-buf nil
                                            "--batch" "--eval" eval-expr)))
                                   (push f ekg-agent--tool-processes)
                                   (futur-bind
                                    f
                                    (lambda (exit-code)
                                      (let ((output (with-current-buffer output-buf
                                                      (buffer-string))))
                                        (kill-buffer output-buf)
                                        (run-at-time
                                         0 nil
                                         (lambda ()
                                           (funcall callback
                                                    (if (zerop exit-code)
                                                        output
                                                      (format "Error: Process exited with %d: %s"
                                                              exit-code output)))))
                                        nil))
                                    (lambda (err)
                                      (when (buffer-live-p output-buf)
                                        (kill-buffer output-buf))
                                      (run-at-time
                                       0 nil
                                       (lambda ()
                                         (funcall callback (format "Error: %S" err))))
                                      nil)))
                               (error
                                (funcall callback (format "Error: %s" (error-message-string err))))))
                 :name "run_elisp"
                 :description "Evaluate arbitrary Emacs Lisp and return the printed result of the final form.  Do not use this when another tool is more appropriate.  If that other tool is giving errors, report that to the user instead."
                 :args '((:name "elisp" :type string :description "The Emacs Lisp code to evaluate." :required t)
                         (:name "return" :type string :enum ["result" "buffer"]
                                :description "Whether to return the result of the evaluated elisp, or the buffer after the elisp has been evaluated. If there is an error, it will be returned regardless of this value."
                                :required t))
                 :async t))

(defconst ekg-agent-tool-code
  (make-llm-tool :function #'ekg-agent--run-code
                 :name "run_code_tool"
                 :description "Run the configured coding tool (such as 'claude code') command with the prompt and return the result.  If you want to accomplish a significant task that may not be possible with the tools you have access, ask this tool, which has more functionality."
                 :args '((:name "prompt" :type string :description "The prompt to pass to the tool."))
                 :async t))

(defun ekg-agent--tools (extra-tools)
  "Return the list of tools available to the agent.
EXTRA-TOOLS is a list of additional tools beyond
`ekg-agent-base-tools' and `ekg-agent-extra-tools' to include."
  (seq-uniq
   (append ekg-agent-base-tools
           ekg-agent-extra-tools
           extra-tools)
   (lambda (a b) (string-equal (llm-tool-name a) (llm-tool-name b)))))

(defconst ekg-agent-tool-subagent
  (make-llm-tool
   :function (lambda (callback instructions)
               (ekg-agent--log "Sub-agent started: %s"
                               (truncate-string-to-width instructions 80))
               (let ((prompt (llm-make-chat-prompt
                              instructions
                              :context (concat
                                        (ekg-agent-instructions-intro)
                                        "\n\nYou are a sub-agent working on a specific task. "
                                        "Complete the given task using the tools available to you. "
                                        "When you are done, call the subagent_end tool.\n\n"
                                        (format "The current date and time is %s."
                                                (format-time-string "%F %R")))
                              :tools (ekg-agent--tools (list ekg-agent-tool-subagent-end))
                              :tool-options (make-llm-tool-options :tool-choice 'any))))
                 (ekg-agent--iterate prompt
                                     1
                                     (lambda (status)
                                       (funcall callback
                                                (format "Subagent result: %s" status)))
                                     '("subagent_end")
                                     (ekg-agent--timeout-deadline))))
   :name "run_subagent"
   :description "Run a sub-agent with the given instructions. The sub-agent has its own conversation with access to all the standard ekg tools, and runs until it calls end. Use this for tasks that require multiple steps or complex tool use."
   :args '((:name "instructions" :type string :description "Detailed instructions for the sub-agent to follow."))
   :async t))

(defun ekg-agent--ensure-log-window ()
  "Ensure the agent log buffer is displayed in a side window.
`ekg-agent--current-log-buffer' must be bound to the log buffer."
  (let* ((buf ekg-agent--current-log-buffer)
         (params (append
                  `((side . ,ekg-agent-log-window-side)
                    (slot . 1))
                  (if (memq ekg-agent-log-window-side '(left right))
                      `((window-width . ,ekg-agent-log-window-size))
                    `((window-height . ,ekg-agent-log-window-size))))))
    (let ((win (display-buffer-in-side-window buf params)))
      (set-window-dedicated-p win t)
      win)))

(defun ekg-agent--log-raw (line)
  "Append LINE to the agent log buffer without a timestamp.
`ekg-agent--current-log-buffer' must be bound to the log buffer."
  (when-let ((buf ekg-agent--current-log-buffer))
    (when (buffer-live-p buf)
      (with-current-buffer buf
        (let ((inhibit-read-only t))
          (goto-char (point-max))
          (insert line "\n"))
        (let ((win (get-buffer-window buf t)))
          (when win
            (set-window-point win (point-max))))))))

(defun ekg-agent--log (format-string &rest args)
  "Append a log line built from FORMAT-STRING and ARGS to the agent log buffer.
`ekg-agent--current-log-buffer' must be bound to the log buffer."
  (when-let ((buf ekg-agent--current-log-buffer))
    (when (buffer-live-p buf)
      (with-current-buffer buf
        (let ((inhibit-read-only t))
          (goto-char (point-max))
          (insert (format-time-string "%F %T "))
          (insert (apply #'format format-string args))
          (insert "\n"))
        (let ((win (get-buffer-window buf t)))
          (when win
            (set-window-point win (point-max))))))))

(defun ekg-agent--log-session-start (label &optional detail)
  "Start a new session in the agent log with LABEL and optional DETAIL."
  (ekg-agent--ensure-log-window)
  (ekg-agent--log-raw (make-string 72 ?-))
  (ekg-agent--log "`%s`%s" label (if detail (format ": %s" detail) "")))

(defconst ekg-agent--status-width 7
  "Character width of status labels in tool log lines.
Both \"STARTED\" and \" DONE  \" are this width for in-place replacement.")

(defun ekg-agent--log-tool-start (log-buf tool-name)
  "Log that TOOL-NAME has started in LOG-BUF.
Returns a marker at the status text position, used by
`ekg-agent--log-tool-done' to update the line in place."
  (when (and log-buf (buffer-live-p log-buf))
    (with-current-buffer log-buf
      (setq ekg-agent--current-activity (format "Running: %s" tool-name))
      (force-mode-line-update)
      (let ((inhibit-read-only t)
            (marker (make-marker)))
        (goto-char (point-max))
        (insert (propertize (format-time-string "%F %T")
                            'face 'ekg-agent-log-timestamp))
        (insert " ")
        (set-marker marker (point))
        (insert (propertize "STARTED" 'face 'ekg-agent-log-started))
        (insert " ")
        (insert (propertize tool-name 'face 'ekg-agent-log-tool-name))
        (insert "\n")
        (let ((win (get-buffer-window log-buf t)))
          (when win
            (set-window-point win (point-max))))
        marker))))

(defun ekg-agent--log-tool-done (log-buf marker)
  "Update the tool log line at MARKER in LOG-BUF to show done.
Replaces the STARTED status text with DONE, keeping the rest of
the line intact."
  (when (and log-buf (buffer-live-p log-buf) marker (marker-buffer marker))
    (with-current-buffer log-buf
      (setq ekg-agent--current-activity nil)
      (force-mode-line-update)
      (let ((inhibit-read-only t))
        (save-excursion
          (goto-char marker)
          (delete-char ekg-agent--status-width)
          (insert (propertize " DONE  " 'face 'ekg-agent-log-done)))))))

(defun ekg-agent--sanitize-tool-result (result)
  "Ensure RESULT is safe to include in a JSON-serialized LLM request.
Strips Emacs raw-byte characters (U+3FFF80 and above) that cause
`json-serialize' to fail, and converts non-string results to
strings."
  (let ((s (if (stringp result)
               result
             (format "%s" result))))
    (replace-regexp-in-string "[\x3fff80-\x3fffff]" "" s)))

(defun ekg-agent--wrap-tool-function (tool log-buf origin-buf)
  "Return a copy of TOOL with its function wrapped for resilience.

LOG-BUF is the buffer to log to.  ORIGIN-BUF is the buffer from
which the agent was invoked; tool functions execute there so that
buffer-local state such as `major-mode' and `default-directory'
are correct.

The wrapper provides four layers of protection:

1. ORIGIN BUFFER: Tool functions run in ORIGIN-BUF while
   `ekg-agent--current-log-buffer' is dynamically bound to LOG-BUF,
   so that any logging from within the tool targets the log buffer.

2. LOGGING: Logs STARTED when the tool is called and DONE when it
   returns (sync) or calls back (async).

3. ONCE-ONLY CALLBACK: For async tools, the callback is guarded so
   it can only fire once.  If a tool\\'s error handling accidentally
   calls the callback a second time, it is silently ignored.

4. RESULT SANITIZATION: All tool results are passed through
   `ekg-agent--sanitize-tool-result' to strip characters that would
   cause `json-serialize' to fail downstream."
  (let* ((original-fn (llm-tool-function tool))
         (tool-name (llm-tool-name tool))
         (async-p (llm-tool-async tool)))
    (make-llm-tool
     :function (if async-p
                   (lambda (callback &rest args)
                     (let ((marker (ekg-agent--log-tool-start log-buf tool-name))
                           (called nil)
                           (ekg-agent--current-log-buffer log-buf))
                       (condition-case err
                           (if (buffer-live-p origin-buf)
                               (with-current-buffer origin-buf
                                 (apply original-fn
                                        (lambda (result)
                                          (unless called
                                            (setq called t)
                                            (ekg-agent--log-tool-done log-buf marker)
                                            (funcall callback
                                                     (ekg-agent--sanitize-tool-result result))))
                                        args))
                             (apply original-fn
                                    (lambda (result)
                                      (unless called
                                        (setq called t)
                                        (ekg-agent--log-tool-done log-buf marker)
                                        (funcall callback
                                                 (ekg-agent--sanitize-tool-result result))))
                                    args))
                         (error
                          (unless called
                            (setq called t)
                            (ekg-agent--log-tool-done log-buf marker)
                            (funcall callback
                                     (format "Error: %s" (error-message-string err))))))))
                 (lambda (&rest args)
                   (let ((marker (ekg-agent--log-tool-start log-buf tool-name))
                         (ekg-agent--current-log-buffer log-buf))
                     (condition-case err
                         (let ((result (if (buffer-live-p origin-buf)
                                           (with-current-buffer origin-buf
                                             (apply original-fn args))
                                         (apply original-fn args))))
                           (ekg-agent--log-tool-done log-buf marker)
                           (ekg-agent--sanitize-tool-result result))
                       (error
                        (ekg-agent--log-tool-done log-buf marker)
                        (format "Error: %s" (error-message-string err)))))))
     :name (llm-tool-name tool)
     :description (llm-tool-description tool)
     :args (llm-tool-args tool)
     :async async-p)))

(defun ekg-agent--wrap-prompt-tools (prompt log-buf origin-buf)
  "Wrap all tools on PROMPT for logging and origin-buffer execution.
LOG-BUF is the agent log buffer.  ORIGIN-BUF is the buffer from
which the agent was invoked; tool functions execute there."
  (setf (llm-chat-prompt-tools prompt)
        (mapcar (lambda (tool)
                  (ekg-agent--wrap-tool-function tool log-buf origin-buf))
                (llm-chat-prompt-tools prompt))))

(defun ekg-agent--log-status (status)
  "Log final STATUS from the agent."
  (if (eq status 'error)
      (ekg-agent--log "Error")
    (ekg-agent--log "Done: %s" status)))

(defun ekg-agent--make-status-callback (&optional extra-callback)
  "Return a status callback that logs, then call EXTRA-CALLBACK."
  (lambda (status)
    (ekg-agent--log-status status)
    (when extra-callback
      (funcall extra-callback status))))

(defun ekg-agent--timeout-deadline ()
  "Return the timeout deadline as a float, or nil if no timeout is set."
  (when (and (numberp ekg-agent-timeout-seconds)
             (> ekg-agent-timeout-seconds 0))
    (+ (float-time) ekg-agent-timeout-seconds)))

(defun ekg-agent--clean-orphaned-tool-interactions (prompt)
  "Remove a trailing assistant tool-use interaction from PROMPT.
When cancelling mid-tool-execution, the prompt may end with an
assistant interaction containing structured tool-use data but no
matching `tool-results' interaction.  LLM providers will reject
this, so remove it.  A tool-use interaction is detected by having
non-string content in the assistant role."
  (let ((interactions (llm-chat-prompt-interactions prompt)))
    (when interactions
      (let ((last-interaction (car (last interactions))))
        (when (and (eq (llm-chat-prompt-interaction-role last-interaction)
                       'assistant)
                   (not (stringp
                         (llm-chat-prompt-interaction-content
                          last-interaction))))
          (setf (llm-chat-prompt-interactions prompt)
                (butlast interactions)))))))

(defun ekg-agent--prompt-append-user-message (prompt message)
  "Append a user MESSAGE to PROMPT."
  (condition-case err
      (setf (llm-chat-prompt-interactions prompt)
            (append (llm-chat-prompt-interactions prompt)
                    (list (make-llm-chat-prompt-interaction
                           :role 'user
                           :content message))))
    (error
     (ekg-agent--log "Unable to append message to prompt: %s"
                     (error-message-string err)))))

(defun ekg-agent--timeout-warning-message ()
  "Return a message instructing the agent to save state before stopping."
  (format "Time limit reached. Before this session ends, immediately create a note with your current state using `create_note` (tag it with `%s`). Also call `summarize_state` with a brief update. Then finish by calling `end` or the appropriate completion tool."
          ekg-agent-self-info-tag))

(defun ekg-agent--record-status-update (&optional timestamp)
  "Record TIMESTAMP as the latest status update time for the log buffer."
  (when-let ((buf ekg-agent--current-log-buffer))
    (when (buffer-live-p buf)
      (with-current-buffer buf
        (setq ekg-agent--last-status-update-time
              (or timestamp (float-time)))
        (setq ekg-agent--last-status-reminder-time nil)))))

(defun ekg-agent--status-reminder-message ()
  "Return a prompt reminder asking for a meaningful status update."
  (concat
   "Before doing more work, call `summarize_state` with a brief but "
   "meaningful progress update. Include: (1) what you finished, (2) what "
   "you are doing now, and (3) what you will do next or what is blocked."))

(defun ekg-agent--maybe-remind-status-update (prompt)
  "Add a reminder to PROMPT when the agent owes the user a status update."
  (when-let ((buf ekg-agent--current-log-buffer))
    (when (buffer-live-p buf)
      (with-current-buffer buf
        (let ((threshold ekg-agent-status-reminder-seconds)
              (last ekg-agent--last-status-update-time)
              (now (float-time)))
          (when (and ekg-agent--running-p
                     (numberp threshold)
                     (> threshold 0)
                     (numberp last)
                     (>= (- now last) threshold)
                     (or (null ekg-agent--last-status-reminder-time)
                         (< ekg-agent--last-status-reminder-time last)
                         (>= (- now ekg-agent--last-status-reminder-time)
                             threshold)))
            (setq ekg-agent--last-status-reminder-time now)
            (ekg-agent--prompt-append-user-message
             prompt
             (ekg-agent--status-reminder-message))
            t))))))

(defun ekg-agent--summarize-state (state)
  "Write STATE to the agent log window."
  (ekg-agent--with-error-as-text
    (ekg-agent--ensure-log-window)
    (ekg-agent--record-status-update)
    (ekg-agent--log "State: %s" state)
    "ok"))

(defconst ekg-agent-tool-summarize-state
  (make-llm-tool :function #'ekg-agent--summarize-state
                 :name "summarize_state"
                 :description "Summarize the current state in the agent log window.  During long tasks, call this at least once a minute.  Include what you just finished, what you are doing now, and what is next or blocked."
                 :args '((:name "state" :type string :description "Brief but meaningful summary of completed work, current step, and next step or blocker."))))

(defconst ekg-agent-tool-read-agents-md
  (make-llm-tool :function (lambda (dir)
                             (ekg-agent--with-error-as-text
                               (or (ekg-agent--read-agents-md dir)
                                   (format "No AGENTS.md found in %s" dir))))
                 :name "read_agents_md"
                 :description "Read the AGENTS.md file from a specified directory.  AGENTS.md files contain user instructions and preferences for agents."
                 :args '((:name "dir" :type string :description "The directory path to read AGENTS.md from."))))

(defun ekg-agent--line-id (path line-num)
  "Return a 3-char base64 identifier unique to PATH and LINE-NUM."
  (let* ((input (format "%s:%d" (file-truename path) line-num))
         (hash (secure-hash 'md5 input))
         ;; Take first 2 bytes of the hex hash (4 hex chars = 2 bytes),
         ;; base64-encode them to get a short identifier, then truncate
         ;; to 3 chars.  2 bytes → 4 base64 chars, we use the first 3.
         (raw (unibyte-string (string-to-number (substring hash 0 2) 16)
                              (string-to-number (substring hash 2 4) 16))))
    (substring (base64-encode-string raw t) 0 3)))

(defun ekg-agent--file-content (path)
  "Return the text content of the file at PATH as a property-free string.
Reads from an existing buffer if one is visiting PATH.
PATH should already be resolved via `file-truename'."
  (let ((buf (find-buffer-visiting path)))
    (if buf
        (with-current-buffer buf
          (substring-no-properties (buffer-string)))
      (with-temp-buffer
        (insert-file-contents path)
        (buffer-string)))))

(defun ekg-agent--resolve-line-id (path id)
  "Resolve line identifier ID to a line number for the file at PATH."
  (let ((total (length (split-string (ekg-agent--file-content path) "\n"))))
    (or (cl-loop for i from 1 to total
                 when (string= (ekg-agent--line-id path i) id)
                 return i)
        (error "Line identifier %s not found in %s" id path))))

(defun ekg-agent--nonempty-string-p (s)
  "Return non-nil if S is a non-nil, non-empty string."
  (and (stringp s) (not (string-empty-p s))))

(defun ekg-agent--read-file (path &optional begin end range-type)
  "Read file at PATH, returning contents with line identifiers.

Each line is prefixed with a 3-char identifier derived from the
file path and line number.  If the file is visiting a buffer,
read from the buffer.

BEGIN and END restrict the output to a range.  RANGE-TYPE is
either \"line_number\" or \"identifier\" and indicates how to
interpret BEGIN and END.  Empty strings for BEGIN, END, or
RANGE-TYPE are treated as nil.  Returns a string without text
properties."
  (ekg-agent--with-error-as-text
    (let* ((truepath (file-truename path))
           ;; LLMs sometimes pass empty strings for omitted optional args.
           (begin (and (ekg-agent--nonempty-string-p begin) begin))
           (end (and (ekg-agent--nonempty-string-p end) end))
           (range-type (and (ekg-agent--nonempty-string-p range-type)
                            range-type)))
      (unless (or (find-buffer-visiting truepath) (file-exists-p truepath))
        (error "File not found: %s" path))
      (unless (or (find-buffer-visiting truepath) (file-readable-p truepath))
        (error "File not readable: %s" path))
      (let* ((lines (split-string (ekg-agent--file-content truepath) "\n"))
             (total (length lines))
             (start (cond
                     ((null begin) 1)
                     ((or (null range-type) (string= range-type "line_number"))
                      (max 1 (if (stringp begin) (string-to-number begin) begin)))
                     ((string= range-type "identifier")
                      (ekg-agent--resolve-line-id truepath begin))
                     (t (error "Unknown range_type: %s" range-type))))
             (finish (cond
                      ((null end) total)
                      ((or (null range-type) (string= range-type "line_number"))
                       (min total (if (stringp end) (string-to-number end) end)))
                      ((string= range-type "identifier")
                       (ekg-agent--resolve-line-id truepath end))
                      (t total)))
             (selected (cl-loop for i from start to finish
                                for line in (nthcdr (1- start) lines)
                                collect (format "%s: %s"
                                                (ekg-agent--line-id truepath i)
                                                line))))
        (substring-no-properties (mapconcat #'identity selected "\n"))))))

(defun ekg-agent--adjust-indentation (text target-column)
  "Adjust indentation of TEXT so its first line aligns to TARGET-COLUMN.
The relative indentation between lines is preserved.  Only
leading whitespace is removed; lines with less whitespace than
the delta are left unchanged."
  (let* ((lines (split-string text "\n"))
         (first-line (car lines))
         (_ (string-match "\\`[ \t]*" first-line))
         (first-indent (length (match-string 0 first-line)))
         (delta (- first-indent target-column)))
    (if (zerop delta)
        text
      (mapconcat
       (lambda (line)
         (if (string-empty-p line)
             line
           (if (> delta 0)
               ;; Strip delta leading whitespace chars.
               (let ((prefix (substring line 0 (min delta (length line)))))
                 (if (string-match-p "\\`[ \t]*\\'" prefix)
                     (substring line (length prefix))
                   line))
             ;; Add whitespace to reach target.
             (concat (make-string (- delta) ?\s) line))))
       lines "\n"))))

(defun ekg-agent--edit-file (path begin-id begin-text end-id end-text replacement)
  "Edit file at PATH by replacing a region identified by boundary markers.

BEGIN-ID is the line identifier where BEGIN-TEXT starts.
END-ID is the line identifier where END-TEXT starts.
REPLACEMENT replaces from the start of BEGIN-TEXT through the end
of END-TEXT (inclusive).

If the file is already open in a buffer, edit the buffer in place
without saving.  Otherwise, edit in a temp buffer and save to
disk.

Returns the file content around the edited region with line
identifiers."
  (ekg-agent--with-error-as-text
    (let* ((truepath (file-truename path))
           (begin-line (ekg-agent--resolve-line-id truepath begin-id))
           (end-line (ekg-agent--resolve-line-id truepath end-id)))
      (unless (file-exists-p truepath)
        (error "File not found: %s" path))
      (let* ((existing-buf (find-buffer-visiting truepath))
             (edit-buf (or existing-buf
                           (generate-new-buffer " *ekg-agent-edit*"))))
        (unwind-protect
            (with-current-buffer edit-buf
              (unless existing-buf
                (insert-file-contents truepath))
              (goto-char (point-min))
              (forward-line (1- begin-line))
              (let ((region-start (point))
                    (line-indent (current-indentation)))
                (unless (search-forward begin-text (line-end-position) t)
                  (error "Begin text not found on line %d" begin-line))
                (setq region-start (match-beginning 0))
                ;; Expand to include leading whitespace so the
                ;; replacement controls the full indentation.
                (let ((line-start (line-beginning-position)))
                  (when (string-match-p
                         "\\`[ \t]*\\'"
                         (buffer-substring-no-properties line-start region-start))
                    (setq region-start line-start)))
                (goto-char (point-min))
                (forward-line (1- end-line))
                (unless (search-forward end-text (line-end-position) t)
                  (error "End text not found on line %d" end-line))
                (let ((region-end (match-end 0)))
                  (delete-region region-start region-end)
                  (goto-char region-start)
                  (insert (ekg-agent--adjust-indentation
                           replacement line-indent))))
              (unless existing-buf
                (write-region (point-min) (point-max) truepath nil 'silent)))
          (unless existing-buf
            (kill-buffer edit-buf)))
        (ekg-agent--read-file truepath
                              (max 1 (- begin-line 2))
                              (+ end-line 5))))))

(defconst ekg-agent-tool-read-file
  (make-llm-tool
   :function #'ekg-agent--read-file
   :name "read_file"
   :description "Read a file and return its contents.  Each line is prefixed with a unique 3-character identifier (not a line number).  Use these identifiers when calling edit_file.  Optionally restrict to a range by passing begin/end as either line numbers or identifiers, with range_type indicating which.  If the file is open in an Emacs buffer, reads from the buffer (which may have unsaved changes)."
   :args '((:name "path" :type string :description "The file path to read." :required t)
           (:name "begin" :type string :description "Start of range: a line number or a line identifier.  Omit to start from the beginning.")
           (:name "end" :type string :description "End of range: a line number or a line identifier.  Omit to read to the end.")
           (:name "range_type" :type string :enum ["line_number" "identifier"]
                  :description "How to interpret begin and end.  Required when begin or end is set."))))

(defconst ekg-agent-tool-edit-file
  (make-llm-tool
   :function #'ekg-agent--edit-file
   :name "edit_file"
   :description "Edit a file by replacing text between two boundary markers.  The begin and end positions are specified using the unique line identifiers returned by read_file.  The begin_text on the begin line and end_text on the end line are both included in the replacement.  Returns the edited region with surrounding context and new line identifiers.  If the file is open in an Emacs buffer, edits the buffer in place without saving; otherwise saves to disk."
   :args '((:name "path" :type string :description "The file path to edit." :required t)
           (:name "begin_id" :type string :description "The 3-character line identifier where the replacement region starts." :required t)
           (:name "begin_text" :type string :description "The text on the begin line that marks the start of the region to replace." :required t)
           (:name "end_id" :type string :description "The 3-character line identifier where the replacement region ends." :required t)
           (:name "end_text" :type string :description "The text on the end line that marks the end of the region to replace (inclusive)." :required t)
           (:name "replacement" :type string :description "The new text to insert in place of the matched region." :required t))))

(defun ekg-agent--write-file (path content)
  "Write CONTENT to the file at PATH, creating it if necessary.
If PATH is open in an Emacs buffer, replace the buffer contents
without saving.  Otherwise write directly to disk, creating parent
directories as needed.  Returns the file content with line
identifiers, like `ekg-agent--read-file'."
  (ekg-agent--with-error-as-text
    (let ((truepath (file-truename
                     (expand-file-name path))))
      (let ((buf (find-buffer-visiting truepath)))
        (if buf
            (with-current-buffer buf
              (erase-buffer)
              (insert content))
          (let ((dir (file-name-directory truepath)))
            (unless (file-directory-p dir)
              (make-directory dir t))
            (write-region content nil truepath))))
      (ekg-agent--read-file truepath))))

(defconst ekg-agent-tool-write-file
  (make-llm-tool
   :function #'ekg-agent--write-file
   :name "write_file"
   :description "Write content to a file, creating it (and parent directories) if it does not exist.  If the file is already open in an Emacs buffer, replaces the buffer contents without saving.  Returns the new file content with line identifiers."
   :args '((:name "path" :type string :description "The file path to write." :required t)
           (:name "content" :type string :description "The full text content to write to the file." :required t))))

(defun ekg-agent--buffer-line-id (buffer-name line-num)
  "Return a 3-char base64 identifier unique to BUFFER-NAME and LINE-NUM."
  (let* ((input (format "buf:%s:%d" buffer-name line-num))
         (hash (secure-hash 'md5 input))
         (raw (unibyte-string (string-to-number (substring hash 0 2) 16)
                              (string-to-number (substring hash 2 4) 16))))
    (substring (base64-encode-string raw t) 0 3)))

(defun ekg-agent--resolve-buffer-line-id (buffer-name id)
  "Resolve line identifier ID to a line number for the buffer BUFFER-NAME."
  (let* ((buf (get-buffer buffer-name))
         (total (with-current-buffer buf
                  (count-lines (point-min) (point-max)))))
    ;; count-lines can undercount when the buffer doesn't end with a
    ;; newline, so add 1 to ensure we check the last line.
    (or (cl-loop for i from 1 to (1+ total)
                 when (string= (ekg-agent--buffer-line-id buffer-name i) id)
                 return i)
        (error "Line identifier %s not found in buffer %s" id buffer-name))))

(defun ekg-agent--list-buffers (&optional regex-filter)
  "List buffers, optionally filtering names by REGEX-FILTER.
Returns a string with one buffer per line showing name, size, major
mode, and file (if any).  Internal buffers (names starting with space)
are excluded."
  (ekg-agent--with-error-as-text
    (let* ((regex-filter (and (ekg-agent--nonempty-string-p regex-filter)
                              regex-filter))
           (bufs (seq-filter
                  (lambda (b)
                    (let ((name (buffer-name b)))
                      (and (not (string-prefix-p " " name))
                           (or (null regex-filter)
                               (string-match-p regex-filter name)))))
                  (buffer-list)))
           (lines (mapcar
                   (lambda (b)
                     (with-current-buffer b
                       (format "%s  (%d bytes, %s%s)"
                               (buffer-name b)
                               (buffer-size)
                               major-mode
                               (if buffer-file-name
                                   (format ", file: %s" buffer-file-name)
                                 ""))))
                   bufs)))
      (if lines
          (mapconcat #'identity lines "\n")
        "No buffers found."))))

(defconst ekg-agent-tool-list-buffers
  (make-llm-tool
   :function #'ekg-agent--list-buffers
   :name "list_buffers"
   :description "List open Emacs buffers.  Returns one line per buffer showing name, size, major mode, and associated file (if any).  Internal buffers (names starting with a space) are excluded."
   :args '((:name "regex_filter" :type string
                  :description "Optional regex to filter buffer names by."))))

(defun ekg-agent--read-buffer (buffer-name &optional begin end range-type)
  "Read BUFFER-NAME, returning contents with line identifiers.

Each line is prefixed with a 3-char identifier derived from the
buffer name and line number.  The overall begin and end buffer
positions of the returned content are included at the top as a
header line.

BEGIN and END restrict the output to a range.  RANGE-TYPE is
either \"line_number\" or \"identifier\" and indicates how to
interpret BEGIN and END.  Empty strings for BEGIN, END, or
RANGE-TYPE are treated as nil."
  (ekg-agent--with-error-as-text
    (let* ((begin (and (ekg-agent--nonempty-string-p begin) begin))
           (end (and (ekg-agent--nonempty-string-p end) end))
           (range-type (and (ekg-agent--nonempty-string-p range-type)
                            range-type))
           (buf (get-buffer buffer-name)))
      (unless buf
        (error "Buffer not found: %s" buffer-name))
      (with-current-buffer buf
        (let* ((content (buffer-substring-no-properties (point-min) (point-max)))
               (lines (split-string content "\n"))
               (total (length lines))
               (start (cond
                       ((null begin) 1)
                       ((or (null range-type) (string= range-type "line_number"))
                        (max 1 (if (stringp begin) (string-to-number begin) begin)))
                       ((string= range-type "identifier")
                        (ekg-agent--resolve-buffer-line-id buffer-name begin))
                       (t (error "Unknown range_type: %s" range-type))))
               (finish (cond
                        ((null end) total)
                        ((or (null range-type) (string= range-type "line_number"))
                         (min total (if (stringp end) (string-to-number end) end)))
                        ((string= range-type "identifier")
                         (ekg-agent--resolve-buffer-line-id buffer-name end))
                        (t total)))
               ;; Compute buffer positions for the returned range.
               (begin-pos (save-excursion
                            (goto-char (point-min))
                            (forward-line (1- start))
                            (point)))
               (end-pos (save-excursion
                          (goto-char (point-min))
                          (forward-line (1- finish))
                          (line-end-position)))
               (selected (cl-loop for i from start to finish
                                  for line in (nthcdr (1- start) lines)
                                  collect (format "%s: %s"
                                                  (ekg-agent--buffer-line-id buffer-name i)
                                                  line))))
          (format "begin_pos: %d  end_pos: %d\n%s"
                  begin-pos end-pos
                  (substring-no-properties
                   (mapconcat #'identity selected "\n"))))))))

(defconst ekg-agent-tool-read-buffer
  (make-llm-tool
   :function #'ekg-agent--read-buffer
   :name "read_buffer"
   :description "Read an Emacs buffer and return its contents.  Each line is prefixed with a unique 3-character identifier.  The first line of the output shows the overall begin_pos and end_pos (buffer point positions) of the returned range.  Use these identifiers when calling edit_buffer.  Optionally restrict to a range by passing begin/end as either line numbers or identifiers."
   :args '((:name "buffer_name" :type string :description "The name of the buffer to read." :required t)
           (:name "begin" :type string :description "Start of range: a line number or a line identifier.  Omit to start from the beginning.")
           (:name "end" :type string :description "End of range: a line number or a line identifier.  Omit to read to the end.")
           (:name "range_type" :type string :enum ["line_number" "identifier"]
                  :description "How to interpret begin and end.  Required when begin or end is set."))))

(defun ekg-agent--edit-buffer (buffer-name begin-id begin-text
                                           end-id end-text replacement)
  "Edit BUFFER-NAME by replacing a region between boundary markers.

BEGIN-ID is the line identifier where BEGIN-TEXT starts.
END-ID is the line identifier where END-TEXT starts.
REPLACEMENT replaces from the start of BEGIN-TEXT through the end
of END-TEXT (inclusive).

Returns the buffer content around the edited region with line
identifiers."
  (ekg-agent--with-error-as-text
    (let ((buf (get-buffer buffer-name)))
      (unless buf
        (error "Buffer not found: %s" buffer-name))
      (let ((begin-line (ekg-agent--resolve-buffer-line-id buffer-name begin-id))
            (end-line (ekg-agent--resolve-buffer-line-id buffer-name end-id)))
        (with-current-buffer buf
          (goto-char (point-min))
          (forward-line (1- begin-line))
          (let ((region-start (point))
                (line-indent (current-indentation)))
            (unless (search-forward begin-text (line-end-position) t)
              (error "Begin text not found on line %d" begin-line))
            (setq region-start (match-beginning 0))
            ;; Expand to include leading whitespace so the replacement
            ;; controls the full indentation.
            (let ((line-start (line-beginning-position)))
              (when (string-match-p
                     "\\`[ \t]*\\'"
                     (buffer-substring-no-properties line-start region-start))
                (setq region-start line-start)))
            (goto-char (point-min))
            (forward-line (1- end-line))
            (unless (search-forward end-text (line-end-position) t)
              (error "End text not found on line %d" end-line))
            (let ((region-end (match-end 0)))
              (delete-region region-start region-end)
              (goto-char region-start)
              (insert (ekg-agent--adjust-indentation
                       replacement line-indent)))))
        (ekg-agent--read-buffer buffer-name
                                (number-to-string (max 1 (- begin-line 2)))
                                (number-to-string (+ end-line 5))
                                "line_number")))))

(defconst ekg-agent-tool-edit-buffer
  (make-llm-tool
   :function #'ekg-agent--edit-buffer
   :name "edit_buffer"
   :description "Edit a buffer by replacing text between two boundary markers.  The begin and end positions are specified using the unique line identifiers returned by read_buffer.  The begin_text on the begin line and end_text on the end line are both included in the replacement.  Returns the edited region with surrounding context, including begin_pos/end_pos."
   :args '((:name "buffer_name" :type string :description "The name of the buffer to edit." :required t)
           (:name "begin_id" :type string :description "The 3-character line identifier where the replacement region starts." :required t)
           (:name "begin_text" :type string :description "The text on the begin line that marks the start of the region to replace." :required t)
           (:name "end_id" :type string :description "The 3-character line identifier where the replacement region ends." :required t)
           (:name "end_text" :type string :description "The text on the end line that marks the end of the region to replace (inclusive)." :required t)
           (:name "replacement" :type string :description "The new text to insert in place of the matched region." :required t))))

(defun ekg-agent--resolve-buffer-point (buffer-name point line-id text)
  "Resolve a point in BUFFER-NAME from either POINT, or LINE-ID + TEXT.
Returns a buffer position (integer).  At least one of POINT or
LINE-ID must be provided."
  (let ((point (and (ekg-agent--nonempty-string-p point) point))
        (line-id (and (ekg-agent--nonempty-string-p line-id) line-id))
        (text (and (ekg-agent--nonempty-string-p text) text)))
    (cond
     (point
      (let ((pos (if (stringp point) (string-to-number point) point)))
        (with-current-buffer (get-buffer buffer-name)
          (when (or (< pos (point-min)) (> pos (point-max)))
            (error "Point %d is outside buffer range [%d, %d]"
                   pos (point-min) (point-max))))
        pos))
     (line-id
      (let ((line-num (ekg-agent--resolve-buffer-line-id buffer-name line-id)))
        (with-current-buffer (get-buffer buffer-name)
          (save-excursion
            (goto-char (point-min))
            (forward-line (1- line-num))
            (if text
                (progn
                  (unless (search-forward text (line-end-position) t)
                    (error "Text %S not found on line %d" text line-num))
                  (match-beginning 0))
              (point))))))
     (t (error "Must provide either point or line_id")))))

(defun ekg-agent--run-interactive-command (buffer-name command &optional point line-id text)
  "Run interactive COMMAND in BUFFER-NAME at a resolved position.

The position is resolved from either POINT (a buffer position) or
LINE-ID (a 3-char identifier from read_buffer) optionally refined by
TEXT on that line.  If both POINT and LINE-ID are given, POINT takes
precedence.

Returns the buffer content around the position after the command
executes, including begin_pos/end_pos."
  (ekg-agent--with-error-as-text
    (let ((buf (get-buffer buffer-name)))
      (unless buf
        (error "Buffer not found: %s" buffer-name))
      (let ((pos (ekg-agent--resolve-buffer-point buffer-name point line-id text))
            (cmd (if (stringp command) (intern command) command)))
        (unless (commandp cmd)
          (error "Not an interactive command: %s" command))
        (with-current-buffer buf
          (goto-char pos)
          (call-interactively cmd)
          ;; Return context: ~10 lines around the post-command point,
          ;; since the command may have moved point.
          (let ((current-line (line-number-at-pos (point))))
            (ekg-agent--read-buffer buffer-name
                                    (number-to-string (max 1 (- current-line 5)))
                                    (number-to-string (+ current-line 5))
                                    "line_number")))))))

(defconst ekg-agent-tool-run-interactive-command
  (make-llm-tool
   :function #'ekg-agent--run-interactive-command
   :name "run_interactive_command"
   :description "Execute an interactive Emacs command in a buffer at a specific position.  The position can be specified as either a buffer point (integer) or a line identifier (from read_buffer) optionally refined by text on that line.  If both point and line_id are given, point takes precedence.  Returns the buffer content around the position after the command executes."
   :args '((:name "buffer_name" :type string :description "The name of the buffer." :required t)
           (:name "command" :type string :description "The interactive Emacs command to run (e.g. \"org-todo\", \"indent-region\")." :required t)
           (:name "point" :type string :description "Buffer position (integer as string) where the command should be executed.  Takes precedence over line_id if both are given.")
           (:name "line_id" :type string :description "A 3-character line identifier from read_buffer to locate the position.")
           (:name "text" :type string :description "Text on the identified line to refine the position.  Point is placed at the start of this text.  Only used with line_id."))))

(defun ekg-agent--run-command (callback command &optional directory)
  "Run shell COMMAND asynchronously and call CALLBACK with the result.
DIRECTORY, if given, is used as `default-directory' for the process."
  (condition-case err
      (let* ((default-directory (if directory
                                    (file-truename directory)
                                  default-directory))
             (output-buf (generate-new-buffer " *ekg-agent-cmd*" t))
             (proc (make-process
                    :name "ekg-agent-command"
                    :buffer output-buf
                    :command (list shell-file-name shell-command-switch command)
                    :sentinel (lambda (process _event)
                                (when (memq (process-status process) '(exit signal))
                                  (let ((result
                                         (with-current-buffer (process-buffer process)
                                           (format "Exit code: %d\n%s"
                                                   (process-exit-status process)
                                                   (buffer-string)))))
                                    (kill-buffer (process-buffer process))
                                    (funcall callback (substring-no-properties result))))))))
        (push proc ekg-agent--tool-processes))
    (error (funcall callback (format "Error: %s" (error-message-string err))))))

(defconst ekg-agent-tool-run-command
  (make-llm-tool
   :function #'ekg-agent--run-command
   :name "run_command"
   :description "Run a shell command and return its combined stdout/stderr and exit code."
   :args '((:name "command" :type string :description "The shell command to run." :required t)
           (:name "directory" :type string :description "Working directory for the command.  Defaults to the current buffer's directory."))
   :async t))

(defcustom ekg-agent-web-max-chars 20000
  "Maximum number of characters to return from web page content.
Content beyond this limit is truncated with a notice."
  :type 'integer
  :group 'ekg-agent)

(defun ekg-agent--web-render-html (html-string url)
  "Render HTML-STRING as readable text using shr, as eww does.
URL is used for resolving relative links."
  (with-temp-buffer
    (insert html-string)
    (let ((dom (libxml-parse-html-region (point-min) (point-max))))
      (erase-buffer)
      (let ((shr-width 80)
            (shr-use-fonts nil)
            (shr-bullet "- ")
            (shr-current-font 'default)
            (shr-base (when url (url-generic-parse-url url))))
        (shr-insert-document dom))
      ;; shr can produce Emacs "raw byte" characters (U+3FFF80 and
      ;; above) that aren't valid Unicode, causing json-serialize to
      ;; fail with json-value-p errors downstream.  Strip them.
      (let ((text (string-trim
                   (replace-regexp-in-string
                    "[\x3fff80-\x3fffff]" ""
                    (buffer-substring-no-properties
                     (point-min) (point-max))))))
        (if (> (length text) ekg-agent-web-max-chars)
            (concat (substring text 0 ekg-agent-web-max-chars)
                    "\n\n[Content truncated at "
                    (number-to-string ekg-agent-web-max-chars) " characters]")
          text)))))

(defun ekg-agent--web-browse (callback url)
  "Fetch URL and return its rendered text content via CALLBACK.
Uses `url-retrieve' for async fetching and `shr' for rendering,
the same engine that powers eww.  Only http and https URLs are
supported."
  (if (not (string-match-p "\\`https?://" url))
      (funcall callback "Error: Only http and https URLs are supported.")
    (condition-case err
        (let ((buf (url-retrieve
                    url
                    (lambda (status)
                      (let ((url-buf (current-buffer)))
                        ;; Build the result string inside condition-case,
                        ;; then call the callback OUTSIDE it.  This prevents
                        ;; errors from within the callback chain (e.g.
                        ;; json-serialize in a subsequent LLM request) from
                        ;; being caught here and re-triggering the callback.
                        (let ((result
                               (condition-case err
                                   (if-let ((err-val (plist-get status :error)))
                                       (format "Error fetching URL: %S"
                                               err-val)
                                     (goto-char (point-min))
                                     (let ((header-end
                                            (or (search-forward "\n\n" nil t)
                                                (point-min))))
                                       (let* ((html (buffer-substring-no-properties
                                                     header-end (point-max)))
                                              (text (ekg-agent--web-render-html
                                                     html url)))
                                         (if (string-empty-p text)
                                             "Page loaded but no readable text content found."
                                           (format "Content from %s:\n\n%s"
                                                   url text)))))
                                 (error
                                  (format "Error rendering page: %s"
                                          (error-message-string err))))))
                          (kill-buffer url-buf)
                          (funcall callback result))))
                    nil t)))
          (when-let ((proc (get-buffer-process buf)))
            (push proc ekg-agent--tool-processes)))
      (error (funcall callback (format "Error: %s"
                                       (error-message-string err)))))))

(defconst ekg-agent-tool-web-browse
  (make-llm-tool
   :function #'ekg-agent--web-browse
   :name "web_browse"
   :description "Fetch a web page and return its content as readable text.  Uses the same rendering engine as Emacs eww browser."
   :args '((:name "url" :type string :description "The URL to fetch." :required t))
   :async t))

(defun ekg-agent--web-search (callback query)
  "Search the web for QUERY and return results via CALLBACK.
Uses `eww-search-prefix' to construct the search URL."
  (require 'eww)
  (let ((search-url (concat eww-search-prefix (url-hexify-string query))))
    (ekg-agent--web-browse callback search-url)))

(defconst ekg-agent-tool-web-search
  (make-llm-tool
   :function #'ekg-agent--web-search
   :name "web_search"
   :description "Search the web for a query and return the search results page as text.  Uses the search engine configured in `eww-search-prefix' (DuckDuckGo by default)."
   :args '((:name "query" :type string :description "The search query." :required t))
   :async t))

(defconst ekg-agent-base-tools
  (list
   ekg-agent-tool-all-tags
   ekg-agent-tool-any-tags
   ekg-agent-tool-get-note-by-id
   ekg-agent-tool-search-notes
   ekg-agent-tool-list-tags
   ekg-agent-tool-append-to-note
   ekg-agent-tool-replace-note
   ekg-agent-tool-create-note
   ekg-agent-tool-summarize-state
   ekg-agent-tool-subagent
   ekg-agent-tool-ask-user
   ekg-agent-tool-read-agents-md
   ekg-agent-tool-read-file
   ekg-agent-tool-edit-file
   ekg-agent-tool-write-file
   ekg-agent-tool-list-buffers
   ekg-agent-tool-read-buffer
   ekg-agent-tool-edit-buffer
   ekg-agent-tool-run-interactive-command
   ekg-agent-tool-web-browse
   ekg-agent-tool-web-search)
  "List of base tools available to the agent.
These tools are necessary for basic agent functionality.")

(defcustom ekg-agent-extra-tools nil
  "List of additional tools available to the agent.
These tools are used in addition to `ekg-agent-base-tools' in
`ekg-agent-evaluate-status' and `ekg-agent-note-response'.

This is the place to opt into tools like `ekg-agent-tool-run-elisp',
or `ekg-agent-tool-code'."
  :type '(repeat (sexp :tag "Tool"))
  :group 'ekg-agent)

(defun ekg-agent--ask (question context &optional extra-tools)
  "Ask the ekg agent a QUESTION and display the result.

CONTEXT is what we'll display to the agent as context.  If nil no
additional context is added.

EXTRA-TOOLS is a list of additional tools used for this request.

The agent has access to all the defined tools, and can create notes or
display results in a popup buffer, or ask the user a question.  The
agent will decide which is best."
  (let* ((prompt (concat (ekg-agent-instructions-intro)
                         "\n\nYour instructions are to answer the user's question, given below. "
                         "You have access to tools to help you. "
                         "After each tool call you will be given a chance to make more tool calls. "
                         "Use `create_note` freely throughout your work to record progress, "
                         "findings, and skills — it will NOT end your session. "
                         "When you are FINISHED with ALL work (including the actual task, any "
                         "skill notes, and your retrospective), call `display_result_in_popup` "
                         "to present your final summary to the user.  This is the ONLY way to "
                         "end your session."
                         (when context
                           "\n\nHere is some additional context to help you:\n")
                         context
                         "\n\n"
                         (format "The current date and time is %s."
                                 (format-time-string "%F %R")))))
    (ekg-agent--iterate (llm-make-chat-prompt
                         question
                         :context prompt
                         :tools
                         (ekg-agent--tools (append
                                            (list ekg-agent-tool-end
                                                  ekg-agent-tool-popup-result)
                                            extra-tools))
                         :tool-options (make-llm-tool-options :tool-choice 'any))
                        0
                        (ekg-agent--make-status-callback)
                        '("display_result_in_popup")
                        (ekg-agent--timeout-deadline))))

(defun ekg-agent-ask (question)
  "Ask the ekg agent a QUESTION and display the result.
The agent has access to all the ekg tools, and can create notes
or display results in a popup buffer.  The agent will decide
which is best."
  (interactive "sQuestion: ")
  (ekg-connect)
  (ekg-agent--ask question
                  (concat
                   "The last 10 notes:\n\n"
                   (mapconcat #'ekg-llm-note-to-text
                              (ekg-get-latest-modified 10) "\n\n"))))

(defun ekg-agent-ask-with-note (question &optional id extra-tools)
  "Ask the agent QUESTION with the note with ID as context.

If ID is nil, we will use the current buffer's associated note, or the
note at point if in a `ekg-notes-mode` buffer.

EXTRA-TOOLS is a list of additional tools to make available to the
agent."
  (interactive "sQuestion: \n")
  (ekg-connect)
  (let* ((note (or (and id (ekg-agent--get-note-with-id id))
                   (ekg-current-note-or-error-expanded)))
         (note-text (ekg-llm-note-to-text note))
         (prompt-notes (ekg-get-notes-cotagged-with-tags
                        (ekg-note-tags note) ekg-llm-prompt-tag))
         (prompt-context (when prompt-notes
                           (concat "\n\nPrompt instructions from co-tagged notes:\n"
                                   (mapconcat (lambda (n)
                                                (string-trim
                                                 (substring-no-properties
                                                  (ekg-display-note-text n nil 'plaintext))))
                                              prompt-notes "\n")))))
    (ekg-agent--ask question
                    (concat
                     "The user is issuing instructions with a note as context. This is the text of that note:\n"
                     note-text
                     prompt-context)
                    extra-tools)))

(defun ekg-agent-ask-with-buffer (instructions)
  "Issue INSTRUCTIONS to the agent, with the current buffer as context."
  (interactive "sInstructions: ")
  (ekg-connect)
  (ekg-agent--ask instructions
                  (concat
                   (format "The current buffer is named %s%s, the major mode is %s.  The content is:\n"
                           (buffer-name)
                           (if buffer-file-name
                               (format " (file: %s)" buffer-file-name)
                             "")
                           major-mode)
                   (buffer-substring-no-properties (point-min) (point-max)))))

(defun ekg-agent-ask-with-region (instructions start end)
  "Issue INSTRUCTIONS to the agent, with the region from START to END as context."
  (interactive "sInstructions: \nr")
  (ekg-connect)
  (ekg-agent--ask instructions
                  (concat
                   (format "The current buffer is named %s%s, the major mode is %s.  The content is the selected region:\n"
                           (buffer-name)
                           (if buffer-file-name
                               (format " (file: %s)" buffer-file-name)
                             "")
                           major-mode)
                   (buffer-substring-no-properties start end))))

(defun ekg-agent-latest-notes-context ()
  "Return the context for an agent for new sessions.

This includes the latest 10 notes and the org agenda, and the last 10
self info."
  (concat
   "The last 10 notes:\n"
   (let ((ekg-llm-note-numwords 300))
     (mapconcat #'ekg-llm-note-to-text
                (ekg-get-latest-modified 10) "\n\n"))
   (when (featurep 'org-ql)
     (concat
      "\n\nTODO items from org-mode:\n"
      (with-temp-buffer (org-ql-search org-agenda-files '(todo) :buffer (current-buffer))
                        (buffer-substring-no-properties (point-min) (point-max)))))
   "\n\nThe last 10 self-info notes:\n"
   (mapconcat #'ekg-llm-note-to-text
              (seq-take (ekg-get-notes-with-tag ekg-agent-self-info-tag)
                        10)
              "\n\n")))

(defun ekg-agent--read-agents-md (dir)
  "Read AGENTS.md from DIR, returning its contents or nil if not found."
  (let ((file (expand-file-name "AGENTS.md" dir)))
    (when (file-readable-p file)
      (with-temp-buffer
        (insert-file-contents file)
        (buffer-string)))))

(defun ekg-agent--agents-md-context ()
  "Return AGENTS.md content from the current and home directories.
Returns nil if no AGENTS.md files are found."
  (let* ((home-dir (expand-file-name "~"))
         (current-dir (expand-file-name default-directory))
         (home-content (ekg-agent--read-agents-md home-dir))
         (current-content (unless (string= current-dir (file-name-as-directory home-dir))
                            (ekg-agent--read-agents-md current-dir)))
         (parts nil))
    (when home-content
      (push (format "From %s:\n%s" (expand-file-name "AGENTS.md" home-dir) home-content) parts))
    (when current-content
      (push (format "From %s:\n%s" (expand-file-name "AGENTS.md" current-dir) current-content) parts))
    (when parts
      (concat "\n\nUser instructions from AGENTS.md files:\n\n"
              (string-join (nreverse parts) "\n\n")))))

(defun ekg-agent-cotagged-prompt-tags ()
  "Return a list of all tags that are cotagged with the prompt tag."
  (let (result)
    (dolist (note (ekg-get-notes-with-tag ekg-llm-prompt-tag))
      (dolist (tag (ekg-note-tags note))
        (when (not (string-equal tag ekg-llm-prompt-tag))
          (push tag result))))
    result))

(defun ekg-agent-instructions-intro ()
  "Introductory instructions for the ekg agent.

These instructions guide the agent on how to effectively use ekg
notes as memory for tasks.  Follow these rules to ensure proper
note-taking and knowledge retention."
  (let ((timeout-desc (if (and (numberp ekg-agent-timeout-seconds)
                               (> ekg-agent-timeout-seconds 0))
                          (format "%s seconds" ekg-agent-timeout-seconds)
                        "no timeout configured")))
    (format
     "IMPORTANT: Read this whole section before starting any task. These rules are mandatory, not optional.

ekg is an external memory system that stores notes in a database
organized by tags. When you work on a task, you **must** use ekg to:
- **Remember** important context, decisions, and findings
- **Share** knowledge across tasks and sessions
- **Learn** from past work and avoid repeating mistakes

The rules below ensure you use ekg effectively. Failure to follow these
rules will result in lost context and repeated work.

== MANDATORY WORKFLOW ==

Before you begin any substantive work on a task:

1. **INITIAL INVESTIGATION (DO NOT SKIP)**
   - Look up the current project's ekg tags. If there is an EKG-PROJECT
     property in the org file, use that as the project tag.  Or, look at
     existing tags that match the description.
   - Search the ekg database for existing notes with relevant tags.
   - Read existing notes to understand what's already known.
   - Read the project's AGENTS.md file (if present) or check
     ~/.pi/agent/AGENTS.md for global instructions.
   - Read existing notes that contain instructions for directories,
     skills, or projects mentioned in the task description.  Each of
     these will have a tag cotagged with the prompt tag.  Existing
     cotags with the prompt tag are: %s.
   - When working with specific files, check for file resource notes.
     These are notes whose ID is `file:<absolute-path>` and are tagged
     with `doc/<filename>`.  Use `get_note_by_id` with `file:<path>`
     or search for notes with the `doc/<filename>` tag.  These notes
     contain file-specific conventions and instructions.
   - If you find relevant notes, reference them in your task description.
   - **CONTRADICTION HANDLING**: If your task instructions conflict
     with conventions stored in ekg notes (especially `prompt`-tagged
     skill notes), you MUST call `ask_user` to confirm which to follow
     before proceeding.  Never silently override a stored convention.

2. **CREATE INITIAL TASK NOTE**
   - Create a new note with:
     - A descriptive title based on the task.
     - Tags: at minimum `agent-task`, plus any project-specific tags.
     - Include the org task ID and description as the core content.
     - Note the ekg note ID returned (you'll need it for updates).
   - This note is your \"task journal\" - all findings go here.  It
     will be the source of information if we need to recover the state
     of the work in progress.

3. **DOCUMENT PROGRESS AND UPDATE THE USER THROUGHOUT THE TASK**
   - Before you start significant work: add an entry describing what you plan to do.
   - When you discover important information: add a note about it.
   - When you hit a problem or make a decision: document the rationale.
   - Be specific: include file paths, code snippets, key insights, and context.
   - Be sure and call the state summarization tool regularly, so that
     the user understands the state of the work.

4. **CREATE SKILL NOTES FOR REUSABLE KNOWLEDGE**
   Any time you discover something generally useful — a pattern,
   convention, gotcha, best practice, or technique — you MUST create
   a **skill note** so it will be automatically shown in future tasks.

   A skill note is a note tagged with `%s` plus one or more topic
   tags (e.g., `elisp`, `performance`, `error-handling`, or a project
   name).  Notes tagged with `%s` are special: they are automatically
   included as instructions when the agent works on tasks that share
   any of the co-tags.  This is how the agent learns and improves
   over time.

   Examples of when to create a skill note:
   - You discover a performance anti-pattern (tag: `%s` + `elisp` + `performance`)
   - You identify a project convention (tag: `%s` + `<project-name>`)
   - You learn a useful technique (tag: `%s` + relevant topic)
   - Your instructions could be improved (tag: `%s` + relevant context)

In org-mode, you can link to a note with `[[ekg-note:<id>][<link display
text>]]`.  Tags can be linked with `[[ekg-tag:<tag>][<link display
text>]]`.

In markdown mode, there's no way to link directly to notes, but you can
use [[tag]] to link to a tag.

== SUBAGENT MEMORY HANDOFF ==

When delegating work to a sub-agent via `run_subagent`:
- **Before** spawning the subagent, ensure all relevant context
  (project conventions, decisions, file locations) is stored in ekg
  notes with appropriate tags so the subagent can find them.
- In the subagent instructions, mention which ekg tags to search for
  context (e.g., \"Check notes tagged with 'widget-maker' for project
  conventions\").
- The subagent has full access to ekg tools and follows the same memory
  workflow, so it will search for and create notes.

== FINAL STEPS (DO NOT SKIP) ==

When the task is complete:

5. **RETROSPECTIVE AND KNOWLEDGE EXTRACTION**
   - Ask: what did I learn that's generally useful?
   - For each learning, create a skill note (tagged with `%s` + topics).
   - If something went wrong, document the lesson as a skill note.

6. **UPDATE THE TASK NOTE**
   - Add a final entry summarizing the outcome
   - Link to any new notes you created
   - Mark the task as done in the org file

Use the `summarize_state` tool regularly to write brief but meaningful
status updates to the user-visible agent log window.  During long
tasks, do this at least once a minute, and also whenever your plan
changes or a step may take a while.  Each update should say what you
finished, what you are doing now, and what comes next or what is
blocked.

Do not batch so much work between status updates that the user waits
more than a minute for the next one.  If a set of edits or tool calls
may take a while, break it into smaller chunks and call
`summarize_state` between chunks.  For repetitive file edits, prefer
working one file at a time unless you are confident the batch will stay
well under a minute.

The session may end if a total task timeout (%s) has been configured. Or,
some error may interrupt the processing. Because your processing can end
unexpected, you MUST occasionally write out your current state to an ekg
note (tag with `%s`, and a tag you choose to represent the
task). If you receive a timeout warning, do this immediately and then
finish. When executing a long-running task (more than a couple of tool
calls), start saving your state every few tool calls to a note.

When creating a note, text that you add will automatically have the tags
surrounding it to indicate that it was written by an LLM.  Do not add
these tags manually."
     (concat "[" (string-join (ekg-agent-cotagged-prompt-tags) " ") "]")
     ekg-llm-prompt-tag  ; step 4 first mention
     ekg-llm-prompt-tag  ; step 4 "Notes tagged with..."
     ekg-llm-prompt-tag  ; step 4 example 1
     ekg-llm-prompt-tag  ; step 4 example 2
     ekg-llm-prompt-tag  ; step 4 example 3
     ekg-llm-prompt-tag  ; step 4 example 4
     ekg-llm-prompt-tag  ; step 5
     timeout-desc
     ekg-agent-self-info-tag)))

(defun ekg-agent--load-org-instructions ()
  "Legacy function - kept for backwards compatibility.
This function now returns the default instructions as a fallback.")

(defun ekg-agent-instructions-evaluate-status ()
  "Return instructions for the agent to evaluate user status and help them."
  (format
   "Your goal is to help the user.  Act as a coach.  When
applicable, write notes to the user that will help them.  For example:

1. Check in with the user about their current progress on their goals.
2. Respond to the latest notes on their progress.
3. Point out a relevant note that the user may have forgotten about, or facts that they didn't know.
4. If you find gaps or errors in the notes, point them out to the user.
5. Suggest books, articles or resource papers that the user may find useful.

Provided below is a list of their recent notes and org-mode TODO
items (if any).  If you need to explore the notes, you can do that with
the tools available to you.  After each tool call you will be given a
chance to make more tool calls, one of which is to create a new note.
When you are done, you can call the end tool to indicate that you are
finished.  Try to make no more than 4 tool calls before calling the end
tool to finish your work.

Following these steps:

1. Based on the recent messages and org-mode tasks, see if there's
something obvious to follow up on.  If not, call the end tool.
2. If necessary to get more information, look at relevant tags or search.
This can be one or two tool calls but no more.
3. After getting more information, you may want to write a note to
yourself with relevant findings.
4. Write a note to the user, using the relevant tags, with the content
you think is appropriate.
5. Call the end tool to finish your work.

The date and time is %s.\n"
   (format-time-string "%F %R")))

(defun ekg-agent-evaluate-status ()
  "Run the ekg agent to evaluate status and help the user.
The agent will review recent notes and TODO items, then decide whether
to create new notes or perform other actions to help the user."
  (interactive)
  (ekg-agent--iterate (llm-make-chat-prompt
                       (ekg-agent-latest-notes-context)
                       :context
                       (concat (ekg-agent-instructions-intro) "\n"
                               (ekg-agent-instructions-evaluate-status))
                       :tools
                       (ekg-agent--tools (list ekg-agent-tool-end))
                       :tool-options (make-llm-tool-options :tool-choice 'any))
                      0
                      (ekg-agent--make-status-callback)
                      '("end")
                      (ekg-agent--timeout-deadline)))

(defun ekg-agent--prompt-id (prompt)
  "From llm PROMPT, call an LLM to get a short identifier."
  (let (id)
    (llm-chat (ekg-agent--provider)
              (llm-make-chat-prompt
               (llm-chat-prompt-interaction-content
                (car (seq-filter (lambda (interaction)
                                   (eq (llm-chat-prompt-interaction-role interaction) 'user))
                                 (llm-chat-prompt-interactions prompt))))
               :context "From user input of the what they are instruction an agent to do, call the tool to report a short name.  "
               :tools (list
                       (make-llm-tool :function (lambda (result) (setq id result))
                                      :name "report_id"
                                      :description "Report on the decided id so that the agent can use this id to name an emacs buffer that will hold the status of the agent.  "
                                      :args '((:name "id" :type string
                                                     :description "This id will be for naming an emacs buffer, so use lowercase and dashes.  This should be about 3-5 words.  Example name: 'check-gnus-email-and-respond'.  "))))
               :tool-options (make-llm-tool-options :tool-choice 'any)))
    id))

(defvar ekg-agent-log-mode-map
  (let ((map (make-sparse-keymap)))
    (define-key map (kbd "C-c C-c") #'ekg-agent-continue)
    (define-key map (kbd "C-c C-k") #'ekg-agent-cancel)
    (define-key map (kbd "C-c C-q") #'ekg-agent-force-cancel)
    map)
  "Keymap for `ekg-agent-log-mode'.")

(define-minor-mode ekg-agent-log-mode
  "Minor mode for ekg agent log buffers.
Provides commands to continue or cancel agent work.

\\{ekg-agent-log-mode-map}"
  :lighter (:eval (if ekg-agent--running-p " Agent:run" " Agent:idle"))
  :keymap ekg-agent-log-mode-map)

(defun ekg-agent--set-stopped (log-buf)
  "Mark the agent as no longer running in LOG-BUF.
Return non-nil if the agent was actually running and is now stopped.
This acts as a once-only guard to ensure status callbacks fire exactly once."
  (when (and log-buf (buffer-live-p log-buf))
    (with-current-buffer log-buf
      (when ekg-agent--running-p
        (setq ekg-agent--running-p nil)
        (setq ekg-agent--current-request nil)
        (setq ekg-agent--current-activity nil)
        (force-mode-line-update)
        t))))

(defun ekg-agent--iterate (prompt iteration-num &optional status-callback end-tools deadline timeout-final)
  "Run an iteration of the ekg agent with PROMPT and ITERATION-NUM.

PROMPT is the chat prompt for the LLM.

ITERATION-NUM is the current iteration count.  0 is the setup iteration,
1 is the first iteration in which we call start the agentic loop.

STATUS-CALLBACK is called exactly once when the agent finishes.  The
argument is a string with the end-tool result on success, or the symbol
\\='error on failure.

END-TOOLS is a list of tool names that will end the iteration.

DEADLINE is a float time when the agent should stop.

If TIMEOUT-FINAL is non-nil, this is the final iteration before
stopping.

This is to start, and after every tool call to continue the agent
session.  At iteration 0 the log buffer is created and
`ekg-agent-log-mode' is enabled."
  (if (= iteration-num 0)
      ;; Set up everything.  Capture the current buffer as the origin
      ;; buffer before switching to the log buffer.
      (let* ((origin-buf (current-buffer))
             (id (ekg-agent--prompt-id prompt))
             (buf (get-buffer-create (format ekg-agent-log-buffer-name-format id))))
        (with-current-buffer buf
          (erase-buffer)
          ;; Set up major mode first since it calls
          ;; `kill-all-local-variables'.
          (when (featurep 'markdown-mode)
            (markdown-mode)
            (when (featurep 'flycheck)
              (flycheck-mode 0))
            (when (featurep 'flymake)
              (flymake-mode 0)))
          (let ((ekg-agent--current-log-buffer buf))
            (ekg-agent--log-session-start (buffer-name)))
          (insert (format "Agent session for: %s\n\n" id))
          (setq ekg-agent--prompt prompt)
          (setq ekg-agent--end-tools end-tools)
          (setq ekg-agent--running-p t)
          (setq ekg-agent--cancelled-p nil)
          (setq ekg-agent--current-request nil)
          (setq ekg-agent--tool-processes nil)
          (setq ekg-agent--status-callback status-callback)
          (setq ekg-agent--origin-buffer origin-buf)
          (setq ekg-agent--last-status-update-time (float-time))
          (setq ekg-agent--last-status-reminder-time nil)
          (ekg-agent-log-mode 1)
          (setq header-line-format
                '(:eval (ekg-agent--format-header-line)))
          (ekg-agent--wrap-prompt-tools prompt buf origin-buf)
          (goto-char (point-min))
          (ekg-agent--iterate prompt 1
                              status-callback
                              end-tools
                              deadline
                              timeout-final)))
    ;; iteration > 0: run the agent loop.
    ;; current-buffer should be the log buffer (guaranteed by
    ;; iteration 0 and by the with-current-buffer around the recursive
    ;; call below).
    (let ((log-buf (current-buffer))
          (ekg-agent--current-log-buffer (current-buffer))
          (expired (and deadline (> (float-time) deadline))))
      (when (and expired (not timeout-final))
        (ekg-agent--log "Timeout reached; requesting final state note.")
        (ekg-agent--prompt-append-user-message prompt (ekg-agent--timeout-warning-message))
        (setq timeout-final t))
      (ekg-agent--maybe-remind-status-update prompt)
      (when (and log-buf (buffer-live-p log-buf))
        (with-current-buffer log-buf
          (setq ekg-agent--current-activity "Waiting for LLM response…")
          (force-mode-line-update)
          (ekg-agent--log "⟳ Waiting for LLM response…")))
      (condition-case err
          (let ((request
                  (ekg-agent--llm-chat-async-with-retry
                   (ekg-agent--provider)
                   prompt
                   (lambda (result)
                     ;; Guard the entire callback body: this runs in a
                     ;; process sentinel context where uncaught errors
                     ;; vanish silently, leaving the agent hung.
                     ;; Bind the log buffer so that any logging from
                     ;; status callbacks targets the log, not whatever
                     ;; buffer the process sentinel happened to use.
                     (let ((ekg-agent--current-log-buffer log-buf))
                       (condition-case err
                           (if (and (buffer-live-p log-buf)
                                    (buffer-local-value 'ekg-agent--cancelled-p log-buf))
                               ;; Cancelled by user
                               (when (ekg-agent--set-stopped log-buf)
                                 (ekg-agent--log "Agent stopped (cancelled by user)")
                                 (when status-callback (funcall status-callback "stopped by user")))
                             ;; Normal processing of result
                             (let ((result-alist (plist-get result :tool-results))
                                   (end-tools (or end-tools '("end"))))
                               (unless result-alist
                                 ;; Everything must be a tool call, by default if it just gets
                                 ;; non-tool output we'll log it, and then instruct it to use
                                 ;; tools to end if needed.
                                 (ekg-agent--log "%s" (plist-get result :text))
                                 (llm-chat-prompt-append-response
                                  prompt
                                  (format "That has been communicated to the user.  Please always call tools.  This session cannot end until you call one of the following tools: %s."
                                          (string-join end-tools ", "))))

                               (cond
                                (timeout-final
                                 (when (ekg-agent--set-stopped log-buf)
                                   (when status-callback (funcall status-callback "stopped by timeout"))))
                                ((seq-find (lambda (end-tool) (assoc-default end-tool result-alist)) end-tools)
                                 (when (ekg-agent--set-stopped log-buf)
                                   (when status-callback
                                     (funcall
                                      status-callback
                                      (mapconcat (lambda (end-tool)
                                                   (format "%s" (assoc-default end-tool result-alist)))
                                                 (seq-filter (lambda (end-tool) (assoc-default end-tool result-alist))
                                                             end-tools)
                                                 ", ")))))
                                (t
                                 ;; Recurse in the log buffer so that the
                                 ;; next iteration sees the correct
                                 ;; current-buffer for log-buf.
                                 (with-current-buffer log-buf
                                   (ekg-agent--iterate prompt
                                                       (+ 1 iteration-num)
                                                       status-callback
                                                       end-tools
                                                       deadline))))))
                         (error
                          (when (ekg-agent--set-stopped log-buf)
                            (ekg-agent--log "Error in callback: %s"
                                            (error-message-string err))
                            (when status-callback (funcall status-callback 'error)))))))
                   (lambda (_ err)
                     (let ((ekg-agent--current-log-buffer log-buf))
                       (when (ekg-agent--set-stopped log-buf)
                         (ekg-agent--log "LLM error: %s" err)
                         (when status-callback (funcall status-callback 'error)))))
                   t
                   log-buf)))
            (when (buffer-live-p log-buf)
              (with-current-buffer log-buf
                (setq ekg-agent--current-request request))))
        (error
         (when (ekg-agent--set-stopped log-buf)
           (ekg-agent--log "Error starting LLM request: %s"
                           (error-message-string err))
           (when status-callback (funcall status-callback 'error))))))))

(defun ekg-agent-continue (message)
  "Continue the agent from where it left off.
Prompts for a MESSAGE with additional instructions for the agent."
  (interactive (list (read-string "Instructions for agent: ")))
  (unless ekg-agent--prompt
    (user-error "No agent prompt available to continue"))
  (when ekg-agent--running-p
    (user-error "Agent is already running"))
  (setq ekg-agent--cancelled-p nil)
  (setq ekg-agent--running-p t)
  (setq ekg-agent--current-request nil)
  (setq ekg-agent--tool-processes nil)
  (force-mode-line-update)
  (ekg-agent--clean-orphaned-tool-interactions ekg-agent--prompt)
  (let ((cb (ekg-agent--make-status-callback))
        (ekg-agent--current-log-buffer (current-buffer)))
    (setq ekg-agent--status-callback cb)
    (setq ekg-agent--last-status-update-time (float-time))
    (setq ekg-agent--last-status-reminder-time nil)
    (ekg-agent--log-session-start "Continuing agent")
    (when (and message (not (string-empty-p message)))
      (ekg-agent--prompt-append-user-message ekg-agent--prompt message)
      (ekg-agent--log "User message: %s" message))
    (ekg-agent--iterate ekg-agent--prompt
                        1
                        cb
                        ekg-agent--end-tools
                        (ekg-agent--timeout-deadline))))

(defun ekg-agent-cancel ()
  "Cancel the current agent run, preserving the prompt for continuation.
The agent will stop after the current in-flight LLM call completes.
Use \\[ekg-agent-continue] to resume."
  (interactive)
  (unless ekg-agent--running-p
    (user-error "No agent is currently running"))
  (setq ekg-agent--cancelled-p t)
  (let ((ekg-agent--current-log-buffer (current-buffer)))
    (ekg-agent--log "Cancellation requested; will stop after current tool call")))

(defun ekg-agent-force-cancel ()
  "Force-cancel the current agent run by killing in-flight requests.
Unlike `ekg-agent-cancel', which waits for the current operation to
finish, this immediately kills the LLM request and any tool
subprocesses.  Use \\[ekg-agent-continue] to resume."
  (interactive)
  (unless ekg-agent--running-p
    (user-error "No agent is currently running"))
  (setq ekg-agent--cancelled-p t)
  ;; Kill the in-flight LLM request.
  (when ekg-agent--current-request
    (ignore-errors (llm-cancel-request ekg-agent--current-request))
    (setq ekg-agent--current-request nil))
  ;; Kill any active tool subprocesses or abort futures.
  (dolist (item ekg-agent--tool-processes)
    (cond
     ((futur-p item)
      (ignore-errors (futur-abort item "force-cancelled")))
     ((processp item)
      (when (process-live-p item)
        (ignore-errors (delete-process item))))))
  (setq ekg-agent--tool-processes nil)
  ;; Clean up orphaned tool-use interactions from the prompt.
  (when ekg-agent--prompt
    (ekg-agent--clean-orphaned-tool-interactions ekg-agent--prompt))
  ;; Mark as stopped and fire the status callback exactly once.
  (let ((log-buf (current-buffer))
        (ekg-agent--current-log-buffer (current-buffer)))
    (when (ekg-agent--set-stopped log-buf)
      (ekg-agent--log "Agent force-cancelled")
      (when ekg-agent--status-callback
        (funcall ekg-agent--status-callback "force cancelled")))))

(defun ekg-agent-evaluate-status-daily ()
  "Run the ekg agent to evaluate status as a daily routine."
  (interactive)
  (message "Running the daily ekg agent...")
  (ekg-agent-evaluate-status))

(defun ekg-agent--parse-time (time-string)
  "Parse TIME-STRING in HH:MM format and return seconds until that time today.
If the time has already passed today, return seconds until that time tomorrow."
  (unless (string-match "\\`\\([0-9]\\{1,2\\}\\):\\([0-9]\\{2\\}\\)\\'" time-string)
    (error "Invalid time format: %s (expected HH:MM)" time-string))
  (let* ((hour (string-to-number (match-string 1 time-string)))
         (minute (string-to-number (match-string 2 time-string)))
         (now (decode-time))
         (target-time (encode-time 0 minute hour
                                   (nth 3 now) (nth 4 now) (nth 5 now))))
    (when (or (< hour 0) (>= hour 24) (< minute 0) (>= minute 60))
      (error "Invalid time: %s (hour must be 0-23, minute 0-59)" time-string))
    (let ((seconds-until (- (float-time target-time) (float-time))))
      (if (< seconds-until 0)
          ;; Time has passed today, schedule for tomorrow
          (+ seconds-until 86400)
        seconds-until))))

(defun ekg-agent-schedule-daily ()
  "Schedule the daily agent evaluation to run at `ekg-agent-daily-time'.
If already scheduled, cancel the existing timer and create a new one."
  (interactive)
  (ekg-agent-cancel-daily)
  (let ((seconds-until (ekg-agent--parse-time ekg-agent-daily-time)))
    (setq ekg-agent--daily-timer
          (run-at-time seconds-until 86400 #'ekg-agent-evaluate-status-daily))
    (message "EKG agent scheduled to run daily at %s" ekg-agent-daily-time)))

(defun ekg-agent-cancel-daily ()
  "Cancel the scheduled daily agent evaluation."
  (interactive)
  (when ekg-agent--daily-timer
    (cancel-timer ekg-agent--daily-timer)
    (setq ekg-agent--daily-timer nil)
    (message "EKG agent daily schedule cancelled")))

(defun ekg-agent-note-response (&optional arg)
  "Respond to the current note using the agent.
This is similar to `ekg-llm-send-and-append-note', but runs an
agent loop with tools, instead of just appending text.

The agent is given the context of the last 10 notes with similar
tags.

ARG, if non-nil, allows editing the instructions."
  (interactive "P")
  (unless ekg-note
    (error "No note in current buffer"))
  (ekg-note-update-from-buffer)
  (save-excursion
    (let* ((ekg-agent-tool-append-response
            (make-llm-tool
             :function (lambda (content)
                         (let* ((enclosure (assoc-default major-mode ekg-llm-format-output nil '("_BEGIN_" . "_END_")))
                                (new-text (concat
                                           (car enclosure) "\n"
                                           content "\n"
                                           (cdr enclosure))))
                           (save-excursion
                             (goto-char (point-max))
                             (insert new-text))))
             :name "append_to_current_note"
             :description "Append content to the current note."
             :args '((:name "content" :type string :description "The content to append to the current note."))))
           (ekg-agent-tool-replace-response
            (make-llm-tool
             :function (lambda (content)
                         (let* ((enclosure (assoc-default major-mode ekg-llm-format-output nil '("_BEGIN_" . "_END_")))
                                (new-text (concat
                                           (car enclosure) "\n"
                                           content "\n"
                                           (cdr enclosure))))
                           (erase-buffer)
                           (insert new-text)))
             :name "replace_current_note"
             :description "Replace the content of the current note."
             :args '((:name "content" :type string :description "The new content for the current note."))))
           (instructions (ekg-llm-instructions-for-note ekg-note))
           (instructions-for-use (if arg
                                     (read-string "Instructions: " instructions)
                                   instructions))
           (context-notes (seq-take (seq-remove (lambda (n) (equal (ekg-note-id n) (ekg-note-id ekg-note)))
                                                (ekg-get-notes-with-any-tags
                                                 (append
                                                  (ekg-note-tags ekg-note)
                                                  (list ekg-agent-self-info-tag))))
                                    10))
           (context-notes-json (let ((ekg-llm-note-numwords 100))
                                 (mapconcat #'ekg-llm-note-to-text context-notes "\n\n")))
           (current-note-json (let ((ekg-llm-note-numwords 10000))
                                (ekg-llm-note-to-text ekg-note)))
           (prompt (concat "You are a note-response agent for ekg, an Emacs knowledge base.
Your job is to respond to the user's current note by appending or
replacing its content.  You have tools to search existing notes for
context if needed, but your primary goal is to produce a response
for the current note.

Do NOT create separate notes (via `create_note`) unless there is a
compelling reason — your response belongs in the current note.

Your instructions:\n"
                           instructions-for-use
                           "\n\nYou have access to tools to search and read existing notes
for context.  After each tool call you will be given a chance to make
more tool calls.

IMPORTANT: You MUST end your session by calling one of these two tools:
- `append_to_current_note`: Appends your response to the current note.
- `replace_current_note`: Replaces the current note content entirely.

Calling either of these tools will end your session.  There is no other
way to end the session.  Prefer `append_to_current_note` by default,
unless the user is explicitly asking for a rewrite or replacement.

The user input will be the note they are currently editing.\n\n"
                           (format "The current date and time is %s.\n"
                                   (format-time-string "%F %R"))
                           (format "Some notes matching the tags or context: %s\n"
                                   context-notes-json))))
      (let ((overlay (make-overlay (point-max) (point-max) nil t t)))
        (overlay-put overlay 'after-string (propertize " [LLM response computing]" 'face 'shadow))
        (ekg-agent--iterate (llm-make-chat-prompt
                             current-note-json
                             :context prompt
                             :tools
                             (ekg-agent--tools (list
                                                ekg-agent-tool-append-response
                                                ekg-agent-tool-replace-response))
                             :tool-options (make-llm-tool-options :tool-choice 'any))
                            0
                            (ekg-agent--make-status-callback
                             (lambda (_status)
                               (delete-overlay overlay)))
                            '("append_to_current_note" "replace_current_note")
                            (ekg-agent--timeout-deadline))))))

(defvar ekg-agent-minor-mode-map
  (let ((map (make-sparse-keymap)))
    (set-keymap-parent map ekg-llm-capture-mode-map)
    (define-key map (kbd "C-c .") #'ekg-agent-note-response)
    map)
  "Keymap for ekg-agent bindings in capture and edit modes.
Inherits from `ekg-llm-capture-mode-map' but overrides
\\[ekg-agent-note-response] to use the agent instead of the plain LLM.")

(define-minor-mode ekg-agent-minor-mode
  "Minor mode providing agent keybindings in ekg capture/edit buffers.
This replaces `ekg-llm-minor-mode' when `ekg-agent' is loaded."
  :lighter nil
  :keymap ekg-agent-minor-mode-map)

;; Replace ekg-llm-minor-mode with ekg-agent-minor-mode on the hooks.
(remove-hook 'ekg-capture-mode-hook #'ekg-llm-minor-mode)
(remove-hook 'ekg-edit-mode-hook #'ekg-llm-minor-mode)
(add-hook 'ekg-capture-mode-hook #'ekg-agent-minor-mode)
(add-hook 'ekg-edit-mode-hook #'ekg-agent-minor-mode)

(defun ekg-agent-show-internal-notes ()
  "Show notes with agent internal tags."
  (interactive)
  (ekg-show-notes-with-tag ekg-agent-self-info-tag))

;;;###autoload
(defun ekg-agent-add-note (text tags mode)
  "Add a note from an agent with TEXT, TAGS, and MODE.

TAGS should be a list of tag strings.  MODE should be a symbol like
`org-mode', `markdown-mode', or `text-mode'.  Returns the note
ID on success, signals an error on failure.

This function automatically:
- Adds the `ekg-agent-author-tag' to the tags
- Applies all functions from `ekg-capture-auto-tag-funcs' (e.g., date tags)
- Wraps the text in the appropriate LLM output format based on MODE

This is intended to be used from the command-line so agents can easily
add properly formatted notes to ekg."
  (ekg-connect)
  (let* ((mode-sym (if (stringp mode) (intern mode) mode))
         (enclosure (assoc-default mode-sym ekg-llm-format-output nil '("_BEGIN_" . "_END_")))
         (formatted-text (concat (car enclosure) "\n"
                                 text "\n"
                                 (cdr enclosure)))
         ;; Apply auto-tag functions (e.g., date tags)
         (auto-tags (mapcan (lambda (f) (funcall f)) ekg-capture-auto-tag-funcs))
         (all-tags (seq-uniq (append tags auto-tags (list ekg-agent-author-tag))))
         (note (ekg-note-create :text formatted-text
                                :mode mode-sym
                                :tags all-tags)))
    (ekg-save-note note)
    (message "Agent note created with ID: %s" (ekg-note-id note))
    (ekg-note-id note)))

(defun ekg-agent--filter-properties (props)
  "Filter PROPS plist, removing large internal properties like embeddings."
  (let (result)
    (cl-loop for (key val) on props by #'cddr
             unless (eq key :embedding/embedding)
             do (setq result (plist-put result key val)))
    result))

(defun ekg-agent--note-to-alist (note &optional max-words)
  "Convert NOTE to an alist suitable for JSON encoding.
If MAX-WORDS is specified, truncate the text to that many words."
  (let* ((ekg-truncation-method 'word)
         (text (or (ekg-note-text note) ""))
         (truncated-text (if (and max-words (> (length text) 0))
                             (ekg-truncate-at text max-words "…")
                           text)))
    `((id . ,(ekg-note-id note))
      (text . ,truncated-text)
      (mode . ,(symbol-name (ekg-note-mode note)))
      (tags . ,(ekg-note-tags note))
      (creation_time . ,(ekg-note-creation-time note))
      (modified_time . ,(ekg-note-modified-time note))
      (properties . ,(ekg-agent--filter-properties (ekg-note-properties note))))))

(defun ekg-agent--notes-to-json (notes &optional max-words)
  "Convert list of NOTES to a JSON array string.
If MAX-WORDS is specified, truncate each note's text to that many words."
  (json-encode (mapcar (lambda (note)
                         (ekg-agent--note-to-alist note max-words))
                       notes)))

(cl-defun ekg-agent--get-notes (&key tags any-tags note-id semantic-search text-search latest (num 10))
  "Get notes from ekg based on search criteria.

This is a helper function that returns a list of note objects.
Use `ekg-agent-read-notes' for CLI access with JSON output.

This function supports multiple modes of operation:

1. By tags (AND): Provide TAGS as a list of tag strings.
2. By tags (OR): Provide ANY-TAGS as a list of tag strings.
3. By note ID: Provide NOTE-ID as a number or string.
4. By semantic search: Provide SEMANTIC-SEARCH as a query string.
5. By text search: Provide TEXT-SEARCH as a query string.
6. Latest modified: Provide LATEST as non-nil.

NUM is the maximum number of notes to return (default 10).

Returns a list of note objects."
  (ekg-connect)
  ;; The agent must be able to find its own self-info notes even when
  ;; searching by other tags.  ekg-hidden-tags causes notes with
  ;; agent/self-info to be filtered out of normal tag queries, so we
  ;; remove that tag from the hidden list within agent tool context.
  (let ((ekg-hidden-tags (remove ekg-agent-self-info-tag ekg-hidden-tags)))
    (cond
     ;; Latest modified notes
     (latest
      (ekg-get-latest-modified num))

     ;; Read by note ID
     (note-id
      (let ((note (ekg-get-note-with-id (if (stringp note-id)
                                            (string-to-number note-id)
                                          note-id))))
        (if note
            (list note)
          (error "Note with ID %s not found" note-id))))

     ;; Semantic search (requires embeddings)
     (semantic-search
      (unless ekg-embedding-provider
        (error "Semantic search requires ekg-embedding-provider to be configured"))
      (let ((embedding (llm-embedding ekg-embedding-provider semantic-search)))
        (delq nil (mapcar #'ekg-get-note-with-id
                          (ekg-embedding-n-most-similar-notes embedding num)))))

     ;; Text search (full-text search)
     (text-search
      (seq-take
       (seq-filter #'ekg-note-active-p
                   (delq nil (mapcar #'ekg-get-note-with-id
                                     (triples-fts-query-subject ekg-db text-search ekg-query-pred-abbrevs))))
       num))

     ;; Tag-based search with OR logic (already sorted by creation time)
     (any-tags
      (seq-take (ekg-get-notes-with-any-tags any-tags) num))

     ;; Tag-based search (AND logic)
     (tags
      (seq-take (sort (ekg-get-notes-with-tags tags)
                      #'ekg-sort-by-creation-time)
                num))

     ;; No search criteria
     (t
      (error "Must provide tags, any-tags, note-id, semantic-search, text-search, or latest")))))

;;;###autoload
(cl-defun ekg-agent-read-notes (&key tags note-id semantic-search text-search latest (num 10) (max-words 100))
  "Read notes from ekg and return as JSON.

This function supports multiple modes of operation:

1. By tags (AND): Provide TAGS as a list of tag strings.
2. By note ID: Provide NOTE-ID as a number or string.
3. By semantic search: Provide SEMANTIC-SEARCH as a query string.
4. By text search: Provide TEXT-SEARCH as a query string.
5. Latest modified: Provide LATEST as non-nil.

NUM is the maximum number of notes to return (default 10).
MAX-WORDS is the maximum number of words per note text (default 100).

Returns a JSON string containing an array of note objects.
Each note object contains: id, text, mode, tags, creation_time, modified_time.

This is intended to be used from the command-line so agents can read
notes from ekg."
  (let ((notes (ekg-agent--get-notes :tags tags
                                     :note-id note-id
                                     :semantic-search semantic-search
                                     :text-search text-search
                                     :latest latest
                                     :num num)))
    (ekg-agent--notes-to-json notes max-words)))

;; ekg-org integration

(defun ekg-agent-org--tool-add-item (title content tags parent-id status deadline scheduled)
  "Add a new org task item to EKG.

TITLE is the task title.
CONTENT is the task content/description.
TAGS is a list of additional tags.
PARENT-ID is the parent task ID (nil for no parent)
STATUS is the task status (will be converted to uppercase).
DEADLINE is the deadline timestamp string (ignored if empty).
SCHEDULED is the scheduled timestamp string (ignored if empty)."
  (ekg-agent--with-error-as-text
    (let* ((has-parent (and parent-id (not (equal parent-id 0))
                            (not (string-empty-p (format "%s" parent-id)))))
           (note (ekg-note-create :text content
                                  :tags (append (list ekg-org-task-tag) tags
                                                (when (and status (not (string-empty-p status)))
                                                  (list (concat ekg-org-state-tag-prefix (downcase status)))))
                                  :properties (list :titled/title (list title)))))
      (when has-parent
        (setf (ekg-note-properties note)
              (plist-put (ekg-note-properties note) :org/parent parent-id)))
      ;; Assign sort-order at the end of existing siblings.
      (let* ((siblings (if has-parent
                           (ekg-org-view--sorted-children parent-id)
                         (ekg-org-view--sorted-top-level)))
             (last-id (when siblings
                        (ekg-note-id (car (last siblings)))))
             (sort-order (if siblings
                             (ekg-org-view--assign-order-after siblings last-id)
                           0)))
        (setf (ekg-note-properties note)
              (plist-put (ekg-note-properties note) :org/sort-order sort-order)))
      (when (and deadline (not (string-empty-p deadline)))
        (setf (ekg-note-properties note)
              (plist-put (ekg-note-properties note) :org/deadline (ekg-org--to-timestamp deadline))))
      (when (and scheduled (not (string-empty-p scheduled)))
        (setf (ekg-note-properties note)
              (plist-put (ekg-note-properties note) :org/scheduled (ekg-org--to-timestamp scheduled))))
      (ekg-save-note note)
      (format "Added note with ID %s" (ekg-note-id note)))))

(defconst ekg-agent-org-tool-add-task
  (llm-make-tool
   :function #'ekg-agent-org--tool-add-item
   :name "add_org_item"
   :description "Add a new org-mode task item"
   :args
   '((:name "title" :type string :description "The title/headline of the task" :require t)
     (:name "content" :type string :description "The content/description of the task" :required t)
     (:name "tags" :type array :items (:type string)
            :description "Additional tags for the task (org tags and agent tags will be added automatically)")
     (:name "parent_id" :type integer :description "The parent task ID if exists")
     (:name "status" :type string :description "The task status (TODO, DONE, etc.), will default to TODO if not set")
     (:name "deadline" :type string :description "The deadline timestamp in ISO 8601 format")
     (:name "scheduled" :type string :description "The scheduled timestamp in ISO 8601 format"))))

(defun ekg-agent-org--tool-set-status (id status)
  "Set the status of an org task item.

ID is the note ID.
STATUS is the new status (will be converted to uppercase)."
  (ekg-agent--with-error-as-text
    (let ((note (ekg-get-note-with-id id)))
      (unless note
        (error "No note found with ID %s" id))
      (setf (ekg-note-tags note)
            (cons
             (concat ekg-org-state-tag-prefix (downcase status))
             (seq-remove
              (lambda (tag) (string-prefix-p ekg-org-state-tag-prefix tag))
              (ekg-note-tags note))))
      (ekg-save-note note)
      (format "Set status of note ID %s to %s" id status))))

(defconst ekg-agent-org-tool-set-status
  (llm-make-tool
   :function #'ekg-agent-org--tool-set-status
   :name "set_org_item_status"
   :description "Set the status of an org-mode task item.  Use this when you want to change the status of an existing task, for example setting it to DONE when completing it, or marking it WAITING if it's on hold."
   :args
   '((:name "id" :type integer :description "The ID of the task item" :required t)
     (:name "status" :type string :description "The new status of the task (TODO, DONE, etc.)" :required t))))

(defun ekg-agent-org--tool-list-items (&optional state)
  "List all org task items.

STATE if non-nil, filter by status (e.g., \"TODO\", \"DONE\").
Returns text in Org format, as if they were in an Org file."
  (ekg-agent--with-error-as-text
    (let ((result (ekg-org-generate-org-content
                   nil (when state
                         (lambda (note)
                           (string-equal
                            (downcase state)
                            (downcase (ekg-org--state note))))))))
      (if (string-empty-p result)
          (if state
              (format "No org items found with state %s." state)
            "No org items found.")
        result))))

(defconst ekg-agent-org-tool-list-items
  (llm-make-tool
   :function #'ekg-agent-org--tool-list-items
   :name "list_org_items"
   :description "Return all matching org-mode task items as an Org formatted string."
   :args
   '((:name "state" :type string
            :description "Filter tasks by state (TODO, DONE, etc.)"))))

(defun ekg-agent-org-plan-task ()
  "Plan the current task and add the plan as child tasks using the agent."
  (interactive)
  (let* ((ekg-note (ekg-current-note-or-error-expanded))
         (parent-id (ekg-note-id ekg-note))
         (parent-note (ekg-get-note-with-id parent-id))
         (question (format "Given the task '%s', create a plan to accomplish it by creating subtasks using the tool to add ekg org tasks or add information to existing ekg note tasks. The parent ekg note id is '%s'."
                           (ekg-org--note-title parent-note)
                           parent-id)))
    (ekg-agent-ask-with-note question parent-id (list ekg-agent-org-tool-add-task))))

(defun ekg-agent-org-run-task ()
  "Execute the current org task autonomously using the agent.
The agent receives the full org hierarchy as context (current task,
parent, grandparent, etc.) and instructions to complete the task.
When finished, the agent sets the task status (typically DONE),
which ends the agent session.  No human input is required."
  (interactive)
  (let* ((ekg-note (ekg-current-note-or-error-expanded))
         (task-id (ekg-note-id ekg-note))
         (note (ekg-get-note-with-id task-id))
         (hierarchy (ekg-org--get-hierarchy note))
         (hierarchy-text (ekg-org--hierarchy-to-text hierarchy))
         (title (or (ekg-org--note-title note) "(untitled)"))
         (children (ekg-org-get-child-notes-of-id task-id))
         (children-text (when children
                          (concat "\n\nChild tasks:\n"
                                  (mapconcat
                                   (lambda (child)
                                     (format "  - [%s] %s (id: %s)"
                                             (condition-case nil (ekg-org--state child) (error "UNKNOWN"))
                                             (or (ekg-org--note-title child) "(untitled)")
                                             (ekg-note-id child)))
                                   children "\n"))))
         (prompt-notes (ekg-get-notes-cotagged-with-tags
                        (ekg-note-tags note) ekg-llm-prompt-tag))
         (prompt-context (when prompt-notes
                           (concat "\n\nPrompt instructions from co-tagged notes:\n"
                                   (mapconcat (lambda (n)
                                                (string-trim
                                                 (substring-no-properties
                                                  (ekg-display-note-text n nil 'plaintext))))
                                              prompt-notes "\n"))))
         (context (concat
                   "You are executing an org task autonomously. Here is the full task hierarchy, "
                   "from the root task down to the current task you must execute:\n\n"
                   hierarchy-text
                   (or children-text "")
                   (or prompt-context "")
                   "\n\n"
                   (format "The current date and time is %s." (format-time-string "%F %R"))))
         (question (concat
                    (format "Execute the following task: '%s' (note id: %s).\n\n" title task-id)
                    "Complete this task using the tools available to you. "
                    "You should NOT ask the user for input. Work autonomously.\n\n"
                    "When you are done, you MUST call the `set_org_item_status` tool to "
                    "set this task's status (typically to DONE, or WAITING if blocked). "
                    "Calling `set_org_item_status` will end your session.\n\n"
                    "Before setting the status, use `display_result_in_popup` to tell the user what you did.")))
    (ekg-agent--iterate (llm-make-chat-prompt
                         question
                         :context (concat (ekg-agent-instructions-intro) "\n\n" context)
                         :tools (ekg-agent--tools
                                 (list ekg-agent-tool-popup-result
                                       ekg-agent-org-tool-add-task
                                       ekg-agent-org-tool-set-status
                                       ekg-agent-org-tool-list-items))
                         :tool-options (make-llm-tool-options :tool-choice 'any))
                        0
                        (ekg-agent--make-status-callback)
                        '("set_org_item_status")
                        (ekg-agent--timeout-deadline))))

(provide 'ekg-agent)

;;; ekg-agent.el ends here
