b121bbba17
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>
168 lines
6.0 KiB
Python
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"
|