feat: version voice command + matched-wake note on loose matches

add 'version' (prints claudedo <ver> to console; in vocab + menu). when a command's
wake phrase matched loosely (the transcript didn't contain it literally), the green
heard line appends '(wake: <phrase>)' so e.g. 'okay clouds' -> 'okay claude' is
visible. grammar.parse() now returns the matched phrase on ParsedCommand.wake (via a
new strip_wake_match; strip_wake kept as a thin wrapper).

Signed-off-by: disqualifier <dev@disqualifier.me>
This commit is contained in:
disqualifier 2026-06-26 03:59:52 -04:00
parent 5f05a01423
commit 97591eb24d
3 changed files with 48 additions and 18 deletions

View File

@ -107,7 +107,9 @@ Wake phrases (listen mode), fuzzy-matched. The default list is **"claudedo"**,
no token for the coined word "claudedo" and renders it as real words ("claude do"), no token for the coined word "claudedo" and renders it as real words ("claude do"),
so that spelling is listed explicitly. Matching is lenient (case/space-insensitive). so that spelling is listed explicitly. Matching is lenient (case/space-insensitive).
Add the spellings you actually see (turn on `print_heard` to find them). In PTT mode Add the spellings you actually see (turn on `print_heard` to find them). In PTT mode
the wake phrase is optional. the wake phrase is optional. When a command's wake phrase matched loosely (e.g. you
said "okay clouds"), the heard line notes which phrase it assumed —
`heard "okay clouds list" -> LIST (wake: okay claude)`.
| Say | Does | | Say | Does |
|---|---| |---|---|
@ -127,6 +129,7 @@ the wake phrase is optional.
| `list` | list running `claude-*` sessions to the daemon console | | `list` | list running `claude-*` sessions to the daemon console |
| `commands` (alias `help`/`menu`) | print the voice-command menu to the console | | `commands` (alias `help`/`menu`) | print the voice-command menu to the console |
| `customs` (alias `custom`) | custom commands — arriving in v0.2.0 (stub for now) | | `customs` (alias `custom`) | custom commands — arriving in v0.2.0 (stub for now) |
| `version` | print the claudedo version to the console |
| `cancel` / `escape` | back out of a prompt | | `cancel` / `escape` | back out of a prompt |
Optional filler (`select` / `use` / `choose`) may precede any command and is ignored: Optional filler (`select` / `use` / `choose`) may precede any command and is ignored:

View File

@ -16,7 +16,7 @@ import sys
import time import time
from pathlib import Path from pathlib import Path
from . import audio, grammar, inject, target from . import __version__, audio, grammar, inject, target
from .config import Config from .config import Config
from .console import HELP, SYSTEM, VOICE, Console from .console import HELP, SYSTEM, VOICE, Console
from .stt import Transcriber from .stt import Transcriber
@ -174,9 +174,14 @@ class Daemon:
return return
action = parsed.action action = parsed.action
# a command was recognized — echo what we heard (green) before acting. # a command was recognized — echo what we heard (green) before acting. note the
self._console.emit(VOICE, f'heard "{transcript}" -> {self._describe(action)} {self._timing()}', # matched wake phrase when the transcript didn't literally contain it (so a
"green") # loose match like "okay clouds" -> "okay claude" is visible).
wake_note = ""
if parsed.wake and parsed.wake.replace(" ", "") not in transcript.lower().replace(" ", ""):
wake_note = f" (wake: {parsed.wake})"
self._console.emit(VOICE, f'heard "{transcript}" -> {self._describe(action)}'
f'{wake_note} {self._timing()}', "green")
def blue(s): def blue(s):
return self._console.paint(s, "brightblue") return self._console.paint(s, "brightblue")
@ -211,6 +216,9 @@ class Daemon:
if action.name == "customs": if action.name == "customs":
self._console.emit(SYSTEM, "custom commands arrive in v0.2.0 (contexts.toml)") self._console.emit(SYSTEM, "custom commands arrive in v0.2.0 (contexts.toml)")
return return
if action.name == "version":
self._console.emit(SYSTEM, f"claudedo {__version__}")
return
if action.name == "debug": if action.name == "debug":
self._console.emit(VOICE, f'debug: "{action.arg}"', "yellow") self._console.emit(VOICE, f'debug: "{action.arg}"', "yellow")
return return

