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"),
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
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 |
|---|---|
@ -127,6 +129,7 @@ the wake phrase is optional.
| `list` | list running `claude-*` sessions to the daemon 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) |
| `version` | print the claudedo version to the console |
| `cancel` / `escape` | back out of a prompt |
Optional filler (`select` / `use` / `choose`) may precede any command and is ignored:

View File

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

View File

@ -52,6 +52,7 @@ _UNSET_VERBS = ("unset", "unsticky")
_LIST_VERBS = ("list", "sessions")
_COMMANDS_VERBS = ("commands", "help", "menu")
_CUSTOMS_VERBS = ("customs", "custom")
_VERSION_VERBS = ("version",)
_SELECT_VERBS = ("select", "option", "choose", "number")
# 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
+ _CANCEL_VERBS + _TYPE_VERBS + _BACKSPACE_VERBS + _SPACE_VERBS + _ADD_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")
)
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
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
action: Action | None
wake: str | None = None
def normalize(text: str) -> str:
@ -145,6 +150,7 @@ def command_menu() -> list[tuple[str, str]]:
("unset / list", "clear sticky / list sessions"),
("mode ptt|listen", "switch input mode"),
("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(" ", "")}
def strip_wake(transcript: str, wake_phrases: list[str], threshold: float,
require_wake: bool) -> str | None:
"""return the command remainder after the wake phrase.
def strip_wake_match(transcript: str, wake_phrases: list[str], threshold: float,
require_wake: bool) -> tuple[str | None, str | None]:
"""return (command remainder, matched wake phrase).
if ``require_wake`` (listen mode) and no wake phrase is found at the start,
return None so the daemon discards the utterance. if not required (ptt mode),
a leading wake phrase is stripped when present but its absence is fine.
if ``require_wake`` (listen mode) and no wake phrase is found at the start, the
remainder is None so the daemon discards the utterance. if not required (ptt
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
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)
if not norm:
return None if require_wake else ""
return (None, None) if require_wake else ("", None)
words = norm.split(" ")
best_remainder: str | None = None
best_phrase: str | None = None
best_score = 0.0
for phrase in wake_phrases:
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:
best_score = score
best_remainder = " ".join(words[take:]).strip()
best_phrase = phrase
if best_remainder is not None:
return best_remainder
return None if require_wake else norm
return best_remainder, best_phrase
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:
@ -285,6 +302,8 @@ def match_command(remainder: str, threshold: float) -> Action | None:
return Action("commands")
if _fuzzy_in(head, _LIST_VERBS, threshold):
return Action("list")
if _fuzzy_in(head, _VERSION_VERBS, threshold):
return Action("version")
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
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:
return None
@ -326,4 +345,4 @@ def parse(transcript: str, wake_phrases: list[str], wake_threshold: float,
tokens = _strip_filler(tokens, filler, 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)