Phase 3b: Command parsing, AI context, player instructions

- 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) <noreply@anthropic.com>
This commit is contained in:
roberts 2026-03-30 13:15:59 -05:00
parent b609d4c896
commit be4476ce4d
3 changed files with 395 additions and 4 deletions

View file

@ -98,15 +98,33 @@ def build_system_prompt(
# Custom notes # Custom notes
custom = custom_notes.strip() if custom_notes else "" 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 # Build the prompt — keep it SHORT
parts = [ parts = [
identity, identity,
personality, personality,
lang, lang,
custom, custom,
context_line,
"", "",
"Rules: Reply in ONE short sentence. Under 15 words. Talk like a normal person.", "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.", "Plain text only. Do not start with your name.",
] ]

View file

@ -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

View file

@ -16,6 +16,7 @@ from dougbot.db.models import DougModel, PersonaConfig
from dougbot.bridge.node_manager import NodeManager from dougbot.bridge.node_manager import NodeManager
from dougbot.bridge.ws_client import BridgeWSClient from dougbot.bridge.ws_client import BridgeWSClient
from dougbot.core.brain import DougBrain 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.ollama_client import OllamaClient
from dougbot.ai.prompt_builder import build_system_prompt from dougbot.ai.prompt_builder import build_system_prompt
from dougbot.utils.logging import get_logger from dougbot.utils.logging import get_logger
@ -383,7 +384,11 @@ class MainWindow(QMainWindow):
# Check if message is directed at Doug # Check if message is directed at Doug
if self._active_doug and self._should_respond(message): 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": elif event == "player_joined":
username = data.get("username", "Unknown") username = data.get("username", "Unknown")
@ -405,13 +410,41 @@ class MainWindow(QMainWindow):
if self._brain: if self._brain:
self._brain.update_from_event(event, data) 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 ── # ── Brain Chat ──
def _on_brain_wants_chat(self, context: str, prompt: str): def _on_brain_wants_chat(self, context: str, prompt: str):
"""Brain wants Doug to say something unprompted.""" """Brain wants Doug to say something unprompted."""
if not self._active_doug: if not self._active_doug:
return return
# Use AI to generate what Doug says
self._generate_response("SYSTEM", f"[Context: {context}] {prompt}") self._generate_response("SYSTEM", f"[Context: {context}] {prompt}")
# ── Chat AI ── # ── Chat AI ──
@ -453,9 +486,23 @@ class MainWindow(QMainWindow):
self.dashboard.log_viewer.append_system(f"Thinking... ({sender} said: {message[:50]})") 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( system_prompt = build_system_prompt(
name=doug.name, age=doug.age, persona=persona, name=doug.name, age=doug.age, persona=persona,
context=ai_context,
custom_notes=doug.custom_notes, custom_notes=doug.custom_notes,
) )