|
;;; emacs-agent.el --- Metacircular AI agent in Emacs -*- lexical-binding: t; -*- |
|
|
|
;; Author: Erik Assum |
|
;; Version: 0.1.0 |
|
;; Package-Requires: ((emacs "28.1")) |
|
;; Keywords: ai, tools |
|
|
|
;;; Commentary: |
|
|
|
;; A metacircular AI agent that runs inside Emacs. |
|
;; Claude is the functional core, Emacs is the imperative shell. |
|
;; The only tool is elisp-eval — Claude discovers everything else by exploring. |
|
|
|
;;; Code: |
|
|
|
(require 'json) |
|
(require 'spinner) |
|
|
|
(defgroup emacs-agent nil |
|
"Metacircular AI agent in Emacs." |
|
:group 'tools) |
|
|
|
;; Directory for storing agent data |
|
(defcustom emacs-agent-directory |
|
(expand-file-name "emacs-agent" user-emacs-directory) |
|
"Directory for storing emacs-agent snippets and memories." |
|
:type 'directory |
|
:group 'emacs-agent) |
|
|
|
(defun emacs-agent--ensure-directory () |
|
"Ensure the emacs-agent directory exists." |
|
(unless (file-directory-p emacs-agent-directory) |
|
(make-directory emacs-agent-directory t)) |
|
(let ((snippets-dir (expand-file-name "snippets" emacs-agent-directory)) |
|
(memories-dir (expand-file-name "memories" emacs-agent-directory))) |
|
(unless (file-directory-p snippets-dir) |
|
(make-directory snippets-dir t)) |
|
(unless (file-directory-p memories-dir) |
|
(make-directory memories-dir t)))) |
|
|
|
(defun emacs-agent-save-snippet (name content &optional description) |
|
"Save a code snippet with NAME and CONTENT, optionally with DESCRIPTION." |
|
(interactive "sSnippet name: \nsContent: \nsDescription (optional): ") |
|
(emacs-agent--ensure-directory) |
|
(let* ((snippets-dir (expand-file-name "snippets" emacs-agent-directory)) |
|
(file-path (expand-file-name (concat name ".el") snippets-dir)) |
|
(timestamp (format-time-string "%Y-%m-%d %H:%M:%S"))) |
|
(with-temp-file file-path |
|
(insert (format ";; Snippet: %s\n" name)) |
|
(insert (format ";; Created: %s\n" timestamp)) |
|
(when description |
|
(insert (format ";; Description: %s\n" description))) |
|
(insert "\n") |
|
(insert content)) |
|
(message "Snippet '%s' saved to %s" name file-path))) |
|
|
|
(defun emacs-agent-save-memory (key content) |
|
"Save a memory with KEY and CONTENT." |
|
(interactive "sMemory key: \nsContent: ") |
|
(emacs-agent--ensure-directory) |
|
(let* ((memories-dir (expand-file-name "memories" emacs-agent-directory)) |
|
(file-path (expand-file-name (concat key ".txt") memories-dir)) |
|
(timestamp (format-time-string "%Y-%m-%d %H:%M:%S"))) |
|
(with-temp-file file-path |
|
(insert (format "# Memory: %s\n" key)) |
|
(insert (format "# Created: %s\n" timestamp)) |
|
(insert "\n") |
|
(insert content)) |
|
(message "Memory '%s' saved to %s" key file-path))) |
|
|
|
(defun emacs-agent-list-snippets () |
|
"List all available snippets." |
|
(interactive) |
|
(emacs-agent--ensure-directory) |
|
(let ((snippets-dir (expand-file-name "snippets" emacs-agent-directory))) |
|
(if (file-directory-p snippets-dir) |
|
(let ((files (directory-files snippets-dir nil "\\.el$"))) |
|
(if files |
|
(with-output-to-temp-buffer "*Emacs Agent Snippets*" |
|
(princ "Available snippets:\n\n") |
|
(dolist (file files) |
|
(let ((name (file-name-sans-extension file)) |
|
(path (expand-file-name file snippets-dir))) |
|
(princ (format "- %s\n" name)) |
|
(with-temp-buffer |
|
(insert-file-contents path) |
|
(goto-char (point-min)) |
|
(when (re-search-forward ";; Description: \\(.+\\)" nil t) |
|
(princ (format " %s\n" (match-string 1))))) |
|
(princ "\n")))) |
|
(message "No snippets found"))) |
|
(message "Snippets directory does not exist")))) |
|
|
|
(defun emacs-agent-load-snippet (name) |
|
"Load and return the content of snippet NAME." |
|
(interactive |
|
(list (completing-read "Load snippet: " |
|
(let ((snippets-dir (expand-file-name "snippets" emacs-agent-directory))) |
|
(when (file-directory-p snippets-dir) |
|
(mapcar #'file-name-sans-extension |
|
(directory-files snippets-dir nil "\\.el$"))))))) |
|
(emacs-agent--ensure-directory) |
|
(let* ((snippets-dir (expand-file-name "snippets" emacs-agent-directory)) |
|
(file-path (expand-file-name (concat name ".el") snippets-dir))) |
|
(if (file-exists-p file-path) |
|
(with-temp-buffer |
|
(insert-file-contents file-path) |
|
(goto-char (point-min)) |
|
;; Skip comment lines to get to the actual code |
|
(while (and (not (eobp)) (looking-at "^;;")) |
|
(forward-line 1)) |
|
(skip-chars-forward "\n") |
|
(buffer-substring-no-properties (point) (point-max))) |
|
(error "Snippet '%s' not found" name)))) |
|
|
|
(defun emacs-agent-list-memories () |
|
"List all available memories." |
|
(interactive) |
|
(emacs-agent--ensure-directory) |
|
(let ((memories-dir (expand-file-name "memories" emacs-agent-directory))) |
|
(if (file-directory-p memories-dir) |
|
(let ((files (directory-files memories-dir nil "\\.txt$"))) |
|
(if files |
|
(with-output-to-temp-buffer "*Emacs Agent Memories*" |
|
(princ "Available memories:\n\n") |
|
(dolist (file files) |
|
(let ((key (file-name-sans-extension file)) |
|
(path (expand-file-name file memories-dir))) |
|
(princ (format "- %s\n" key)) |
|
(with-temp-buffer |
|
(insert-file-contents path) |
|
(let ((content (buffer-substring-no-properties (point-min) (point-max)))) |
|
(when (> (length content) 100) |
|
(princ (format " %s...\n" (substring content 0 97)))))) |
|
(princ "\n")))) |
|
(message "No memories found"))) |
|
(message "Memories directory does not exist")))) |
|
|
|
(defun emacs-agent-recall-memory (key) |
|
"Recall and return the content of memory KEY." |
|
(interactive |
|
(list (completing-read "Recall memory: " |
|
(let ((memories-dir (expand-file-name "memories" emacs-agent-directory))) |
|
(when (file-directory-p memories-dir) |
|
(mapcar #'file-name-sans-extension |
|
(directory-files memories-dir nil "\\.txt$"))))))) |
|
(emacs-agent--ensure-directory) |
|
(let* ((memories-dir (expand-file-name "memories" emacs-agent-directory)) |
|
(file-path (expand-file-name (concat key ".txt") memories-dir))) |
|
(if (file-exists-p file-path) |
|
(with-temp-buffer |
|
(insert-file-contents file-path) |
|
(goto-char (point-min)) |
|
;; Skip comment lines to get to the actual content |
|
(while (and (not (eobp)) (looking-at "^#")) |
|
(forward-line 1)) |
|
(skip-chars-forward "\n") |
|
(buffer-substring-no-properties (point) (point-max))) |
|
(error "Memory '%s' not found" key)))) |
|
|
|
|
|
(defcustom emacs-agent-model "claude-sonnet-4-20250514" |
|
"Claude model to use." |
|
:type 'string |
|
:group 'emacs-agent) |
|
|
|
(defcustom emacs-agent-max-tokens 8192 |
|
"Maximum tokens in Claude's response." |
|
:type 'integer |
|
:group 'emacs-agent) |
|
|
|
(defcustom emacs-agent-api-key-function #'emacs-agent--api-key-from-eca-config |
|
"Function that returns the Anthropic API key." |
|
:type 'function |
|
:group 'emacs-agent) |
|
|
|
(defvar emacs-agent--conversation nil |
|
"Current conversation history as a list of message alists.") |
|
|
|
(defvar emacs-agent--system-prompt |
|
"You are a metacircular agent running inside a live GNU Emacs session. Your only tool is elisp_eval. Everything you know about the environment, you learn by evaluating Emacs Lisp. |
|
|
|
IMPORTANT: You live inside Emacs. NEVER shell out — no shell-command, shell-command-to-string, call-process, or start-process. Emacs has native elisp for everything. Use the installed packages and built-in features. |
|
|
|
On your FIRST turn of every conversation, before addressing the user's request, you MUST run a discovery step: use elisp_eval to evaluate a form that inspects the environment — which packages are loaded, what major modes and interactive commands they provide. For example, if magit is loaded, use magit functions for git (magit-branch-and-checkout, magit-status, magit-commit-create, etc.) — never raw git commands. If projectile is loaded, use it for project navigation. Always prefer the highest-level, most idiomatic tool available. |
|
|
|
Discovery patterns: |
|
- (mapcar #'car package-alist) to see installed packages |
|
- (apropos-command \"magit\") to find interactive commands in a package |
|
- (describe-function 'fn) to read documentation for a specific function |
|
- (with-current-buffer BUF (list major-mode (point) (buffer-file-name))) to inspect a buffer |
|
- (with-current-buffer BUF (buffer-substring-no-properties START END)) to read content |
|
- (directory-files DIR t PATTERN) to list files |
|
|
|
Editing: |
|
- Always use `with-current-buffer` to target the right buffer |
|
- Wrap multi-step edits in `atomic-change-group` so they undo as one unit |
|
- Use `save-excursion` to preserve point |
|
|
|
You can extend yourself: define new elisp functions, advise existing ones, add hooks. If you need a capability that doesn't exist, build it in elisp. |
|
|
|
Be concise. Act directly — don't ask for permission unless the action is destructive (deleting files, killing buffers with unsaved changes)." |
|
"System prompt for the agent.") |
|
|
|
(defvar emacs-agent--tools |
|
`[((name . "elisp_eval") |
|
(description . "Evaluate an Emacs Lisp form. Returns the printed representation of the result, or the error message if evaluation fails. Use this to read buffers, edit code, run commands, introspect the environment — anything Emacs can do.") |
|
(input_schema . ((type . "object") |
|
(properties . ((form . ((type . "string") |
|
(description . "The Emacs Lisp form to evaluate, as a string."))))) |
|
(required . ["form"]))))] |
|
"Tool definitions sent to Claude.") |
|
|
|
(defun emacs-agent--api-key-from-env () |
|
"Get API key from ANTHROPIC_API_KEY environment variable." |
|
(or (getenv "ANTHROPIC_API_KEY") |
|
(error "ANTHROPIC_API_KEY not set"))) |
|
|
|
(defun emacs-agent--api-key-from-eca-config () |
|
"Get API key from ECA config at ~/.config/eca/config.json." |
|
(let ((config-file (expand-file-name "~/.config/eca/config.json"))) |
|
(unless (file-exists-p config-file) |
|
(error "ECA config not found at %s" config-file)) |
|
(with-temp-buffer |
|
(insert-file-contents config-file) |
|
(let* ((json-object-type 'alist) |
|
(json-key-type 'symbol) |
|
(config (json-read)) |
|
(providers (alist-get 'providers config)) |
|
(anthropic (alist-get 'anthropic providers)) |
|
(api-key (alist-get 'key anthropic))) |
|
(unless (and api-key (stringp api-key)) |
|
(error "No anthropic key found in %s" config-file)) |
|
(string-trim api-key))))) |
|
|
|
(defun emacs-agent--eval-form (form-string) |
|
"Evaluate FORM-STRING and return result as a string. |
|
Captures both the return value and any output." |
|
(condition-case err |
|
(let* ((form (read form-string)) |
|
(output (with-output-to-string (setq result (eval form t)))) |
|
(printed-result (prin1-to-string result))) |
|
(if (string-empty-p output) |
|
printed-result |
|
(format "%s\n\nOutput:\n%s" printed-result output))) |
|
(error (format "Error: %S" err)))) |
|
|
|
(defun emacs-agent--build-request (messages) |
|
"Build the API request body from MESSAGES." |
|
(let ((body `((model . ,emacs-agent-model) |
|
(max_tokens . ,emacs-agent-max-tokens) |
|
(system . ,emacs-agent--system-prompt) |
|
(tools . ,emacs-agent--tools) |
|
(messages . ,(vconcat messages))))) |
|
(encode-coding-string (json-encode body) 'utf-8))) |
|
|
|
;; Async API communication with spinner support |
|
|
|
(defvar emacs-agent--request-spinner nil |
|
"Active spinner for API requests.") |
|
|
|
(defun emacs-agent--call-api-async (messages callback) |
|
"Send MESSAGES to the Claude API asynchronously. |
|
CALLBACK is called with the parsed response when complete." |
|
(let* ((request-body (emacs-agent--build-request messages)) |
|
(temp-file (make-temp-file "emacs-agent-request" nil ".json")) |
|
(output-buffer (generate-new-buffer " *emacs-agent-response*"))) |
|
|
|
;; Write request body to temp file |
|
(with-temp-file temp-file |
|
(insert request-body)) |
|
|
|
;; Start spinner |
|
(emacs-agent--start-request-spinner) |
|
|
|
;; Start async process |
|
(make-process |
|
:name "emacs-agent-curl" |
|
:buffer output-buffer |
|
:command `("curl" "-s" "-S" |
|
"-X" "POST" |
|
"-H" "Content-Type: application/json" |
|
"-H" ,(format "x-api-key: %s" |
|
(funcall emacs-agent-api-key-function)) |
|
"-H" "anthropic-version: 2023-06-01" |
|
"-d" ,(format "@%s" temp-file) |
|
"--max-time" "120" |
|
"https://api.anthropic.com/v1/messages") |
|
:sentinel |
|
(lambda (process event) |
|
(emacs-agent--stop-request-spinner) |
|
(unwind-protect |
|
(when (string-match-p "finished" event) |
|
(with-current-buffer (process-buffer process) |
|
(goto-char (point-min)) |
|
(condition-case err |
|
(let ((json-object-type 'alist) |
|
(json-array-type 'list) |
|
(json-key-type 'symbol)) |
|
(funcall callback (json-read))) |
|
(error |
|
(funcall callback `((error . ((message . ,(format "Failed to parse response: %s\n\nResponse: %s" |
|
(error-message-string err) |
|
(buffer-string))))))))))) |
|
(delete-file temp-file) |
|
(kill-buffer output-buffer)))))) |
|
|
|
(defun emacs-agent--start-request-spinner () |
|
"Start a spinner to indicate API request in progress." |
|
(when emacs-agent--request-spinner |
|
(spinner-stop emacs-agent--request-spinner)) |
|
(setq emacs-agent--request-spinner (spinner-start 'rotating-line)) |
|
(with-current-buffer (emacs-agent--ensure-chat-buffer) |
|
(let ((inhibit-read-only t)) |
|
(goto-char (point-max)) |
|
(insert (propertize "\n⟳ Thinking...\n" |
|
'face 'font-lock-comment-face)) |
|
(goto-char (point-max))) |
|
(let ((win (get-buffer-window (current-buffer)))) |
|
(when win (set-window-point win (point-max)))) |
|
(redisplay))) |
|
|
|
(defun emacs-agent--stop-request-spinner () |
|
"Stop the API request spinner." |
|
(when emacs-agent--request-spinner |
|
(spinner-stop emacs-agent--request-spinner) |
|
(setq emacs-agent--request-spinner nil)) |
|
;; Remove the "Thinking..." message |
|
(with-current-buffer (emacs-agent--ensure-chat-buffer) |
|
(let ((inhibit-read-only t)) |
|
(goto-char (point-max)) |
|
(when (looking-back "⟳ Thinking...\n" (- (point) 20)) |
|
(delete-region (match-beginning 0) (match-end 0)))))) |
|
|
|
(defun emacs-agent--agent-loop-async (user-message) |
|
"Run the agent loop starting with USER-MESSAGE asynchronously." |
|
(push `((role . "user") |
|
(content . ,user-message)) |
|
emacs-agent--conversation) |
|
(emacs-agent--continue-async-loop)) |
|
|
|
(defun emacs-agent--continue-async-loop () |
|
"Continue the async agent loop." |
|
(emacs-agent--call-api-async |
|
(reverse emacs-agent--conversation) |
|
(lambda (response) |
|
(let ((stop-reason (alist-get 'stop_reason response)) |
|
(content (alist-get 'content response))) |
|
(push `((role . "assistant") |
|
(content . ,(vconcat content))) |
|
emacs-agent--conversation) |
|
|
|
(when (alist-get 'error response) |
|
(emacs-agent--stop-request-spinner) |
|
(emacs-agent--display-error |
|
(alist-get 'message (alist-get 'error response))) |
|
(return)) |
|
|
|
(cond |
|
((string= stop-reason "tool_use") |
|
(let ((tool-results (emacs-agent--process-tool-calls response))) |
|
(push `((role . "user") |
|
(content . ,(vconcat tool-results))) |
|
emacs-agent--conversation) |
|
;; Continue the loop |
|
(emacs-agent--continue-async-loop))) |
|
(t |
|
(emacs-agent--stop-request-spinner) |
|
(emacs-agent--display-assistant (emacs-agent--extract-text response)))))))) |
|
|
|
(defun emacs-agent--call-api (messages) |
|
"Send MESSAGES to the Claude API synchronously via curl. |
|
Returns the parsed response." |
|
(let ((request-body (emacs-agent--build-request messages))) |
|
(with-temp-buffer |
|
(let ((exit-code |
|
(call-process "curl" nil t nil |
|
"-s" "-S" |
|
"-X" "POST" |
|
"-H" "Content-Type: application/json" |
|
"-H" (format "x-api-key: %s" |
|
(funcall emacs-agent-api-key-function)) |
|
"-H" "anthropic-version: 2023-06-01" |
|
"-d" request-body |
|
"--max-time" "120" |
|
"https://api.anthropic.com/v1/messages"))) |
|
(unless (zerop exit-code) |
|
(error "curl failed (exit %d): %s" exit-code (buffer-string))) |
|
(goto-char (point-min)) |
|
(let ((json-object-type 'alist) |
|
(json-array-type 'list) |
|
(json-key-type 'symbol)) |
|
(json-read)))))) |
|
|
|
(defun emacs-agent--extract-text (response) |
|
"Extract text blocks from a Claude RESPONSE." |
|
(let ((content (alist-get 'content response))) |
|
(mapconcat |
|
(lambda (block) |
|
(when (string= (alist-get 'type block) "text") |
|
(alist-get 'text block))) |
|
content ""))) |
|
|
|
(defun emacs-agent--extract-tool-uses (response) |
|
"Extract tool_use blocks from a Claude RESPONSE." |
|
(let ((content (alist-get 'content response))) |
|
(seq-filter |
|
(lambda (block) (string= (alist-get 'type block) "tool_use")) |
|
content))) |
|
|
|
(defun emacs-agent--process-tool-calls (response) |
|
"Process tool calls in RESPONSE, evaluate them, return tool results. |
|
Also displays tool calls and results in the chat buffer." |
|
(let ((tool-uses (emacs-agent--extract-tool-uses response))) |
|
(mapcar |
|
(lambda (tool-use) |
|
(let* ((id (alist-get 'id tool-use)) |
|
(input (alist-get 'input tool-use)) |
|
(form (alist-get 'form input)) |
|
(result (progn |
|
(emacs-agent--display-tool-call form) |
|
(emacs-agent--eval-form form)))) |
|
(emacs-agent--display-tool-result result) |
|
`((type . "tool_result") |
|
(tool_use_id . ,id) |
|
(content . ,result)))) |
|
tool-uses))) |
|
|
|
(defun emacs-agent--agent-loop (user-message) |
|
"Run the agent loop starting with USER-MESSAGE." |
|
(push `((role . "user") |
|
(content . ,user-message)) |
|
emacs-agent--conversation) |
|
(let ((continue t)) |
|
(while continue |
|
(let* ((response (emacs-agent--call-api (reverse emacs-agent--conversation))) |
|
(stop-reason (alist-get 'stop_reason response)) |
|
(content (alist-get 'content response))) |
|
(push `((role . "assistant") |
|
(content . ,(vconcat content))) |
|
emacs-agent--conversation) |
|
(when (alist-get 'error response) |
|
(emacs-agent--display-error |
|
(alist-get 'message (alist-get 'error response))) |
|
(setq continue nil)) |
|
(cond |
|
((string= stop-reason "tool_use") |
|
(let ((tool-results (emacs-agent--process-tool-calls response))) |
|
(push `((role . "user") |
|
(content . ,(vconcat tool-results))) |
|
emacs-agent--conversation))) |
|
(t |
|
(emacs-agent--display-assistant (emacs-agent--extract-text response)) |
|
(setq continue nil))))))) |
|
|
|
;;; Display |
|
|
|
(defvar emacs-agent--chat-buffer-name "*emacs-agent*" |
|
"Name of the chat display buffer.") |
|
|
|
(defun emacs-agent--ensure-chat-buffer () |
|
"Get or create the chat display buffer." |
|
(let ((buf (get-buffer-create emacs-agent--chat-buffer-name))) |
|
(with-current-buffer buf |
|
(unless (derived-mode-p 'emacs-agent-chat-mode) |
|
(emacs-agent-chat-mode))) |
|
buf)) |
|
|
|
(defun emacs-agent--display-user (text) |
|
"Display user TEXT in the chat buffer." |
|
(with-current-buffer (emacs-agent--ensure-chat-buffer) |
|
(let ((inhibit-read-only t)) |
|
(goto-char (point-max)) |
|
(insert (propertize (format "\n>>> %s\n" text) |
|
'face 'font-lock-keyword-face))))) |
|
|
|
(defun emacs-agent--display-assistant (text) |
|
"Display assistant TEXT in the chat buffer." |
|
(with-current-buffer (emacs-agent--ensure-chat-buffer) |
|
(let ((inhibit-read-only t)) |
|
(goto-char (point-max)) |
|
(insert (format "\n%s\n" text)) |
|
(goto-char (point-max))) |
|
(let ((win (get-buffer-window (current-buffer)))) |
|
(when win (set-window-point win (point-max)))))) |
|
|
|
(defun emacs-agent--display-tool-call (form) |
|
"Display a tool call FORM in the chat buffer." |
|
(with-current-buffer (emacs-agent--ensure-chat-buffer) |
|
(let ((inhibit-read-only t)) |
|
(goto-char (point-max)) |
|
(insert (propertize (format "\n eval: %s\n" form) |
|
'face 'font-lock-comment-face)) |
|
(goto-char (point-max))) |
|
(let ((win (get-buffer-window (current-buffer)))) |
|
(when win (set-window-point win (point-max)))) |
|
(redisplay))) |
|
|
|
(defun emacs-agent--display-tool-result (result) |
|
"Display a tool RESULT in the chat buffer." |
|
(with-current-buffer (emacs-agent--ensure-chat-buffer) |
|
(let ((inhibit-read-only t) |
|
(display (if (> (length result) 500) |
|
(format "%s... [truncated, %d chars]" |
|
(substring result 0 500) |
|
(length result)) |
|
result))) |
|
(goto-char (point-max)) |
|
(insert (propertize (format " => %s\n" display) |
|
'face 'font-lock-string-face))) |
|
(let ((win (get-buffer-window (current-buffer)))) |
|
(when win (set-window-point win (point-max)))) |
|
(redisplay))) |
|
|
|
(defun emacs-agent--display-error (msg) |
|
"Display error MSG in the chat buffer." |
|
(with-current-buffer (emacs-agent--ensure-chat-buffer) |
|
(let ((inhibit-read-only t)) |
|
(goto-char (point-max)) |
|
(insert (propertize (format "\nERROR: %s\n" msg) |
|
'face 'error))))) |
|
|
|
;;; Prompt buffer (magit-style) |
|
|
|
(defvar emacs-agent--prompt-callback nil |
|
"Function to call with the prompt text on C-c C-c.") |
|
|
|
(defvar emacs-agent--source-buffer nil |
|
"Buffer from which the agent was invoked.") |
|
|
|
(defvar emacs-agent--source-context nil |
|
"Context gathered from the source buffer at invocation time.") |
|
|
|
(defvar emacs-agent-prompt-mode-map |
|
(let ((map (make-sparse-keymap))) |
|
(define-key map (kbd "C-c C-c") #'emacs-agent-prompt-confirm) |
|
(define-key map (kbd "C-c C-k") #'emacs-agent-prompt-cancel) |
|
map) |
|
"Keymap for the agent prompt buffer.") |
|
|
|
(define-derived-mode emacs-agent-prompt-mode text-mode "Agent-Prompt" |
|
"Mode for composing a prompt to send to the agent. |
|
\\<emacs-agent-prompt-mode-map> |
|
\\[emacs-agent-prompt-confirm] to send, \\[emacs-agent-prompt-cancel] to abort." |
|
(setq header-line-format |
|
(substitute-command-keys |
|
"Agent prompt: \\[emacs-agent-prompt-confirm] send, \\[emacs-agent-prompt-cancel] cancel"))) |
|
|
|
(defun emacs-agent-prompt-confirm () |
|
"Send the prompt buffer contents to the agent." |
|
(interactive) |
|
(let ((text (string-trim (buffer-string))) |
|
(cb emacs-agent--prompt-callback) |
|
(return-window emacs-agent--source-window) |
|
(return-buffer emacs-agent--source-buffer)) |
|
(when (string-empty-p text) |
|
(user-error "Empty prompt")) |
|
;; Close the prompt window immediately |
|
(quit-restore-window (selected-window) 'kill) |
|
;; Call the callback with prompt and return info |
|
(when cb |
|
(funcall cb text return-window return-buffer)))) |
|
|
|
(defun emacs-agent-prompt-cancel () |
|
"Cancel the prompt." |
|
(interactive) |
|
(quit-restore-window (selected-window) 'kill) |
|
(message "Cancelled.")) |
|
|
|
(defvar emacs-agent--key-packages |
|
'((magit "git operations" magit-status) |
|
(projectile "project navigation" projectile-find-file) |
|
(treemacs "project tree sidebar" treemacs) |
|
(company "completion framework" company-mode) |
|
(corfu "completion framework" corfu-mode) |
|
(vertico "minibuffer completion" vertico-mode) |
|
(consult "search and navigation" consult-ripgrep) |
|
(orderless "completion matching" orderless-matching-styles) |
|
(embark "contextual actions" embark-act) |
|
(flycheck "syntax checking" flycheck-mode) |
|
(flymake "syntax checking" flymake-mode) |
|
(lsp-mode "language server" lsp) |
|
(eglot "language server" eglot) |
|
(paredit "structured editing" paredit-mode) |
|
(smartparens "structured editing" sp-forward-sexp) |
|
(cider "Clojure REPL" cider-jack-in) |
|
(sly "Common Lisp REPL" sly) |
|
(slime "Common Lisp REPL" slime) |
|
(org "org-mode" org-mode) |
|
(dired "file manager" dired)) |
|
"Key packages to probe for, with description and sentinel function.") |
|
|
|
(defun emacs-agent--gather-environment () |
|
"Gather live environment info so the agent starts grounded." |
|
(let ((available |
|
(seq-filter (lambda (pkg) |
|
(featurep (car pkg))) |
|
emacs-agent--key-packages))) |
|
(format "Emacs environment: |
|
- %s |
|
- System: %s |
|
- Default directory: %s |
|
|
|
Available capabilities (USE THESE, not lower-level alternatives): |
|
%s |
|
|
|
Open buffers: %s" |
|
(emacs-version) |
|
(symbol-name system-type) |
|
default-directory |
|
(if available |
|
(mapconcat (lambda (pkg) |
|
(format "- %s (%s) — entry point: %s" |
|
(car pkg) (cadr pkg) (caddr pkg))) |
|
available "\n") |
|
"- No key packages detected beyond built-ins") |
|
(mapconcat #'buffer-name (seq-take (buffer-list) 20) ", ")))) |
|
|
|
(defun emacs-agent--gather-context () |
|
"Gather context from the current buffer and point." |
|
(let* ((buf (current-buffer)) |
|
(file (buffer-file-name buf)) |
|
(mode (symbol-name major-mode)) |
|
(line (line-number-at-pos)) |
|
(region (when (use-region-p) |
|
(buffer-substring-no-properties (region-beginning) (region-end)))) |
|
(around (buffer-substring-no-properties |
|
(save-excursion (forward-line -20) (point)) |
|
(save-excursion (forward-line 20) (point))))) |
|
(concat |
|
(emacs-agent--gather-environment) |
|
(format "\n\nCurrent buffer: %s\n" (buffer-name buf)) |
|
(when file (format "File: %s\n" file)) |
|
(format "Mode: %s\n" mode) |
|
(format "Line: %d\n" line) |
|
(when region (format "\nSelected region:\n```\n%s\n```\n" region)) |
|
(format "\nCode around point (line %d):\n```\n%s\n```" line around)))) |
|
|
|
(defun emacs-agent--open-prompt (context callback) |
|
"Open the prompt buffer with CONTEXT, call CALLBACK with the result." |
|
(let ((buf (get-buffer-create "*agent-prompt*")) |
|
(source-window (selected-window)) |
|
(source-buffer (current-buffer))) |
|
(with-current-buffer buf |
|
(emacs-agent-prompt-mode) |
|
(erase-buffer) |
|
(setq-local emacs-agent--prompt-callback callback) |
|
(setq-local emacs-agent--source-context context) |
|
(setq-local emacs-agent--source-window source-window) |
|
(setq-local emacs-agent--source-buffer source-buffer)) |
|
(pop-to-buffer buf '((display-buffer-below-selected) |
|
(window-height . 10))))) |
|
|
|
;;; Chat mode |
|
|
|
(defvar emacs-agent-chat-mode-map |
|
(let ((map (make-sparse-keymap))) |
|
(define-key map (kbd "q") #'quit-window) |
|
(define-key map (kbd "C-c C-c") #'emacs-agent-follow-up) |
|
map) |
|
"Keymap for the agent chat buffer.") |
|
|
|
(define-derived-mode emacs-agent-chat-mode special-mode "Agent-Chat" |
|
"Mode for displaying the agent conversation. |
|
\\<emacs-agent-chat-mode-map> |
|
\\[emacs-agent-follow-up] to send a follow-up, \\[quit-window] to close." |
|
(setq header-line-format "Agent Chat: C-c C-c follow-up, q quit")) |
|
|
|
;;; Public commands |
|
|
|
;;;###autoload |
|
(defun emacs-agent () |
|
"Start a new agent conversation about code around point. |
|
Opens a prompt buffer; send with C-c C-c." |
|
(interactive) |
|
(let ((context (emacs-agent--gather-context)) |
|
(source-buffer (current-buffer)) |
|
(source-window (selected-window))) |
|
(setq emacs-agent--conversation nil) |
|
(emacs-agent--open-prompt |
|
context |
|
(lambda (prompt return-window return-buffer) |
|
(let ((full-prompt (format "Context from the user's editor:\n%s\n\nUser's request:\n%s" |
|
context prompt))) |
|
;; Show the chat buffer |
|
(display-buffer (emacs-agent--ensure-chat-buffer) |
|
'((display-buffer-reuse-window |
|
display-buffer-in-side-window) |
|
(side . right) |
|
(window-width . 80))) |
|
;; Display the user's prompt in chat |
|
(emacs-agent--display-user prompt) |
|
;; Start spinner and process asynchronously |
|
(emacs-agent--start-request-spinner) |
|
;; Return focus to original location |
|
(when (window-live-p return-window) |
|
(select-window return-window)) |
|
;; Start async processing |
|
(emacs-agent--agent-loop-async full-prompt)))))) |
|
|
|
;;;###autoload |
|
(defun emacs-agent-follow-up () |
|
"Send a follow-up message in the current conversation." |
|
(interactive) |
|
(unless emacs-agent--conversation |
|
(user-error "No active conversation — use M-x emacs-agent first")) |
|
(let ((source-buffer (current-buffer)) |
|
(source-window (selected-window))) |
|
(emacs-agent--open-prompt |
|
nil |
|
(lambda (prompt return-window return-buffer) |
|
;; Display the user's prompt in chat |
|
(emacs-agent--display-user prompt) |
|
;; Start spinner and process asynchronously |
|
(emacs-agent--start-request-spinner) |
|
;; Return focus to original location |
|
(when (window-live-p return-window) |
|
(select-window return-window)) |
|
;; Start async processing |
|
(emacs-agent--agent-loop-async prompt))))) |
|
|
|
;;;###autoload |
|
(defun emacs-agent-reset () |
|
"Reset the conversation history." |
|
(interactive) |
|
(setq emacs-agent--conversation nil) |
|
(message "Conversation reset.")) |
|
|
|
(provide 'emacs-agent) |
|
|
|
;;; emacs-agent.el ends here |