"""Interactive agent terminal — parse and execute whitelisted commands.""" from __future__ import annotations import json import os from typing import Any, Optional import httpx from app.db import fetch_all, fetch_one from app.services import agent_integration, agent_souls, herman, webbuilder_agent from app.services.agent_events_log import log_agent_event from app.services.agent_names import normalize_agent_key TOOLS_URL = os.getenv("TOOLS_API_URL", "http://tools-api:8700").rstrip("/") BROWSER_URL = os.getenv("BROWSER_AGENT_URL", "http://browser-agent:7790").rstrip("/") Line = dict[str, Any] GLOBAL_HELP = [ "help — dit overzicht", "status — huidige status & taak", "history — laatste events (in terminal)", "handoff [notitie]", "say — log een notitie", "ask — AI-antwoord in rol van deze agent", ] AGENT_HELP: dict[str, list[str]] = { "herman": [ "briefing — dagrapport genereren", " — vraag / opdracht aan Herman", ], "browser": [ "monitor add — site toevoegen aan monitor", "monitor list — actieve sites", "browse — pagina openen", "crawl — alle sites crawlen + parse-overzicht", "parse — laatste parse-resultaten", "parse — parse van specifieke site", "intel — hype & trend analyse (samenvatting)", "intel — filter analyse", ], "research": ["run — research pipeline starten", "briefs — recente briefs"], "retail": ["rss — retail RSS verversen", "scores — halal opportunity scores"], "sysops": [ "backup — config backup (approval)", "scan — maintenance scan", "topology — infra overzicht", "status — sysops status", ], "marketing": ["reco — aanbevelingen genereren"], "packaging": ["design — packaging opdracht (via Herman)"], "webbuilder": [ "build — website bouwen (agy op Hermes)", "projects — bestaande website-projecten", "status [project] — build-status", "preview [project] — preview-URL", ], "email": ["sync — inbox sync trigger"], "finance": ["margins — marge-overzicht (placeholder)"], } def _line(msg: str, *, detail: str = "", line_type: str = "output") -> Line: return {"type": line_type, "message": msg, "detail": detail} def _help_lines(key: str) -> list[Line]: lines = [_line("Beschikbare commando's:", line_type="output")] for h in GLOBAL_HELP: lines.append(_line(" " + h, line_type="output")) extra = AGENT_HELP.get(key, []) if extra: lines.append(_line(f"— {key} —", line_type="output")) for h in extra: lines.append(_line(" " + h, line_type="output")) return lines def _status_lines(key: str, soul: dict[str, Any]) -> list[Line]: recent = soul.get("recent_events") or [] last = recent[0] if recent else {} lines = [ _line(f"{soul.get('display_name') or key} · {soul.get('role_title') or 'agent'}"), _line(f"Status: {last.get('status') or soul.get('current_status') or 'standby'}"), _line(f"Taak: {last.get('title') or soul.get('current_task') or '—'}"), _line(f"Events totaal: {soul.get('event_count') or 0}"), ] resp = (soul.get("responsibilities") or "").strip() if resp: lines.append(_line(resp[:220], detail=resp if len(resp) > 220 else "")) return lines async def _tools_post(path: str, payload: dict | None = None, timeout: float = 180.0) -> dict[str, Any]: async with httpx.AsyncClient(timeout=timeout) as client: resp = await client.post(f"{TOOLS_URL}{path}", json=payload or {}) try: data = resp.json() except Exception: data = {"ok": False, "detail": resp.text[:500]} if resp.status_code >= 400: data.setdefault("ok", False) data.setdefault("detail", resp.text[:500]) return data async def _tools_get(path: str, timeout: float = 60.0) -> dict[str, Any]: async with httpx.AsyncClient(timeout=timeout) as client: resp = await client.get(f"{TOOLS_URL}{path}") try: return resp.json() except Exception: return {"ok": False, "detail": resp.text[:500]} async def _browser_post(path: str, payload: dict, timeout: float = 120.0) -> dict[str, Any]: async with httpx.AsyncClient(timeout=timeout) as client: resp = await client.post(f"{BROWSER_URL}{path}", json=payload) try: return resp.json() except Exception: return {"ok": False, "detail": resp.text[:500]} def _handoff(src: str, dst_raw: str, htype: str, note: str) -> list[Line]: dst = normalize_agent_key(dst_raw) if not dst: return [_line("Onbekende agent: " + dst_raw, line_type="error")] payload = {"note": note} if note else {} try: agent_integration.create_handoff(src, dst, handoff_type=htype, payload=payload) return [_line(f"Handoff → {dst} ({htype})" + (f": {note}" if note else ""))] except Exception as exc: return [_line("Handoff mislukt: " + str(exc), line_type="error")] async def _agent_ask(key: str, soul: dict[str, Any], question: str) -> list[Line]: name = soul.get("display_name") or key role = soul.get("role_title") or "" prompt = ( f"Je bent {name} ({key}), {role}. " f"Beantwoord kort en actiegericht in het Nederlands.\n\nOpdracht: {question}" ) result = await herman.chat(prompt, channel=f"terminal:{key}") reply = (result.get("reply") or "").strip() delegated = result.get("delegated_agents") or [] lines = [_line(reply or "(geen antwoord)", detail=reply)] if delegated: lines.append(_line("Gedelegeerd: " + ", ".join(delegated), line_type="handoff_out")) log_agent_event( key, "terminal_out", reply[:255] if reply else "Antwoord", reply[:4000], channel="terminal", metadata={"delegated": delegated, "question": question[:500]}, ) return lines def _format_crawl_results(result: dict[str, Any]) -> list[Line]: if result.get("message") and not result.get("results"): return [_line(result["message"], line_type="output")] lines: list[Line] = [ _line( f"Crawl klaar — {result.get('sites', 0)} site(s), " f"{result.get('changed', 0)} wijziging(en), {result.get('errors', 0)} fout(en)" ) ] for r in result.get("results") or []: if r.get("status") == "ERROR": lines.append(_line(f"✗ {r.get('name') or r.get('url')}: {r.get('error')}", line_type="error")) continue flag = "△" if r.get("changed") else "✓" lines.append( _line( f"{flag} {r.get('title') or r.get('name')} — {r.get('word_count', 0)} woorden, " f"{r.get('links_count', 0)} links", detail=(r.get("excerpt") or "")[:200], ) ) headings = r.get("headings") or [] if headings: lines.append(_line(" H: " + " · ".join(headings[:4])[:180], line_type="output")) for link in (r.get("links_sample") or [])[:3]: lbl = link.get("label") or link.get("href") or "" lines.append(_line(" → " + lbl[:70], line_type="output")) return lines def _format_parse_items(items: list[dict[str, Any]]) -> list[Line]: if not items: return [_line("Geen parse-resultaten — voer eerst crawl uit.")] lines: list[Line] = [_line(f"Parse-overzicht ({len(items)} pagina's):")] for p in items: lines.append( _line( f"#{p.get('site_id') or '—'} {p.get('title') or p.get('url')} — " f"{p.get('word_count', 0)} woorden", detail=(p.get("excerpt") or "")[:200], ) ) if p.get("meta_description"): lines.append(_line(" desc: " + str(p["meta_description"])[:120], line_type="output")) for h in (p.get("headings") or [])[:3]: lines.append(_line(" · " + h[:90], line_type="output")) return lines async def _run_agent_command(key: str, soul: dict[str, Any], cmd: str, args: list[str], raw: str) -> list[Line]: if key == "herman": if cmd == "briefing": try: content = await herman.generate_briefing() preview = (content or "")[:400] log_agent_event("herman", "terminal_out", "Briefing gegenereerd", content[:4000], channel="terminal") return [_line("CEO briefing gegenereerd.", detail=preview)] except Exception as exc: return [_line("Briefing mislukt: " + str(exc), line_type="error")] result = await herman.chat(raw, channel="terminal") reply = (result.get("reply") or "").strip() delegated = result.get("delegated_agents") or [] lines = [_line(reply or "(geen antwoord)", detail=reply)] if delegated: lines.append(_line("→ " + ", ".join(delegated), line_type="handoff_out")) log_agent_event("herman", "terminal_out", reply[:255] if reply else "Antwoord", reply[:4000], channel="terminal") return lines if key == "browser": if cmd == "monitor" and args and args[0].lower() == "add" and len(args) >= 2: url = args[1] from app.services.monitor import add_site site = add_site(url, name=url[:80]) log_agent_event( "browser", "monitor_site_added", f"Terminal: site toegevoegd {url[:120]}", "", channel="terminal", metadata={"url": url, "site_id": site.get("id")}, ) return [_line(f"Monitor: {url} toegevoegd (id {site.get('id')})")] if cmd == "monitor" and args and args[0].lower() == "list": rows = fetch_all( "SELECT id, url, is_active FROM monitored_sites ORDER BY id DESC LIMIT 15" ) if not rows: return [_line("Geen monitor-sites.")] return [_line(f"#{r['id']} {'✓' if r.get('is_active') else '○'} {r['url']}") for r in rows] if cmd == "browse" and args: url = args[0] data = await _browser_post("/browse", {"url": url, "purpose": "terminal"}) log_agent_event("browser", "browse", f"Terminal browse: {url[:120]}", json.dumps(data)[:500], channel="terminal") title = data.get("title") or data.get("url") or url return [_line(f"Browse OK: {title}")] if cmd == "crawl": from app.services.monitor import trigger_crawl site_id = int(args[0]) if args and str(args[0]).isdigit() else None result = trigger_crawl(site_id) log_agent_event( "browser", "monitor_crawl", f"Terminal crawl ({result.get('sites', 0)} sites)", json.dumps([{k: r.get(k) for k in ("url", "title", "word_count", "status")} for r in result.get("results", [])])[:800], channel="terminal", ) return _format_crawl_results(result) if cmd == "parse": from app.services.monitor import list_parse_results sid = int(args[0]) if args and str(args[0]).isdigit() else None return _format_parse_items(list_parse_results(site_id=sid, limit=10)) if cmd == "intel": from app.services.monitor import build_parse_intelligence q = " ".join(args) if args else None data = build_parse_intelligence(query=q, limit=20) lines = [ _line( f"Analyse: {data['summary']['pages']} pagina's · " f"{data['summary']['total_words']} woorden · " f"{data['summary']['themes_detected']} trend-termen" ), _line("Open Browser → tab Parse Analyse voor volledig overzicht", line_type="output"), ] for t in (data.get("hype_terms") or [])[:12]: flag = "★ " if t.get("cross_site") else "" lines.append( _line(f"{flag}{t['term']} ({t['score']}) — {', '.join(t.get('sites') or [])[:3]}", line_type="output") ) return lines if key == "research": if cmd == "run": data = await _tools_post("/research/run") ok = data.get("ok", True) log_agent_event("research", "terminal_action", "Research run (terminal)", json.dumps(data)[:500], channel="terminal") return [_line("Research pipeline: " + ("OK" if ok else "fout"), detail=json.dumps(data)[:300])] if cmd == "briefs": data = await _tools_get("/research/briefs") items = data if isinstance(data, list) else data.get("items") or [] if not items: return [_line("Geen briefs.")] return [_line(str(b.get("title") or b.get("id") or b))[:120] for b in items[:8]] if key == "retail": if cmd == "rss": data = await _tools_post("/retail/rss/refresh") log_agent_event("retail", "terminal_action", "RSS refresh (terminal)", json.dumps(data)[:300], channel="terminal") return [_line("RSS verversd.", detail=json.dumps(data)[:200])] if cmd == "scores": data = await _tools_post("/retail/compute-opportunities") return [_line("Opportunity scores bijgewerkt.", detail=json.dumps(data)[:200])] if key == "sysops": if cmd == "backup": data = await _tools_post("/ops/backup/run") log_agent_event("sysops", "terminal_action", "Backup (terminal)", json.dumps(data)[:400], channel="terminal") return [_line("Backup uitgevoerd.", detail=json.dumps(data)[:300])] if cmd == "scan": data = await _tools_post("/ops/maintenance/scan") return [_line("Maintenance scan klaar.", detail=json.dumps(data)[:300])] if cmd == "topology": data = await _tools_get("/ops/topology") return [_line("Topology geladen.", detail=json.dumps(data)[:400])] if cmd == "status": data = await _tools_get("/ops/status") return [_line(json.dumps(data)[:350], detail=json.dumps(data)[:800])] if key == "marketing" and cmd == "reco": data = await _tools_post("/recommendations/generate") return [_line("Aanbevelingen gegenereerd.", detail=json.dumps(data)[:300])] if key == "packaging" and cmd == "design": brief = " ".join(args) or raw result = await herman.chat(f"Packaging design: {brief}", channel="terminal:packaging") reply = (result.get("reply") or "").strip() return [_line(reply, detail=reply)] if key == "webbuilder": if cmd == "build": brief = rest or raw if not brief: return [_line("Gebruik: build ", line_type="error")] try: outcome = await webbuilder_agent.generate_from_message(brief, channel="terminal", wait=False) proj = outcome.get("project") or "?" preview = outcome.get("preview_url") or "—" return [ _line(f"Build gestart: {proj}"), _line(f"Preview: {preview}", line_type="output"), _line("Volg voortgang live in dit terminal-venster.", line_type="output"), ] except Exception as exc: return [_line("Build mislukt: " + str(exc), line_type="error")] if cmd == "projects": data = await webbuilder_agent.list_projects() items = data.get("items") or [] if not items: return [_line("Geen projecten op Hermes/NAS.")] return [ _line( f"{p.get('slug')} — {p.get('files', 0)} bestand(en)" + (" · index.html ✓" if p.get("has_index") else "") ) for p in items ] if cmd == "status": slug = args[0] if args else "" if not slug: return [_line("Gebruik: status ", line_type="error")] st = await webbuilder_agent.get_build_status(slug) lines = [_line(f"{st.get('project')} · {st.get('status')} — {st.get('message') or ''}")] tail = (st.get("log_tail") or "")[:300] if tail: lines.append(_line(tail, line_type="output")) return lines if cmd == "preview": slug = args[0] if args else "" if slug: st = await webbuilder_agent.get_build_status(slug) url = st.get("preview_url") or webbuilder_agent.HERMES_PREVIEW_BASE else: url = webbuilder_agent.HERMES_PREVIEW_BASE return [_line(f"Preview: {url}")] return await _agent_ask(key, soul, raw) async def execute_terminal_command( agent_key: str, command: str, *, issued_by: str = "ceo", ) -> dict[str, Any]: key = normalize_agent_key(agent_key) soul = agent_souls.get_soul(key) if not soul: raise ValueError("Agent niet gevonden") raw = (command or "").strip() if not raw: raise ValueError("Leeg commando") log_agent_event( key, "terminal_in", raw[:255], "", channel="terminal", metadata={"issued_by": issued_by, "command": raw[:500]}, ) parts = raw.split() cmd = parts[0].lower() args = parts[1:] rest = " ".join(args) if cmd in ("help", "?", "h"): lines = _help_lines(key) elif cmd == "status": lines = _status_lines(key, soul) elif cmd == "handoff": if len(args) < 2: lines = [_line("Gebruik: handoff [notitie]", line_type="error")] else: note = " ".join(args[2:]) if len(args) > 2 else "" lines = _handoff(key, args[0], args[1], note) elif cmd == "say": if not rest: lines = [_line("Gebruik: say ", line_type="error")] else: log_agent_event(key, "terminal_note", rest[:255], rest, channel="terminal") lines = [_line("Genoteerd.")] elif cmd == "ask": if not rest: lines = [_line("Gebruik: ask ", line_type="error")] else: lines = await _agent_ask(key, soul, rest) elif cmd == "history": events = agent_souls.list_agent_events(key, limit=8) if not events: lines = [_line("Geen history.")] else: lines = [] for ev in reversed(events): t = (ev.get("created_at") or "")[11:19] or "--:--" lines.append(_line(f"{t} {ev.get('title') or ev.get('event_type')}")) else: lines = await _run_agent_command(key, soul, cmd, args, raw) return {"ok": True, "agent_key": key, "command": raw, "lines": lines}