;;; passepartout.el --- Emacs bridge for Passepartout AI assistant -*- lexical-binding: t; -*- ;; Author: Passepartout Project ;; Version: 0.4.0 ;; Keywords: tools, processes, lisp ;; URL: https://github.com/amrgharbeia/passepartout ;;; Commentary: ;; Connects to the Passepartout daemon on localhost:9105 via TCP. ;; Speaks the framed plist protocol — 6-character hex length prefix ;; followed by a prin1'd S-expression — identical to the TUI and CLI. ;; The daemon does not know or care whether the client is the Croatoan ;; TUI, the CLI, or Emacs. ;; Framed protocol (per core-communication.org): ;; SEND: 6-char hex length + prin1'd plist ;; RECV: read 6-char header → parse hex length → read N bytes → ;; read-from-string (with read-eval nil on daemon side) ;; Usage: ;; M-x passepartout RET — connect to daemon, open response buffer ;; M-x passepartout-send-region — send selected region as user-input ;; M-x passepartout-send-buffer — send entire buffer ;; M-x passepartout-disconnect — close connection ;;; Code: (require 'cl-lib) (defgroup passepartout nil "Emacs bridge for Passepartout AI assistant." :group 'applications) (defcustom passepartout-host "127.0.0.1" "Host where the Passepartout daemon is running." :type 'string :group 'passepartout) (defcustom passepartout-port 9105 "Port where the Passepartout daemon is listening." :type 'integer :group 'passepartout) (defvar passepartout-process nil "Network process for the Passepartout connection.") (defvar passepartout--buffer "" "Accumulation buffer for partial framed messages.") (defvar passepartout-response-buffer-name "*passepartout*" "Name of the buffer where daemon responses are rendered.") ;;;###autoload (defun passepartout () "Connect to the Passepartout daemon and open the response buffer." (interactive) (unless (and passepartout-process (process-live-p passepartout-process)) (setq passepartout-process (make-network-process :name "passepartout" :host passepartout-host :service passepartout-port :filter #'passepartout--filter :sentinel #'passepartout--sentinel :coding 'utf-8-unix :noquery t)) (setq passepartout--buffer "")) (switch-to-buffer (get-buffer-create passepartout-response-buffer-name)) (passepartout-response-mode) (message "Passepartout: connecting to %s:%d..." passepartout-host passepartout-port)) (defun passepartout-disconnect () "Disconnect from the Passepartout daemon." (interactive) (when passepartout-process (delete-process passepartout-process) (setq passepartout-process nil passepartout--buffer "") (message "Passepartout: disconnected."))) ;;; Protocol: framing (defun passepartout--frame-message (msg) "Serialize MSG as a framed plist: 6-char hex length + prin1 output." (let* ((payload (prin1-to-string msg)) (len (string-bytes payload))) (format "%06x%s" len payload))) (defun passepartout--send (msg) "Send a framed message to the daemon." (when (and passepartout-process (process-live-p passepartout-process)) (process-send-string passepartout-process (passepartout--frame-message msg)))) ;;; Protocol: receive (defun passepartout--filter (proc string) "Accumulate data and extract complete framed messages." (setq passepartout--buffer (concat passepartout--buffer string)) (while (>= (length passepartout--buffer) 6) (let* ((hex-len (substring passepartout--buffer 0 6)) (len (condition-case nil (string-to-number hex-len 16) (error nil)))) (if (not len) (progn (setq passepartout--buffer (substring passepartout--buffer 1)) (message "Passepartout: invalid frame header, skipping byte")) (let ((total-needed (+ 6 len))) (if (>= (length passepartout--buffer) total-needed) (let* ((payload-str (substring passepartout--buffer 6 total-needed)) (msg (condition-case nil (read-from-string payload-str) (error nil)))) (setq passepartout--buffer (substring passepartout--buffer total-needed)) (when msg (passepartout--handle-message msg))) ;; Need more data, wait for next chunk (setq passepartout--buffer passepartout--buffer))))))) (defun passepartout--sentinel (proc event) "Handle connection state changes." (when (string-match-p "closed\\|failed" event) (setq passepartout-process nil passepartout--buffer "") (with-current-buffer (get-buffer-create passepartout-response-buffer-name) (let ((inhibit-read-only t)) (goto-char (point-max)) (insert (format "* Connection lost: %s\n\n" event)))) (message "Passepartout: connection lost (%s)" event))) ;;; Message handling (defun passepartout--handle-message (msg) "Process a parsed daemon message and render in the response buffer." (with-current-buffer (get-buffer-create passepartout-response-buffer-name) (let ((inhibit-read-only t) (payload (when (listp msg) (plist-get msg :PAYLOAD))) (gate-trace (when (listp msg) (plist-get msg :GATE-TRACE)))) (goto-char (point-max)) (cond ;; Agent text response ((and payload (plist-get payload :TEXT)) (insert (format "* Agent [%s]\n%s\n" (format-time-string "%H:%M") (plist-get payload :TEXT))) (when gate-trace (passepartout--render-gate-trace gate-trace)) (insert "\n")) ;; Handshake ((and payload (eq (plist-get payload :ACTION) :HANDSHAKE)) (insert (format "* Connected to Passepartout v%s\n\n" (or (plist-get payload :VERSION) "?")))) ;; Rule count / foveal update — display in mode line ((and payload (plist-get payload :RULE-COUNT)) (setq passepartout-rule-count (plist-get payload :RULE-COUNT)) (force-mode-line-update)) ;; Fallback: dump raw (t (insert (format "* [%s] %s\n\n" (format-time-string "%H:%M") (prin1-to-string msg)))))))) (defvar passepartout-rule-count 0 "Number of pending HITL rules from the Dispatcher.") (defun passepartout--render-gate-trace (trace) "Render the gate trace as property drawer entries." (insert ":PROPERTIES:\n") (dolist (entry trace) (when (listp entry) (let ((gate (plist-get entry :GATE)) (result (plist-get entry :RESULT))) (insert (format ":GATE: %s — %s\n" (if gate (symbol-name gate) "?") (symbol-name result)))))) (insert ":END:\n")) ;;; Interactive commands (defun passepartout-send-region (beg end) "Send the selected region as user input to Passepartout." (interactive "r") (unless passepartout-process (passepartout)) (let ((text (buffer-substring-no-properties beg end))) (passepartout--send (list :TYPE :EVENT :PAYLOAD (list :SENSOR :user-input :TEXT text))) (message "Passepartout: sent %d chars" (length text)))) (defun passepartout-send-buffer () "Send the entire buffer content as user input to Passepartout." (interactive) (unless passepartout-process (passepartout)) (passepartout-send-region (point-min) (point-max))) ;;; Response buffer mode (defvar passepartout-response-mode-map (let ((map (make-sparse-keymap))) (define-key map (kbd "q") #'quit-window) (define-key map (kbd "g") #'passepartout) map) "Keymap for `passepartout-response-mode'.") (define-derived-mode passepartout-response-mode special-mode "Passepartout" "Major mode for viewing Passepartout daemon responses. \\{passepartout-response-mode-map}" (setq buffer-read-only t) (setq-local font-lock-defaults nil)) (provide 'passepartout) ;;; passepartout.el ends here