From 53b52a2337bc83f7401533b137651c6350c049db Mon Sep 17 00:00:00 2001 From: Jaco Bezuidenhout Date: Wed, 27 May 2026 12:26:52 +0000 Subject: [PATCH] Phase 3: approval gate, execute intent, approve/reject routes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - New: app/approval.py — HMAC-signed tokens, pending action store, 15-min TTL - New: /approve/{token} and /reject/{token} GET routes (public, for Discord links) - New: /internal/queue-action POST (for Citadel propose_file_change) - New: execute intent in classifier — restart/rebuild + agent name queues action - Updated: tools.py — notify_raven_with_actions, call_qyburn_rebuild/restart - Updated: intent.py — EXECUTE_VERBS, STACK_NAMES, extract_execute_target - Updated: SYSTEM_PROMPT reflects Phase 3 capabilities - Updated: docker-compose.yml — JON_SECRET, JON_PUBLIC_URL, RAVEN_URL, QYBURN_URL --- app/approval.py | 108 +++++++++++++++++ app/intent.py | 192 ++++++++++++++++++++++++++++- app/main.py | 297 +++++++++++++++++++++++++++++++++++++++++++-- app/tools.py | 125 +++++++++++++++++++ docker-compose.yml | 4 + 5 files changed, 710 insertions(+), 16 deletions(-) create mode 100644 app/approval.py diff --git a/app/approval.py b/app/approval.py new file mode 100644 index 0000000..92b5c1d --- /dev/null +++ b/app/approval.py @@ -0,0 +1,108 @@ +"""Jon Snow approval gate — token management and pending action store.""" +import base64 +import hashlib +import hmac +import os +import time +import uuid +from dataclasses import dataclass, field +from typing import Optional + +JON_SECRET = os.getenv("JON_SECRET", "change-me") +TOKEN_TTL = 900 # 15 minutes + + +@dataclass +class PendingAction: + id: str + description: str + action_type: str # "docker_rebuild", "docker_restart", "file_write" + params: dict + plane_issue_id: Optional[str] = None + plane_project_id: Optional[str] = None + created_at: float = field(default_factory=time.time) + + +_pending: dict[str, PendingAction] = {} +_used_tokens: set[str] = set() + + +def queue_action( + description: str, + action_type: str, + params: dict, + plane_issue_id: Optional[str] = None, + plane_project_id: Optional[str] = None, +) -> PendingAction: + """Create and store a pending action. Returns the action with a fresh UUID.""" + action = PendingAction( + id=str(uuid.uuid4()), + description=description, + action_type=action_type, + params=params, + plane_issue_id=plane_issue_id, + plane_project_id=plane_project_id, + ) + _pending[action.id] = action + return action + + +def generate_token(action_id: str, purpose: str) -> str: + """Generate a signed, time-stamped base64url token for approve or reject.""" + ts = str(int(time.time())) + msg = f"{action_id}:{ts}:{purpose}".encode() + sig = hmac.new(JON_SECRET.encode(), msg, hashlib.sha256).hexdigest()[:20] + raw = f"{action_id}:{ts}:{purpose}:{sig}" + return base64.urlsafe_b64encode(raw.encode()).decode().rstrip("=") + + +def _pad(s: str) -> str: + return s + "=" * (4 - len(s) % 4) + + +def verify_token(token: str) -> tuple[str, str]: + """Decode and verify a token. Returns (action_id, purpose). + Raises ValueError on expired, used, or invalid signature.""" + try: + raw = base64.urlsafe_b64decode(_pad(token)).decode() + parts = raw.split(":") + if len(parts) != 4: + raise ValueError("malformed token") + action_id, ts, purpose, sig = parts + except Exception: + raise ValueError("invalid token format") + + if token in _used_tokens: + raise ValueError("token already used") + + now = int(time.time()) + if now - int(ts) > TOKEN_TTL: + raise ValueError("token expired") + + # Verify HMAC + msg = f"{action_id}:{ts}:{purpose}".encode() + expected_sig = hmac.new(JON_SECRET.encode(), msg, hashlib.sha256).hexdigest()[:20] + if not hmac.compare_digest(sig, expected_sig): + raise ValueError("invalid token signature") + + _used_tokens.add(token) + return action_id, purpose + + +def pop_action(action_id: str) -> Optional[PendingAction]: + """Remove and return a pending action by ID. Returns None if not found.""" + return _pending.pop(action_id, None) + + +def get_action(action_id: str) -> Optional[PendingAction]: + """Get a pending action without removing it.""" + return _pending.get(action_id) + + +def cleanup_expired() -> int: + """Remove actions older than TOKEN_TTL. Returns count removed.""" + cutoff = time.time() - TOKEN_TTL + expired = [aid for aid, a in _pending.items() if a.created_at < cutoff] + for aid in expired: + del _pending[aid] + return len(expired) diff --git a/app/intent.py b/app/intent.py index 09aa067..ddee9d4 100644 --- a/app/intent.py +++ b/app/intent.py @@ -1,4 +1,4 @@ -AGENT_NAMES = {"hodor", "bran", "varys", "sam", "raven", "qyburn", "citadel", "jon"} +AGENT_NAMES = {"hodor", "bran", "varys", "sam", "raven", "qyburn", "citadel", "jon", "hermes"} STATUS_PHRASES = { "status", "health", "running", "last run", "what did", "when did", @@ -9,7 +9,17 @@ STATUS_PHRASES = { TASK_PHRASES = { "create task", "add task", "add issue", "create issue", "log task", "log this", "new task", "new issue", "add to plane", "add to backlog", - "plan", "schedule", "remind", "track", "todo", "to do", + "schedule", "remind", "track", "todo", "to do", +} + +# "plan" removed from TASK_PHRASES — it substring-matches "plane" (the tool name) +TASK_WORDS = {"plan"} + +QUERY_PHRASES = { + "list", "list all", "show all", "what are", "give me", "get", + "my projects", "all projects", "list projects", "show projects", + "issues", "tasks", "backlog", "what's in", "whats in", "show issues", + "list issues", "issues in", "tasks in", "work items", "work item", } RESEARCH_PHRASES = { @@ -17,12 +27,61 @@ RESEARCH_PHRASES = { "how does", "documentation", "docs", } +CREATE_PROJECT_PHRASES = { + "create project", "new project", "make project", "add project", + "create a project", "new project called", "create project called", + "start a project", "set up a project", + "create a plane project", "create plane project", + "create a new project", "make a new project", +} + +# Execute intent — words that signal a request to run/deploy a container +EXECUTE_VERBS = {"restart", "rebuild", "redeploy", "reload", "deploy"} + +# Short agent name → Docker stack directory name +STACK_NAMES = { + "hodor": "hodor-gateway", + "bran": "bran-changelog", + "varys": "varys-monitor", + "sam": "sam-research", + "raven": "raven-notify", + "qyburn": "qyburn-coder", + "citadel": "citadel-mcp", + "jon": "jon-snow", + "hermes": "hermes-cloud", + "monitoring": "monitoring", + "netbox": "netbox", + "plane": "plane", + "gitea": "gitea", + "searxng": "searxng", +} + def classify_intent(message: str) -> str: msg = message.lower() words = set(msg.split()) - # Agent name + query word → status + # Project creation — check before task/query + if any(p in msg for p in CREATE_PROJECT_PHRASES): + return "create_project" + + # Execute intent — agent/stack name + action verb → queue for approval + if words & EXECUTE_VERBS and words & set(STACK_NAMES.keys()): + return "execute" + if any(v in msg for v in EXECUTE_VERBS) and any(s in msg for s in STACK_NAMES): + return "execute" + + # Plane query — list/show projects or issues (check before task to avoid "plan" ⊂ "plane" bug) + if ("project" in msg or "projects" in msg) and any(p in msg for p in QUERY_PHRASES): + return "query" + if "plane" in words and any(p in msg for p in QUERY_PHRASES): + return "query" + # Issue list query — "show issues in X", "list tasks for X", "work items for X" + has_issue_word = bool(words & ISSUE_KEYWORDS) or any(p in msg for p in ISSUE_PHRASES) + if has_issue_word and any(p in msg for p in {"show", "list", "get", "what", "give", "in", "for"}): + return "query" + + # Agent name + status word → status if words & AGENT_NAMES: if any(p in msg for p in STATUS_PHRASES) or words & {"status", "check", "output", "run"}: return "status" @@ -30,6 +89,12 @@ def classify_intent(message: str) -> str: # Explicit task phrases → task if any(p in msg for p in TASK_PHRASES): return "task" + # Word-boundary match for single-word task terms (avoids "plan" matching "plane") + if words & TASK_WORDS: + return "task" + # Natural task creation: "add X to [project]" + if msg.startswith("add ") and " to " in msg: + return "task" # Generic status signal words if any(p in msg for p in STATUS_PHRASES) and words & AGENT_NAMES: @@ -54,6 +119,36 @@ def extract_agent_name(message: str) -> str | None: return None +def extract_execute_target(message: str) -> tuple[str, str] | None: + """Extract (action_type, stack_dir_name) from an execute message. + + Examples: + 'restart citadel' → ('docker_restart', 'citadel-mcp') + 'rebuild raven' → ('docker_rebuild', 'raven-notify') + 'redeploy hermes' → ('docker_rebuild', 'hermes-cloud') + + Returns None if action type or stack name cannot be determined. + """ + msg = message.lower() + words = set(msg.split()) + + action_type = None + if words & {"restart", "reload"}: + action_type = "docker_restart" + elif words & {"rebuild", "redeploy", "deploy"}: + action_type = "docker_rebuild" + + stack_dir = None + for short, full in STACK_NAMES.items(): + if short in words: + stack_dir = full + break + + if action_type and stack_dir: + return (action_type, stack_dir) + return None + + PROJECT_KEYWORDS = { "bni": "BNI Scheduler", "scheduler": "BNI Scheduler", @@ -67,9 +162,100 @@ PROJECT_KEYWORDS = { "portal": "Nexum Portal", "authelia": "Nexum Portal", "nexum": "Nexum Portal", + "general": "General / Admin", + "admin": "General / Admin", + "agent": "Agent Ecosystem", + "ecosystem": "Agent Ecosystem", + "milkwood": "Milkwood Primary", + "grgl": "GRGL", + "private": "Private Clients", } +ISSUE_KEYWORDS = {"issue", "issues", "task", "tasks", "backlog", "ticket", "tickets"} +ISSUE_PHRASES = {"work items", "work item"} + + +def is_issue_query(message: str) -> bool: + msg = message.lower() + return bool(set(msg.split()) & ISSUE_KEYWORDS) or any(p in msg for p in ISSUE_PHRASES) + + +def extract_task_title(message: str) -> str: + """Extract clean task title from natural phrasing like 'add buy milk to General'.""" + msg = message.strip() + lower = msg.lower() + if lower.startswith("add ") and " to " in lower: + parts = msg.rsplit(" to ", 1) + title = parts[0][4:].strip() + return title if title else msg + for prefix in ("create task:", "add task:", "log task:", "new task:", "log this:"): + if lower.startswith(prefix): + return msg[len(prefix):].strip() + return msg + + +def extract_compound_task(message: str) -> str | None: + """Extract a task title from compound messages like 'create project X and add [todo item,] Y'. + Returns the task title or None if no compound task found.""" + lower = message.lower() + # Split on compound markers + for marker in ("and add to do item,", "and add todo item,", "and add a task,", + "and add task,", "and add issue,", "and add to do,", + "and add to-do,", "and add,"): + idx = lower.find(marker) + if idx != -1: + return message[idx + len(marker):].strip().strip('"\'') + # Simpler: "and add [title]" — only if no comma separator + idx = lower.find(" and add ") + if idx != -1: + candidate = message[idx + 9:].strip() + # Skip if it looks like a project destination ("and add to X") + if not candidate.lower().startswith("to "): + return candidate.strip('"\'') + return None + + +def _trim_at_compound(text: str) -> str: + """Trim text at compound boundaries like 'and add'.""" + lower = text.lower() + for stop in (" and add", " then add", ", and", " also add"): + idx = lower.find(stop) + if idx != -1: + text = text[:idx] + return text.strip().strip('"\',.') + + +def extract_new_project_name(message: str) -> str | None: + """Extract project name from 'create project called X', 'new project: X', etc.""" + msg = message.strip() + lower = msg.lower() + for marker in ("called ", "named ", "name ", ": ", "- "): + idx = lower.find(marker) + if idx != -1: + name = _trim_at_compound(msg[idx + len(marker):]) + if name: + return name + for prefix in ("create a new project ", "create a plane project ", "create plane project ", + "create a project ", "create project ", "new project ", "make a new project ", + "make project ", "add project ", "start a project ", "set up a project "): + if lower.startswith(prefix): + name = _trim_at_compound(msg[len(prefix):]) + if name: + return name + return None + + +def extract_task_destination(message: str) -> str | None: + """Extract the destination project from 'add X to Y' → returns Y, or None.""" + lower = message.strip().lower() + if lower.startswith("add ") and " to " in lower: + parts = message.strip().rsplit(" to ", 1) + if len(parts) == 2: + return parts[1].strip() + return None + + def extract_project_name(message: str) -> str | None: msg = message.lower() for kw, project in PROJECT_KEYWORDS.items(): diff --git a/app/main.py b/app/main.py index 991c376..7b2aa88 100644 --- a/app/main.py +++ b/app/main.py @@ -2,6 +2,7 @@ import asyncio import json import logging import os +import re import time from datetime import datetime, timezone from pathlib import Path @@ -12,17 +13,39 @@ from fastapi.middleware.cors import CORSMiddleware from fastapi.responses import JSONResponse, StreamingResponse from pydantic import BaseModel +from .approval import cleanup_expired, generate_token, pop_action, queue_action, verify_token from .brain import stream_completion -from .intent import classify_intent, extract_agent_name, extract_project_name -from .tools import create_plane_issue, get_agent_output, get_all_agent_status +from .intent import ( + classify_intent, + extract_agent_name, + extract_compound_task, + extract_execute_target, + extract_new_project_name, + extract_project_name, + extract_task_destination, + extract_task_title, + is_issue_query, +) +from .tools import ( + call_qyburn_rebuild, + call_qyburn_restart, + create_plane_issue, + create_plane_project, + get_agent_output, + get_all_agent_status, + get_plane_issues, + get_plane_projects, + notify_raven_with_actions, +) logging.basicConfig(level=logging.INFO, format="%(asctime)s %(name)s %(levelname)s %(message)s") logger = logging.getLogger("jon-snow") AGENT_OS_DIR = Path(os.getenv("AGENT_OS_DIR", "/opt/agent-os")) SITES_DIR = Path(os.getenv("SITES_DIR", "/opt/sites")) +JON_PUBLIC_URL = os.getenv("JON_PUBLIC_URL", "https://jon.nxm.co.za") -app = FastAPI(title="Jon Snow", version="0.2.0") +app = FastAPI(title="Jon Snow", version="0.3.0") app.add_middleware(CORSMiddleware, allow_origins=["*"], allow_methods=["*"], allow_headers=["*"]) SYSTEM_PROMPT = """You are Jon Snow, chief of staff for the NxM home lab agent ecosystem on a self-hosted Linux server (172.27.40.3). @@ -34,17 +57,19 @@ Live agents you coordinate: - sam (8500): Research agent — SearXNG + Ollama synthesis - raven (8400): Notifications — Discord webhook + Gmail SMTP - qyburn (8700): LLM coding agent — qwen2.5-coder:14b, approve/reject workflow -- citadel (8300): MCP tool registry — 16 tools including Plane integration +- citadel (8300): MCP tool registry — 21 tools including Plane integration +- hermes: Cloud intelligence agent — claude-sonnet-4-6 brain, connected to Citadel read-only -Infrastructure: Ubuntu Docker host 172.27.40.3, Ollama at 172.27.40.20:11434, Netbird VPN (100.119.x.x), Plane project management, Gitea at git.nxm.co.za. +Infrastructure: Ubuntu Docker host 172.27.40.3, Ollama at 172.27.40.20:11434, Headscale VPN, Plane project management, Gitea at git.nxm.co.za. -Your current capabilities (Phase 2): +Your capabilities (Phase 3): 1. Report agent status — last run time, success/failure, output summary -2. Log tasks to Plane project management +2. List Plane projects and log tasks to Plane project management 3. Answer questions about the infrastructure 4. Route complex questions to your SMART_MODEL brain +5. Queue execution actions (restart/rebuild agents) — sends approval request via Discord before executing -You cannot yet execute tasks autonomously — that is Phase 3. When a user submits a task, log it to Plane and confirm. +For execution requests (restart/rebuild/redeploy), always queue for approval — never execute directly. Be concise — the user is often on mobile. Use short markdown lists, not long paragraphs.""" @@ -63,6 +88,12 @@ class ChatRequest(BaseModel): max_tokens: int | None = None +class QueueActionRequest(BaseModel): + description: str + action_type: str + params: dict + + # --- Output helpers --- def _write_status(intent: str, summary: str, status: str = "success") -> None: @@ -142,7 +173,7 @@ async def _stream_llm(messages: list[dict], use_smart: bool = False) -> AsyncGen @app.get("/health") async def health(): - return {"status": "ok", "agent": "jon-snow", "version": "0.2.0"} + return {"status": "ok", "agent": "jon-snow", "version": "0.3.0"} @app.get("/v1/models") @@ -153,6 +184,123 @@ async def list_models(): } +@app.get("/approve/{token}") +async def approve_action(token: str): + """Public endpoint — called when user clicks Approve in Discord. Executes the queued action.""" + cleanup_expired() + try: + action_id, purpose = verify_token(token) + except ValueError as e: + return JSONResponse( + {"error": str(e), "message": "Approval failed — token may be expired or already used."}, + status_code=400, + ) + + if purpose != "approve": + return JSONResponse({"error": "wrong token purpose"}, status_code=400) + + action = pop_action(action_id) + if not action: + return JSONResponse( + {"error": "action not found", "message": "Action not found — it may have already been executed or expired."}, + status_code=404, + ) + + logger.info(f"approved: {action.action_type} — {action.description}") + + try: + if action.action_type == "docker_rebuild": + result = await call_qyburn_rebuild(action.params["stack_name"]) + return JSONResponse({ + "ok": True, + "message": f"✅ Rebuilding {action.params['stack_name']}. Check container logs for progress.", + "result": result, + }) + elif action.action_type == "docker_restart": + result = await call_qyburn_restart(action.params["stack_name"]) + return JSONResponse({ + "ok": True, + "message": f"✅ Restarting {action.params['stack_name']}.", + "result": result, + }) + elif action.action_type == "file_write": + path = action.params.get("path", "") + content = action.params.get("content", "") + safe_path = re.sub(r"\.\.", "", path).lstrip("/") + target = SITES_DIR / safe_path + target.parent.mkdir(parents=True, exist_ok=True) + target.write_text(content) + return JSONResponse({ + "ok": True, + "message": f"✅ File written: /opt/sites/{safe_path}", + }) + else: + return JSONResponse( + {"error": f"Unknown action type: {action.action_type}"}, + status_code=400, + ) + except Exception as e: + logger.error(f"action execution failed: {e}") + return JSONResponse({"error": f"Execution failed: {e}"}, status_code=500) + + +@app.get("/reject/{token}") +async def reject_action(token: str): + """Public endpoint — called when user clicks Reject in Discord. Discards the queued action.""" + cleanup_expired() + try: + action_id, purpose = verify_token(token) + except ValueError as e: + return JSONResponse( + {"error": str(e), "message": "Rejection failed — token may be expired or already used."}, + status_code=400, + ) + + if purpose != "reject": + return JSONResponse({"error": "wrong token purpose"}, status_code=400) + + action = pop_action(action_id) + if not action: + return JSONResponse( + {"error": "action not found", "message": "Action not found — it may have already been handled."}, + status_code=404, + ) + + logger.info(f"rejected: {action.action_type} — {action.description}") + return JSONResponse({ + "ok": True, + "message": f"❌ Action rejected: {action.description}", + }) + + +@app.post("/internal/queue-action") +async def internal_queue_action(req: QueueActionRequest): + """Internal endpoint — called by Citadel's propose_file_change tool to queue a file write for approval.""" + action = queue_action( + description=req.description, + action_type=req.action_type, + params=req.params, + ) + + approve_url = f"{JON_PUBLIC_URL}/approve/{generate_token(action.id, 'approve')}" + reject_url = f"{JON_PUBLIC_URL}/reject/{generate_token(action.id, 'reject')}" + + await notify_raven_with_actions( + description=req.description, + approve_url=approve_url, + reject_url=reject_url, + action_type=req.action_type, + ) + + logger.info(f"queued from citadel: {req.action_type} — {req.description}") + return { + "ok": True, + "action_id": action.id, + "approve_url": approve_url, + "reject_url": reject_url, + } + + @app.post("/v1/chat/completions") async def chat_completions(req: ChatRequest): user_message = next((m.content for m in reversed(req.messages) if m.role == "user"), "") @@ -178,16 +326,139 @@ async def chat_completions(req: ChatRequest): yield chunk summary = f"Status query: {user_message[:100]}" + elif intent == "execute": + target = extract_execute_target(user_message) + if not target: + response_text = "I couldn't identify what to execute. Try: _restart citadel_ or _rebuild raven_." + summary = "execute: no target extracted" + else: + action_type, stack_name = target + verb = "Restart" if action_type == "docker_restart" else "Rebuild" + description = f"{verb} {stack_name}" + + action = queue_action( + description=description, + action_type=action_type, + params={"stack_name": stack_name}, + ) + + approve_url = f"{JON_PUBLIC_URL}/approve/{generate_token(action.id, 'approve')}" + reject_url = f"{JON_PUBLIC_URL}/reject/{generate_token(action.id, 'reject')}" + + await notify_raven_with_actions( + description=description, + approve_url=approve_url, + reject_url=reject_url, + action_type=action_type, + ) + + response_text = ( + f"⚡ Approval requested for: **{description}**\n\n" + f"A Discord notification has been sent with approve/reject links.\n" + f"Token expires in 15 minutes." + ) + summary = f"execute queued: {description}" + + async for chunk in _stream_text(response_text): + yield chunk + + elif intent == "create_project": + name = extract_new_project_name(user_message) + if not name: + response_text = "What should the project be called?" + summary = "create_project: no name extracted" + else: + try: + project = await create_plane_project(name) + response_text = ( + f"Project created.\n\n" + f"**{project['name']}** (`{project['identifier']}`)" + ) + summary = f"Created project: {project['name']}" + # Handle compound: "create project X and add [task]" + task_title = extract_compound_task(user_message) + if task_title: + issue = await create_plane_issue(task_title, project["name"]) + response_text += ( + f"\n\n**{issue['title']}** added.\n" + f"Project: *{issue['project']}* | #{issue['sequence_id']}" + ) + summary += f" + issue: {task_title[:60]}" + else: + response_text += f"\n\nAdd issues with: _add [task] to {project['name']}_" + except Exception as e: + response_text = f"Couldn't create project: {e}" + summary = f"create_project error: {e}" + async for chunk in _stream_text(response_text): + yield chunk + + elif intent == "query": + try: + if is_issue_query(user_message): + # Match against live project list — most robust, no static keywords needed + projects = await get_plane_projects() + msg_lower = user_message.lower() + project = None + # Longest-name-first so "General / Admin" beats "General" + for p in sorted(projects, key=lambda x: len(x["name"]), reverse=True): + name_words = p["name"].lower().replace("/", " ").replace("-", " ") + # Match if all significant words of the project name appear in the message + sig_words = [w for w in name_words.split() if len(w) > 2] + if sig_words and all(w in msg_lower for w in sig_words): + project = p + break + # Or if the identifier appears + if p.get("identifier", "").lower() in msg_lower: + project = p + break + if not project: + # Fall back to static keyword map + hint = extract_project_name(user_message) + if hint: + _, _ = await get_plane_issues(hint) # resolve via tools + project_name_fb, issues_fb = await get_plane_issues(hint) + if project_name_fb: + project = {"id": None, "name": project_name_fb} + + if not project: + names = "\n".join(f"- {p['name']}" for p in projects) + response_text = f"Which project? Available:\n\n{names}" + else: + project_name, issues = await get_plane_issues(project["name"]) + if not issues: + response_text = f"**{project['name']}** — no issues found." + else: + PRIORITY_ICON = {"urgent": "🔴", "high": "🟠", "medium": "🟡", "low": "🔵", "none": "⚪"} + lines = "\n".join( + f"- [{i['state']}] {PRIORITY_ICON.get(i['priority'], '')} {i['title']}" + for i in issues + ) + response_text = f"**{project['name']} — {len(issues)} issue(s):**\n\n{lines}" + summary = f"Issues query: {user_message[:80]}" + else: + projects = await get_plane_projects() + lines = "\n".join( + f"- **{p.get('identifier', '?')}** — {p['name']}" + for p in projects + ) + response_text = f"**Plane Projects ({len(projects)}):**\n\n{lines}" + summary = f"Listed {len(projects)} Plane projects" + except Exception as e: + response_text = f"Couldn't reach Plane: {e}" + summary = f"Plane query error: {e}" + async for chunk in _stream_text(response_text): + yield chunk + elif intent == "task": - project_hint = extract_project_name(user_message) - title = user_message.strip() + # Live destination match first, static keyword map as fallback + project_hint = extract_task_destination(user_message) or extract_project_name(user_message) + title = extract_task_title(user_message) try: issue = await create_plane_issue(title, project_hint) response_text = ( f"Task logged to Plane.\n\n" f"**{issue['title']}** \n" - f"Project: *{issue['project']}* | #{issue['sequence_id']}\n\n" - f"I can't execute tasks yet (Phase 3). It's in the backlog." + f"Project: *{issue['project']}* | #{issue['sequence_id']}" ) summary = f"Task created: {issue['title']}" except Exception as e: diff --git a/app/tools.py b/app/tools.py index 8d1b122..eb253b6 100644 --- a/app/tools.py +++ b/app/tools.py @@ -1,3 +1,4 @@ +import asyncio import json import logging import os @@ -11,6 +12,9 @@ PLANE_URL = os.getenv("PLANE_URL", "http://172.27.40.3:8095") PLANE_WORKSPACE = os.getenv("PLANE_WORKSPACE", "nxm") PLANE_API_KEY = os.getenv("PLANE_API_KEY", "") +RAVEN_URL = os.getenv("RAVEN_URL", "http://raven-notify:8400") +QYBURN_URL = os.getenv("QYBURN_URL", "http://qyburn-coder:8700") + AGENT_FULL_NAMES = [ "hodor-gateway", "bran-changelog", "varys-monitor", "sam-research", "raven-notify", "qyburn-coder", "citadel-mcp", "jon-snow", @@ -63,6 +67,29 @@ def get_agent_output(sites_dir: Path, agent_name: str) -> str | None: return content[:3000] +def _make_identifier(name: str) -> str: + import re + return re.sub(r"[^A-Z0-9]", "", name.upper())[:10] or "PROJ" + + +async def create_plane_project(name: str) -> dict: + identifier = _make_identifier(name) + headers = {"X-Api-Key": PLANE_API_KEY, "Content-Type": "application/json"} + async with httpx.AsyncClient() as client: + resp = await client.post( + f"{PLANE_URL}/api/v1/workspaces/{PLANE_WORKSPACE}/projects/", + headers=headers, + json={"name": name, "identifier": identifier, "network": 2}, + timeout=10, + ) + if resp.status_code == 400: + data = resp.json() + raise ValueError(data.get("identifier", [data])[0] if "identifier" in data else str(data)) + resp.raise_for_status() + data = resp.json() + return {"name": data["name"], "identifier": data["identifier"]} + + async def get_plane_projects() -> list[dict]: headers = {"X-Api-Key": PLANE_API_KEY} async with httpx.AsyncClient() as client: @@ -74,6 +101,51 @@ async def get_plane_projects() -> list[dict]: return resp.json().get("results", []) +async def get_plane_issues(project_hint: str) -> tuple[str, list[dict]]: + """Return (project_name, issues). Resolves project by name/identifier fragment.""" + projects = await get_plane_projects() + hint = project_hint.lower() + + project = None + for p in projects: + if p.get("identifier", "").lower() == hint: + project = p + break + if not project: + matches = [p for p in projects if hint in p["name"].lower()] + if matches: + project = matches[0] + + if not project: + return ("", []) + + headers = {"X-Api-Key": PLANE_API_KEY} + async with httpx.AsyncClient() as client: + states_resp, issues_resp = await asyncio.gather( + client.get( + f"{PLANE_URL}/api/v1/workspaces/{PLANE_WORKSPACE}/projects/{project['id']}/states/", + headers=headers, timeout=10, + ), + client.get( + f"{PLANE_URL}/api/v1/workspaces/{PLANE_WORKSPACE}/projects/{project['id']}/issues/", + headers=headers, timeout=10, + ), + ) + states_resp.raise_for_status() + issues_resp.raise_for_status() + + state_map = {s["id"]: s["name"] for s in states_resp.json().get("results", [])} + issues = [ + { + "title": i.get("name", ""), + "state": state_map.get(i.get("state", ""), ""), + "priority": i.get("priority", "none"), + } + for i in issues_resp.json().get("results", []) + ] + return (project["name"], issues) + + def _resolve_project(projects: list[dict], hint: str | None) -> dict: if hint: hint_lower = hint.lower() @@ -110,3 +182,56 @@ async def create_plane_issue(title: str, project_hint: str | None = None) -> dic "title": title[:80], "project": project["name"], } + + +# --------------------------------------------------------------------------- +# Approval gate helpers +# --------------------------------------------------------------------------- + +async def notify_raven_with_actions( + description: str, + approve_url: str, + reject_url: str, + action_type: str, +) -> None: + """Send a Discord approval request via Raven's /notify-with-actions endpoint.""" + payload = { + "description": description, + "approve_url": approve_url, + "reject_url": reject_url, + "action_type": action_type, + "source": "jon-snow", + } + async with httpx.AsyncClient() as client: + try: + resp = await client.post( + f"{RAVEN_URL}/notify-with-actions", + json=payload, + timeout=10, + ) + resp.raise_for_status() + logger.info(f"raven notified for action: {action_type}") + except Exception as e: + logger.error(f"raven notify failed: {e}") + + +async def call_qyburn_rebuild(stack_name: str) -> dict: + """Trigger a docker rebuild (compose up -d --build) via Qyburn.""" + async with httpx.AsyncClient() as client: + resp = await client.post( + f"{QYBURN_URL}/rebuild/{stack_name}", + timeout=300, # builds can take a while + ) + resp.raise_for_status() + return resp.json() + + +async def call_qyburn_restart(stack_name: str) -> dict: + """Trigger a docker restart (compose restart) via Qyburn.""" + async with httpx.AsyncClient() as client: + resp = await client.post( + f"{QYBURN_URL}/restart/{stack_name}", + timeout=120, + ) + resp.raise_for_status() + return resp.json() diff --git a/docker-compose.yml b/docker-compose.yml index 41b374e..9efbe13 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -18,6 +18,10 @@ services: PLANE_API_KEY: ${PLANE_API_KEY} AGENT_OS_DIR: /opt/agent-os SITES_DIR: /opt/sites + RAVEN_URL: ${RAVEN_URL:-http://raven-notify:8400} + QYBURN_URL: ${QYBURN_URL:-http://qyburn-coder:8700} + JON_PUBLIC_URL: ${JON_PUBLIC_URL:-https://jon.nxm.co.za} + JON_SECRET: ${JON_SECRET} networks: - proxy