From 7c6c3a8fbfd3274791fe92307b4ea709c4898ed7 Mon Sep 17 00:00:00 2001 From: operator Date: Mon, 5 Oct 2026 15:58:33 +0000 Subject: [PATCH] feat(cli): add interactive chat REPL mode for native muse CLI --- bin/muse | 7 ++ bin/muse-chat-repl.py | 194 ++++++++++++++++++++++++++++++++++++++++++ docs/AGENT-TOOLING.md | 2 + 3 files changed, 203 insertions(+) create mode 100755 bin/muse-chat-repl.py diff --git a/bin/muse b/bin/muse index 13bde8b..33d8cf7 100755 --- a/bin/muse +++ b/bin/muse @@ -26,6 +26,7 @@ show_usage() { done echo "" echo "Common commands:" + echo " chat [--thread ] Launch interactive conversational shell / REPL" echo " status Check agent status, sessions, and unread" echo " threads List active threads and sidechats" echo " history --thread View message history" @@ -96,6 +97,12 @@ if [[ ${#POSITIONAL[@]} -eq 0 ]]; then POSITIONAL=("status") fi +# If subcommand is 'chat', launch interactive chat REPL +if [[ "${POSITIONAL[0]}" == "chat" ]]; then + shift_args=("${POSITIONAL[@]:1}") + exec python3 "$NETVM_BIN/muse-chat-repl.py" "$ACCOUNT" "${shift_args[@]}" +fi + # Execute command; if auth fails and auto-cdp fails, offer interactive OTP sign-in if running interactively set +e "$NETVM_BIN/muse-cli-node" "$ACCOUNT" "${POSITIONAL[@]}" diff --git a/bin/muse-chat-repl.py b/bin/muse-chat-repl.py new file mode 100755 index 0000000..78f6df1 --- /dev/null +++ b/bin/muse-chat-repl.py @@ -0,0 +1,194 @@ +#!/usr/bin/env python3 +""" +muse-chat-repl.py: Fast interactive terminal chat session for a NetVM muse agent. +Uses direct gateway calls via muse-cli-node in the agent's netns. +""" +import sys +import os +import json +import time +import subprocess +from pathlib import Path + +NETVM_BIN = Path(__file__).resolve().parent + +# Color formatting helpers +def c_bold(s): return f"\033[1m{s}\033[0m" +def c_cyan(s): return f"\033[36m{s}\033[0m" +def c_green(s): return f"\033[32m{s}\033[0m" +def c_yellow(s): return f"\033[33m{s}\033[0m" +def c_red(s): return f"\033[31m{s}\033[0m" +def c_dim(s): return f"\033[2m{s}\033[0m" + +def run_muse(node, *args): + cmd = [str(NETVM_BIN / "muse-cli-node"), node] + list(args) + res = subprocess.run(cmd, capture_output=True, text=True) + return res.returncode, res.stdout, res.stderr + +def get_threads(node): + rc, out, _ = run_muse(node, "threads") + if rc != 0: + return [] + try: + data = json.loads(out) + if isinstance(data, list): + return data + except Exception: + pass + return [] + +def select_thread(node): + print(c_bold(f"\n=== SELECT THREAD FOR {node.upper()} ===") + c_dim(" (Prefer sidechats per CHAT_POLICY.md)\n")) + threads = get_threads(node) + + # Filter and prioritize sidechats + top_threads = threads[:15] + for idx, t in enumerate(top_threads, 1): + title = t.get("title") or "[Untitled]" + sid = t.get("session_id") + updated = t.get("updated", "") + print(f" [{idx:2d}] {c_bold(title[:40]):<42} {c_dim(sid[:8])} {c_dim(updated)}") + + print(f"\n [n] {c_green('+ Create a new sidechat')}") + print(f" [c] {c_cyan('Custom thread UUID')}") + print(f" [q] Quit\n") + + while True: + try: + choice = input(f"Choose option [1-{len(top_threads)}, n, c, q]: ").strip().lower() + if not choice: + continue + if choice == "q": + sys.exit(0) + if choice == "n": + title = input("Enter title for new sidechat: ").strip() + if not title: + title = "operator-session" + print(c_dim(f"Creating sidechat '{title}'...")) + rc, out, err = run_muse(node, "session-start", "--title", title) + if rc == 0: + try: + res = json.loads(out) + tid = res.get("session_id") + if tid: + print(c_green(f"✔ Created session {tid}")) + return tid, title + except Exception: + pass + print(c_red(f"Failed to create session: {err or out}")) + continue + if choice == "c": + tid = input("Enter thread UUID: ").strip() + if tid: + return tid, tid[:8] + continue + if choice.isdigit(): + num = int(choice) + if 1 <= num <= len(top_threads): + t = top_threads[num - 1] + return t["session_id"], t.get("title") or t["session_id"][:8] + except (KeyboardInterrupt, EOFError): + print() + sys.exit(0) + +def render_history(node, thread_id, limit=5): + rc, out, _ = run_muse(node, "history", "--thread", thread_id, "--limit", str(limit)) + if rc != 0: + return [] + try: + msgs = json.loads(out) + if isinstance(msgs, list): + print(c_dim(f"\n--- Recent Messages ({len(msgs)}) ---")) + for m in msgs: + role = m.get("role", "") + text = m.get("text", "") + if role == "assistant": + print(f"{c_yellow(node.upper())}: {text}") + else: + print(f"{c_green('YOU')}: {text}") + print(c_dim("----------------------------\n")) + return msgs + except Exception: + pass + return [] + +def main(): + if len(sys.argv) < 2: + print("Usage: muse-chat-repl.py [--thread ]", file=sys.stderr) + sys.exit(1) + + node = sys.argv[1] + thread_id = None + thread_title = None + + if len(sys.argv) >= 4 and sys.argv[2] in ("--thread", "-t"): + thread_id = sys.argv[3] + thread_title = thread_id[:8] + + if not thread_id: + thread_id, thread_title = select_thread(node) + + print(c_bold(f"\nEntering chat with {c_cyan(node.upper())} in thread '{thread_title}' ({thread_id[:8]}...)")) + print(c_dim("Commands: /exit (or /quit), /refresh, /history, /switch\n")) + + last_msgs = render_history(node, thread_id, limit=5) + last_seq = last_msgs[-1].get("seq", 0) if last_msgs else 0 + + while True: + try: + prompt_str = f"{c_green('super')} ({c_cyan(thread_title[:16])}) > " + line = input(prompt_str).strip() + if not line: + continue + + if line in ("/exit", "/quit", ":q"): + print(c_dim("\nExited chat session.\n")) + break + elif line in ("/refresh", "/history"): + last_msgs = render_history(node, thread_id, limit=10) + continue + elif line == "/switch": + thread_id, thread_title = select_thread(node) + print(c_bold(f"\nSwitched to thread '{thread_title}' ({thread_id[:8]}...)\n")) + render_history(node, thread_id, limit=5) + continue + + # Send message + print(c_dim("Sending..."), end="\r", flush=True) + rc, out, err = run_muse(node, "send", "--thread", thread_id, line) + if rc != 0: + print(c_red(f"Error sending message: {err or out}")) + continue + + print(c_green("Sent. Waiting for response..."), end="\r", flush=True) + + # Poll for assistant reply up to 30 seconds + start_time = time.time() + got_reply = False + while time.time() - start_time < 30: + time.sleep(2) + rc, out, _ = run_muse(node, "history", "--thread", thread_id, "--limit", "3") + if rc == 0: + try: + msgs = json.loads(out) + if msgs and msgs[-1].get("role") == "assistant": + # Check if newer than our sent context + if msgs[-1].get("seq", 0) > last_seq: + print(" " * 50, end="\r") # clear line + print(f"{c_yellow(node.upper())}: {msgs[-1].get('text')}\n") + last_seq = msgs[-1].get("seq", 0) + got_reply = True + break + except Exception: + pass + + if not got_reply: + print(" " * 50, end="\r") + print(c_dim("(Agent is still processing in background. Use /refresh to inspect)\n")) + + except (KeyboardInterrupt, EOFError): + print(c_dim("\nExited chat session.\n")) + break + +if __name__ == "__main__": + main() diff --git a/docs/AGENT-TOOLING.md b/docs/AGENT-TOOLING.md index 02e4ad4..579feca 100644 --- a/docs/AGENT-TOOLING.md +++ b/docs/AGENT-TOOLING.md @@ -52,6 +52,8 @@ You can directly interact with the headless Muse gateway inside your isolated ne ```bash # Using native muse wrapper (interactive prompt & account enforcement) +muse -a chat # Interactive conversational REPL with thread selection +muse -a chat --thread # Direct conversational REPL in specified thread muse -a status muse -a threads muse -a history --thread --limit 10