feat(cli): add interactive chat REPL mode for native muse CLI
This commit is contained in:
@@ -26,6 +26,7 @@ show_usage() {
|
||||
done
|
||||
echo ""
|
||||
echo "Common commands:"
|
||||
echo " chat [--thread <id>] Launch interactive conversational shell / REPL"
|
||||
echo " status Check agent status, sessions, and unread"
|
||||
echo " threads List active threads and sidechats"
|
||||
echo " history --thread <id> 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[@]}"
|
||||
|
||||
Executable
+194
@@ -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 <account> [--thread <uuid>]", 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()
|
||||
@@ -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 <account> chat # Interactive conversational REPL with thread selection
|
||||
muse -a <account> chat --thread <id> # Direct conversational REPL in specified thread
|
||||
muse -a <account> status
|
||||
muse -a <account> threads
|
||||
muse -a <account> history --thread <thread_uuid> --limit 10
|
||||
|
||||
Reference in New Issue
Block a user