View File

@ -52,6 +52,7 @@ _UNSET_VERBS = ("unset", "unsticky")
_LIST_VERBS = ("list", "sessions") _LIST_VERBS = ("list", "sessions")
_COMMANDS_VERBS = ("commands", "help", "menu") _COMMANDS_VERBS = ("commands", "help", "menu")
_CUSTOMS_VERBS = ("customs", "custom") _CUSTOMS_VERBS = ("customs", "custom")
_VERSION_VERBS = ("version",)
_SELECT_VERBS = ("select", "option", "choose", "number") _SELECT_VERBS = ("select", "option", "choose", "number")
# every command/synonym word, for biasing the STT toward the vocabulary we expect. # every command/synonym word, for biasing the STT toward the vocabulary we expect.
@ -59,7 +60,8 @@ _COMMAND_WORDS = (
_YES_VERBS + _NO_VERBS + _APPROVE_VERBS + _DENY_VERBS + _SUBMIT_VERBS _YES_VERBS + _NO_VERBS + _APPROVE_VERBS + _DENY_VERBS + _SUBMIT_VERBS
+ _CANCEL_VERBS + _TYPE_VERBS + _BACKSPACE_VERBS + _SPACE_VERBS + _ADD_VERBS + _CANCEL_VERBS + _TYPE_VERBS + _BACKSPACE_VERBS + _SPACE_VERBS + _ADD_VERBS
+ _ERASE_VERBS + _DEBUG_VERBS + _MODE_VERBS + _STICKY_VERBS + _ONESHOT_VERBS + _UNSET_VERBS + _ERASE_VERBS + _DEBUG_VERBS + _MODE_VERBS + _STICKY_VERBS + _ONESHOT_VERBS + _UNSET_VERBS
+ _LIST_VERBS + _COMMANDS_VERBS + _CUSTOMS_VERBS + _SELECT_VERBS + ("ptt", "listen") + _LIST_VERBS + _COMMANDS_VERBS + _CUSTOMS_VERBS + _VERSION_VERBS
+ _SELECT_VERBS + ("ptt", "listen")
+ ("one", "two", "three", "four") + ("one", "two", "three", "four")
) )
DEFAULT_FILLER = ("select", "use", "choose") DEFAULT_FILLER = ("select", "use", "choose")
@ -85,11 +87,14 @@ class ParsedCommand:
one_shot is the session short-name from a leading ``target <name>`` (this command one_shot is the session short-name from a leading ``target <name>`` (this command
only; does not change the sticky default), or None. action is the command to run, only; does not change the sticky default), or None. action is the command to run,
or None if nothing matched after the wake phrase / one-shot / filler. or None if nothing matched after the wake phrase / one-shot / filler. wake is the
configured wake phrase that matched (e.g. "okay claude" for a heard "okay clouds"),
or None.
""" """
one_shot: str | None one_shot: str | None
action: Action | None action: Action | None
wake: str | None = None
def normalize(text: str) -> str: def normalize(text: str) -> str:
@ -145,6 +150,7 @@ def command_menu() -> list[tuple[str, str]]:
("unset / list", "clear sticky / list sessions"), ("unset / list", "clear sticky / list sessions"),
("mode ptt|listen", "switch input mode"), ("mode ptt|listen", "switch input mode"),
("commands / customs", "this menu / custom commands (v0.2.0)"), ("commands / customs", "this menu / custom commands (v0.2.0)"),
("version", "print the claudedo version"),
] ]
@ -158,13 +164,15 @@ def _wake_variants(phrase: str) -> set[str]:
return {norm, norm.replace(" ", "")} return {norm, norm.replace(" ", "")}
def strip_wake(transcript: str, wake_phrases: list[str], threshold: float, def strip_wake_match(transcript: str, wake_phrases: list[str], threshold: float,
require_wake: bool) -> str | None: require_wake: bool) -> tuple[str | None, str | None]:
"""return the command remainder after the wake phrase. """return (command remainder, matched wake phrase).
if ``require_wake`` (listen mode) and no wake phrase is found at the start, if ``require_wake`` (listen mode) and no wake phrase is found at the start, the
return None so the daemon discards the utterance. if not required (ptt mode), remainder is None so the daemon discards the utterance. if not required (ptt
a leading wake phrase is stripped when present but its absence is fine. mode), a leading wake phrase is stripped when present but its absence is fine.
the matched phrase is the configured wake phrase that best matched (e.g. "okay
claude" for a heard "okay clouds"), or None when none matched.
matches leniently on a despaced prefix (whisper splits/joins the coined word matches leniently on a despaced prefix (whisper splits/joins the coined word
inconsistently) but always slices the remainder on a WORD boundary of the inconsistently) but always slices the remainder on a WORD boundary of the
@ -172,10 +180,11 @@ def strip_wake(transcript: str, wake_phrases: list[str], threshold: float,
""" """
norm = normalize(transcript) norm = normalize(transcript)
if not norm: if not norm:
return None if require_wake else "" return (None, None) if require_wake else ("", None)
words = norm.split(" ") words = norm.split(" ")
best_remainder: str | None = None best_remainder: str | None = None
best_phrase: str | None = None
best_score = 0.0 best_score = 0.0
for phrase in wake_phrases: for phrase in wake_phrases:
variants = _wake_variants(phrase) variants = _wake_variants(phrase)
@ -189,10 +198,18 @@ def strip_wake(transcript: str, wake_phrases: list[str], threshold: float,
if score >= threshold and score > best_score: if score >= threshold and score > best_score:
best_score = score best_score = score
best_remainder = " ".join(words[take:]).strip() best_remainder = " ".join(words[take:]).strip()
best_phrase = phrase
if best_remainder is not None: if best_remainder is not None:
return best_remainder return best_remainder, best_phrase
return None if require_wake else norm return (None, None) if require_wake else (norm, None)
def strip_wake(transcript: str, wake_phrases: list[str], threshold: float,
require_wake: bool) -> str | None:
"""return the command remainder after the wake phrase (None if no wake in listen
mode). thin wrapper over strip_wake_match for callers that don't need the phrase"""
return strip_wake_match(transcript, wake_phrases, threshold, require_wake)[0]
def _fuzzy_in(token: str, options: tuple[str, ...], threshold: float) -> bool: def _fuzzy_in(token: str, options: tuple[str, ...], threshold: float) -> bool:
@ -285,6 +302,8 @@ def match_command(remainder: str, threshold: float) -> Action | None:
return Action("commands") return Action("commands")
if _fuzzy_in(head, _LIST_VERBS, threshold): if _fuzzy_in(head, _LIST_VERBS, threshold):
return Action("list") return Action("list")
if _fuzzy_in(head, _VERSION_VERBS, threshold):
return Action("version")
return None return None
@ -314,7 +333,7 @@ def parse(transcript: str, wake_phrases: list[str], wake_threshold: float,
ParsedCommand with action=None means a wake phrase was present but no command ParsedCommand with action=None means a wake phrase was present but no command
matched. matched.
""" """
remainder = strip_wake(transcript, wake_phrases, wake_threshold, require_wake) remainder, wake = strip_wake_match(transcript, wake_phrases, wake_threshold, require_wake)
if remainder is None: if remainder is None:
return None return None
@ -326,4 +345,4 @@ def parse(transcript: str, wake_phrases: list[str], wake_threshold: float,
tokens = _strip_filler(tokens, filler, command_threshold) tokens = _strip_filler(tokens, filler, command_threshold)
action = match_command(" ".join(tokens), command_threshold) action = match_command(" ".join(tokens), command_threshold)
return ParsedCommand(one_shot=one_shot, action=action) return ParsedCommand(one_shot=one_shot, action=action, wake=wake)