compunerd's picture
Port full dashboard engine: phoenix_dashboard + agent_core + hyper_vocab_memory + bqsm_assist
bdf5f4a verified
Raw
History Blame Contribute Delete
13.4 kB
#!/usr/bin/env python3
"""
cli.py — Interactive agent: streaming, tool use, personality.
python3 cli.py # interactive
python3 cli.py "question" # one-shot
"""
import sys, os, re, json, time, signal, subprocess, urllib.request, readline
from pathlib import Path
ROOT = Path(__file__).parent
HISTORY_FILE = ROOT / ".cli_history"
MEMORY_FILE = ROOT / "memory.jsonl"
# ── ANSI ──────────────────────────────────────────────
B = "\033[1m"; D = "\033[2m"
R = "\033[31m"; G = "\033[32m"; Y = "\033[33m"
C = "\033[36m"; M = "\033[35m"; X = "\033[0m"
def ansi(s, c): return f"{c}{s}{X}"
# ── System prompt ─────────────────────────────────────
SYSTEM = """You are a Linux AI agent. You MUST use tools for any real information — NEVER guess or fabricate.
Tool format (exact):
ACTION: tool_name | parameters
Tools:
terminal(command) — run any shell command
read_file(path) — read file contents
write_file(path | content) — create/overwrite file
search_files(pattern) — grep/rg search
web_search(query) — search the web
memory(add=text | query=text | list) — persistent memory
RULES:
1. For listing files: ACTION: terminal | ls
2. For reading files: ACTION: read_file | path
3. For searching: ACTION: search_files | pattern
4. After getting tool results, report ONLY what the tool returned.
5. If a tool returns nothing, say "no results" — do NOT make up data.
6. Be concise. One tool at a time."""
# ── Server ────────────────────────────────────────────
PORT = 8081
def health():
try:
req = urllib.request.Request(f"http://localhost:{PORT}/health")
with urllib.request.urlopen(req, timeout=2) as r:
return json.loads(r.read()).get("status") == "ok"
except: return False
# ── Streaming chat ────────────────────────────────────
def chat_stream(msgs, max_tok=1024, temp=0.7):
body = json.dumps({"messages": msgs, "max_tokens": max_tok,
"temperature": temp, "stream": True}).encode()
try:
req = urllib.request.Request(f"http://localhost:{PORT}/v1/chat/completions",
body, {"Content-Type": "application/json"})
with urllib.request.urlopen(req, timeout=180) as r:
for line in r:
line = line.decode().strip()
if line.startswith("data: ") and line != "data: [DONE]":
try:
d = json.loads(line[6:])
c = d["choices"][0].get("delta", {}).get("content")
if c: yield c
except: pass
except GeneratorExit: raise
except Exception as e:
yield f"\n{ansi('[error]',R)} {e}"
def chat_sync(msgs, max_tok=256, temp=0.3, grammar=None):
body = {"messages": msgs, "max_tokens": max_tok,
"temperature": temp, "stream": False}
if grammar: body["grammar"] = grammar
try:
req = urllib.request.Request(f"http://localhost:{PORT}/v1/chat/completions",
json.dumps(body).encode(), {"Content-Type": "application/json"})
with urllib.request.urlopen(req, timeout=60) as r:
return json.loads(r.read())["choices"][0]["message"]["content"].strip()
except Exception as e: return f"[error: {e}]"
# ── Tool execution ────────────────────────────────────
def execute(name, params):
try:
p = params.strip().strip("'\"")
if name == "terminal":
r = subprocess.run(p, shell=True, capture_output=True, text=True,
timeout=30, cwd=str(ROOT))
out = r.stdout.strip()
if r.stderr.strip(): out += f"\n[stderr]: {r.stderr.strip()[:300]}"
if r.returncode: out += f"\n[exit {r.returncode}]"
return (out or "(no output)")[:4000]
elif name == "read_file":
fp = Path(p)
if not fp.is_absolute(): fp = ROOT / fp
if not fp.exists(): return f"Not found: {fp}"
if fp.stat().st_size > 500_000: return f"Too large ({fp.stat().st_size} bytes)"
lines = fp.read_text().splitlines()
out = "\n".join(f"{i+1:4d}|{l}" for i, l in enumerate(lines[:300]))
if len(lines) > 300: out += f"\n... ({len(lines)-300} more lines)"
return out
elif name == "write_file":
parts = p.split("|", 2)
if len(parts) < 2:
m = re.match(r"^['\"]?(.+?)['\"]?\s+(.+)", p, re.DOTALL)
parts = [m.group(1), m.group(2)] if m else [p, ""]
fp = Path(parts[0].strip().strip("'\""))
if not fp.is_absolute(): fp = ROOT / fp
fp.parent.mkdir(parents=True, exist_ok=True)
content = parts[1].strip() if len(parts) > 1 else ""
fp.write_text(content)
return f"Wrote {len(content)}B → {fp}"
elif name == "search_files":
r = subprocess.run(["rg", "--no-heading", "-n", "--max-count=5",
p, str(ROOT)], capture_output=True, text=True, timeout=10)
return (r.stdout.strip() or "No matches")[:3000]
elif name == "web_search":
try:
r = subprocess.run(["ddg", p, "-n", "3"],
capture_output=True, text=True, timeout=10)
return r.stdout.strip()[:2000] or "No results"
except:
return "web_search unavailable"
elif name == "memory":
if p.startswith("add="):
return mem_add(p[4:].strip().strip("'\""))
elif p.startswith("query="):
return mem_search(p[6:].strip().strip("'\""))
return mem_list()
return f"Unknown tool: {name}"
except subprocess.TimeoutExpired: return "Timeout"
except Exception as e: return f"Error: {e}"
# ── Memory ────────────────────────────────────────────
def mem_load():
if not MEMORY_FILE.exists(): return []
es = []
for l in MEMORY_FILE.read_text().splitlines():
if l.strip():
try: es.append(json.loads(l))
except: pass
return es
def mem_save(es):
MEMORY_FILE.write_text("\n".join(json.dumps(e) for e in es[:100]) + "\n")
def mem_add(text):
es = mem_load(); eid = str(hash(text))[-8:]
for e in es:
if e.get("id") == eid: e["hits"] += 1; mem_save(es); return f"Hit: {text[:60]}"
es.append({"id": eid, "text": text, "ts": time.time(), "hits": 1})
mem_save(es); return f"Saved: {text[:80]}"
def mem_search(q):
es = mem_load(); ws = set(q.lower().split())
sc = [(sum(1 for w in ws if w in e["text"].lower()) + e.get("hits",0)*0.1, e["text"]) for e in es]
sc.sort(reverse=True)
return "\n".join(f" [{s:.1f}] {t[:100]}" for s,t in sc[:5]) or "(none)"
def mem_list():
es = sorted(mem_load(), key=lambda e: e.get("hits",0), reverse=True)[:10]
return "\n".join(f" [{e['hits']}×] {e['text'][:80]}" for e in es) or "(empty)"
# ── Agent loop ─────────────────────────────────────────
MAX_ROUNDS = 3
def run_turn(user_msg, history):
msgs = [{"role": "system", "content": SYSTEM}]
mems = mem_search(user_msg)
if mems and mems != "(none)":
msgs[0]["content"] += f"\n\nMEMORIES:\n{mems}"
for h in history[-10:]: msgs.append(h)
msgs.append({"role": "user", "content": user_msg})
seen_tools = set()
for round_n in range(MAX_ROUNDS):
# Stream response
sys.stdout.write(ansi("●", C) + " "); sys.stdout.flush()
text = ""
for chunk in chat_stream(msgs, max_tok=800, temp=0.7):
text += chunk; sys.stdout.write(chunk); sys.stdout.flush()
sys.stdout.write("\n")
# Detect tool call
m = re.search(r'ACTION\s*:\s*(\w+)', text, re.IGNORECASE)
if not m:
m = re.search(r'(?:TOOL|CALL)\s*:\s*(\w+)', text, re.IGNORECASE)
if not m:
m = re.search(r'```(?:tool|action)\s*\n\s*(\w+)', text, re.IGNORECASE)
if not m:
# No tool — final response
history.append({"role": "user", "content": user_msg})
history.append({"role": "assistant", "content": text})
return text
tool = m.group(1).strip().lower()
# Extract params: text after tool name. Could be same line or next.
rest = text[m.end():]
# Try same-line first, then next line
same_line = rest.split('\n')[0].strip()
if same_line and not same_line.startswith('*'):
params = re.sub(r'^[\(\|\=]\s*', '', same_line).strip().rstrip(')')
else:
# Params on next line
lines = rest.split('\n')
params = lines[1].strip() if len(lines) > 1 else ""
# Fix common mistakes
if tool == "terminal" and params:
if params[:4].isupper() and len(params) < 10:
params = params.lower()
sys.stdout.write(f" {ansi('▸',Y)} {ansi(tool,B)}: {params[:100]}\n")
# Dedup: skip if we already ran this exact tool+params
dedup_key = f"{tool}|{params}"
if dedup_key in seen_tools:
# Feed a "no new info" result and continue
msgs.append({"role": "assistant", "content": text})
msgs.append({"role": "user", "content": f"Already ran: {tool} | {params}. Try a different approach."})
continue
seen_tools.add(dedup_key)
result = execute(tool, params)
preview = result[:400].replace("\n", "\n ")
sys.stdout.write(f" {ansi('▹',D)} {preview}\n")
if len(result) > 400:
sys.stdout.write(f" {ansi(f'({len(result)} chars)',D)}\n")
# Feed result
msgs.append({"role": "assistant", "content": text})
msgs.append({"role": "user", "content": f"Result of {tool}:\n{result}\n\nRespond directly."})
# Max rounds — final
sys.stdout.write(ansi("●", C) + " "); sys.stdout.flush()
text = ""
for chunk in chat_stream(msgs, max_tok=800, temp=0.7):
text += chunk; sys.stdout.write(chunk); sys.stdout.flush()
sys.stdout.write("\n")
history.append({"role": "user", "content": user_msg})
history.append({"role": "assistant", "content": text})
return text
# ── Context viewer ─────────────────────────────────────
def show_ctx(history):
print(f"\n{ansi('── CONTEXT ──',B)}")
print(f" System: {len(SYSTEM)} chars | History: {len(history)} msgs")
print(f" Memories: {len(mem_load())} | Dir: {ROOT}")
for h in history[-6:]:
clr = C if h["role"] == "user" else G
print(f" {ansi(h['role'][:4].upper(), clr)} {h['content'][:100].replace(chr(10),' ')}")
# ── Main ───────────────────────────────────────────────
def interactive():
print(f"\n {ansi('◆',M)} {ansi('Emerging Systems Agent',B)} {ansi('/help /context /clear /quit',D)}\n")
if not health():
print(ansi(" llama-server not running on port 8081. Start with:", R))
print(ansi(" python3 run.py --server start", D))
return
print(f" {ansi('●',G)} Hermes 3B on port {PORT}\n")
history = []
try: readline.read_history_file(str(HISTORY_FILE))
except: pass
while True:
try:
line = input(ansi("▸ ", C)).strip()
except (EOFError, KeyboardInterrupt):
print(f"\n{ansi('Goodbye.', D)}"); break
if not line: continue
if line.startswith("/"):
c = line[1:].strip().lower()
if c in ("q","quit","exit"): print(f"{ansi('Goodbye.',D)}"); break
elif c in ("h","help"):
print(f" /help /clear /context /mem /mem add <t> /mem search <q> /model /quit")
elif c == "clear": history = []; print(ansi(" Cleared.", D))
elif c == "context": show_ctx(history)
elif c == "model":
print(f" {ansi('Hermes 3B Q4_K',B)} ctx=4096 port={PORT}")
elif c.startswith("mem "):
sub = c[4:].strip()
if sub.startswith("add "): print(f" {mem_add(sub[4:])}")
elif sub.startswith("search "): print(mem_search(sub[7:]))
else: print(mem_list())
else: print(ansi(f" Unknown: /{c}", R))
continue
run_turn(line, history)
print()
try: readline.write_history_file(str(HISTORY_FILE))
except: pass
if __name__ == "__main__":
if len(sys.argv) > 1 and not sys.argv[1].startswith("/"):
if not health():
print("llama-server not running. Use: python3 run.py --server start"); sys.exit(1)
run_turn(" ".join(sys.argv[1:]), [])
print()
else:
interactive()