From 43b36d2a0b32b2a0fddbe13175514d6ab5cf676e Mon Sep 17 00:00:00 2001 From: disqualifier Date: Thu, 25 Jun 2026 20:16:29 -0400 Subject: [PATCH] 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 ' 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 --- README.md | 34 ++++++++++++----- config.toml | 4 ++ pyproject.toml | 2 +- src/claudedo/__init__.py | 2 +- src/claudedo/__main__.py | 30 ++++++++++++--- src/claudedo/config.py | 3 ++ src/claudedo/daemon.py | 35 +++++++++++------ src/claudedo/grammar.py | 81 +++++++++++++++++++++++++++++++++------- src/claudedo/target.py | 80 +++++++++++++++++++++++++++------------ 9 files changed, 207 insertions(+), 64 deletions(-) diff --git a/README.md b/README.md index 80cc7b6..a38d30b 100644 --- a/README.md +++ b/README.md @@ -20,7 +20,7 @@ mic (WSLg/PulseAudio RDPSource) -> sounddevice capture -> faster-whisper (local STT, on-device) -> 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) -> tmux send-keys -t "" ``` @@ -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 status # running? mode? target session? claudedo stop # stop a running daemon -claudedo switch # retarget to claude- +claudedo set # set the sticky target -> claude- (alias: switch) +claudedo unset # clear the sticky target +claudedo list # list running claude-* sessions 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) | | `type ` | insert literal text, **no** submit (read-before-send; say "send") | | `mode ptt` / `mode listen` | switch input mode | -| `switch ` / `target ` | retarget to `claude-` | +| `set ` (alias `sticky`/`switch`) | set the **sticky** target → `claude-` (persists) | +| `target ` | **one-shot** override: run that command on `claude-` 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 | +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). ## Targeting -`~/.claude-active` holds the target session name (e.g. `claude-rethink-public`). The -**cc kit** writes this file when you attach, so the target is "the project you most -recently attached to". `claudedo switch ` / `target ` overwrites it. If -the file is missing or the session no longer exists, `claudedo` injects nothing and -logs a warning (it never guesses a target). +`~/.claude-active` holds the **sticky** target session name (e.g. +`claude-rethink-public`). The **cc kit** writes this file when you attach, and +`claudedo set ` (alias `sticky`/`switch`) overwrites it; `unset` clears it. +A `target ` voice command is a **one-shot** that does NOT touch the sticky +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-` 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**: because the kit forces an explicit name (no basename guessing), you always know the exact word to say. diff --git a/config.toml b/config.toml index f7350c4..1f7b9bd 100644 --- a/config.toml +++ b/config.toml @@ -51,3 +51,7 @@ max_utterance = 15.0 type_autosend = false # fuzzy match ratio (0..1) required to accept a wake phrase / command token. 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"] diff --git a/pyproject.toml b/pyproject.toml index 6ce12cb..4d6a72b 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "claudedo" -version = "0.1.0" +version = "0.1.1" description = "voice-control daemon for claude code (local STT -> tmux send-keys)" readme = "README.md" requires-python = ">=3.10" diff --git a/src/claudedo/__init__.py b/src/claudedo/__init__.py index ce0d4d9..1997cee 100644 --- a/src/claudedo/__init__.py +++ b/src/claudedo/__init__.py @@ -1,3 +1,3 @@ """claudedo — voice-control daemon for claude code (local STT -> tmux send-keys)""" -__version__ = "0.1.0" +__version__ = "0.1.1" diff --git a/src/claudedo/__main__.py b/src/claudedo/__main__.py index d4f9beb..eebb55a 100644 --- a/src/claudedo/__main__.py +++ b/src/claudedo/__main__.py @@ -185,9 +185,26 @@ def cmd_install(_args: argparse.Namespace) -> int: 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) - 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 @@ -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("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("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") - sw.add_argument("name", help="project short-name (claude- prefix optional)") - sw.set_defaults(func=cmd_switch) + for verb in ("set", "switch"): + sp_set = sub.add_parser(verb, help="set the sticky target session") + sp_set.add_argument("name", help="project short-name (claude- prefix optional)") + sp_set.set_defaults(func=cmd_set) return p diff --git a/src/claudedo/config.py b/src/claudedo/config.py index 4671464..8604ccb 100644 --- a/src/claudedo/config.py +++ b/src/claudedo/config.py @@ -49,6 +49,7 @@ class Config: max_utterance: float type_autosend: bool match_threshold: float + filler_words: tuple[str, ...] 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)), type_autosend=bool(_require(raw, "behavior", "type_autosend", (bool,), False)), 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, ) if not 0.0 < cfg.match_threshold <= 1.0: diff --git a/src/claudedo/daemon.py b/src/claudedo/daemon.py index 47190ce..f457373 100644 --- a/src/claudedo/daemon.py +++ b/src/claudedo/daemon.py @@ -160,10 +160,12 @@ class Daemon: def _handle(self, transcript: str) -> None: cfg = self.config require_wake = self.mode == "listen" - action = grammar.parse(transcript, cfg.wake_phrases, cfg.match_threshold, require_wake) - if action is None: + parsed = grammar.parse(transcript, cfg.wake_phrases, cfg.match_threshold, require_wake, + filler=cfg.filler_words) + if parsed is None or parsed.action is None: self._emit(f'heard: "{transcript}" -> no command matched') return + action = parsed.action if action.name == "mode": new_mode = str(action.arg) @@ -172,24 +174,33 @@ class Daemon: self._emit(f"mode -> {new_mode}") self._refresh_state() return - if action.name == "switch": + if action.name == "set": session = target.set_target(str(action.arg)) - self._emit(f"target -> {session}") + self._emit(f"set sticky -> {session}") self._refresh_state() return - - session = target.resolve_target() - if session is None: - self._emit(f'heard: "{transcript}" -> matched: {self._describe(action)} ' - f'-> ERROR no target session (did nothing)') + if action.name == "unset": + target.unset_target() + self._emit("unset (cleared)") + self._refresh_state() 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: 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 inject.perform(session, action) - self._emit(f"injected: {self._describe(action)} -> {session}") + self._emit(f"{prefix} -> injected {self._describe(action)} -> {session}") @staticmethod def _describe(action) -> str: diff --git a/src/claudedo/grammar.py b/src/claudedo/grammar.py index 5069e76..f44cbd7 100644 --- a/src/claudedo/grammar.py +++ b/src/claudedo/grammar.py @@ -27,20 +27,40 @@ _NUMBER_WORDS = { _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) class Action: """a matched command: a name plus an optional argument. - names: yes, no, select, approve, deny, submit, type, mode, switch, cancel. - arg carries the select index (int), the literal text for ``type``, the mode for - ``mode``, or the session short-name for ``switch``. + names: yes, no, select, approve, deny, submit, type, cancel, mode, set, unset, + list. arg carries the select index (int), the literal text for ``type``, the mode + for ``mode``, or the session short-name for ``set``. """ name: str 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 `` (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: """lowercase, strip punctuation, collapse whitespace, map number words to digits""" 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: - """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() if not remainder: return None @@ -125,12 +150,11 @@ def match_command(remainder: str, threshold: float) -> Action | None: return Action("deny") if _fuzzy_in(head, ("send", "enter", "submit"), threshold): return Action("submit") - if _fuzzy_in(head, ("cancel", "escape", "stop"), threshold): + if _fuzzy_in(head, ("cancel", "escape"), threshold): return Action("cancel") - if _fuzzy_in(head, ("select", "option", "choose", "number"), threshold) and rest: - if rest[0] in _INDEX_WORDS: - return Action("select", _INDEX_WORDS[rest[0]]) + if _fuzzy_in(head, _SELECT_VERBS, threshold) and rest and rest[0] in _INDEX_WORDS: + return Action("select", _INDEX_WORDS[rest[0]]) if _fuzzy_in(head, ("type", "dictate", "write"), threshold): text = " ".join(rest).strip() @@ -143,17 +167,48 @@ def match_command(remainder: str, threshold: float) -> Action | None: return Action("mode", "listen") return None - if _fuzzy_in(head, ("switch", "target"), threshold) and rest: + if _fuzzy_in(head, _STICKY_VERBS, threshold) and 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 +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, - require_wake: bool) -> Action | None: - """full parse: wake gate then command match. None means discard""" + require_wake: bool, filler: tuple[str, ...] = DEFAULT_FILLER) -> ParsedCommand | None: + """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) if remainder is 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) diff --git a/src/claudedo/target.py b/src/claudedo/target.py index 6650622..05ca5a1 100644 --- a/src/claudedo/target.py +++ b/src/claudedo/target.py @@ -18,8 +18,9 @@ def session_name(name: str) -> str: 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 - commands ``switch libs`` / ``target libs`` all resolve to ``claude-libs``. an - already-prefixed name is returned unchanged so callers can pass either form. + commands ``set libs`` (sticky) / ``target libs`` (one-shot) all resolve to + ``claude-libs``. an already-prefixed name is returned unchanged so callers can + pass either form. """ name = name.strip() 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: - """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") def set_target(name: str) -> str: - """map a project short-name via session_name() and persist it. returns the - resolved session name.""" + """map a project short-name via session_name() and persist it as the sticky + default. returns the resolved session name.""" session = session_name(name) write_active(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: """true if a tmux session with this name currently exists""" if not name: @@ -62,24 +73,47 @@ def session_exists(name: str) -> bool: return result.returncode == 0 -def resolve_target() -> str | None: - """return the active session name only if it exists; else log and return None. +def list_sessions() -> list[str]: + """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 - is the project most recently ATTACHED to (the cc kit writes ~/.claude-active on - attach); upgrade to the session claude most recently asked a question in, via - tmux session_activity timestamps (list-sessions -F '#{session_name} - #{session_activity}', pick the highest-activity claude-* session) or by scraping - panes (capture-pane) for a waiting-prompt UI. +def resolve(one_shot: str | None = None) -> tuple[str | None, str]: + """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. + returns (session_or_None, reason). a None session means inject nothing; the + reason explains why (for the daemon console / CLI message). resolution order: + + 1. one-shot present -> claude- 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 not name: - log.warning("no active session set (%s missing/empty) — run `cc` to attach", ACTIVE_FILE) - return None - if not session_exists(name): - log.warning("target session %r no longer exists — skipping injection", name) - return None - return name + if one_shot is not None: + session = session_name(one_shot) + if session_exists(session): + return session, f"one-shot {session}" + return None, f"one-shot {session} does not exist (did nothing)" + + sticky = read_active() + 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"