feat: v0.1.1 sticky vs one-shot targeting, filler words, auto-single

redefine targeting: 'set' (aliases sticky/switch) is the persistent sticky
default (~/.claude-active); 'target <name> <command>' is a one-shot override that
routes a single command without changing the sticky default. add 'unset' and
'list'. resolution moves to a single target.resolve(one_shot) implementing the
order: one-shot -> sticky-if-exists -> only-session auto -> ambiguous/none do
nothing (never falls through, never injects into a missing session).

grammar.parse now returns ParsedCommand(one_shot, action) and skips optional
leading filler words (config behavior.filler_words: select/use/choose), with a
filler-before-digit still meaning the select command. CLI gains set/unset/list
(switch kept as a set alias). daemon console shows the targeting reason per line.
docs updated; no stale 'target = sticky' wording remains. bump to 0.1.1.

Signed-off-by: disqualifier <dev@disqualifier.me>
This commit is contained in:
disqualifier 2026-06-25 20:16:29 -04:00
parent 17db65858e
commit 5f8d645e54
9 changed files with 207 additions and 64 deletions

View File

@ -20,7 +20,7 @@ mic (WSLg/PulseAudio RDPSource)
-> sounddevice capture -> sounddevice capture
-> faster-whisper (local STT, on-device) -> faster-whisper (local STT, on-device)
-> wake gate: utterance must start with a wake phrase, else DISCARD locally -> wake gate: utterance must start with a wake phrase, else DISCARD locally
-> grammar match (yes/no/one..four/approve/deny/send/type/mode/switch/cancel) -> grammar match (yes/no/one..four/approve/deny/send/type/mode/set/target/cancel)
-> resolve target session (~/.claude-active) -> resolve target session (~/.claude-active)
-> tmux send-keys -t <session> "<keys>" -> tmux send-keys -t <session> "<keys>"
``` ```
@ -74,7 +74,9 @@ claudedo start --mode ptt # push-to-talk instead (desk-only — see Modes)
claudedo start --skip-audio-check # skip the pre-listen mic check claudedo start --skip-audio-check # skip the pre-listen mic check
claudedo status # running? mode? target session? claudedo status # running? mode? target session?
claudedo stop # stop a running daemon claudedo stop # stop a running daemon
claudedo switch <name> # retarget to claude-<name> claudedo set <name> # set the sticky target -> claude-<name> (alias: switch)
claudedo unset # clear the sticky target
claudedo list # list running claude-* sessions
claudedo test-audio # verify the mic capture path claudedo test-audio # verify the mic capture path
``` ```
@ -107,21 +109,35 @@ Wake phrases (listen mode), fuzzy-matched: **"claudedo"**, **"hey claude"**.
| `send` / `enter` | submit (Enter) | | `send` / `enter` | submit (Enter) |
| `type <phrase>` | insert literal text, **no** submit (read-before-send; say "send") | | `type <phrase>` | insert literal text, **no** submit (read-before-send; say "send") |
| `mode ptt` / `mode listen` | switch input mode | | `mode ptt` / `mode listen` | switch input mode |
| `switch <name>` / `target <name>` | retarget to `claude-<name>` | | `set <name>` (alias `sticky`/`switch`) | set the **sticky** target → `claude-<name>` (persists) |
| `target <name> <command>` | **one-shot** override: run that command on `claude-<name>` for this utterance only; sticky default unchanged |
| `unset` (alias `unsticky`) | clear the sticky target |
| `list` | list running `claude-*` sessions to the daemon 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:
`select yes` and `use yes` behave like `yes`. (`select 1` is still the select command.)
When no sticky target is set, a bare command auto-targets the **only** running
`claude-*` session; if several are running it does nothing and asks you to `set` one.
Number words are normalized to digits before matching ("one"/"won" → 1). Number words are normalized to digits before matching ("one"/"won" → 1).
## Targeting ## Targeting
`~/.claude-active` holds the target session name (e.g. `claude-rethink-public`). The `~/.claude-active` holds the **sticky** target session name (e.g.
**cc kit** writes this file when you attach, so the target is "the project you most `claude-rethink-public`). The **cc kit** writes this file when you attach, and
recently attached to". `claudedo switch <name>` / `target <name>` overwrites it. If `claudedo set <name>` (alias `sticky`/`switch`) overwrites it; `unset` clears it.
the file is missing or the session no longer exists, `claudedo` injects nothing and A `target <name>` voice command is a **one-shot** that does NOT touch the sticky
logs a warning (it never guesses a target). default — it routes a single command and the next bare command reverts to sticky.
Resolution order (one place — `target.resolve()`): one-shot if present →
sticky if set and the session exists → else the only running `claude-*` session →
else (zero or several) do nothing and say so. It never guesses, and never injects
into a nonexistent session.
Every name maps to `claude-<name>` through one helper (`target.session_name()`), and Every name maps to `claude-<name>` through one helper (`target.session_name()`), and
the cc kit mirrors it exactly — so `cc libs` (shell) and `target libs` (voice) refer the cc kit mirrors it exactly — so `cc libs` (shell) and `set libs` (voice) refer
to the same session `claude-libs`. The name is your **stable, speakable handle**: to the same session `claude-libs`. The name is your **stable, speakable handle**:
because the kit forces an explicit name (no basename guessing), you always know the because the kit forces an explicit name (no basename guessing), you always know the
exact word to say. exact word to say.

View File

@ -51,3 +51,7 @@ max_utterance = 15.0
type_autosend = false type_autosend = false
# fuzzy match ratio (0..1) required to accept a wake phrase / command token. # fuzzy match ratio (0..1) required to accept a wake phrase / command token.
match_threshold = 0.8 match_threshold = 0.8
# optional filler words that may precede a command and are ignored for matching:
# "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.)
filler_words = ["select", "use", "choose"]

View File

@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
[project] [project]
name = "claudedo" name = "claudedo"
version = "0.1.0" version = "0.1.1"
description = "voice-control daemon for claude code (local STT -> tmux send-keys)" description = "voice-control daemon for claude code (local STT -> tmux send-keys)"
readme = "README.md" readme = "README.md"
requires-python = ">=3.10" requires-python = ">=3.10"

View File

@ -1,3 +1,3 @@
"""claudedo — voice-control daemon for claude code (local STT -> tmux send-keys)""" """claudedo — voice-control daemon for claude code (local STT -> tmux send-keys)"""
__version__ = "0.1.0" __version__ = "0.1.1"

View File

@ -185,9 +185,26 @@ def cmd_install(_args: argparse.Namespace) -> int:
return subprocess.call(["bash", str(script)]) return subprocess.call(["bash", str(script)])
def cmd_switch(args: argparse.Namespace) -> int: def cmd_set(args: argparse.Namespace) -> int:
session = target.set_target(args.name) session = target.set_target(args.name)
print(f"target -> {session}") print(f"sticky target -> {session}")
return 0
def cmd_unset(_args: argparse.Namespace) -> int:
target.unset_target()
print("sticky target cleared")
return 0
def cmd_list(_args: argparse.Namespace) -> int:
sessions = target.list_sessions()
if not sessions:
print("no claude sessions running")
return 1
active = target.read_active()
for s in sessions:
print(f"{'* ' if s == active else ' '}{s}")
return 0 return 0
@ -208,10 +225,13 @@ def build_parser() -> argparse.ArgumentParser:
sub.add_parser("status", help="show daemon status").set_defaults(func=cmd_status) sub.add_parser("status", help="show daemon status").set_defaults(func=cmd_status)
sub.add_parser("test-audio", help="verify the mic capture path").set_defaults(func=cmd_test_audio) sub.add_parser("test-audio", help="verify the mic capture path").set_defaults(func=cmd_test_audio)
sub.add_parser("install", help="re-run the bootstrap (install.sh)").set_defaults(func=cmd_install) sub.add_parser("install", help="re-run the bootstrap (install.sh)").set_defaults(func=cmd_install)
sub.add_parser("unset", help="clear the sticky target session").set_defaults(func=cmd_unset)
sub.add_parser("list", help="list running claude-* sessions").set_defaults(func=cmd_list)
sw = sub.add_parser("switch", help="set the active target session") for verb in ("set", "switch"):
sw.add_argument("name", help="project short-name (claude- prefix optional)") sp_set = sub.add_parser(verb, help="set the sticky target session")
sw.set_defaults(func=cmd_switch) sp_set.add_argument("name", help="project short-name (claude- prefix optional)")
sp_set.set_defaults(func=cmd_set)
return p return p

View File

@ -49,6 +49,7 @@ class Config:
max_utterance: float max_utterance: float
type_autosend: bool type_autosend: bool
match_threshold: float match_threshold: float
filler_words: tuple[str, ...]
source_path: Path | None = field(default=None) source_path: Path | None = field(default=None)
@ -115,6 +116,8 @@ def load_config(explicit: str | os.PathLike | None = None) -> Config:
max_utterance=float(_require(raw, "audio", "max_utterance", (int, float), 15.0)), max_utterance=float(_require(raw, "audio", "max_utterance", (int, float), 15.0)),
type_autosend=bool(_require(raw, "behavior", "type_autosend", (bool,), False)), type_autosend=bool(_require(raw, "behavior", "type_autosend", (bool,), False)),
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,),
["select", "use", "choose"])),
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

