From 97591eb24d9864d789375bed7d854258c4b4d53c Mon Sep 17 00:00:00 2001 From: disqualifier Date: Fri, 26 Jun 2026 03:59:52 -0400 Subject: [PATCH] feat: version voice command + matched-wake note on loose matches add 'version' (prints claudedo 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: )' 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 --- README.md | 5 ++++- src/claudedo/daemon.py | 16 +++++++++++---- src/claudedo/grammar.py | 45 +++++++++++++++++++++++++++++------------ 3 files changed, 48 insertions(+), 18 deletions(-) diff --git a/README.md b/README.md index 23f2ec1..399e8f9 100644 --- a/README.md +++ b/README.md @@ -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: diff --git a/src/claudedo/daemon.py b/src/claudedo/daemon.py index 70e0c50..9683142 100644 --- a/src/claudedo/daemon.py +++ b/src/claudedo/daemon.py @@ -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 diff --git a/src/claudedo/grammar.py b/src/claudedo/grammar.py index aa16dbd..df95b73 100644 --- a/src/claudedo/grammar.py +++ b/src/claudedo/grammar.py @@ -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 `` (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)