From be4476ce4d5a3a96e7477e0159d47e4760837280 Mon Sep 17 00:00:00 2001 From: roberts Date: Mon, 30 Mar 2026 13:15:59 -0500 Subject: [PATCH] Phase 3b: Command parsing, AI context, player instructions MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - CommandParser: regex-based parsing for follow, stop, go, chest, craft, mine, give, attack, look commands - Commands converted to high-priority tasks in the brain's queue - AI prompt now includes real-time context: current action, health, nearby players, hostiles, time of day - Doug answers "what are you doing?" truthfully based on actual state - Player commands get personality-appropriate AI acknowledgments - Brain's wants_ai_chat signal wired for unprompted chat Supported commands: "Doug, follow me" → follow_player task "Doug, stop" → stop task "Doug, open that chest" → open_chest task "Doug, sort the chest" → sort_chest task "Doug, craft a pickaxe" → craft_item task "Doug, mine some stone" → find + dig task "Doug, attack that" → attack_nearest_hostile task Co-Authored-By: Claude Opus 4.6 (1M context) --- dougbot/ai/prompt_builder.py | 20 +- dougbot/core/command_parser.py | 326 +++++++++++++++++++++++++++++++++ dougbot/gui/main_window.py | 53 +++++- 3 files changed, 395 insertions(+), 4 deletions(-) create mode 100644 dougbot/core/command_parser.py diff --git a/dougbot/ai/prompt_builder.py b/dougbot/ai/prompt_builder.py index e5fd762..e919f78 100644 --- a/dougbot/ai/prompt_builder.py +++ b/dougbot/ai/prompt_builder.py @@ -98,15 +98,33 @@ def build_system_prompt( # Custom notes custom = custom_notes.strip() if custom_notes else "" + # Context — what Doug is currently doing/seeing + context_line = "" + if context: + ctx_parts = [] + if context.get("current_action") and context["current_action"] != "idle": + ctx_parts.append(f"Currently: {context['current_action']}") + if context.get("health", 20) < 10: + ctx_parts.append(f"Low health ({context['health']})") + if context.get("nearby_players"): + ctx_parts.append(f"With: {', '.join(context['nearby_players'])}") + if context.get("time_of_day") == "night": + ctx_parts.append("It's night") + if context.get("nearby_hostiles"): + ctx_parts.append(f"Mobs nearby: {', '.join(context['nearby_hostiles'][:2])}") + if ctx_parts: + context_line = f"Right now: {'. '.join(ctx_parts)}." + # Build the prompt — keep it SHORT parts = [ identity, personality, lang, custom, + context_line, "", "Rules: Reply in ONE short sentence. Under 15 words. Talk like a normal person.", - "NEVER make up activities. If asked what you are doing, say not much or just hanging out.", + "If asked what you are doing, answer based on your current activity above.", "Plain text only. Do not start with your name.", ] diff --git a/dougbot/core/command_parser.py b/dougbot/core/command_parser.py new file mode 100644 index 0000000..b0295d9 --- /dev/null +++ b/dougbot/core/command_parser.py @@ -0,0 +1,326 @@ +""" +Parse player chat messages into actionable commands for Doug. +Uses keyword matching for common commands and falls back to AI for complex requests. +""" + +import re +from dataclasses import dataclass +from typing import Optional + +from dougbot.core.task_queue import Task, Priority +from dougbot.utils.logging import get_logger + +log = get_logger("core.commands") + + +@dataclass +class ParsedCommand: + """A command extracted from a chat message.""" + action: str # What to do + target: str = "" # Who/what it's about + params: dict = None # Extra parameters + raw_message: str = "" # Original message + confidence: float = 1.0 # How sure we are (1.0 = keyword match, <1.0 = fuzzy) + + def __post_init__(self): + if self.params is None: + self.params = {} + + +class CommandParser: + """Parse player messages into commands.""" + + # Movement commands + FOLLOW_PATTERNS = [ + r"(?:come|follow)\s+(?:me|here|over here)", + r"(?:come|walk|go)\s+(?:to|over)\s+(?:me|here)", + r"(?:follow)\s+(?:me|along)", + r"(?:over here|come here)", + ] + + STOP_PATTERNS = [ + r"^stop", + r"(?:stop|halt|wait|stay|freeze|don'?t move)", + r"stand\s+still", + ] + + GO_PATTERNS = [ + r"(?:go|walk|move|run|head)\s+(?:to|toward|towards|over to)\s+(.+)", + r"(?:go|walk|move)\s+(?:north|south|east|west|forward|back|left|right)", + ] + + # Interaction commands + CHEST_PATTERNS = [ + r"(?:open|check|look in|look at|inspect)\s+(?:that|this|the)?\s*(?:chest|barrel|container|box)", + r"(?:sort|organize|clean)\s+(?:that|this|the)?\s*(?:chest|barrel|container|box|inventory)", + r"what'?s?\s+in\s+(?:that|this|the)\s+(?:chest|barrel|container)", + ] + + CRAFT_PATTERNS = [ + r"(?:craft|make|build|create)\s+(?:a\s+|an\s+|some\s+)?(.+)", + ] + + MINE_PATTERNS = [ + r"(?:mine|dig|break|destroy)\s+(?:that|this|the)?\s*(.+)", + ] + + GIVE_PATTERNS = [ + r"(?:give|hand|pass|toss)\s+(?:me|us)\s+(?:a\s+|an\s+|some\s+)?(.+)", + ] + + # Social commands + ATTACK_PATTERNS = [ + r"(?:attack|fight|kill|hit)\s+(?:that|this|the)?\s*(.+)", + ] + + LOOK_PATTERNS = [ + r"(?:look|look at|check out)\s+(?:that|this|the|over there|around)", + ] + + def __init__(self, doug_name: str): + self._doug_name = doug_name + self._short_name = doug_name.lower().split("-")[0].split("_")[0] + + def parse(self, sender: str, message: str) -> Optional[ParsedCommand]: + """Parse a chat message into a command, if it's directed at Doug.""" + msg = message.lower().strip() + + # Remove Doug's name from the beginning if present + for prefix in [f"{self._doug_name.lower()},", f"{self._doug_name.lower()} ", + f"{self._short_name},", f"{self._short_name} "]: + if msg.startswith(prefix): + msg = msg[len(prefix):].strip() + break + + # Try each command pattern + cmd = self._try_follow(msg, sender) + if cmd: return cmd + + cmd = self._try_stop(msg, sender) + if cmd: return cmd + + cmd = self._try_go(msg, sender) + if cmd: return cmd + + cmd = self._try_chest(msg, sender) + if cmd: return cmd + + cmd = self._try_craft(msg, sender) + if cmd: return cmd + + cmd = self._try_mine(msg, sender) + if cmd: return cmd + + cmd = self._try_give(msg, sender) + if cmd: return cmd + + cmd = self._try_attack(msg, sender) + if cmd: return cmd + + cmd = self._try_look(msg, sender) + if cmd: return cmd + + # No keyword match — return None (caller can use AI to interpret) + return None + + def _try_follow(self, msg: str, sender: str) -> Optional[ParsedCommand]: + for pattern in self.FOLLOW_PATTERNS: + if re.search(pattern, msg, re.IGNORECASE): + return ParsedCommand( + action="follow_player", + target=sender, + raw_message=msg, + ) + return None + + def _try_stop(self, msg: str, sender: str) -> Optional[ParsedCommand]: + for pattern in self.STOP_PATTERNS: + if re.search(pattern, msg, re.IGNORECASE): + return ParsedCommand(action="stop", raw_message=msg) + return None + + def _try_go(self, msg: str, sender: str) -> Optional[ParsedCommand]: + for pattern in self.GO_PATTERNS: + match = re.search(pattern, msg, re.IGNORECASE) + if match: + target = match.group(1) if match.lastindex else "" + return ParsedCommand( + action="go_to", + target=target.strip(), + raw_message=msg, + ) + return None + + def _try_chest(self, msg: str, sender: str) -> Optional[ParsedCommand]: + for pattern in self.CHEST_PATTERNS: + if re.search(pattern, msg, re.IGNORECASE): + is_sort = any(w in msg for w in ["sort", "organize", "clean"]) + return ParsedCommand( + action="sort_chest" if is_sort else "check_chest", + raw_message=msg, + ) + return None + + def _try_craft(self, msg: str, sender: str) -> Optional[ParsedCommand]: + for pattern in self.CRAFT_PATTERNS: + match = re.search(pattern, msg, re.IGNORECASE) + if match: + item = match.group(1).strip() if match.lastindex else "" + # Normalize item name + item = item.replace(" ", "_").lower() + return ParsedCommand( + action="craft", + target=item, + raw_message=msg, + ) + return None + + def _try_mine(self, msg: str, sender: str) -> Optional[ParsedCommand]: + for pattern in self.MINE_PATTERNS: + match = re.search(pattern, msg, re.IGNORECASE) + if match: + target = match.group(1).strip() if match.lastindex else "" + return ParsedCommand( + action="mine", + target=target.replace(" ", "_").lower(), + raw_message=msg, + ) + return None + + def _try_give(self, msg: str, sender: str) -> Optional[ParsedCommand]: + for pattern in self.GIVE_PATTERNS: + match = re.search(pattern, msg, re.IGNORECASE) + if match: + item = match.group(1).strip() if match.lastindex else "" + return ParsedCommand( + action="give", + target=item.replace(" ", "_").lower(), + params={"recipient": sender}, + raw_message=msg, + ) + return None + + def _try_attack(self, msg: str, sender: str) -> Optional[ParsedCommand]: + for pattern in self.ATTACK_PATTERNS: + match = re.search(pattern, msg, re.IGNORECASE) + if match: + target = match.group(1).strip() if match.lastindex else "" + return ParsedCommand( + action="attack", + target=target, + raw_message=msg, + ) + return None + + def _try_look(self, msg: str, sender: str) -> Optional[ParsedCommand]: + for pattern in self.LOOK_PATTERNS: + if re.search(pattern, msg, re.IGNORECASE): + return ParsedCommand(action="look_around", raw_message=msg) + return None + + +def command_to_task(cmd: ParsedCommand, behaviors) -> Optional[Task]: + """Convert a parsed command into a Task for the queue.""" + + if cmd.action == "follow_player": + return Task( + name=f"follow_{cmd.target}", + priority=Priority.HIGH, + action="follow_player", + params={"name": cmd.target, "range": 3}, + description=f"Following {cmd.target}", + timeout=60, + ) + + elif cmd.action == "stop": + return Task( + name="stop", + priority=Priority.HIGH, + action="stop", + params={}, + description="Stopping", + timeout=5, + ) + + elif cmd.action == "check_chest": + # Find nearest container + if behaviors.nearby_containers: + container = behaviors.nearby_containers[0] + return Task( + name="check_chest", + priority=Priority.HIGH, + action="open_chest", + params=container["position"], + description="Checking the chest", + timeout=15, + callback="on_container_opened", + ) + + elif cmd.action == "sort_chest": + if behaviors.nearby_containers: + container = behaviors.nearby_containers[0] + return Task( + name="sort_chest", + priority=Priority.HIGH, + action="open_chest", + params=container["position"], + description="Sorting the chest", + timeout=30, + callback="on_sort_container", + ) + + elif cmd.action == "craft": + return Task( + name=f"craft_{cmd.target}", + priority=Priority.HIGH, + action="craft_item", + params={"itemName": cmd.target, "count": 1}, + description=f"Crafting {cmd.target.replace('_', ' ')}", + timeout=15, + ) + + elif cmd.action == "mine": + return Task( + name=f"mine_{cmd.target}", + priority=Priority.HIGH, + action="find_blocks", + params={"blockName": cmd.target, "radius": 16, "count": 1}, + description=f"Looking for {cmd.target.replace('_', ' ')} to mine", + timeout=30, + callback="on_found_blocks_to_mine", + ) + + elif cmd.action == "attack": + return Task( + name="attack_command", + priority=Priority.HIGH, + action="attack_nearest_hostile", + params={"range": 8}, + description=f"Attacking!", + timeout=10, + ) + + elif cmd.action == "look_around": + return Task( + name="look_around_command", + priority=Priority.HIGH, + action="scan_surroundings", + params={"radius": 16}, + description="Looking around", + timeout=10, + callback="on_look_around_report", + ) + + elif cmd.action == "go_to": + # For named destinations, we'd need a memory system + # For now, try to interpret as a player name + return Task( + name=f"go_to_{cmd.target}", + priority=Priority.HIGH, + action="follow_player", + params={"name": cmd.target, "range": 3}, + description=f"Going to {cmd.target}", + timeout=30, + ) + + return None diff --git a/dougbot/gui/main_window.py b/dougbot/gui/main_window.py index d9ab706..1b08c56 100644 --- a/dougbot/gui/main_window.py +++ b/dougbot/gui/main_window.py @@ -16,6 +16,7 @@ from dougbot.db.models import DougModel, PersonaConfig from dougbot.bridge.node_manager import NodeManager from dougbot.bridge.ws_client import BridgeWSClient from dougbot.core.brain import DougBrain +from dougbot.core.command_parser import CommandParser, command_to_task from dougbot.ai.ollama_client import OllamaClient from dougbot.ai.prompt_builder import build_system_prompt from dougbot.utils.logging import get_logger @@ -383,7 +384,11 @@ class MainWindow(QMainWindow): # Check if message is directed at Doug if self._active_doug and self._should_respond(message): - self._generate_response(sender, message) + # Try command parsing first + handled = self._try_command(sender, message) + if not handled: + # Fall back to AI chat response + self._generate_response(sender, message) elif event == "player_joined": username = data.get("username", "Unknown") @@ -405,13 +410,41 @@ class MainWindow(QMainWindow): if self._brain: self._brain.update_from_event(event, data) + # ── Command Parsing ── + + def _try_command(self, sender: str, message: str) -> bool: + """Try to parse a chat message as a command. Returns True if handled.""" + if not self._active_doug or not self._brain: + return False + + parser = CommandParser(self._active_doug.name) + cmd = parser.parse(sender, message) + if not cmd: + return False + + # Convert command to task + task = command_to_task(cmd, self._brain._behaviors) + if not task: + return False + + # Add task to brain's queue + self._brain._tasks.add(task) + log.info(f"Command from {sender}: {cmd.action} → task '{task.name}'") + + # Generate a personality-appropriate acknowledgment via AI + ack_prompt = ( + f"{sender} asked you to: {message}. " + f"You understood and are now doing it. Acknowledge briefly." + ) + self._generate_response("SYSTEM", f"[Command: {cmd.action}] {ack_prompt}") + return True + # ── Brain Chat ── def _on_brain_wants_chat(self, context: str, prompt: str): """Brain wants Doug to say something unprompted.""" if not self._active_doug: return - # Use AI to generate what Doug says self._generate_response("SYSTEM", f"[Context: {context}] {prompt}") # ── Chat AI ── @@ -453,9 +486,23 @@ class MainWindow(QMainWindow): self.dashboard.log_viewer.append_system(f"Thinking... ({sender} said: {message[:50]})") - # Build prompt + # Build context from brain state + ai_context = None + if self._brain: + b = self._brain._behaviors + ai_context = { + "current_action": self._brain.current_action, + "health": b.health, + "food": b.food, + "nearby_players": [p["name"] for p in b.nearby_players], + "nearby_hostiles": [h["type"] for h in b.nearby_hostiles[:3]], + "time_of_day": "night" if b.is_night else "day", + } + + # Build prompt with context system_prompt = build_system_prompt( name=doug.name, age=doug.age, persona=persona, + context=ai_context, custom_notes=doug.custom_notes, )