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", "job item", "job ticket", "work order", } # "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()) # Open WebUI background tasks — sent automatically after every chat response. # They include the full chat history, so they'd mis-trigger execute/task intents. # Patterns: "### Task:", "Query: " (query augmentation), meta-task keywords. if (msg.startswith("### task:") or msg.startswith("query:") or "chat history" in msg or "summarizing the chat" in msg or "categorizing the main themes" in msg or "follow-up questions" in msg or "3-5 word title" in msg): return "planning" # 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" # Explicit task phrases — check before query to prevent "add task X" → query mis-route if any(p in msg for p in TASK_PHRASES): return "task" if words & TASK_WORDS: return "task" # 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" # Use word-boundary check (words set) to avoid "in" matching inside "incident" etc. has_issue_word = bool(words & ISSUE_KEYWORDS) or any(p in msg for p in ISSUE_PHRASES) if has_issue_word and words & {"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" # Natural task creation: "add X to [project]" if msg.startswith("add ") and " to " in msg: return "task" # "please add" / "add" anywhere + known project keyword → task if "add" in words and any(kw in msg for kw in PROJECT_KEYWORDS): 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'.""" import re as _re msg = message.strip() lower = msg.lower() # Explicit prefixes for prefix in ("create task:", "add task:", "log task:", "new task:", "log this:"): if lower.startswith(prefix): return msg[len(prefix):].strip() # Item noun phrases — "add X a job/work item [description]" # Check these before "add X to Y" so the wrong " to " isn't captured for noun in (r'\bjob item\b', r'\bwork item\b', r'\bwork order\b', r'\bjob ticket\b'): m = _re.search(noun + r'\s*(.*)', lower) if m: remainder = msg[m.start(1):].strip() for filler in ("that we need to ", "that is ", "which is ", "to "): if remainder.lower().startswith(filler): remainder = remainder[len(filler):] # "project, title" leftover → take after the comma if "," in remainder: remainder = remainder[remainder.index(",") + 1:].strip() if remainder.strip(): return remainder.strip() # "please add X to [project], title" or "add X to [project], title" work_msg = msg[7:].strip() if lower.startswith("please ") else msg work_lower = work_msg.lower() if work_lower.startswith("add ") and " to " in work_lower: to_idx = work_lower.rfind(" to ") after_to = work_msg[to_idx + 4:] if "," in after_to: title_candidate = after_to[after_to.index(",") + 1:].strip() if title_candidate: return title_candidate # No comma — what's between "add " and " to [project]" is the title before_to = work_msg[4:to_idx].strip() for noise in ("a work item", "an issue", "a task", "a job item", "a ticket", "an item", "a to-do", "a todo"): if before_to.lower().startswith(noise): remainder = before_to[len(noise):].strip() if remainder: return remainder if before_to: return before_to 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