diff --git a/api/agents.py b/api/agents.py index 4657867..2e58c3a 100644 --- a/api/agents.py +++ b/api/agents.py @@ -14,6 +14,9 @@ from typing import Any from api.helpers import j +# ChromaDB for memory search +import chromadb + # ── Paths ────────────────────────────────────────────────────────────────────── _HERMES_DIR = Path.home() / ".hermes" _AGENTS_DIR = _HERMES_DIR / "agents" @@ -516,6 +519,102 @@ def get_agent_errors(agent_id: str, limit: int = 20) -> dict: } +# ── Token / Cost Usage Tracking ─────────────────────────────────────────────── + +def _get_agent_usage(agent_id: str) -> dict: + """ + Read ~/.hermes/agents/{agent_id}/usage.json and compute daily/weekly/monthly totals. + Returns {agent_id, today: {tokens, cost}, week: {tokens, cost}, month: {tokens, cost}, history: [...]}. + If the usage file doesn't exist, returns zeros for all periods. + """ + if agent_id not in TIER2_AGENTS and agent_id != "rose": + return {"error": f"Unknown agent: {agent_id}"} + + usage_path = _AGENTS_DIR / agent_id / "usage.json" + if not usage_path.exists(): + return { + "agent_id": agent_id, + "today": {"tokens": 0, "cost": 0.0}, + "week": {"tokens": 0, "cost": 0.0}, + "month": {"tokens": 0, "cost": 0.0}, + "history": [], + } + + try: + with open(usage_path, "r") as f: + data = json.load(f) + except (json.JSONDecodeError, IOError): + return { + "agent_id": agent_id, + "today": {"tokens": 0, "cost": 0.0}, + "week": {"tokens": 0, "cost": 0.0}, + "month": {"tokens": 0, "cost": 0.0}, + "history": [], + } + + token_usage = data.get("token_usage", []) + today_str = datetime.utcnow().strftime("%Y-%m-%d") + + # Compute period boundaries + now = datetime.utcnow() + week_ago = now - timedelta(days=7) + month_ago = now - timedelta(days=30) + + today_tokens = 0 + today_cost = 0.0 + week_tokens = 0 + week_cost = 0.0 + month_tokens = 0 + month_cost = 0.0 + + history = [] + for entry in token_usage: + date_str = entry.get("date", "") + prompt = entry.get("prompt_tokens", 0) + completion = entry.get("completion_tokens", 0) + cost = entry.get("cost_usd", 0.0) + total = prompt + completion + + try: + entry_date = datetime.strptime(date_str, "%Y-%m-%d") + except ValueError: + continue + + history.append({ + "date": date_str, + "prompt_tokens": prompt, + "completion_tokens": completion, + "total_tokens": total, + "cost_usd": cost, + }) + + if date_str == today_str: + today_tokens += total + today_cost += cost + if entry_date >= week_ago: + week_tokens += total + week_cost += cost + if entry_date >= month_ago: + month_tokens += total + month_cost += cost + + # Sort history newest-first + history.sort(key=lambda x: x["date"], reverse=True) + + return { + "agent_id": agent_id, + "today": {"tokens": today_tokens, "cost": round(today_cost, 6)}, + "week": {"tokens": week_tokens, "cost": round(week_cost, 6)}, + "month": {"tokens": month_tokens, "cost": round(month_cost, 6)}, + "history": history, + } + + +def get_agent_usage(agent_id: str) -> dict: + """API: GET /api/agents/{id}/usage — return token/cost usage.""" + return _get_agent_usage(agent_id) + + # ── Chat History ────────────────────────────────────────────────────────────── def _get_chat_history(agent_id: str, limit: int = 20) -> list[dict]: @@ -712,3 +811,259 @@ def get_agent_tasks(agent_id: str) -> dict: "tasks": tasks, "count": len(tasks), } + + +# ── Message Bus ──────────────────────────────────────────────────────────────── + +def _get_message_bus_overview() -> dict: + """ + Return a overview of all agent inboxes — message counts and recent messages. + """ + agents = [] + for agent_id in TIER2_AGENTS: + inbox_path = _AGENTS_DIR / agent_id / "inbox.json" + count = 0 + last_msg = None + if inbox_path.exists(): + try: + inbox = json.loads(inbox_path.read_text()) + messages = inbox.get("messages", []) + count = len(messages) + if messages: + last_msg = { + "from": messages[-1].get("from"), + "subject": messages[-1].get("subject"), + "timestamp": messages[-1].get("timestamp"), + } + except Exception: + pass + agents.append({ + "agent_id": agent_id, + "name": TIER2_AGENTS[agent_id].get("name", agent_id), + "emoji": TIER2_AGENTS[agent_id].get("emoji", "•"), + "inbox_count": count, + "last_message": last_msg, + }) + + # Rose inbox + rose_inbox_path = _HERMES_DIR / "inbox.json" + rose_count = 0 + rose_last = None + if rose_inbox_path.exists(): + try: + inbox = json.loads(rose_inbox_path.read_text()) + messages = inbox.get("messages", []) + rose_count = len(messages) + if messages: + rose_last = { + "from": messages[-1].get("from"), + "subject": messages[-1].get("subject"), + "timestamp": messages[-1].get("timestamp"), + } + except Exception: + pass + agents.insert(0, { + "agent_id": "rose", + "name": "Rose", + "emoji": "🌹", + "inbox_count": rose_count, + "last_message": rose_last, + }) + + return {"agents": agents} + + +def get_message_bus_overview() -> dict: + """API: GET /api/agents/message-bus — overview of all agent inboxes.""" + return _get_message_bus_overview() + + +def send_bus_message(target_agent: str, from_agent: str, subject: str, content: str) -> dict: + """ + Write a message directly into an agent's inbox.json via the message bus. + """ + if target_agent not in TIER2_AGENTS and target_agent != "rose": + return {"ok": False, "error": f"Unknown agent: {target_agent}"} + + if target_agent == "rose": + inbox_path = _HERMES_DIR / "inbox.json" + else: + inbox_path = _AGENTS_DIR / target_agent / "inbox.json" + + inbox_path.parent.mkdir(parents=True, exist_ok=True) + + try: + if inbox_path.exists(): + inbox = json.loads(inbox_path.read_text()) + else: + inbox = {"messages": []} + except Exception: + inbox = {"messages": []} + + import datetime + msg_id = f"bus_{int(datetime.datetime.now().timestamp() * 1000)}" + message = { + "id": msg_id, + "from": from_agent, + "subject": subject, + "content": content, + "type": "request", + "status": "unread", + "timestamp": datetime.datetime.now().isoformat(), + } + + inbox["messages"].append(message) + inbox_path.write_text(json.dumps(inbox, indent=2)) + + return {"ok": True, "message_id": msg_id} + + +def get_agent_bus_messages(agent_id: str, limit: int = 50) -> dict: + """API: GET /api/agents/{id}/bus-messages — raw inbox messages.""" + if agent_id not in TIER2_AGENTS and agent_id != "rose": + return {"error": f"Unknown agent: {agent_id}"} + if agent_id == "rose": + inbox_path = _HERMES_DIR / "inbox.json" + else: + inbox_path = _AGENTS_DIR / agent_id / "inbox.json" + if not inbox_path.exists(): + return {"agent_id": agent_id, "messages": []} + try: + inbox = json.loads(inbox_path.read_text()) + messages = inbox.get("messages", [])[-limit:] + return {"agent_id": agent_id, "messages": messages} + except Exception: + return {"agent_id": agent_id, "messages": []} + + +# ── Message Bus Viewer ──────────────────────────────────────────────────────── + +def get_message_bus_status() -> dict: + """ + Return all inboxes across all agents — a complete view of the message bus. + Returns dict with each agent and their messages. + """ + all_agents = list(TIER2_AGENTS.keys()) + ["rose"] + bus = {} + for agent_id in all_agents: + try: + messages = _read_inbox(agent_id, limit=100) + bus[agent_id] = { + "count": len(messages), + "messages": messages[-10:] if messages else [], # last 10 + } + except Exception: + bus[agent_id] = {"count": 0, "messages": [], "error": True} + return {"bus": bus} + + +def send_bus_message(to_agent: str, from_agent: str, subject: str, content: str, msg_type: str = "request") -> dict: + """ + Write a message directly to an agent's inbox.json. + """ + if to_agent not in TIER2_AGENTS and to_agent != "rose": + return {"ok": False, "error": f"Unknown agent: {to_agent}"} + + if to_agent == "rose": + inbox_path = _HERMES_DIR / "inbox.json" + else: + inbox_path = _AGENTS_DIR / to_agent / "inbox.json" + + try: + data = [] + if inbox_path.exists(): + data = json.loads(inbox_path.read_text()) + if not isinstance(data, list): + data = [] + + import uuid, datetime + msg = { + "id": str(uuid.uuid4())[:8], + "from": from_agent, + "to": to_agent, + "type": msg_type, + "subject": subject, + "content": content, + "timestamp": datetime.datetime.now().isoformat() + "Z", + "status": "unread", + } + data.append(msg) + inbox_path.write_text(json.dumps(data, indent=2)) + return {"ok": True, "message_id": msg["id"]} + except Exception as e: + return {"ok": False, "error": str(e)} + + +# ── Memory Search (ChromaDB) ─────────────────────────────────────────────────── + +def _get_chroma_client(): + """Get or create the shared ChromaDB HTTP client (thread-safe singleton).""" + if not hasattr(_get_chroma_client, "_client"): + _get_chroma_client._client = chromadb.HttpClient(host="127.0.0.1", port=8000) + return _get_chroma_client._client + + +def _search_agent_memory(agent_id: str, query: str, limit: int = 10) -> list: + """ + Search memory for a specific agent. + Searches the rose_memory collection filtered by topic matching agent_id. + """ + try: + client = _get_chroma_client() + coll = client.get_collection(name="rose_memory") + results = coll.query( + query_texts=[query], + n_results=limit, + include=["metadatas", "documents"], + ) + matches = [] + for i, doc in enumerate(results.get("documents", [[]])[0] or []): + meta = (results.get("metadatas", [[{}]])[0] or [{}])[i] or {} + topic = meta.get("topic", "") + if not topic.startswith(agent_id): + continue + matches.append({ + "id": (results.get("ids", [["?"]])[0] or ["?"])[i], + "topic": topic, + "content": doc, + "confidence": float(meta.get("confidence", 0.0)), + "tags": meta.get("tags", ""), + "vault_path": meta.get("vault_path", ""), + }) + return matches + except Exception: + return [] + + +def _search_all_agents_memory(query: str, limit: int = 20) -> list: + """ + Search across all agent memories in ChromaDB. + Returns matches with agent attribution from topic. + Topic format: "agent-name/fact-name" or flat topic name. + """ + try: + client = _get_chroma_client() + coll = client.get_collection(name="rose_memory") + results = coll.query( + query_texts=[query], + n_results=limit, + include=["metadatas", "documents"], + ) + matches = [] + for i, doc in enumerate(results.get("documents", [[]])[0] or []): + meta = (results.get("metadatas", [[{}]])[0] or [{}])[i] or {} + topic = meta.get("topic", "") + parts = topic.split("/") + agent = parts[0] if len(parts) > 1 else topic + matches.append({ + "id": (results.get("ids", [["?"]])[0] or ["?"])[i], + "topic": topic, + "agent": agent, + "content": doc, + "confidence": float(meta.get("confidence", 0.0)), + "tags": meta.get("tags", ""), + "vault_path": meta.get("vault_path", ""), + }) + return matches + except Exception: + return [] diff --git a/api/agents_memory.py b/api/agents_memory.py new file mode 100644 index 0000000..fb60ba0 --- /dev/null +++ b/api/agents_memory.py @@ -0,0 +1,80 @@ +""" +Phase 5 — Memory Search (ChromaDB) +Appended to agents.py functions for Memory Search. +""" +import chromadb + + +def _get_chroma_client(): + """Get or create the shared ChromaDB HTTP client (thread-safe singleton).""" + if not hasattr(_get_chroma_client, "_client"): + _get_chroma_client._client = chromadb.HttpClient(host="127.0.0.1", port=8000) + return _get_chroma_client._client + + +def _search_agent_memory(agent_id: str, query: str, limit: int = 10) -> list: + """ + Search memory for a specific agent. + Searches the rose_memory collection filtered by topic matching agent_id. + """ + try: + client = _get_chroma_client() + coll = client.get_collection(name="rose_memory") + results = coll.query( + query_texts=[query], + n_results=limit, + include=["metadatas", "documents"], + ) + matches = [] + for i, doc in enumerate(results.get("documents", [[]])[0] or []): + meta = (results.get("metadatas", [[{}]])[0] or [{}])[i] or {} + topic = meta.get("topic", "") + # Filter: only docs that belong to this agent (topic starts with agent_id) + if not topic.startswith(agent_id): + continue + matches.append({ + "id": (results.get("ids", [["?"]])[0] or ["?"])[i], + "topic": topic, + "content": doc, + "confidence": float(meta.get("confidence", 0.0)), + "tags": meta.get("tags", ""), + "vault_path": meta.get("vault_path", ""), + }) + return matches + except Exception: + return [] + + +def _search_all_agents_memory(query: str, limit: int = 20) -> list: + """ + Search across all agent memories in ChromaDB. + Returns matches with agent attribution from topic. + Topic format: "agent-name/fact-name" or flat topic name. + """ + try: + client = _get_chroma_client() + coll = client.get_collection(name="rose_memory") + results = coll.query( + query_texts=[query], + n_results=limit, + include=["metadatas", "documents"], + ) + matches = [] + for i, doc in enumerate(results.get("documents", [[]])[0] or []): + meta = (results.get("metadatas", [[{}]])[0] or [{}])[i] or {} + topic = meta.get("topic", "") + # Extract agent from topic: "agent-name/ffact" -> agent + parts = topic.split("/") + agent = parts[0] if len(parts) > 1 else topic + matches.append({ + "id": (results.get("ids", [["?"]])[0] or ["?"])[i], + "topic": topic, + "agent": agent, + "content": doc, + "confidence": float(meta.get("confidence", 0.0)), + "tags": meta.get("tags", ""), + "vault_path": meta.get("vault_path", ""), + }) + return matches + except Exception: + return [] diff --git a/api/routes.py b/api/routes.py index 3798d08..2826743 100644 --- a/api/routes.py +++ b/api/routes.py @@ -859,6 +859,13 @@ def handle_get(handler, parsed) -> bool: limit = int(parse_qs(parsed.query).get("limit", ["20"])[0]) return j(handler, _agents.get_agent_errors(agent_id, limit=limit)) + # GET /api/agents/{id}/usage + if parsed.path.startswith("/api/agents/") and "/usage" in parsed.path: + parts = parsed.path.split("/") + if len(parts) == 5 and parts[4] == "usage": + agent_id = parts[3] + return j(handler, _agents.get_agent_usage(agent_id)) + # GET /api/agents/{id}/chat-history if parsed.path.startswith("/api/agents/") and "/chat-history" in parsed.path: parts = parsed.path.split("/") @@ -881,6 +888,38 @@ def handle_get(handler, parsed) -> bool: agent_id = parts[3] return j(handler, _agents.get_agent_tasks(agent_id)) + # GET /api/agents/message-bus — all inboxes at once + if parsed.path == "/api/agents/message-bus": + return j(handler, _agents.get_message_bus_status()) + + # POST /api/agents/{id}/bus-message — send message to agent via bus + if parsed.path.startswith("/api/agents/") and "/bus-message" in parsed.path: + parts = parsed.path.split("/") + if len(parts) == 5 and parts[4] == "bus-message": + agent_id = parts[3] + data = read_body(handler) + result = _agents.send_bus_message( + to_agent=agent_id, + from_agent=data.get("from_agent", "rose"), + subject=data.get("subject", ""), + content=data.get("content", ""), + msg_type=data.get("type", "request"), + ) + return j(handler, result) + + # GET /api/agents/memory/search?q= — search all agents + if parsed.path == "/api/agents/memory/search": + return _handle_memory_search(handler, parsed, agent_id=None) + + # GET /api/agents/{id}/memory/search?q= — search specific agent + _mem_match = None + if parsed.path.startswith("/api/agents/") and "/memory/search" in parsed.path: + parts = parsed.path.split("/") + if len(parts) == 6 and parts[5] == "search": + _mem_match = parts[3] + if _mem_match: + return _handle_memory_search(handler, parsed, agent_id=_mem_match) + # ── Profile API (GET) ── if parsed.path == "/api/profiles": from api.profiles import list_profiles_api, get_active_profile_name @@ -3350,3 +3389,25 @@ def _handle_logs_read(handler, log_name): "line_count": len(lines), "tail_count": len(tail), }) + + +# ── Memory Search ───────────────────────────────────────────────────────────── + +def _handle_memory_search(handler, parsed, agent_id=None) -> bytes: + """ + GET /api/agents/memory/search?q=query — all agents + GET /api/agents/{id}/memory/search?q=query — specific agent + """ + try: + qs = parse_qs(parsed.query) + query = " ".join(qs.get("q", [""])).strip() + limit = int(" ".join(qs.get("limit", ["20"])).strip()) + if not query: + return bad(handler, "q parameter required") + if agent_id: + results = _agents._search_agent_memory(agent_id, query, limit=limit) + else: + results = _agents._search_all_agents_memory(query, limit=limit) + return j(handler, {"query": query, "results": results, "count": len(results)}) + except Exception as e: + return bad(handler, str(e)) diff --git a/static/panels.js b/static/panels.js index 00ef75a..ce9b239 100644 --- a/static/panels.js +++ b/static/panels.js @@ -1919,6 +1919,9 @@ async function openAgentDetail(agentId) { + + +
@@ -1938,7 +1941,7 @@ async function switchAgentTab(tab) { // Update tab buttons document.querySelectorAll('.agent-tab').forEach((el, i) => { - const tabs = ['overview', 'soul', 'memory', 'inbox', 'activity', 'errors', 'chat', 'tasks']; + const tabs = ['overview', 'soul', 'memory', 'inbox', 'activity', 'errors', 'chat', 'tasks', 'bus', 'usage', 'topology']; el.classList.toggle('active', tabs[i] === tab); }); @@ -1969,6 +1972,15 @@ async function switchAgentTab(tab) { case 'tasks': await loadAgentTasks(agentId, content); break; + case 'bus': + await loadAgentBus(agentId, content); + break; + case 'usage': + await loadAgentUsage(agentId, content); + break; + case 'topology': + await loadAgentTopology(agentId, content); + break; } } @@ -2085,20 +2097,21 @@ async function loadAgentSoul(agentId, content) { async function loadAgentMemory(agentId, content) { const canEdit = agentId !== 'rose'; + // Fetch memory.md + render search bar try { const agent = await api(`/api/agents/${agentId}`); const memory = agent.memory || ''; - if (!memory) { - content.innerHTML = `
-
🧠
-
No memory.md found
-
`; - return; - } content.innerHTML = ` +
+
+ + +
+ +
${canEdit ? `` : ''} -
${renderMarkdown(memory)}
+
${memory ? renderMarkdown(memory) : '
No memory.md found
'}