Files
Bolt-Pet/bolt_pet/pet_actions.py
T
themajesticmagician b121bbba17 Multi-monitor jumps, screen OCR, and generated sprite art
petctl gains screen verbs: `jump` (1-based number, name, next/prev/
primary/other, or a direction resolved from real geometry), `monitors`,
and `read` for OCR of a monitor's contents.

- monitors.py: pure layout model + jump-target resolution. The monitor
  list is published by PetWindow from QGuiApplication.screens() over a
  queued signal, so the controller and window agree on what "monitor 2"
  means; xrandr and Qt order screens differently on the same machine.
- screen_text.py: pull-only OCR (mss capture + Tesseract/RapidOCR).
  Nothing captures unless the server asks, and the text rides back up
  the tool-result relay so Bolt can read a screen mid-turn. Both deps
  optional, soft-failing with a reason. SCREEN_TEXT=false removes it.
- Query verbs are answered in controller._handle_command rather than
  pet_actions.describe(), because their output is the point.
- scripts/generate_bolt_sprites.py draws every frame; walk/ is a
  side-view cycle stepped by distance travelled, not by the animation
  timer, so the planted paw tracks the window exactly. sprite.py loads
  it via EXTRA_ANIMATIONS keyed by name, with has() so callers can
  decline a placeholder blob.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-28 16:17:40 -06:00

168 lines
6.0 KiB
Python

"""Commands that drive the pet's *body* instead of the shell.
The server relays shell commands to this machine (see server_client.
run_local_command). Rather than inventing a new payload type the desk API
doesn't speak — this client can't change the server — a small `petctl`
pseudo-command is intercepted before it ever reaches `subprocess`: if Bolt
emits `petctl move top-left` or `petctl emote wave`, the pet does it and
returns a normal-looking command output string, so from the server's side
it's just another tool call that worked.
Pure parsing logic — no Qt, no subprocess — so it's cheap to unit test. The
UI half lives in ui/pet_window.py (apply_action).
"""
from __future__ import annotations
import shlex
from typing import Optional
# What Bolt is allowed to type. Anything else falls through to a real shell.
_PREFIXES = ("petctl", "bolt-pet", "pet")
ANCHORS = (
"top-left", "top", "top-right",
"left", "center", "right",
"bottom-left", "bottom", "bottom-right",
"cursor", "random",
)
EMOTES = ("wave", "hop", "spin", "nod", "shake", "bounce", "wiggle")
# Where `petctl jump` can be aimed. A bare number (1-based) works too, as does
# any unique part of a monitor's name — resolution lives in monitors.resolve().
MONITOR_SPECS = (
"next", "prev", "primary", "other", "random",
"left", "right", "up", "down",
)
HELP = (
"petctl move <x> <y> | <" + "|".join(ANCHORS) + ">\n"
"petctl jump <monitor number|" + "|".join(MONITOR_SPECS) + "|name>\n"
"petctl monitors\n"
"petctl read [monitor number|here|all]\n"
"petctl emote <" + "|".join(EMOTES) + ">\n"
"petctl say <text>\n"
"petctl wander on|off\n"
"petctl nap on|off"
)
class ActionError(Exception):
"""Bad petctl syntax — reported back to the server as command output."""
def is_pet_command(command: str) -> bool:
parts = (command or "").strip().split()
return bool(parts) and parts[0].lower() in _PREFIXES
def _bool_arg(value: str) -> bool:
value = value.lower()
if value in ("on", "true", "yes", "1", "start", "enable"):
return True
if value in ("off", "false", "no", "0", "stop", "disable"):
return False
raise ActionError(f"expected on/off, got {value!r}")
def parse(command: str) -> Optional[dict]:
"""Parse a `petctl ...` string into an action dict, or None if this isn't
a pet command at all (caller should run it as a real shell command).
Raises ActionError on a pet command that doesn't make sense."""
if not is_pet_command(command):
return None
try:
parts = shlex.split(command.strip())
except ValueError as exc: # unbalanced quotes
raise ActionError(f"couldn't parse arguments: {exc}") from exc
verb = (parts[1].lower() if len(parts) > 1 else "help")
args = parts[2:]
if verb in ("help", "-h", "--help"):
return {"action": "help"}
if verb in ("move", "goto", "walk"):
if not args:
raise ActionError("move needs a target: " + ", ".join(ANCHORS) + ", or x y")
if len(args) >= 2 and _looks_numeric(args[0]) and _looks_numeric(args[1]):
return {"action": "move", "x": int(float(args[0])), "y": int(float(args[1]))}
anchor = args[0].lower().replace("_", "-")
if anchor not in ANCHORS:
raise ActionError(f"unknown position {args[0]!r}; try one of: " + ", ".join(ANCHORS))
return {"action": "move", "anchor": anchor}
if verb in ("jump", "monitor", "screen"):
if not args:
raise ActionError(
"jump needs a monitor: a number, a name, or one of "
+ ", ".join(MONITOR_SPECS)
)
# The spec isn't validated here on purpose: which monitors exist is a
# runtime fact this pure module doesn't have. monitors.resolve() does
# it once the published screen list is in hand.
return {"action": "jump", "target": " ".join(args).strip()}
if verb in ("monitors", "screens", "displays"):
return {"action": "monitors"}
if verb in ("read", "look", "ocr", "see"):
target = (" ".join(args).strip() or "here").lower()
return {"action": "read", "target": target}
if verb in ("emote", "do"):
if not args:
raise ActionError("emote needs a name: " + ", ".join(EMOTES))
emote = args[0].lower()
if emote not in EMOTES:
raise ActionError(f"unknown emote {args[0]!r}; try one of: " + ", ".join(EMOTES))
return {"action": "emote", "emote": emote}
if verb == "say":
text = " ".join(args).strip()
if not text:
raise ActionError("say needs something to say")
return {"action": "say", "text": text}
if verb == "wander":
if not args:
raise ActionError("wander needs on or off")
return {"action": "wander", "enabled": _bool_arg(args[0])}
if verb in ("nap", "sleep", "dnd"):
if not args:
raise ActionError("nap needs on or off")
return {"action": "nap", "enabled": _bool_arg(args[0])}
raise ActionError(f"unknown petctl verb {verb!r}\n{HELP}")
def _looks_numeric(value: str) -> bool:
try:
float(value)
return True
except ValueError:
return False
def describe(action: dict) -> str:
"""The text handed back to the server as this "command"'s output. Phrased
as a completed fact so the model doesn't narrate the mechanics of it."""
kind = action.get("action")
if kind == "move":
where = action.get("anchor") or f"({action.get('x')}, {action.get('y')})"
return f"[pet] walking to {where}"
if kind == "jump":
return f"[pet] jumping to monitor {action['target']}"
if kind == "emote":
return f"[pet] {action['emote']}"
if kind == "say":
return "[pet] showing that in the speech bubble"
if kind == "wander":
return "[pet] wandering " + ("enabled" if action["enabled"] else "disabled")
if kind == "nap":
return "[pet] " + ("napping" if action["enabled"] else "awake")
if kind == "help":
return HELP
return "[pet] ok"