diff --git a/.gitignore b/.gitignore index 64782c6..1025706 100644 --- a/.gitignore +++ b/.gitignore @@ -12,3 +12,4 @@ test_input.txt /tmp/*.lisp *.fasl docs/#DESIGN_DECISIONS.org# docs/DESIGN_DECISIONS.org~ +extras/*.elc diff --git a/extras/passepartout.el b/extras/passepartout.el new file mode 100644 index 0000000..1a3b4bf --- /dev/null +++ b/extras/passepartout.el @@ -0,0 +1,214 @@ +;;; 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