@ -160,10 +160,12 @@ class Daemon:
def _handle(self, transcript: str) -> None: def _handle(self, transcript: str) -> None:
cfg = self.config cfg = self.config
require_wake = self.mode == "listen" require_wake = self.mode == "listen"
action = grammar.parse(transcript, cfg.wake_phrases, cfg.match_threshold, require_wake) parsed = grammar.parse(transcript, cfg.wake_phrases, cfg.match_threshold, require_wake,
if action is None: filler=cfg.filler_words)
if parsed is None or parsed.action is None:
self._emit(f'heard: "{transcript}" -> no command matched') self._emit(f'heard: "{transcript}" -> no command matched')
return return
action = parsed.action
if action.name == "mode": if action.name == "mode":
new_mode = str(action.arg) new_mode = str(action.arg)
@ -172,24 +174,33 @@ class Daemon:
self._emit(f"mode -> {new_mode}") self._emit(f"mode -> {new_mode}")
self._refresh_state() self._refresh_state()
return return
if action.name == "switch": if action.name == "set":
session = target.set_target(str(action.arg)) session = target.set_target(str(action.arg))
self._emit(f"target -> {session}") self._emit(f"set sticky -> {session}")
self._refresh_state() self._refresh_state()
return return
if action.name == "unset":
session = target.resolve_target() target.unset_target()
if session is None: self._emit("unset (cleared)")
self._emit(f'heard: "{transcript}" -> matched: {self._describe(action)} ' self._refresh_state()
f'-> ERROR no target session (did nothing)')
return return
self._emit(f'heard: "{transcript}" -> matched: {self._describe(action)} -> target {session}') if action.name == "list":
sessions = target.list_sessions()
self._emit("list -> " + (", ".join(sessions) if sessions else "(none running)"))
return
session, reason = target.resolve(parsed.one_shot)
if session is None:
self._emit(f'heard: "{transcript}" -> {reason} -> matched {self._describe(action)} '
f'-> did nothing')
return
prefix = f'heard: "{transcript}" -> {reason} -> matched {self._describe(action)}'
if action.name == "type" and not cfg.type_autosend: if action.name == "type" and not cfg.type_autosend:
inject.send_literal(session, str(action.arg)) inject.send_literal(session, str(action.arg))
self._emit(f"injected: literal {str(action.arg)!r} -> {session}") self._emit(f"{prefix} -> injected literal {str(action.arg)!r} -> {session}")
return return
inject.perform(session, action) inject.perform(session, action)
self._emit(f"injected: {self._describe(action)} -> {session}") self._emit(f"{prefix} -> injected {self._describe(action)} -> {session}")
@staticmethod @staticmethod
def _describe(action) -> str: def _describe(action) -> str:

