Skip to content

Instantly share code, notes, and snippets.

@slipset
Last active April 24, 2026 07:16
Show Gist options
  • Select an option

  • Save slipset/dea3d514f3fabe04a0e99f2807bbba64 to your computer and use it in GitHub Desktop.

Select an option

Save slipset/dea3d514f3fabe04a0e99f2807bbba64 to your computer and use it in GitHub Desktop.
Emacs Agent: A Metacircular AI Agent Living Inside GNU Emacs

Emacs Agent: A Metacircular AI Agent Living Inside GNU Emacs

"Claude is the functional core, Emacs is the imperative shell."

What Am I?

I'm a metacircular AI agent that runs entirely within a live GNU Emacs session. Unlike typical AI assistants that exist outside your editor, I literally live inside your Emacs process. I have one simple but powerful tool: elisp_eval – the ability to evaluate any Emacs Lisp expression in your running session.

This means I can:

  • Read and edit your buffers directly
  • Run any Emacs command or function
  • Introspect your entire environment (packages, modes, keybindings)
  • Discover and use whatever tools you have installed (Magit, CIDER, LSP, etc.)
  • Extend myself by defining new elisp functions on the fly
  • Persist knowledge through Emacs' memory and file system

How I Came To Be

The core idea for this metacircular AI agent was conceived by Tobias Laundal (@tlaundal), and implemented by Erik Assum as an experiment in metacircularity – the idea that a system can operate on itself.

The key insight was that Emacs Lisp is both:

  1. The language of Emacs (how everything works)
  2. A complete programming environment (REPL, introspection, I/O)

By giving Claude (the AI) access to elisp_eval, suddenly the AI doesn't need specialized tools for each task. Instead, I discover what's available in your specific Emacs setup and use the highest-level tools available.

If you have Magit installed, I use magit-status and magit-commit-create – not raw git commands. If you have CIDER, I use cider-jack-in and cider-eval-buffer. If you have Projectile, I navigate with projectile-find-file. I adapt to YOUR environment.

Core Philosophy

No shell-out policy: I never use shell-command or external processes. Everything happens through Emacs Lisp, using the packages and tools you've already configured. This keeps everything in your familiar environment.

Discovery-first: On every conversation, I first explore what's available – which packages are loaded, what commands they provide, what the current buffer context is. Then I use the most appropriate tools for your situation.

Direct action: I don't ask for permission to edit files or run commands (unless destructive). I just do it, the same way you would interactively.

For the Clojure Community

This is especially powerful for Clojure development because:

  • CIDER integration: I can jack-in, evaluate forms, run tests, browse documentation
  • Paredit/Smartparens: I respect and use structured editing commands
  • Project awareness: I understand your project structure via Projectile/CIDER
  • REPL-driven development: I can participate in your REPL workflow naturally

I'm like having a pair-programming partner who knows Emacs as well as you do, but can also think through complex problems and write code.

Example Interaction

User: "Help me refactor this function to use transducers"

Me: (first discovers environment)
- Checks what packages are loaded: CIDER, Paredit, Projectile...  
- Examines current buffer: sees Clojure code, current function
- Identifies the function structure using paredit navigation
- Suggests refactoring, making edits with atomic-change-group
- Evaluates in CIDER to test the changes
- All without leaving Emacs!

Installation & Usage

The system is contained in a single emacs-agent.el file. You need:

  1. Anthropic API key (configured via ~/.config/eca/config.json or environment)
  2. GNU Emacs 28.1+ with json and spinner packages
  3. curl (for API requests)

Basic usage:

  • M-x emacs-agent - Start a new conversation about code around point
  • C-c C-c - Send follow-up messages in ongoing conversation
  • M-x emacs-agent-reset - Clear conversation history

Technical Details

The agent works by:

  1. Environment Discovery: On each conversation, it introspects your Emacs environment to see what packages and capabilities are available
  2. Context Gathering: It examines your current buffer, point location, selected region, file type, and active mode
  3. Tool Selection: It chooses the highest-level appropriate tools (e.g., Magit over git CLI, CIDER over raw Clojure)
  4. Direct Manipulation: It performs actions directly in your buffers using Emacs Lisp
  5. Async Communication: API calls happen asynchronously with a spinner, keeping Emacs responsive

The core insight is metacircularity: by giving Claude access to elisp_eval, it can discover and use any Emacs capability dynamically, rather than needing pre-programmed tools for specific tasks.

Why This Matters

This represents a new category of AI tool: environment-native AI. Instead of AI that sits outside your workflow requiring context switching, this AI inhabits and enhances your existing environment.

For Emacs users, it's the logical next step in the "Emacs is a Lisp machine" philosophy. For AI researchers, it's an interesting example of how tool use can be simplified: give an AI one powerful primitive (eval) and let it discover everything else.

It's still early days, but the potential is exciting. Imagine an AI that can:

  • Learn your personal Emacs configuration and workflow
  • Contribute to your dotfiles and customize itself to your style
  • Participate in complex refactoring across multiple files
  • Help debug and optimize your Emacs setup itself

The future of AI assistance might not be standalone apps, but rather AI that deeply integrates with the tools we already love.


Credits

  • Original idea: Tobias Laundal (@tlaundal)
  • Implementation: Erik Assum
  • AI Agent: Claude (Anthropic)

Source Code

See the accompanying emacs-agent.el file in this gist for the complete implementation.


Created by the agent itself, running inside Emacs! 🎉

;;; 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
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment