:PROPERTIES: :ID: cli-gateway-skill :CREATED: [2026-04-13 Mon 17:00] :END: #+TITLE: SKILL: CLI Gateway (Universal Literate Note) #+STARTUP: content #+FILETAGS: :gateway:cli:io:autonomy: * Overview The *CLI Gateway* is the primary interaction point for the OpenCortex MVP. It provides a lightweight TCP socket server that allows local terminal clients to communicate with the daemon. It ensures a frictionless "First Contact" experience immediately following installation. * Phase A: Demand (PRD) :PROPERTIES: :STATUS: SIGNED :END: ** 1. Purpose Provide a secure, local, and low-latency terminal interface for the OpenCortex. ** 2. Success Criteria - [X] *Ingress:* Accept plain-text messages over TCP port 9105. - [X] *Normalisation:* Inject messages into the harness as `:chat-message` signals. - [X] *Egress:* Implement the `:cli` actuator to route agent responses back to the correct client socket. - [X] *Client:* Provide a standalone client script for the user's host machine. * Phase B: Blueprint (PROTOCOL) :PROPERTIES: :STATUS: SIGNED :END: ** 1. Architectural Intent The gateway runs a multi-threaded TCP server. Each connection is handled in its own thread. Inbound lines are wrapped in a Signal and processed. The `:cli` actuator retrieves the `:reply-stream` from the signal context to send the response back to the specific connected client. ** 2. Semantic Interfaces - Inbound: `(:sensor :chat-message :channel :cli :text "...")` - Outbound: `(:type :REQUEST :target :cli :text "...")` * Phase D: Build (Implementation) ** Package Context #+begin_src lisp (in-package :opencortex) #+end_src ** State: Server Control #+begin_src lisp (defvar *cli-server-thread* nil) (defvar *cli-server-socket* nil) (defvar *cli-port* 9105) #+end_src ** Actuator: CLI Response The CLI actuator writes the agent's response back to the client's network stream. It applies a simple "Agent: " prefix for clarity. #+begin_src lisp (defun execute-cli-action (action context) "Sends a framed message back to the connected CLI client." (let* ((payload (getf action :payload)) (text (or (getf payload :text) (getf action :text))) (stream (getf context :reply-stream))) (handler-case (if (and stream (open-stream-p stream)) (progn (format stream "~a" (frame-message (format nil "~s" (list :type :chat :text text)))) (finish-output stream) (format stream "~a" (frame-message (format nil "~s" '(:type :status :scribe :idle :gardener :sleeping)))) (finish-output stream)) (harness-log "CLI ERROR: No active or open reply stream for signal.")) (error (c) (harness-log "CLI ACTUATOR ERROR: ~a" c))))) #+end_src ** Server: Client Handler Handles an individual TCP connection. It reads lines until the connection is closed. #+begin_src lisp (defun handle-cli-slash-command (cmd stream) "Handles TUI slash commands by returning structured Lisp s-expressions." (cond ((string= cmd "/status") (format stream "~a" (frame-message (format nil "~s" '(:type :status :scribe :idle :gardener :sleeping)))) (finish-output stream)) ((string= cmd "/exit") (prin1 '(:type :info :text "Goodbye!") stream) (terpri stream) (finish-output stream)) (t (prin1 (list :type :error :text (format nil "Unknown command: ~a" cmd)) stream) (terpri stream) (finish-output stream)))) (defun handle-cli-client (stream) "Reads framed messages from a CLI client and injects them as stimuli." (harness-log "CLI: Client connected.") (handler-case (progn ;; 1. Send Handshake (format stream "~a" (frame-message (format nil "~s" (make-hello-message "0.1.0")))) (finish-output stream) (format stream "~a" (frame-message (format nil "~s" '(:type :status :scribe :idle :gardener :sleeping)))) (finish-output stream) ;; 2. Communication Loop (loop (let ((msg (read-framed-message stream))) (cond ((eq msg :eof) (return)) ((eq msg :error) (return)) (t (if (and (listp msg) (stringp (getf msg :text)) (char= (char (getf msg :text) 0) #\/)) (handle-cli-slash-command (getf msg :text) stream) (progn (harness-log "CLI: Received input -> ~s" msg) (inject-stimulus msg :stream stream)))))))) (error (c) (harness-log "CLI CLIENT DISCONNECT: ~a" c))) (harness-log "CLI: Client disconnected.")) #+end_src ** Server: Main Loop Listens for new TCP connections on the configured port. #+begin_src lisp (defun start-cli-gateway (&optional (port *cli-port*)) "Starts the TCP listener for local CLI clients." (setf *cli-server-socket* (usocket:socket-listen "0.0.0.0" port :reuse-address t)) (setf *cli-server-thread* (bt:make-thread (lambda () (unwind-protect (loop (let* ((socket (usocket:socket-accept *cli-server-socket*)) (stream (usocket:socket-stream socket))) (bt:make-thread (lambda () (unwind-protect (handle-cli-client stream) (usocket:socket-close socket))) :name "opencortex-cli-client-handler"))) (usocket:socket-close *cli-server-socket*))) :name "opencortex-cli-gateway")) (harness-log "CLI: Gateway listening on port ~a" port)) #+end_src ** Registration #+begin_src lisp (register-actuator :cli #'execute-cli-action) (defskill :skill-gateway-cli :priority 200 :trigger (lambda (ctx) (declare (ignore ctx)) nil) :probabilistic nil :deterministic (lambda (action ctx) (declare (ignore ctx)) action)) #+end_src ** Initialization #+begin_src lisp (start-cli-gateway) #+end_src * Phase E: The Client (Scripts) We tangle a lightweight client script that the user can run on their host machine. ** The Bash Client #+begin_src bash :tangle ../scripts/opencortex-chat.sh :shebang "#!/bin/bash" # opencortex-chat: The terminal mouthpiece for the Autonomous Brain. PORT=9105 HOST=${1:-localhost} # Check for socat (preferred) if command -v socat >/dev/null 2>&1; then # Use socat with READLINE for history and arrow-key support. # It establishes a persistent bidirectional connection. socat READLINE,history=$HOME/.org_agent_history TCP:$HOST:$PORT else # Fallback to nc (netcat) for a single-shot connection if socat is missing. # Note: This is less robust for agents with long-thinking times. echo "WARNING: socat not found. Falling back to nc (no line-editing support)." while true; do read -p "User: " MESSAGE if [ -z "$MESSAGE" ]; then continue; fi echo "$MESSAGE" | nc -N $HOST $PORT done fi #+end_src