;;; madrigal.el --- LLM CRDT-native editing orchestration -*- lexical-binding: t; -*-
;; Copyright (C) 2026
;; Author: madrigal contributors
;; Keywords: tools, convenience, ai
;; Version: 0.1.0
;; Package-Requires: ((emacs "29.1") (llm "0") (crdt "0"))
;;; Commentary:
;; madrigal provides an LLM-driven editing command that routes all edits through
;; CRDT replicas. It auto-manages:
;; - one local CRDT server session used by madrigal
;; - one local CRDT client per logical agent
;; - tool execution in the agent replica, so CRDT propagates edits consistently
;;; Code:
(require 'cl-lib)
(require 'subr-x)
(require 'llm)
(require 'crdt)
(declare-function crdt--share-buffer "crdt" (buffer &optional session network-name))
(declare-function crdt--with-buffer-name-pull "crdt" (spec &rest body))
(defgroup madrigal nil
"LLM-driven CRDT editing."
:group 'tools
:prefix "madrigal-")
(defcustom madrigal-llm-provider nil
"LLM provider object used for madrigal requests.
Set this to an `llm' provider instance, for example from
`make-llm-openai-compatible'."
:type 'sexp)
(defcustom madrigal-context-lines 80
"Number of lines around point to include in context."
:type 'integer)
(defcustom madrigal-default-agent "default"
"Default logical agent ID used by `madrigal-edit'."
:type 'string)
(defcustom madrigal-max-tool-rounds 12
"Maximum model/tool continuation rounds per request."
:type 'integer)
(defcustom madrigal-session-port-base 6530
"Initial port used when creating the madrigal CRDT session."
:type 'integer)
(defcustom madrigal-session-port-span 64
"Number of ports to try when creating the madrigal CRDT session."
:type 'integer)
(defvar madrigal--server-session nil)
(defvar madrigal--session-port nil)
(defvar madrigal--agents (make-hash-table :test #'equal))
(cl-defstruct madrigal--agent
id
display-name
session)
(defun madrigal--live-session-p (session)
(and session
(memq session crdt--session-list)
(let ((proc (crdt--session-network-process session)))
(and proc (process-live-p proc)))))
(defun madrigal--ensure-server-session ()
(if (madrigal--live-session-p madrigal--server-session)
madrigal--server-session
(let ((created nil))
(cl-loop for port from madrigal-session-port-base
below (+ madrigal-session-port-base madrigal-session-port-span)
until created
do (condition-case nil
(let ((crdt-use-tuntox nil)
(crdt-use-stunnel nil))
(setq created
(crdt-new-session
port
nil
nil
"madrigal-server"
crdt-default-session-permissions))
(setq madrigal--session-port port))
(error nil)))
(unless created
(error "madrigal: failed to create CRDT session; checked ports %d-%d"
madrigal-session-port-base
(+ madrigal-session-port-base madrigal-session-port-span -1)))
(setq madrigal--server-session created)
created)))
(defun madrigal--agent-url ()
(format "ein://127.0.0.1:%d" madrigal--session-port))
(defun madrigal--ensure-agent (agent-id)
(let ((agent (gethash agent-id madrigal--agents)))
(if (and agent (madrigal--live-session-p (madrigal--agent-session agent)))
agent
(let* ((server (madrigal--ensure-server-session))
(_ server)
(display-name (format "madrigal-%s" agent-id))
(session (cl-letf (((symbol-function #'crdt-list-buffers)
(lambda (&optional _session) nil)))
(crdt-connect (madrigal--agent-url) display-name)))
(new-agent (make-madrigal--agent
:id agent-id
:display-name display-name
:session session)))
(puthash agent-id new-agent madrigal--agents)
new-agent))))
(defun madrigal--buffer-network-name (buffer)
(with-current-buffer buffer
crdt--buffer-network-name))
(defun madrigal--ensure-shared-in-server (buffer)
(let ((server (madrigal--ensure-server-session)))
(with-current-buffer buffer
(cond
((and crdt-mode (eq crdt--session server))
buffer)
(crdt-mode
(error "madrigal: buffer already belongs to a different CRDT session"))
(t
(crdt--share-buffer
buffer
server
(or (and buffer-file-name
(file-relative-name buffer-file-name default-directory))
(buffer-name buffer))))))))
(defun madrigal--server-buffer-from-network-name (network-name)
(gethash network-name (crdt--session-buffer-table madrigal--server-session)))
(defun madrigal--ensure-agent-replica (agent network-name)
(let* ((session (madrigal--agent-session agent))
(existing (gethash network-name (crdt--session-buffer-table session))))
(if (buffer-live-p existing)
existing
(let ((crdt--session session)
(replica nil))
(crdt--with-buffer-name-pull (network-name :sync t)
(setq replica (current-buffer)))
(unless (buffer-live-p replica)
(error "madrigal: failed to open agent replica for %s" network-name))
replica))))
(defun madrigal--context-window ()
(save-excursion
(let ((line (line-number-at-pos))
(start nil)
(end nil))
(forward-line (- madrigal-context-lines))
(setq start (line-beginning-position))
(goto-char (point))
(forward-line (* 2 madrigal-context-lines))
(setq end (line-end-position))
(list :line line
:point (point)
:start start
:end end
:text (buffer-substring-no-properties start end)))))
(defun madrigal--system-prompt ()
(string-join
(list
"You are an autonomous Emacs editing agent."
"When you need to edit files or buffers, call the tool exec_ops."
"You may chain many operations in one call."
"Prefer one exec_ops call containing a full sequence over multiple calls."
"Use exact buffer/file paths and positions."
"All edits happen in CRDT-managed replicas; avoid speculative rewrites."
"Return concise text once edits are done.")
"\n"))
(defun madrigal--build-user-prompt (user-text)
(let* ((ctx (madrigal--context-window))
(mode (symbol-name major-mode))
(path (or buffer-file-name "(no file)"))
(bname (buffer-name)))
(format
"%s\n\nBuffer: %s\nPath: %s\nMode: %s\nPoint(line): %s\nContext-window:\n%s"
user-text
bname
path
mode
(plist-get ctx :line)
(plist-get ctx :text))))
(defun madrigal--alist-get* (key obj)
(cond
((and (listp obj) (keywordp key))
(or (alist-get (intern (substring (symbol-name key) 1)) obj)
(alist-get (substring (symbol-name key) 1) obj)
(plist-get obj key)))
((listp obj)
(or (alist-get key obj)
(alist-get (if (symbolp key) (symbol-name key) key) obj)
(and (keywordp key) (plist-get obj key))))
(t nil)))
(defun madrigal--as-list (v)
(cond
((vectorp v) (append v nil))
((listp v) v)
(t nil)))
(defun madrigal--resolve-ref (value results)
(if (and (stringp value)
(string-match "^\\$\\([0-9]+\\)\\.\\([A-Za-z0-9_-]+\\)$" value))
(let* ((idx (1- (string-to-number (match-string 1 value))))
(field (intern (match-string 2 value)))
(step (nth idx results)))
(or (madrigal--alist-get* field step)
(madrigal--alist-get* (intern (format ":%s" field)) step)
value))
value))
(defun madrigal--require-integer (value field)
(unless (integerp value)
(error "madrigal: %s must be integer" field))
value)
(defun madrigal--bounded-pos (buffer pos)
(with-current-buffer buffer
(min (max (point-min) pos) (point-max))))
(defun madrigal--tool-exec-ops (agent-id ops)
(let* ((agent (madrigal--ensure-agent agent-id))
(target (madrigal--ensure-shared-in-server (current-buffer)))
(network-name (madrigal--buffer-network-name target))
(replica (madrigal--ensure-agent-replica agent network-name))
(results nil)
(state `((agent . ,agent-id)
(network_name . ,network-name)
(buffer . ,(buffer-name replica)))))
(dolist (raw-op (madrigal--as-list ops))
(let* ((op (or (madrigal--alist-get* 'op raw-op)
(madrigal--alist-get* :op raw-op)))
(op-name (if (symbolp op) (symbol-name op) op))
(row nil))
(unless (stringp op-name)
(error "madrigal: each op needs string field 'op'"))
(setq row (list (cons 'op op-name)))
(pcase op-name
("open_file"
(let* ((path0 (or (madrigal--alist-get* 'path raw-op)
(madrigal--alist-get* :path raw-op)))
(create0 (or (madrigal--alist-get* 'create raw-op)
(madrigal--alist-get* :create raw-op)))
(path (expand-file-name (madrigal--resolve-ref path0 results)))
(createp (and create0 (not (eq create0 :false)))))
(unless (or (file-exists-p path) createp)
(error "madrigal: file does not exist: %s" path))
(unless (file-exists-p path)
(with-temp-buffer (write-region "" nil path nil 0)))
(let ((buf (find-file-noselect path)))
(madrigal--ensure-shared-in-server buf)
(setq network-name (madrigal--buffer-network-name buf))
(setq replica (madrigal--ensure-agent-replica agent network-name))
(setq row (append row
(list (cons 'path path)
(cons 'network_name network-name)
(cons 'buffer (buffer-name replica))))))))
("list_buffers"
(let (names)
(maphash (lambda (k _v) (push k names))
(crdt--session-buffer-table madrigal--server-session))
(setq row (append row (list (cons 'buffers (nreverse names)))))))
("switch_buffer"
(let* ((name0 (or (madrigal--alist-get* 'network_name raw-op)
(madrigal--alist-get* :network_name raw-op)
(madrigal--alist-get* 'buffer raw-op)
(madrigal--alist-get* :buffer raw-op)))
(name (madrigal--resolve-ref name0 results)))
(setq replica (madrigal--ensure-agent-replica agent name))
(setq network-name name)
(setq row (append row
(list (cons 'network_name network-name)
(cons 'buffer (buffer-name replica)))))))
("get_point"
(setq row (append row
(list (cons 'point
(with-current-buffer replica (point)))))))
("read_region"
(let* ((start0 (or (madrigal--alist-get* 'start raw-op)
(madrigal--alist-get* :start raw-op)))
(end0 (or (madrigal--alist-get* 'end raw-op)
(madrigal--alist-get* :end raw-op)))
(start (madrigal--require-integer
(madrigal--resolve-ref start0 results) "start"))
(end (madrigal--require-integer
(madrigal--resolve-ref end0 results) "end")))
(with-current-buffer replica
(let ((s (madrigal--bounded-pos replica start))
(e (madrigal--bounded-pos replica end)))
(setq row (append row
(list (cons 'text
(buffer-substring-no-properties
(min s e)
(max s e))))))))))
("insert_at"
(let* ((pos0 (or (madrigal--alist-get* 'pos raw-op)
(madrigal--alist-get* :pos raw-op)))
(text0 (or (madrigal--alist-get* 'text raw-op)
(madrigal--alist-get* :text raw-op)))
(pos (madrigal--require-integer
(madrigal--resolve-ref pos0 results) "pos"))
(text (format "%s" (madrigal--resolve-ref text0 results))))
(with-current-buffer replica
(goto-char (madrigal--bounded-pos replica pos))
(insert text)
(setq row (append row
(list (cons 'point (point))
(cons 'inserted (length text))))))))
("delete_range"
(let* ((start0 (or (madrigal--alist-get* 'start raw-op)
(madrigal--alist-get* :start raw-op)))
(end0 (or (madrigal--alist-get* 'end raw-op)
(madrigal--alist-get* :end raw-op)))
(start (madrigal--require-integer
(madrigal--resolve-ref start0 results) "start"))
(end (madrigal--require-integer
(madrigal--resolve-ref end0 results) "end")))
(with-current-buffer replica
(let ((s (madrigal--bounded-pos replica start))
(e (madrigal--bounded-pos replica end)))
(delete-region (min s e) (max s e))
(setq row (append row (list (cons 'point (point)))))))))
("replace_range"
(let* ((start0 (or (madrigal--alist-get* 'start raw-op)
(madrigal--alist-get* :start raw-op)))
(end0 (or (madrigal--alist-get* 'end raw-op)
(madrigal--alist-get* :end raw-op)))
(text0 (or (madrigal--alist-get* 'text raw-op)
(madrigal--alist-get* :text raw-op)))
(start (madrigal--require-integer
(madrigal--resolve-ref start0 results) "start"))
(end (madrigal--require-integer
(madrigal--resolve-ref end0 results) "end"))
(text (format "%s" (madrigal--resolve-ref text0 results))))
(with-current-buffer replica
(let ((s (madrigal--bounded-pos replica start))
(e (madrigal--bounded-pos replica end)))
(delete-region (min s e) (max s e))
(goto-char (min s e))
(insert text)
(setq row (append row
(list (cons 'point (point))
(cons 'inserted (length text)))))))))
("save_buffer"
(let ((server-buffer (madrigal--server-buffer-from-network-name network-name)))
(unless (buffer-live-p server-buffer)
(error "madrigal: no server buffer for %s" network-name))
(with-current-buffer server-buffer
(when (and buffer-file-name (buffer-modified-p))
(save-buffer))
(setq row (append row
(list (cons 'saved (and buffer-file-name t))
(cons 'path buffer-file-name)))))))
(_
(error "madrigal: unknown op %s" op-name)))
(push row results)))
(list :state state
:results (nreverse results))))
(defun madrigal--exec-ops-tool (agent-id)
(llm-make-tool
:name "exec_ops"
:description
(concat
"Execute a sequence of CRDT editing operations in the agent replica. "
"Operations: open_file, list_buffers, switch_buffer, get_point, read_region, "
"insert_at, delete_range, replace_range, save_buffer. "
"Supports references like $1.point to consume previous step outputs.")
:args
(list
'(:name "ops"
:type array
:description "Array of operation objects."))
:function (lambda (ops)
(madrigal--tool-exec-ops agent-id ops))))
(defun madrigal--provider-capable-p (capability)
(memq capability (llm-capabilities madrigal-llm-provider)))
(defun madrigal--run-model-loop (provider prompt rounds-left done)
(if (<= rounds-left 0)
(funcall done (list :status 'error :message "madrigal: max rounds reached"))
(let* ((streaming-tool-use (madrigal--provider-capable-p 'streaming-tool-use))
(streaming (madrigal--provider-capable-p 'streaming))
(use-streaming (and streaming streaming-tool-use)))
(if use-streaming
(llm-chat-streaming
provider prompt
(lambda (partial)
(let ((txt (plist-get partial :text)))
(when (and txt (> (length txt) 0))
(message "madrigal: %s" (truncate-string-to-width txt 120 nil nil t)))))
(lambda (final)
(if (plist-get final :tool-results)
(madrigal--run-model-loop provider prompt (1- rounds-left) done)
(funcall done (list :status 'ok :result final))))
(lambda (etype emsg)
(funcall done (list :status 'error :etype etype :message emsg)))
t)
(llm-chat-async
provider prompt
(lambda (final)
(if (plist-get final :tool-results)
(madrigal--run-model-loop provider prompt (1- rounds-left) done)
(funcall done (list :status 'ok :result final :fallback 'non-streaming-tool-use))))
(lambda (etype emsg)
(funcall done (list :status 'error :etype etype :message emsg)))
t)))))
;;;###autoload
(defun madrigal-edit (agent-id prompt-text)
"Send PROMPT-TEXT and current buffer context to LLM agent AGENT-ID.
The model edits via CRDT-safe tools and each AGENT-ID has its own local CRDT
client session managed by madrigal."
(interactive
(list
(read-string (format "Agent (%s): " madrigal-default-agent)
nil nil madrigal-default-agent)
(read-string "Prompt: ")))
(unless madrigal-llm-provider
(user-error "madrigal: set `madrigal-llm-provider' first"))
(madrigal--ensure-server-session)
(let* ((_agent (madrigal--ensure-agent agent-id))
(_shared (madrigal--ensure-shared-in-server (current-buffer)))
(prompt (llm-make-chat-prompt
(madrigal--build-user-prompt prompt-text)
:context (madrigal--system-prompt)
:tools (list (madrigal--exec-ops-tool agent-id)))))
(madrigal--run-model-loop
madrigal-llm-provider
prompt
madrigal-max-tool-rounds
(lambda (status)
(pcase (plist-get status :status)
('ok
(let* ((res (plist-get status :result))
(text (or (plist-get res :text) ""))
(fallback (plist-get status :fallback)))
(if fallback
(message "madrigal: done (fallback %s). %s" fallback text)
(message "madrigal: done. %s" text))))
(_
(message "madrigal: error (%s) %s"
(or (plist-get status :etype) 'error)
(or (plist-get status :message) "unknown"))))))))
(provide 'madrigal)
;;; madrigal.el ends here