feat: Enhance local command handling and introduce local intents
- Refactor `run_local_command` to manage subprocesses more effectively, ensuring child processes are terminated on timeout. - Introduce `_terminate` function to handle process group termination and capture output. - Implement `_command_output` to format command results with a character limit. - Add local intent recognition in `intents.py` to handle commands like "stop", "go to sleep", and "come here" without server interaction. - Normalize user input to match local intents while stripping filler words. - Update tests to cover new local intent functionality and ensure proper command handling. - Enhance speech processing to handle abbreviations and improve spoken output clarity.
This commit is contained in:
@@ -0,0 +1,206 @@
|
||||
"""Things you say to the pet that the server has no business answering.
|
||||
|
||||
"stop", "come here", "go to sleep", "say that again", "use your normal voice" —
|
||||
none of these are questions for Bolt's brain. They're commands to the *body*,
|
||||
and today every one of them costs a full turn: Deepgram, a `/desk/converse`
|
||||
round trip, a model deciding to emit `petctl`, then ElevenLabs. Two to four
|
||||
seconds and three network hops to make the pet walk left, and it only works at
|
||||
all if the server's prompt happens to advertise the right verb — which is
|
||||
exactly why `petctl voice reset` needs a block in the server's pet prompt (see
|
||||
CLAUDE.md) or the model never emits it. Recognising the phrase here removes
|
||||
both the latency and that coupling: "go back to your normal voice" works
|
||||
whether or not the server was ever told the voice can be reset.
|
||||
|
||||
The whole design problem is **not stealing real requests**. Three rules keep
|
||||
it honest:
|
||||
|
||||
1. **Whole-utterance, exact match after normalisation.** Never substring. So
|
||||
"stop" is an intent and "stop the docker container" is a question for the
|
||||
server — the distinction a substring match would destroy.
|
||||
2. **The phrase table is closed and small.** Every entry is something with no
|
||||
plausible reading as a request for Bolt to *do work*. Anything arguable
|
||||
("no thanks", "nothing") is deliberately absent — see rule 3 for why a
|
||||
wrong guess is expensive.
|
||||
3. **Nothing is recognised mid-conversation.** The controller skips this
|
||||
entirely on a follow-up turn: if Bolt just asked you something, your answer
|
||||
belongs to him, and swallowing "never mind" locally would leave the server
|
||||
holding a question it never got an answer to. Local intents are only ever
|
||||
for turns *you* started.
|
||||
|
||||
Both sides of the comparison go through `normalize()` — the table is
|
||||
canonicalised at import — so phrases can be written the way a person says them
|
||||
("go back to your normal voice") without every variant having to be spelled
|
||||
out. Filler is dropped from anywhere, not just the ends, because STT scatters
|
||||
it ("hey bolt, could you please just stop now").
|
||||
|
||||
Pure classification, like pet_actions.parse: this module decides *what was
|
||||
meant* and hands back an action in the same shape pet_actions produces, so
|
||||
`controller.action` and `PetWindow.apply_action` need no new vocabulary. The
|
||||
effects live in controller._handle_local_intent.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from dataclasses import dataclass
|
||||
from typing import Optional
|
||||
|
||||
# Words with no bearing on any command in the table, dropped wherever they
|
||||
# appear. Kept deliberately short: every entry here is a word that can't
|
||||
# distinguish one of these phrases from another, and adding one that can is how
|
||||
# two intents quietly collide (the builder below raises if that happens).
|
||||
_FILLER = frozenset({
|
||||
"the", "a", "an", "my", "your", "yours", "its", "to", "of", "and",
|
||||
"please", "just", "that", "some", "bolt", "thunderbolt", "pet", "buddy",
|
||||
})
|
||||
|
||||
# Dropped only from the front — the politeness/address ramp STT reliably
|
||||
# prefixes. Not safe to drop mid-phrase (a bare "do" or "go" carries meaning
|
||||
# elsewhere), which is why this is separate from _FILLER.
|
||||
_LEADING_FILLER = frozenset({
|
||||
"hey", "hi", "hello", "yo", "ok", "okay", "um", "uh", "er", "so",
|
||||
"can", "could", "would", "will", "you", "i", "id", "like", "lets",
|
||||
"let", "us", "do", "go", "then", "now",
|
||||
})
|
||||
|
||||
_TRAILING_FILLER = frozenset({
|
||||
"ok", "okay", "thanks", "thank", "you", "boy", "already", "now",
|
||||
})
|
||||
|
||||
_KEEP = re.compile(r"[^a-z0-9 ]+")
|
||||
|
||||
|
||||
def normalize(text: str) -> str:
|
||||
"""Reduce an utterance to the bare command, or "" if nothing is left.
|
||||
|
||||
Lowercase, punctuation stripped (STT punctuates inconsistently), filler
|
||||
dropped. Not a stemmer and deliberately not clever — its only job is to
|
||||
make the same command spoken two ways land on the same string, without
|
||||
ever turning one command into a different one."""
|
||||
words = [word for word in _KEEP.sub(" ", (text or "").lower()).split()
|
||||
if word not in _FILLER]
|
||||
while words and words[0] in _LEADING_FILLER:
|
||||
words.pop(0)
|
||||
while words and words[-1] in _TRAILING_FILLER:
|
||||
words.pop()
|
||||
return " ".join(words)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class Intent:
|
||||
"""One recognised local command.
|
||||
|
||||
*action* is a pet_actions-shaped dict for the UI (or None when there's
|
||||
nothing for the body to do); *speak* is what to say out loud, empty for the
|
||||
intents where doing the thing silently *is* the acknowledgement — the pet
|
||||
visibly moves, and a spoken confirmation would only make it slower. "stop"
|
||||
in particular has to be silent: answering "okay!" when told to be quiet is
|
||||
a comedy sketch, not a feature.
|
||||
"""
|
||||
|
||||
name: str
|
||||
action: Optional[dict] = None
|
||||
speak: str = ""
|
||||
|
||||
|
||||
# Intent -> (the Intent, the phrases that mean it, written as spoken).
|
||||
_TABLE: tuple[tuple[Intent, tuple[str, ...]], ...] = (
|
||||
(
|
||||
Intent("stop"),
|
||||
("stop", "stop talking", "stop it", "be quiet", "quiet", "shut up",
|
||||
"hush", "never mind", "nevermind", "forget it", "cancel",
|
||||
"cancel that", "drop it", "enough"),
|
||||
),
|
||||
(
|
||||
Intent("nap", {"action": "nap", "enabled": True}, "Night."),
|
||||
("go to sleep", "take a nap", "have a nap", "go to bed", "bedtime",
|
||||
"goodnight", "good night", "get some rest"),
|
||||
),
|
||||
(
|
||||
Intent("wake", {"action": "nap", "enabled": False}, "I'm up."),
|
||||
("wake up", "get up", "rise and shine", "you're awake", "are you awake"),
|
||||
),
|
||||
(
|
||||
Intent("come", {"action": "move", "anchor": "cursor"}),
|
||||
("come here", "come to me", "come back", "over here", "follow me",
|
||||
"follow my cursor"),
|
||||
),
|
||||
(
|
||||
Intent("go_away", {"action": "move", "anchor": "bottom-right"}),
|
||||
("go away", "move over", "move out of the way", "get out of the way",
|
||||
"out of the way", "hide", "get lost", "shoo", "scram",
|
||||
"go somewhere else"),
|
||||
),
|
||||
(
|
||||
Intent("repeat"), # answered from history by the controller
|
||||
("say that again", "say again", "repeat that", "repeat",
|
||||
"what did you say", "what was that", "come again", "one more time",
|
||||
"again", "sorry what"),
|
||||
),
|
||||
(
|
||||
Intent("wander_on", {"action": "wander", "enabled": True}),
|
||||
("go for a walk", "wander", "wander around", "walk around", "explore",
|
||||
"stretch your legs", "roam"),
|
||||
),
|
||||
(
|
||||
Intent("wander_off", {"action": "wander", "enabled": False}),
|
||||
("stay still", "stay put", "stop moving", "stop wandering",
|
||||
"don't move", "sit", "sit still", "stay", "settle down", "hold still"),
|
||||
),
|
||||
(
|
||||
# Reachable from the server too (petctl voice reset), but only if its
|
||||
# prompt mentions the verb. Recognising it here is what makes the
|
||||
# phrase work regardless of what the server was told.
|
||||
Intent("voice_reset", None, "Back to my own voice."),
|
||||
("use your normal voice", "use your own voice", "your normal voice",
|
||||
"go back to your normal voice", "be yourself", "be yourself again",
|
||||
"stop doing that voice", "drop the voice", "talk normally",
|
||||
"speak normally", "use your real voice"),
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
def _build() -> dict[str, Intent]:
|
||||
"""Canonicalise the table, refusing to build an ambiguous one.
|
||||
|
||||
A phrase that normalises to "" would match an utterance of pure filler
|
||||
("hey bolt"), and one that lands on the same string as a phrase from
|
||||
another intent would silently bind to whichever was declared last. Both are
|
||||
edit-time mistakes, so they fail at import rather than at 3am on a mic."""
|
||||
table: dict[str, Intent] = {}
|
||||
for intent, phrases in _TABLE:
|
||||
for phrase in phrases:
|
||||
key = normalize(phrase)
|
||||
if not key:
|
||||
raise ValueError(f"intent phrase {phrase!r} normalises to nothing")
|
||||
existing = table.get(key)
|
||||
if existing is not None and existing.name != intent.name:
|
||||
raise ValueError(
|
||||
f"phrase {phrase!r} ({key!r}) is claimed by both "
|
||||
f"{existing.name} and {intent.name}"
|
||||
)
|
||||
table[key] = intent
|
||||
return table
|
||||
|
||||
|
||||
_BY_PHRASE = _build()
|
||||
|
||||
# Longest phrase in the table, in words. Anything longer can't match, so a real
|
||||
# request skips normalisation entirely — this runs on every turn.
|
||||
_MAX_WORDS = max(len(phrase.split()) for phrase in _BY_PHRASE)
|
||||
|
||||
|
||||
def recognize(text: str) -> Optional[Intent]:
|
||||
"""The intent *text* expresses, or None to send it to the server.
|
||||
|
||||
None is the safe answer and the common one: anything not matched verbatim
|
||||
against the table belongs to Bolt."""
|
||||
raw = (text or "").strip()
|
||||
if not raw:
|
||||
return None
|
||||
# +6 words of slack for the filler about to be stripped ("hey bolt, could
|
||||
# you please stop" is six words to reach a one-word command).
|
||||
if len(raw.split()) > _MAX_WORDS + 6:
|
||||
return None
|
||||
intent = _BY_PHRASE.get(normalize(raw))
|
||||
return intent
|
||||
Reference in New Issue
Block a user