53b52a2337
- 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
265 lines
9.1 KiB
Python
265 lines
9.1 KiB
Python
AGENT_NAMES = {"hodor", "bran", "varys", "sam", "raven", "qyburn", "citadel", "jon", "hermes"}
|
|
|
|
STATUS_PHRASES = {
|
|
"status", "health", "running", "last run", "what did", "when did",
|
|
"show me", "how is", "is it", "is running", "did it run", "output",
|
|
"summary", "report", "check", "monitor", "alive", "up",
|
|
}
|
|
|
|
TASK_PHRASES = {
|
|
"create task", "add task", "add issue", "create issue", "log task",
|
|
"log this", "new task", "new issue", "add to plane", "add to backlog",
|
|
"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", "search", "find out", "look up", "what is", "explain",
|
|
"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())
|
|
|
|
# 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"
|
|
|
|
# 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:
|
|
return "status"
|
|
|
|
# Status if asking purely about the agent ecosystem
|
|
if words & AGENT_NAMES and not any(p in msg for p in {"build", "implement", "create", "make"}):
|
|
return "status"
|
|
|
|
# Research intent → route to smart model
|
|
if any(p in msg for p in RESEARCH_PHRASES):
|
|
return "planning"
|
|
|
|
return "planning"
|
|
|
|
|
|
def extract_agent_name(message: str) -> str | None:
|
|
msg = message.lower()
|
|
for name in AGENT_NAMES:
|
|
if name in msg:
|
|
return name
|
|
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",
|
|
"monitor": "Monitoring",
|
|
"monitoring": "Monitoring",
|
|
"grafana": "Monitoring",
|
|
"maester": "Maester Reports",
|
|
"report": "Maester Reports",
|
|
"csf": "Maester Reports",
|
|
"nist": "Maester Reports",
|
|
"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():
|
|
if kw in msg:
|
|
return project
|
|
return None
|