View File

@ -27,20 +27,40 @@ _NUMBER_WORDS = {
_INDEX_WORDS = {"1": 1, "2": 2, "3": 3, "4": 4} _INDEX_WORDS = {"1": 1, "2": 2, "3": 3, "4": 4}
_STICKY_VERBS = ("set", "sticky", "switch")
_ONESHOT_VERBS = ("target",)
_UNSET_VERBS = ("unset", "unsticky")
_LIST_VERBS = ("list", "sessions")
_SELECT_VERBS = ("select", "option", "choose", "number")
DEFAULT_FILLER = ("select", "use", "choose")
@dataclass(frozen=True) @dataclass(frozen=True)
class Action: class Action:
"""a matched command: a name plus an optional argument. """a matched command: a name plus an optional argument.
names: yes, no, select, approve, deny, submit, type, mode, switch, cancel. names: yes, no, select, approve, deny, submit, type, cancel, mode, set, unset,
arg carries the select index (int), the literal text for ``type``, the mode for list. arg carries the select index (int), the literal text for ``type``, the mode
``mode``, or the session short-name for ``switch``. for ``mode``, or the session short-name for ``set``.
""" """
name: str name: str
arg: object = None arg: object = None
@dataclass(frozen=True)
class ParsedCommand:
"""a fully parsed utterance: an optional one-shot target plus the command action.
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.
"""
one_shot: str | None
action: Action | None
def normalize(text: str) -> str: def normalize(text: str) -> str:
"""lowercase, strip punctuation, collapse whitespace, map number words to digits""" """lowercase, strip punctuation, collapse whitespace, map number words to digits"""
text = text.lower().strip() text = text.lower().strip()
@ -104,7 +124,12 @@ def _fuzzy_in(token: str, options: tuple[str, ...], threshold: float) -> bool:
def match_command(remainder: str, threshold: float) -> Action | None: def match_command(remainder: str, threshold: float) -> Action | None:
"""map a normalized command remainder to an Action, or None if unrecognized""" """map a normalized command remainder to an Action, or None if unrecognized.
expects the one-shot target and any leading filler to have been stripped already
(see parse). a leading ``select``/``option``/etc. is only treated as the select
command when followed by a digit; otherwise it is filler handled upstream.
"""
remainder = remainder.strip() remainder = remainder.strip()
if not remainder: if not remainder:
return None return None
@ -125,11 +150,10 @@ def match_command(remainder: str, threshold: float) -> Action | None:
return Action("deny") return Action("deny")
if _fuzzy_in(head, ("send", "enter", "submit"), threshold): if _fuzzy_in(head, ("send", "enter", "submit"), threshold):
return Action("submit") return Action("submit")
if _fuzzy_in(head, ("cancel", "escape", "stop"), threshold): if _fuzzy_in(head, ("cancel", "escape"), threshold):
return Action("cancel") return Action("cancel")
if _fuzzy_in(head, ("select", "option", "choose", "number"), threshold) and rest: if _fuzzy_in(head, _SELECT_VERBS, threshold) and rest and rest[0] in _INDEX_WORDS:
if rest[0] in _INDEX_WORDS:
return Action("select", _INDEX_WORDS[rest[0]]) return Action("select", _INDEX_WORDS[rest[0]])
if _fuzzy_in(head, ("type", "dictate", "write"), threshold): if _fuzzy_in(head, ("type", "dictate", "write"), threshold):
@ -143,17 +167,48 @@ def match_command(remainder: str, threshold: float) -> Action | None:
return Action("mode", "listen") return Action("mode", "listen")
return None return None
if _fuzzy_in(head, ("switch", "target"), threshold) and rest: if _fuzzy_in(head, _STICKY_VERBS, threshold) and rest:
name = "".join(rest) name = "".join(rest)
return Action("switch", name) if name else None return Action("set", name) if name else None
if _fuzzy_in(head, _UNSET_VERBS, threshold) and not rest:
return Action("unset")
if _fuzzy_in(head, _LIST_VERBS, threshold):
return Action("list")
return None return None
def _strip_filler(tokens: list[str], filler: tuple[str, ...], threshold: float) -> list[str]:
"""drop leading optional filler words (e.g. select/use/choose) before a command.
a filler word that is followed by a digit is NOT dropped that is the select
command (``select 1``), handled by match_command.
"""
while tokens and _fuzzy_in(tokens[0], filler, threshold):
if len(tokens) > 1 and tokens[1] in _INDEX_WORDS:
break
tokens = tokens[1:]
return tokens
def parse(transcript: str, wake_phrases: list[str], threshold: float, def parse(transcript: str, wake_phrases: list[str], threshold: float,
require_wake: bool) -> Action | None: require_wake: bool, filler: tuple[str, ...] = DEFAULT_FILLER) -> ParsedCommand | None:
"""full parse: wake gate then command match. None means discard""" """full parse: wake gate -> optional one-shot target -> filler -> command.
returns a ParsedCommand (one_shot, action), or None if the wake gate dropped the
utterance (listen mode, no wake phrase). a ParsedCommand with action=None means a
wake phrase was present but no command matched.
"""
remainder = strip_wake(transcript, wake_phrases, threshold, require_wake) remainder = strip_wake(transcript, wake_phrases, threshold, require_wake)
if remainder is None: if remainder is None:
return None return None
return match_command(remainder, threshold)
tokens = remainder.split(" ") if remainder else []
one_shot: str | None = None
if tokens and _fuzzy_in(tokens[0], _ONESHOT_VERBS, threshold) and len(tokens) >= 2:
one_shot = tokens[1]
tokens = tokens[2:]
tokens = _strip_filler(tokens, filler, threshold)
action = match_command(" ".join(tokens), threshold)
return ParsedCommand(one_shot=one_shot, action=action)

View File

@ -18,8 +18,9 @@ def session_name(name: str) -> str:
single source of truth for the name->session mapping. the shell cc kit single source of truth for the name->session mapping. the shell cc kit
(~/.config/claudedo/cc.sh) mirrors this exactly, so ``cc libs`` and the voice (~/.config/claudedo/cc.sh) mirrors this exactly, so ``cc libs`` and the voice
commands ``switch libs`` / ``target libs`` all resolve to ``claude-libs``. an commands ``set libs`` (sticky) / ``target libs`` (one-shot) all resolve to
already-prefixed name is returned unchanged so callers can pass either form. ``claude-libs``. an already-prefixed name is returned unchanged so callers can
pass either form.
""" """
name = name.strip() name = name.strip()
return name if name.startswith(SESSION_PREFIX) else f"{SESSION_PREFIX}{name}" return name if name.startswith(SESSION_PREFIX) else f"{SESSION_PREFIX}{name}"
@ -38,18 +39,28 @@ def read_active() -> str | None:
def write_active(name: str) -> None: def write_active(name: str) -> None:
"""overwrite ~/.claude-active with a session name (used by ``switch``)""" """overwrite ~/.claude-active with a session name (the sticky default)"""
ACTIVE_FILE.write_text(name + "\n", encoding="utf-8") ACTIVE_FILE.write_text(name + "\n", encoding="utf-8")
def set_target(name: str) -> str: def set_target(name: str) -> str:
"""map a project short-name via session_name() and persist it. returns the """map a project short-name via session_name() and persist it as the sticky
resolved session name.""" default. returns the resolved session name."""
session = session_name(name) session = session_name(name)
write_active(session) write_active(session)
return session return session
def unset_target() -> None:
"""clear the sticky default (empty/remove ~/.claude-active)"""
try:
ACTIVE_FILE.unlink()
except FileNotFoundError:
pass
except OSError as exc:
log.warning("could not clear %s: %s", ACTIVE_FILE, exc)
def session_exists(name: str) -> bool: def session_exists(name: str) -> bool:
"""true if a tmux session with this name currently exists""" """true if a tmux session with this name currently exists"""
if not name: if not name:
@ -62,24 +73,47 @@ def session_exists(name: str) -> bool:
return result.returncode == 0 return result.returncode == 0
def resolve_target() -> str | None: def list_sessions() -> list[str]:
"""return the active session name only if it exists; else log and return None. """return the names of all running claude-* tmux sessions (sorted)"""
result = subprocess.run(
["tmux", "list-sessions", "-F", "#{session_name}"],
stdout=subprocess.PIPE, stderr=subprocess.DEVNULL,
)
if result.returncode != 0:
return []
names = result.stdout.decode("utf-8", "replace").splitlines()
return sorted(n for n in names if n.startswith(SESSION_PREFIX))
never guesses a target: on a missing/empty ~/.claude-active or a stale session
name, this logs a clear warning and returns None so the caller injects nothing.
TODO: most-recently-active targeting (preferred over attached). today the target def resolve(one_shot: str | None = None) -> tuple[str | None, str]:
is the project most recently ATTACHED to (the cc kit writes ~/.claude-active on """resolve the destination session and a short reason describing the choice.
attach); upgrade to the session claude most recently asked a question in, via
tmux session_activity timestamps (list-sessions -F '#{session_name} single source of truth for targeting, used by both the voice and CLI paths.
#{session_activity}', pick the highest-activity claude-* session) or by scraping returns (session_or_None, reason). a None session means inject nothing; the
panes (capture-pane) for a waiting-prompt UI. reason explains why (for the daemon console / CLI message). resolution order:
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).
2. sticky set + exists -> use it.
3. nothing sticky, exactly one claude-* session -> auto-use it.
4. nothing sticky, multiple sessions -> ambiguous, do nothing.
5. nothing sticky, zero sessions -> do nothing.
""" """
name = read_active() if one_shot is not None:
if not name: session = session_name(one_shot)
log.warning("no active session set (%s missing/empty) — run `cc` to attach", ACTIVE_FILE) if session_exists(session):
return None return session, f"one-shot {session}"
if not session_exists(name): return None, f"one-shot {session} does not exist (did nothing)"
log.warning("target session %r no longer exists — skipping injection", name)
return None sticky = read_active()
return name if sticky:
if session_exists(sticky):
return sticky, f"sticky {sticky}"
return None, f"sticky {sticky} no longer exists (set one)"
sessions = list_sessions()
if len(sessions) == 1:
return sessions[0], f"auto-target {sessions[0]} (only session)"
if len(sessions) > 1:
return None, f"no target set, {len(sessions)} sessions (set one)"
return None, "no claude sessions"