Phase 3: approval gate, execute intent, approve/reject routes

- 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
This commit is contained in:
2026-05-27 12:26:52 +00:00
parent a25deeb8f4
commit 53b52a2337
5 changed files with 710 additions and 16 deletions
+108
View File
@@ -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)
+189 -3
View File
@@ -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_PHRASES = {
"status", "health", "running", "last run", "what did", "when did", "status", "health", "running", "last run", "what did", "when did",
@@ -9,7 +9,17 @@ STATUS_PHRASES = {
TASK_PHRASES = { TASK_PHRASES = {
"create task", "add task", "add issue", "create issue", "log task", "create task", "add task", "add issue", "create issue", "log task",
"log this", "new task", "new issue", "add to plane", "add to backlog", "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 = { RESEARCH_PHRASES = {
@@ -17,12 +27,61 @@ RESEARCH_PHRASES = {
"how does", "documentation", "docs", "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: def classify_intent(message: str) -> str:
msg = message.lower() msg = message.lower()
words = set(msg.split()) 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 words & AGENT_NAMES:
if any(p in msg for p in STATUS_PHRASES) or words & {"status", "check", "output", "run"}: if any(p in msg for p in STATUS_PHRASES) or words & {"status", "check", "output", "run"}:
return "status" return "status"
@@ -30,6 +89,12 @@ def classify_intent(message: str) -> str:
# Explicit task phrases → task # Explicit task phrases → task
if any(p in msg for p in TASK_PHRASES): if any(p in msg for p in TASK_PHRASES):
return "task" 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 # Generic status signal words
if any(p in msg for p in STATUS_PHRASES) and words & AGENT_NAMES: 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 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 = { PROJECT_KEYWORDS = {
"bni": "BNI Scheduler", "bni": "BNI Scheduler",
"scheduler": "BNI Scheduler", "scheduler": "BNI Scheduler",
@@ -67,9 +162,100 @@ PROJECT_KEYWORDS = {
"portal": "Nexum Portal", "portal": "Nexum Portal",
"authelia": "Nexum Portal", "authelia": "Nexum Portal",
"nexum": "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: def extract_project_name(message: str) -> str | None:
msg = message.lower() msg = message.lower()
for kw, project in PROJECT_KEYWORDS.items(): for kw, project in PROJECT_KEYWORDS.items():
+284 -13
View File
@@ -2,6 +2,7 @@ import asyncio
import json import json
import logging import logging
import os import os
import re
import time import time
from datetime import datetime, timezone from datetime import datetime, timezone
from pathlib import Path from pathlib import Path
@@ -12,17 +13,39 @@ from fastapi.middleware.cors import CORSMiddleware
from fastapi.responses import JSONResponse, StreamingResponse from fastapi.responses import JSONResponse, StreamingResponse
from pydantic import BaseModel from pydantic import BaseModel
from .approval import cleanup_expired, generate_token, pop_action, queue_action, verify_token
from .brain import stream_completion from .brain import stream_completion
from .intent import classify_intent, extract_agent_name, extract_project_name from .intent import (
from .tools import create_plane_issue, get_agent_output, get_all_agent_status 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") logging.basicConfig(level=logging.INFO, format="%(asctime)s %(name)s %(levelname)s %(message)s")
logger = logging.getLogger("jon-snow") logger = logging.getLogger("jon-snow")
AGENT_OS_DIR = Path(os.getenv("AGENT_OS_DIR", "/opt/agent-os")) AGENT_OS_DIR = Path(os.getenv("AGENT_OS_DIR", "/opt/agent-os"))
SITES_DIR = Path(os.getenv("SITES_DIR", "/opt/sites")) 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=["*"]) 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). 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 - sam (8500): Research agent — SearXNG + Ollama synthesis
- raven (8400): Notifications — Discord webhook + Gmail SMTP - raven (8400): Notifications — Discord webhook + Gmail SMTP
- qyburn (8700): LLM coding agent — qwen2.5-coder:14b, approve/reject workflow - 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 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 3. Answer questions about the infrastructure
4. Route complex questions to your SMART_MODEL brain 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.""" 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 max_tokens: int | None = None
class QueueActionRequest(BaseModel):
description: str
action_type: str
params: dict
# --- Output helpers --- # --- Output helpers ---
def _write_status(intent: str, summary: str, status: str = "success") -> None: 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") @app.get("/health")
async def 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") @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") @app.post("/v1/chat/completions")
async def chat_completions(req: ChatRequest): async def chat_completions(req: ChatRequest):
user_message = next((m.content for m in reversed(req.messages) if m.role == "user"), "") 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 yield chunk
summary = f"Status query: {user_message[:100]}" 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": elif intent == "task":
project_hint = extract_project_name(user_message) # Live destination match first, static keyword map as fallback
title = user_message.strip() project_hint = extract_task_destination(user_message) or extract_project_name(user_message)
title = extract_task_title(user_message)
try: try:
issue = await create_plane_issue(title, project_hint) issue = await create_plane_issue(title, project_hint)
response_text = ( response_text = (
f"Task logged to Plane.\n\n" f"Task logged to Plane.\n\n"
f"**{issue['title']}** \n" f"**{issue['title']}** \n"
f"Project: *{issue['project']}* | #{issue['sequence_id']}\n\n" f"Project: *{issue['project']}* | #{issue['sequence_id']}"
f"I can't execute tasks yet (Phase 3). It's in the backlog."
) )
summary = f"Task created: {issue['title']}" summary = f"Task created: {issue['title']}"
except Exception as e: except Exception as e:
+125
View File
@@ -1,3 +1,4 @@
import asyncio
import json import json
import logging import logging
import os 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_WORKSPACE = os.getenv("PLANE_WORKSPACE", "nxm")
PLANE_API_KEY = os.getenv("PLANE_API_KEY", "") 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 = [ AGENT_FULL_NAMES = [
"hodor-gateway", "bran-changelog", "varys-monitor", "sam-research", "hodor-gateway", "bran-changelog", "varys-monitor", "sam-research",
"raven-notify", "qyburn-coder", "citadel-mcp", "jon-snow", "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] 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]: async def get_plane_projects() -> list[dict]:
headers = {"X-Api-Key": PLANE_API_KEY} headers = {"X-Api-Key": PLANE_API_KEY}
async with httpx.AsyncClient() as client: async with httpx.AsyncClient() as client:
@@ -74,6 +101,51 @@ async def get_plane_projects() -> list[dict]:
return resp.json().get("results", []) 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: def _resolve_project(projects: list[dict], hint: str | None) -> dict:
if hint: if hint:
hint_lower = hint.lower() hint_lower = hint.lower()
@@ -110,3 +182,56 @@ async def create_plane_issue(title: str, project_hint: str | None = None) -> dic
"title": title[:80], "title": title[:80],
"project": project["name"], "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()
+4
View File
@@ -18,6 +18,10 @@ services:
PLANE_API_KEY: ${PLANE_API_KEY} PLANE_API_KEY: ${PLANE_API_KEY}
AGENT_OS_DIR: /opt/agent-os AGENT_OS_DIR: /opt/agent-os
SITES_DIR: /opt/sites 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: networks:
- proxy - proxy