feat: auto_target toggle, print_heard debug, more wake spellings

add behavior.auto_target (default false): with no sticky target and exactly one
session running, false requires an explicit set/target rather than guessing; true
auto-uses it. target.resolve() takes the flag. add behavior.print_heard (default
false, debug): opt-in console echo of non-wake transcripts to see how Whisper
renders the wake word. add behavior.filler_words. expand the wake list with the
spellings Whisper actually emits for the coined word ('claude do', 'claude due',
'ok claude', 'okay claude').

Signed-off-by: disqualifier <dev@disqualifier.me>
This commit is contained in:
disqualifier 2026-06-26 01:17:08 -04:00
parent b05f6256c1
commit d734161c97
3 changed files with 23 additions and 4 deletions

View File

@ -5,7 +5,7 @@
# wake phrases for listen mode. fuzzy-matched: case/space-insensitive, lenient on # wake phrases for listen mode. fuzzy-matched: case/space-insensitive, lenient on
# the coined word "claudedo" (whisper renders it inconsistently). number words are # the coined word "claudedo" (whisper renders it inconsistently). number words are
# normalized to digits before command matching. # normalized to digits before command matching.
phrases = ["claudedo", "hey claude"] phrases = ["claudedo", "claude do", "claude due", "hey claude", "ok claude", "okay claude"]
[input] [input]
# "listen" (default): continuous capture; only acts on utterances that start with a # "listen" (default): continuous capture; only acts on utterances that start with a
@ -55,3 +55,14 @@ match_threshold = 0.8
# "select yes" / "use yes" behave like "yes". (a filler word followed by a digit is # "select yes" / "use yes" behave like "yes". (a filler word followed by a digit is
# the select command, e.g. "select 1", and is not dropped.) # the select command, e.g. "select 1", and is not dropped.)
filler_words = ["select", "use", "choose"] filler_words = ["select", "use", "choose"]
# when no sticky target is set and exactly ONE claude-* session is running:
# false (default) -> require an explicit `set <name>` or one-shot `target <name>`;
# a bare command does nothing and tells you to set one.
# true -> auto-target that single session (convenience).
auto_target = false
# DEBUG ONLY — relaxes the privacy invariant. when true, the daemon console prints
# the raw transcript of EVERY utterance, including non-wake speech it would otherwise
# drop silently (shown as `heard (dropped): "<transcript>"`). use it to see exactly
# how Whisper renders your wake word, then turn it OFF. default false: non-wake speech
# is discarded without ever printing the transcript.
print_heard = false

View File

@ -50,6 +50,8 @@ class Config:
type_autosend: bool type_autosend: bool
match_threshold: float match_threshold: float
filler_words: tuple[str, ...] filler_words: tuple[str, ...]
auto_target: bool
print_heard: bool
source_path: Path | None = field(default=None) source_path: Path | None = field(default=None)
@ -118,6 +120,8 @@ def load_config(explicit: str | os.PathLike | None = None) -> Config:
match_threshold=float(_require(raw, "behavior", "match_threshold", (int, float), 0.8)), match_threshold=float(_require(raw, "behavior", "match_threshold", (int, float), 0.8)),
filler_words=tuple(_require(raw, "behavior", "filler_words", (list,), filler_words=tuple(_require(raw, "behavior", "filler_words", (list,),
["select", "use", "choose"])), ["select", "use", "choose"])),
auto_target=bool(_require(raw, "behavior", "auto_target", (bool,), False)),
print_heard=bool(_require(raw, "behavior", "print_heard", (bool,), False)),
source_path=path, source_path=path,
) )
if not 0.0 < cfg.match_threshold <= 1.0: if not 0.0 < cfg.match_threshold <= 1.0:

View File

@ -85,7 +85,7 @@ def list_sessions() -> list[str]:
return sorted(n for n in names if n.startswith(SESSION_PREFIX)) return sorted(n for n in names if n.startswith(SESSION_PREFIX))
def resolve(one_shot: str | None = None) -> tuple[str | None, str]: def resolve(one_shot: str | None = None, auto_target: bool = False) -> tuple[str | None, str]:
"""resolve the destination session and a short reason describing the choice. """resolve the destination session and a short reason describing the choice.
single source of truth for targeting, used by both the voice and CLI paths. single source of truth for targeting, used by both the voice and CLI paths.
@ -95,7 +95,9 @@ def resolve(one_shot: str | None = None) -> tuple[str | None, str]:
1. one-shot present -> claude-<name> for THIS command only; never falls through 1. one-shot present -> claude-<name> for THIS command only; never falls through
to a different session if it doesn't exist (explicit beats convenience). to a different session if it doesn't exist (explicit beats convenience).
2. sticky set + exists -> use it. 2. sticky set + exists -> use it.
3. nothing sticky, exactly one claude-* session -> auto-use it. 3. nothing sticky, exactly one claude-* session:
auto_target=True -> auto-use it;
auto_target=False -> require an explicit set/target, do nothing.
4. nothing sticky, multiple sessions -> ambiguous, do nothing. 4. nothing sticky, multiple sessions -> ambiguous, do nothing.
5. nothing sticky, zero sessions -> do nothing. 5. nothing sticky, zero sessions -> do nothing.
""" """
@ -113,7 +115,9 @@ def resolve(one_shot: str | None = None) -> tuple[str | None, str]:
sessions = list_sessions() sessions = list_sessions()
if len(sessions) == 1: if len(sessions) == 1:
return sessions[0], f"auto-target {sessions[0]} (only session)" if auto_target:
return sessions[0], f"auto-target {sessions[0]} (only session)"
return None, f"no target set ({sessions[0]} running — set one)"
if len(sessions) > 1: if len(sessions) > 1:
return None, f"no target set, {len(sessions)} sessions (set one)" return None, f"no target set, {len(sessions)} sessions (set one)"
return None, "no claude sessions" return None, "no claude sessions"