814 lines
31 KiB
EmacsLisp
814 lines
31 KiB
EmacsLisp
;;; ellie.el --- Emacs client for ollie-9p -*- lexical-binding: t -*-
|
|
|
|
;; Version: 0.3.0
|
|
;; Package-Requires: ((emacs "27.1"))
|
|
;; Keywords: tools, ai, chat
|
|
|
|
;;; Commentary:
|
|
;;
|
|
;; ellie.el talks to an ollie agent session through the ollie-9p CLI tool.
|
|
;; Sessions and agents are exposed as a collapsible tree (like dired for the
|
|
;; 9P namespace). Create sessions by writing to session/new, create agents
|
|
;; by writing to session/{sname}/agent/new — both are regular files.
|
|
;;
|
|
;; Quick start:
|
|
;;
|
|
;; (require 'ellie)
|
|
;; M-x ellie ; opens session tree, attaches to last session
|
|
;;
|
|
;; In the *ellie* buffer (tree view):
|
|
;;
|
|
;; RET / E expand/collapse tree, or select session/agent
|
|
;; C-c C-n new session
|
|
;; C-c C-a new agent in current session
|
|
;; C-c C-c send prompt to selected agent
|
|
;; C-c C-q queue prompt for selected agent
|
|
;; C-c C-s stop running agent
|
|
;; C-c C-k kill session or agent at point
|
|
;; C-c C-p show system prompt
|
|
;; g refresh tree
|
|
;; q quit
|
|
|
|
;;; Code:
|
|
|
|
(require 'ansi-color)
|
|
(require 'markdown-mode nil t)
|
|
|
|
;;;; ──────────────── Customization ────────────────
|
|
|
|
(defgroup ellie nil
|
|
"Emacs client for ollie-9p."
|
|
:group 'tools
|
|
:prefix "ellie-")
|
|
|
|
(defcustom ellie-default-session-opts nil
|
|
"Default options passed when creating a new session.
|
|
An alist of (KEY . VALUE) string pairs; valid keys are
|
|
\"backend\", \"model\", \"agent\", and \"cwd\"."
|
|
:type '(alist :key-type string :value-type string)
|
|
:group 'ellie)
|
|
|
|
(defcustom ellie-input-window-height 8
|
|
"Height in lines of the prompt input window."
|
|
:type 'integer
|
|
:group 'ellie)
|
|
|
|
;;;; ──────────────── Internal state ────────────────
|
|
|
|
(defvar ellie--session-id nil
|
|
"Name of the currently active session.")
|
|
(defvar ellie--agent-id nil
|
|
"Name of the currently active agent.")
|
|
(defvar ellie--chat-stream nil
|
|
"Process object for the chat stream, or nil.")
|
|
(defvar ellie--tree-cache nil
|
|
"Parsed tree data from session/idx: ((paused name id agents...) ...)")
|
|
|
|
(defconst ellie--buf "*ellie*" "Name of the chat display buffer.")
|
|
(defconst ellie--ibuf "*ellie-input*" "Name of the prompt input buffer.")
|
|
(defconst ellie--tbuf "*ellie-sessions*" "Name of the session tree buffer.")
|
|
|
|
;;;; ──────────────── 9P helpers ────────────────
|
|
|
|
(defun ellie--9p-read (path)
|
|
"Read PATH via ollie-9p and return contents as a string.
|
|
Returns nil on any error."
|
|
(condition-case nil
|
|
(with-temp-buffer
|
|
(if (= (call-process "ollie-9p" nil t nil "read" path) 0)
|
|
(buffer-string)
|
|
nil))
|
|
(error nil)))
|
|
|
|
(defun ellie--9p-write (path text)
|
|
"Write TEXT to PATH via ollie-9p."
|
|
(with-temp-buffer
|
|
(insert text)
|
|
(let ((rc (call-process-region (point-min) (point-max)
|
|
"ollie-9p" nil nil nil "write" path)))
|
|
(unless (zerop rc)
|
|
(user-error "ollie-9p write %s failed (exit %d)" path rc)))))
|
|
|
|
(defun ellie--9p-rdwr (path text)
|
|
"Write TEXT to PATH via ollie-9p rdwr and return the response.
|
|
Returns nil on any error."
|
|
(condition-case nil
|
|
(with-temp-buffer
|
|
(insert text)
|
|
(if (= (call-process-region (point-min) (point-max)
|
|
"ollie-9p" t t nil "rdwr" path) 0)
|
|
(buffer-string)
|
|
nil))
|
|
(error nil)))
|
|
|
|
(defun ellie--9p-ls (path)
|
|
"Return list of entry names under PATH via ollie-9p ls.
|
|
Returns nil on error."
|
|
(condition-case nil
|
|
(with-temp-buffer
|
|
(when (= (call-process "ollie-9p" nil t nil "ls" path) 0)
|
|
(let ((lines (split-string (buffer-string) "\n" t)))
|
|
(seq-remove (lambda (s) (string-prefix-p "." s)) lines))))
|
|
(error nil)))
|
|
|
|
(defun ellie--9p-exists-p (path)
|
|
"Return non-nil if PATH exists in the 9P namespace."
|
|
(= (call-process "ollie-9p" nil nil nil "stat" path) 0))
|
|
|
|
;;;; ──────────────── Session/agent paths ────────────────
|
|
|
|
(defun ellie--session-path (&optional sid)
|
|
"Return the 9P path for the session directory.
|
|
Defaults to `ellie--session-id' if SID is nil."
|
|
(format "session/%s" (or sid ellie--session-id
|
|
(user-error "No active ollie session"))))
|
|
|
|
(defun ellie--agent-path (&optional sid aid)
|
|
"Return the 9P path for the agent directory.
|
|
Defaults to current session/agent. If AID is nil, discovers
|
|
the first agent in the session."
|
|
(let* ((s (or sid ellie--session-id (user-error "No session")))
|
|
(a (or aid ellie--agent-id)))
|
|
(if a
|
|
(format "session/%s/agent/%s" s a)
|
|
;; Auto-discover first agent
|
|
(let ((agents (ellie--agents-for s)))
|
|
(if agents
|
|
(let ((aid2 (nth 1 (car agents)))) ; agent ID is the second field
|
|
(when (and (not ellie--agent-id) (string= s ellie--session-id))
|
|
(setq ellie--agent-id aid2))
|
|
(format "session/%s/agent/%s" s aid2))
|
|
(user-error "No agents in session %s" s))))))
|
|
|
|
(defun ellie--session-file (name &optional sid aid)
|
|
"Return the 9P path to file NAME for the given session/agent."
|
|
(format "%s/%s" (ellie--agent-path sid aid) name))
|
|
|
|
(defun ellie--last-session-path ()
|
|
(expand-file-name "~/.config/ollie/last-session"))
|
|
|
|
;;;; ──────────────── Session index parsing ────────────────
|
|
|
|
(defun ellie--parse-session-idx ()
|
|
"Parse session/idx into a list: ((paused name id agents...) ...)
|
|
where agents is (name id state ...)."
|
|
(let ((raw (ellie--9p-read "session/idx"))
|
|
result)
|
|
(when raw
|
|
(dolist (line (split-string raw "\n" t))
|
|
(let* ((parts (split-string line "\t"))
|
|
(name (nth 0 parts))
|
|
(id (nth 1 parts))
|
|
(paused (string= (nth 2 parts) "true"))
|
|
(agents-str (nth 7 parts))
|
|
(agents nil))
|
|
(when agents-str
|
|
(dolist (a (split-string agents-str ";"))
|
|
(let ((aparts (split-string a ":")))
|
|
(when (>= (length aparts) 3)
|
|
(push (list (nth 0 aparts) (nth 1 aparts) (nth 2 aparts)) agents)))))
|
|
(push (list paused name id (nreverse agents)) result))))
|
|
(setq ellie--tree-cache (nreverse result))))
|
|
|
|
(defun ellie--sessions-by-paused ()
|
|
"Return (ACTIVE INACTIVE) lists of (paused name id agents...)."
|
|
(let* ((parsed (ellie--parse-session-idx))
|
|
(active (seq-filter (lambda (e) (not (car e))) parsed))
|
|
(inactive (seq-filter #'car parsed)))
|
|
(list active inactive)))
|
|
|
|
(defun ellie--agents-for (sid)
|
|
"Return agent list (name id state) for session SID from cache."
|
|
(let ((entry (seq-find (lambda (entry)
|
|
(string= (nth 2 entry) sid))
|
|
ellie--tree-cache)))
|
|
(if entry (nth 3 entry)
|
|
;; Fallback: refresh and try again
|
|
(ellie--parse-session-idx)
|
|
(let ((entry2 (seq-find (lambda (entry)
|
|
(string= (nth 2 entry) sid))
|
|
ellie--tree-cache)))
|
|
(if entry2 (nth 3 entry2) nil)))))
|
|
|
|
;;;; ──────────────── Last-session persistence ────────────────
|
|
|
|
(defun ellie--persist-session (sid)
|
|
"Persist the current session/agent for `default-directory'."
|
|
(let* ((path (ellie--last-session-path))
|
|
(cwd (expand-file-name default-directory))
|
|
(raw (and (file-exists-p path)
|
|
(with-temp-buffer
|
|
(insert-file-contents path)
|
|
(buffer-string))))
|
|
(lines (when raw
|
|
(seq-filter (lambda (l) (not (string-empty-p (string-trim l))))
|
|
(split-string raw "\n"))))
|
|
(entry (concat cwd "\t" sid "\t" (or ellie--agent-id "")))
|
|
(found nil)
|
|
(new-lines
|
|
(mapcar (lambda (l)
|
|
(let ((parts (split-string l "\t" nil nil)))
|
|
(if (and (>= (length parts) 2) (string= (car parts) cwd))
|
|
(progn (setq found t) entry)
|
|
l)))
|
|
lines)))
|
|
(make-directory (file-name-directory path) t)
|
|
(with-temp-buffer
|
|
(insert (concat (mapconcat #'identity
|
|
(if found new-lines (append new-lines (list entry)))
|
|
"\n")
|
|
"\n"))
|
|
(write-region (point-min) (point-max) path nil 'silent))))
|
|
|
|
(defun ellie--recall-session ()
|
|
"Return (SESSION-ID AGENT-ID) for `default-directory' if it still exists."
|
|
(let* ((path (ellie--last-session-path))
|
|
(cwd (expand-file-name default-directory))
|
|
(raw (and (file-exists-p path)
|
|
(with-temp-buffer
|
|
(insert-file-contents path)
|
|
(buffer-string)))))
|
|
(when raw
|
|
(catch 'found
|
|
(dolist (line (split-string raw "\n"))
|
|
(let ((parts (split-string line "\t" nil nil)))
|
|
(when (and (>= (length parts) 2)
|
|
(string= (car parts) cwd))
|
|
(let ((sid (string-trim (nth 1 parts)))
|
|
(aid (and (>= (length parts) 3) (string-trim (nth 2 parts)))))
|
|
(when (and (not (string-empty-p sid))
|
|
(ellie--9p-exists-p (format "session/%s" sid)))
|
|
(throw 'found (list sid aid)))))))))))
|
|
|
|
;;;; ──────────────── Session lifecycle ────────────────
|
|
|
|
(defun ellie-new-session (&optional opts)
|
|
"Create a new ollie session and make it current.
|
|
OPTS is an alist of option pairs; defaults to `ellie-default-session-opts'.
|
|
Returns the new session name."
|
|
(interactive)
|
|
(let* ((effective-opts (or opts ellie-default-session-opts))
|
|
(effective-opts (if (assoc "cwd" effective-opts)
|
|
effective-opts
|
|
(cons (cons "cwd" default-directory) effective-opts)))
|
|
(before (ellie--9p-ls "session"))
|
|
(seen (make-hash-table :test 'equal))
|
|
(cmd (mapconcat (lambda (kv)
|
|
(format "%s=%s" (car kv) (cdr kv)))
|
|
effective-opts " ")))
|
|
(dolist (id before) (puthash id t seen))
|
|
(ellie--9p-write "session/new" (concat cmd "\n"))
|
|
(let ((deadline (+ (float-time) 5.0))
|
|
result)
|
|
(while (and (not result) (< (float-time) deadline))
|
|
(dolist (id (ellie--9p-ls "session"))
|
|
(unless (gethash id seen) (setq result id)))
|
|
(unless result (sleep-for 0.05)))
|
|
(unless result
|
|
(user-error "Timed out waiting for a new ollie session"))
|
|
(setq ellie--session-id result
|
|
ellie--agent-id nil)
|
|
(ellie--persist-session result)
|
|
(message "Created ollie session %s" result)
|
|
result)))
|
|
|
|
(defun ellie-new-agent (&optional sid)
|
|
"Create a new agent in session SID (defaults to current).
|
|
Prompts for agent profile and other options."
|
|
(interactive)
|
|
(let* ((s (or sid ellie--session-id (user-error "No session")))
|
|
(profiles (ellie--9p-ls "agents"))
|
|
(agent (if profiles (completing-read "Agent profile: " profiles nil t) "default"))
|
|
(cwd (read-directory-name "Working directory: " default-directory))
|
|
(cmd (format "agent=%s cwd=%s" agent cwd)))
|
|
(ellie--9p-write (format "session/%s/agent/new" s) (concat cmd "\n"))
|
|
(ellie--parse-session-idx)
|
|
(message "Created agent in session %s" s)))
|
|
|
|
(defun ellie-attach (sid &optional aid)
|
|
"Attach to session SID and optionally agent AID."
|
|
(interactive
|
|
(let ((ids (ellie--9p-ls "session")))
|
|
(unless ids (user-error "No ollie sessions available"))
|
|
(list (completing-read "Attach to session: " ids nil t))))
|
|
(unless (ellie--9p-exists-p (format "session/%s" sid))
|
|
(user-error "Session not found: %s" sid))
|
|
(setq ellie--session-id sid
|
|
ellie--agent-id aid)
|
|
(ellie--persist-session sid)
|
|
(message "Attached to ollie session %s (agent %s)" sid (or aid "auto")))
|
|
|
|
(defun ellie-kill-session (&optional sid)
|
|
"Kill the session at point or current session."
|
|
(interactive)
|
|
(let* ((s (or sid ellie--session-id (user-error "No session"))))
|
|
(when (yes-or-no-p (format "Kill ollie session %s? " s))
|
|
(call-process "ollie-9p" nil nil nil "write"
|
|
(format "session/%s/ctl" s) "kill\n")
|
|
(when (string= s ellie--session-id)
|
|
(setq ellie--session-id nil
|
|
ellie--agent-id nil))
|
|
(ellie--parse-session-idx)
|
|
(message "Session %s killed" s))))
|
|
|
|
(defun ellie-kill-agent (&optional sid aid)
|
|
"Kill agent AID in session SID."
|
|
(interactive)
|
|
(let* ((s (or sid ellie--session-id (user-error "No session")))
|
|
(a (or aid ellie--agent-id (user-error "No agent"))))
|
|
(when (yes-or-no-p (format "Kill agent %s in session %s? " a s))
|
|
(call-process "ollie-9p" nil nil nil "write"
|
|
(format "session/%s/agent/%s/ctl" s a) "kill\n")
|
|
(when (and (string= s ellie--session-id) (string= a ellie--agent-id))
|
|
(setq ellie--agent-id nil))
|
|
(ellie--parse-session-idx)
|
|
(message "Agent %s killed" a))))
|
|
|
|
(defun ellie-rename-session (new-name)
|
|
"Rename the current ollie session to NEW-NAME."
|
|
(interactive "sNew session name: ")
|
|
(unless ellie--session-id (user-error "No active session"))
|
|
(when (string-empty-p (string-trim new-name))
|
|
(user-error "Empty name"))
|
|
(ellie--9p-write (format "session/%s/name" ellie--session-id)
|
|
(concat new-name "\n"))
|
|
(setq ellie--session-id new-name
|
|
ellie--agent-id nil)
|
|
(ellie--persist-session new-name)
|
|
(force-mode-line-update)
|
|
(message "Renamed session to %s" new-name))
|
|
|
|
;;;; ──────────────── State ────────────────
|
|
|
|
(defun ellie--state ()
|
|
"Return the current agent state string."
|
|
(condition-case nil
|
|
(let ((s (ellie--9p-read (ellie--session-file "state"))))
|
|
(if s (string-trim s) "unknown"))
|
|
(error "unknown")))
|
|
|
|
(defun ellie--idle-p ()
|
|
"Return non-nil if the current agent is idle."
|
|
(ignore-errors (string= (ellie--state) "idle")))
|
|
|
|
;;;; ──────────────── Agent operations ────────────────
|
|
|
|
(defun ellie-submit (text &optional sid aid)
|
|
"Submit TEXT as a prompt to session SID agent AID."
|
|
(when (string-empty-p (string-trim text))
|
|
(user-error "Empty prompt"))
|
|
(ellie--9p-write (ellie--session-file "prompt" sid aid) text))
|
|
|
|
(defun ellie-queue (text &optional sid aid)
|
|
"Enqueue TEXT as a prompt to run after the current turn."
|
|
(when (string-empty-p (string-trim text))
|
|
(user-error "Empty prompt"))
|
|
(ellie--9p-write (ellie--session-file "fifo" sid aid) text))
|
|
|
|
(defun ellie-stop ()
|
|
"Send a stop signal to the current turn."
|
|
(interactive)
|
|
(ellie--9p-write (ellie--session-file "ctl") "stop\n")
|
|
(message "ollie: stopped"))
|
|
|
|
;;;; ──────────────── Session tree mode ────────────────
|
|
|
|
(defvar ellie-tree-mode-map
|
|
(let ((m (make-sparse-keymap)))
|
|
(define-key m (kbd "RET") #'ellie-tree-activate)
|
|
(define-key m (kbd "e") #'ellie-tree-toggle)
|
|
(define-key m (kbd "n") #'ellie-tree-new-session)
|
|
(define-key m (kbd "N") #'ellie-tree-new-agent)
|
|
(define-key m (kbd "c") #'ellie-tree-chat)
|
|
(define-key m (kbd "C-c C-s") #'ellie-stop)
|
|
(define-key m (kbd "C-c C-k") #'ellie-tree-kill)
|
|
(define-key m (kbd "C-c C-c") #'ellie-tree-send-input)
|
|
(define-key m (kbd "C-c C-q") #'ellie-tree-queue-input)
|
|
(define-key m (kbd "C-c C-p") #'ellie-tree-system-prompt)
|
|
(define-key m (kbd "g") #'ellie-tree-refresh)
|
|
(define-key m (kbd "q") #'kill-buffer-and-window)
|
|
m)
|
|
"Keymap for `ellie-tree-mode'.")
|
|
|
|
(defvar-local ellie--tree-expanded nil
|
|
"Set of expanded keys (session IDs or header names).")
|
|
(defvar-local ellie--tree-data nil
|
|
"Current tree data for rendering.")
|
|
|
|
(defun ellie--tree-build-data ()
|
|
"Build tree data: ((type key label extras...) ...)."
|
|
(let* ((sessions (ellie--parse-session-idx))
|
|
(active (seq-filter (lambda (s) (not (car s))) sessions))
|
|
(inactive (seq-filter #'car sessions))
|
|
result)
|
|
;; First build: expand active header and all sessions
|
|
(when (null ellie--tree-expanded)
|
|
(setq ellie--tree-expanded '("active"))
|
|
(dolist (s sessions)
|
|
(push (nth 2 s) ellie--tree-expanded)))
|
|
(push (list 'header "active" nil "Active") result)
|
|
(when (and active (member "active" ellie--tree-expanded))
|
|
(dolist (s active)
|
|
(let ((name (nth 1 s)) (id (nth 2 s)) (agents (nth 3 s)))
|
|
(push (list 'session id name
|
|
(if agents (format "%s (%d)" name (length agents)) name))
|
|
result)
|
|
(when (and agents (member id ellie--tree-expanded))
|
|
(dolist (a agents)
|
|
(let ((aname (car a)) (aid (nth 1 a)) (state (nth 2 a)))
|
|
(push (list 'agent id aid
|
|
(format " %s [%s]" aname state))
|
|
result)))))))
|
|
;; Inactive header
|
|
(when inactive
|
|
(push (list 'header "inactive" nil "Inactive") result)
|
|
(when (member "inactive" ellie--tree-expanded)
|
|
(dolist (s inactive)
|
|
(let ((name (nth 1 s)) (id (nth 2 s)) (agents (nth 3 s)))
|
|
(push (list 'session id name
|
|
(if agents (format "%s (%d)" name (length agents)) name))
|
|
result)
|
|
(when (and agents (member id ellie--tree-expanded))
|
|
(dolist (a agents)
|
|
(let ((aname (car a)) (aid (nth 1 a)) (state (nth 2 a)))
|
|
(push (list 'agent id aid
|
|
(format " %s [%s]" aname state))
|
|
result))))))))
|
|
(setq ellie--tree-data (nreverse result))))
|
|
|
|
(defun ellie--tree-render ()
|
|
"Render the session tree buffer."
|
|
(ellie--tree-build-data)
|
|
(let ((inhibit-read-only t)
|
|
(buf (get-buffer-create ellie--tbuf)))
|
|
(with-current-buffer buf
|
|
(erase-buffer)
|
|
(dolist (item ellie--tree-data)
|
|
(let ((type (nth 0 item))
|
|
(key (nth 1 item))
|
|
(label (nth 3 item)))
|
|
(insert
|
|
(cond
|
|
((eq type 'header)
|
|
(let* ((exp (member key ellie--tree-expanded))
|
|
(arrow (if exp "▼ " "▶ ")))
|
|
(concat (propertize (concat arrow label)
|
|
'face 'bold 'ellie-key key 'ellie-type 'header)
|
|
"\n")))
|
|
((eq type 'session)
|
|
(let* ((exp (member key ellie--tree-expanded))
|
|
(arrow (if exp "▼ " "▶ ")))
|
|
(concat (propertize (concat " " arrow label)
|
|
'ellie-key key 'ellie-type 'session)
|
|
"\n")))
|
|
((eq type 'agent)
|
|
(concat (propertize label
|
|
'ellie-key (nth 2 item)
|
|
'ellie-parent (nth 1 item)
|
|
'ellie-type 'agent)
|
|
"\n"))))))
|
|
(goto-char (point-min))
|
|
(unless (derived-mode-p 'ellie-tree-mode)
|
|
(ellie-tree-mode))
|
|
(pop-to-buffer buf))))
|
|
|
|
;;;; ──────────────── Tree commands ────────────────
|
|
|
|
(defun ellie-tree-toggle ()
|
|
"Expand or collapse the item at point."
|
|
(interactive)
|
|
(let ((key (get-text-property (point) 'ellie-key))
|
|
(type (get-text-property (point) 'ellie-type)))
|
|
(when (and key (memq type '(header session)))
|
|
(if (member key ellie--tree-expanded)
|
|
(setq ellie--tree-expanded (remove key ellie--tree-expanded))
|
|
(push key ellie--tree-expanded))
|
|
(ellie--tree-render))))
|
|
|
|
(defun ellie-tree-activate ()
|
|
"Activate (select) or expand/collapse the item at point."
|
|
(interactive)
|
|
(let ((key (get-text-property (point) 'ellie-key))
|
|
(type (get-text-property (point) 'ellie-type))
|
|
(parent (get-text-property (point) 'ellie-parent)))
|
|
(cond
|
|
((memq type '(header session))
|
|
(ellie-tree-toggle))
|
|
((eq type 'agent)
|
|
(setq ellie--session-id parent
|
|
ellie--agent-id key)
|
|
(ellie--persist-session parent)
|
|
(ellie-tree-chat)))))
|
|
|
|
(defun ellie-tree-chat ()
|
|
"Open chat buffer for the selected session/agent."
|
|
(interactive)
|
|
(unless ellie--session-id (user-error "No session selected"))
|
|
(let ((buf (get-buffer-create ellie--buf)))
|
|
(with-current-buffer buf
|
|
(unless (derived-mode-p 'ellie-mode)
|
|
(ellie-mode))
|
|
(let ((inhibit-read-only t))
|
|
(erase-buffer))
|
|
(ellie--start-watching))
|
|
(pop-to-buffer buf)))
|
|
|
|
(defun ellie-tree-send-input ()
|
|
"Open input window for the current session/agent."
|
|
(interactive)
|
|
(ellie--open-input nil))
|
|
|
|
(defun ellie-tree-queue-input ()
|
|
"Open input window to queue a prompt."
|
|
(interactive)
|
|
(ellie--open-input t))
|
|
|
|
(defun ellie-tree-system-prompt ()
|
|
"Show system prompt for the current agent."
|
|
(interactive)
|
|
(ellie-show-system-prompt))
|
|
|
|
(defun ellie-tree-new-session ()
|
|
"Create a new session and refresh the tree."
|
|
(interactive)
|
|
(ellie-new-session)
|
|
(ellie--tree-render))
|
|
|
|
(defun ellie-tree-new-agent ()
|
|
"Create a new agent in the current session."
|
|
(interactive)
|
|
(ellie-new-agent)
|
|
(ellie--tree-render))
|
|
|
|
(defun ellie-tree-kill ()
|
|
"Kill the session or agent at point."
|
|
(interactive)
|
|
(let ((type (get-text-property (point) 'ellie-type))
|
|
(key (get-text-property (point) 'ellie-key))
|
|
(parent (get-text-property (point) 'ellie-parent)))
|
|
(cond
|
|
((eq type 'session) (ellie-kill-session key))
|
|
((eq type 'agent) (ellie-kill-agent parent key)))
|
|
(ellie--tree-render)))
|
|
|
|
(defun ellie-tree-refresh ()
|
|
"Refresh the session tree."
|
|
(interactive)
|
|
(ellie--tree-render))
|
|
|
|
(define-derived-mode ellie-tree-mode special-mode "Ellie-Tree"
|
|
"Major mode for the ollie session tree.
|
|
\\{ellie-tree-mode-map}"
|
|
:group 'ellie
|
|
(buffer-disable-undo))
|
|
|
|
;;;; ──────────────── Chat streaming ────────────────
|
|
|
|
(defvar ellie--chat-stream nil
|
|
"Process object for the chat stream, or nil.")
|
|
|
|
(defun ellie--render-chat-output (output)
|
|
"Render block markers in OUTPUT as block headers.
|
|
Suppress `[[[end]]]` markers."
|
|
(let ((start 0)
|
|
rendered)
|
|
(while (string-match "\\[\\[\\[\\([^]]+\\)\\]\\]\\]" output start)
|
|
(push (substring output start (match-beginning 0)) rendered)
|
|
(let ((name (match-string 1 output)))
|
|
(unless (string= name "end")
|
|
(push (propertize (concat name "\n")
|
|
'face '(:weight bold :underline t))
|
|
rendered)))
|
|
(setq start (match-end 0)))
|
|
(apply #'concat (nreverse (cons (substring output start) rendered)))))
|
|
|
|
(defun ellie--chat-filter (proc output)
|
|
"Filter function for the chat stream process.
|
|
Ignore output from a stream that has already been replaced."
|
|
(when (and (eq proc ellie--chat-stream)
|
|
(buffer-live-p (get-buffer ellie--buf)))
|
|
(with-current-buffer ellie--buf
|
|
(let ((inhibit-read-only t)
|
|
(at-end (= (point) (point-max)))
|
|
(start (copy-marker (point-max) t)))
|
|
(save-excursion
|
|
(goto-char (point-max))
|
|
(insert (ellie--render-chat-output output))
|
|
(ansi-color-apply-on-region start (point)))
|
|
(set-marker start nil)
|
|
(font-lock-ensure)
|
|
(when at-end (goto-char (point-max))))
|
|
(force-mode-line-update))))
|
|
|
|
(defun ellie--start-watching ()
|
|
"Start streaming the chat file via ollie-9p read."
|
|
(ellie--stop-watching)
|
|
(when (and ellie--session-id ellie--agent-id)
|
|
(let* ((path (ellie--session-file "chat"))
|
|
(proc (start-process "ellie-chat" nil "ollie-9p" "read" path)))
|
|
(set-process-filter proc #'ellie--chat-filter)
|
|
(set-process-query-on-exit-flag proc nil)
|
|
(setq ellie--chat-stream proc))))
|
|
|
|
(defun ellie--stop-watching ()
|
|
"Kill the chat stream process."
|
|
(when ellie--chat-stream
|
|
(delete-process ellie--chat-stream)
|
|
(setq ellie--chat-stream nil)))
|
|
|
|
;;;; ──────────────── Input buffer ────────────────
|
|
|
|
(defvar-local ellie--input-queued nil
|
|
"Non-nil when this input buffer will enqueue rather than submit.")
|
|
|
|
(defvar ellie-input-mode-map
|
|
(let ((m (make-sparse-keymap)))
|
|
(define-key m (kbd "C-c C-c") #'ellie-input-submit)
|
|
(define-key m (kbd "C-c C-k") #'ellie-input-cancel)
|
|
m)
|
|
"Keymap for `ellie-input-mode'.")
|
|
|
|
(define-derived-mode ellie-input-mode text-mode "Ollie-Input"
|
|
"Major mode for composing ollie prompts.
|
|
\\{ellie-input-mode-map}"
|
|
(setq-local header-line-format
|
|
'(:eval
|
|
(format " %s · C-c C-c submit · C-c C-k cancel"
|
|
(if ellie--input-queued
|
|
(propertize "Queue prompt" 'face 'warning)
|
|
(propertize "Send prompt" 'face 'success))))))
|
|
|
|
(defun ellie--open-input (&optional queued)
|
|
"Open the prompt input window below the current window.
|
|
QUEUED non-nil means the prompt will be enqueued, not submitted immediately."
|
|
(let ((buf (get-buffer-create ellie--ibuf)))
|
|
(with-current-buffer buf
|
|
(erase-buffer)
|
|
(ellie-input-mode)
|
|
(setq ellie--input-queued queued))
|
|
(let ((window (display-buffer buf
|
|
`(display-buffer-below-selected
|
|
(window-height . ,ellie-input-window-height)))))
|
|
(when (window-live-p window)
|
|
(select-window window)
|
|
(with-current-buffer buf
|
|
(goto-char (point-max)))))))
|
|
|
|
(defun ellie-input-submit ()
|
|
"Submit the contents of the input buffer as a prompt."
|
|
(interactive)
|
|
(let ((text (buffer-string))
|
|
(queued ellie--input-queued))
|
|
(ellie--close-input-window)
|
|
(if queued
|
|
(ellie-queue text)
|
|
(ellie-submit text))))
|
|
|
|
(defun ellie-input-cancel ()
|
|
"Discard the input and close the input window."
|
|
(interactive)
|
|
(ellie--close-input-window))
|
|
|
|
(defun ellie--close-input-window ()
|
|
"Kill the input buffer and delete its window."
|
|
(when-let (win (get-buffer-window ellie--ibuf t))
|
|
(delete-window win))
|
|
(when-let (buf (get-buffer ellie--ibuf))
|
|
(kill-buffer buf)))
|
|
|
|
;;;; ──────────────── Chat major mode ────────────────
|
|
|
|
(defvar ellie-mode-map
|
|
(let ((m (make-sparse-keymap)))
|
|
(define-key m (kbd "RET") #'ellie-send-input)
|
|
(define-key m (kbd "SPC") #'ellie-send-input)
|
|
(define-key m (kbd "C-c C-q") #'ellie-queue-input)
|
|
(define-key m (kbd "C-c C-c") #'ellie-stop)
|
|
(define-key m (kbd "C-c C-k") #'ellie-kill-session)
|
|
(define-key m (kbd "C-c C-n") #'ellie-new-and-open)
|
|
(define-key m (kbd "C-c C-a") #'ellie-attach-and-open)
|
|
(define-key m (kbd "C-c C-p") #'ellie-show-system-prompt)
|
|
(define-key m (kbd "n") #'ellie-new-and-open)
|
|
(define-key m (kbd "g") #'ellie-refresh-chat)
|
|
(define-key m (kbd "q") #'ellie-quit-chat)
|
|
m)
|
|
"Keymap for `ellie-mode'.")
|
|
|
|
(defun ellie-send-input ()
|
|
"Open the input window to compose and submit a prompt."
|
|
(interactive)
|
|
(ellie--open-input nil))
|
|
|
|
(defun ellie-queue-input ()
|
|
"Open the input window to compose a queued prompt."
|
|
(interactive)
|
|
(ellie--open-input t))
|
|
|
|
(defun ellie-quit-chat ()
|
|
"Stop the chat stream and bury the chat buffer."
|
|
(interactive)
|
|
(ellie--stop-watching)
|
|
(bury-buffer (get-buffer ellie--buf)))
|
|
|
|
(defun ellie-refresh-chat ()
|
|
"Restart the chat stream."
|
|
(interactive)
|
|
(when (get-buffer ellie--buf)
|
|
(with-current-buffer (get-buffer ellie--buf)
|
|
(let ((inhibit-read-only t))
|
|
(erase-buffer)))
|
|
(ellie--start-watching)))
|
|
|
|
(define-derived-mode ellie-mode special-mode "Ollie"
|
|
"Major mode for the ollie chat interface.
|
|
\\{ellie-mode-map}"
|
|
:group 'ellie
|
|
(buffer-disable-undo)
|
|
(when (fboundp 'markdown-mode)
|
|
(setq-local font-lock-defaults '(markdown-mode-font-lock-keywords t))
|
|
(font-lock-mode 1))
|
|
(setq-local mode-line-format
|
|
`("%e"
|
|
mode-line-front-space
|
|
(:eval
|
|
(propertize (format " ellie:%s/%s"
|
|
(or ellie--session-id "-")
|
|
(or ellie--agent-id "-"))
|
|
'face 'mode-line-buffer-id))
|
|
(:eval
|
|
(let ((state (condition-case nil
|
|
(string-trim (ellie--9p-read (ellie--session-file "state")))
|
|
(error "?"))))
|
|
(if (string= state "idle")
|
|
(propertize "idle" 'face 'success)
|
|
(propertize state 'face 'warning))))
|
|
(:eval
|
|
(condition-case nil
|
|
(let ((s (ellie--9p-read (ellie--session-file "stats"))))
|
|
(when (and s (string-match "ctxsz=\\(.+\\)" s))
|
|
(concat " " (replace-regexp-in-string "%" "%%" (match-string 1 s)))))
|
|
(error nil)))
|
|
" "
|
|
mode-line-end-spaces)))
|
|
|
|
;;;; ──────────────── System prompt ────────────────
|
|
|
|
(defun ellie-show-system-prompt ()
|
|
"Display the rendered system prompt for the current agent."
|
|
(interactive)
|
|
(let* ((path (ellie--session-file "ctl"))
|
|
(text (ellie--9p-rdwr path "systemprompt")))
|
|
(unless text
|
|
(user-error "Could not read system prompt"))
|
|
(let ((buf (get-buffer-create "*ellie-system-prompt*")))
|
|
(with-current-buffer buf
|
|
(let ((inhibit-read-only t))
|
|
(erase-buffer)
|
|
(insert text)
|
|
(goto-char (point-min)))
|
|
(view-mode 1)
|
|
(when (fboundp 'markdown-mode)
|
|
(markdown-mode)))
|
|
(pop-to-buffer buf))))
|
|
|
|
;;;; ──────────────── Entry points ────────────────
|
|
|
|
;;;###autoload
|
|
(defun ellie ()
|
|
"Open the ollie session tree. Attaches to the last known session."
|
|
(interactive)
|
|
(if-let ((last (ellie--recall-session)))
|
|
(progn
|
|
(setq ellie--session-id (car last)
|
|
ellie--agent-id (cadr last))
|
|
(message "Attached to session %s" (car last)))
|
|
(setq ellie--session-id nil
|
|
ellie--agent-id nil))
|
|
(ellie--tree-render)
|
|
(run-at-time 0.5 nil #'ellie-tree-refresh))
|
|
|
|
;;;###autoload
|
|
(defun ellie-tree ()
|
|
"Open the session tree buffer."
|
|
(interactive)
|
|
(ellie--tree-render)
|
|
(run-at-time 0.5 nil #'ellie-tree-refresh))
|
|
|
|
(defun ellie-new-and-open ()
|
|
"Create a new session and open the chat buffer."
|
|
(interactive)
|
|
(ellie-new-session)
|
|
(ellie--tree-render))
|
|
|
|
(defun ellie-attach-and-open (sid)
|
|
"Attach to session SID and open the tree."
|
|
(interactive
|
|
(let ((ids (ellie--9p-ls "session")))
|
|
(unless ids (user-error "No ollie sessions available"))
|
|
(list (completing-read "Session: " ids nil t))))
|
|
(ellie-attach sid)
|
|
(ellie--tree-render))
|
|
|
|
(provide 'ellie)
|
|
|
|
;;; ellie.el ends here |