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:
parent
5f05a01423
commit
97591eb24d
@ -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:
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
@ -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)
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user