4 Commits
Author SHA1 Message Date
dsql d96dc3898f feat: backspace/space/erase editing commands + colored prefixed console
voice editing: 'space [<n>]' inserts spaces, 'backspace [<n>]' (alias delete)
deletes chars, 'erase' (alias clear/wipe) wipes the current input. the daemon
tracks a per-session uncommitted-input char count so backspace is capped at the
last submit boundary and erase clears exactly back to it; submit/set reset it.
keys.py gains BSpace/space; grammar gains a count parser (digits + number words).

new console.py renders every daemon line as 'HH:MM:SS [prefix] message' with
color: [<session>] for injected lines (green), [SYSTEM] for state, [VOICE] for
recognition/drops (red/dim). bump to 0.1.2.

Signed-off-by: disqualifier <dev@disqualifier.me>
2026-06-26 01:17:22 -04:00
dsql d734161c97 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>
2026-06-26 01:17:08 -04:00
dsql b05f6256c1 fix: quiet onnxruntime GPU-discovery warning and faster_whisper INFO
faster-whisper's VAD loads an onnx model that prints a 'GPU device discovery
failed' warning on headless/WSL hosts and chatty INFO per transcribe. raise onnx
log severity, drop the faster_whisper logger to WARNING, and filter the C++-level
discovery line out of stderr during model load + a one-shot warm transcribe (so it
fires once at startup, not in the hot loop). real errors still pass through.

Signed-off-by: disqualifier <dev@disqualifier.me>
2026-06-26 01:16:47 -04:00
dsql 43b36d2a0b 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>
2026-06-25 20:16:29 -04:00
13 changed files with 472 additions and 92 deletions
+41 -14
View File
@@ -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 <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 status # running? mode? target session?
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
```
@@ -106,22 +108,39 @@ Wake phrases (listen mode), fuzzy-matched: **"claudedo"**, **"hey claude"**.
| `approve` / `deny` | allow / deny a permission prompt |
| `send` / `enter` | submit (Enter) |
| `type <phrase>` | insert literal text, **no** submit (read-before-send; say "send") |
| `space [<n>]` | insert n spaces (default 1) |
| `backspace [<n>]` (alias `delete`) | delete n chars (default 1), capped at the last submit boundary |
| `erase` (alias `clear`/`wipe`) | delete everything typed since the last submit/boundary |
| `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 |
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 <name>` / `target <name>` 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 <name>` (alias `sticky`/`switch`) overwrites it; `unset` clears it.
A `target <name>` 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-<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**:
because the kit forces an explicit name (no basename guessing), you always know the
exact word to say.
@@ -155,11 +174,19 @@ If Claude Code changes its prompt UI, re-confirm against a live session and upda
## Config
Everything tunable lives in [`config.toml`](config.toml): wake phrases, mode + PTT
key, Whisper model/language/device, audio segmentation thresholds, and
`type_autosend = false`. The default model is `small`; bump to `medium` if the coined
wake word is recognized poorly. `claudedo -c <path> ...` points at a specific config;
otherwise it searches `$CLAUDEDO_CONFIG`, `~/.config/claudedo/config.toml`, then
`./config.toml`.
key, Whisper model/language/device, audio segmentation thresholds, and `[behavior]`
(`type_autosend`, `filler_words`, `auto_target`, `print_heard`). The default model is
`small`; bump to `medium` if the coined wake word is recognized poorly. `claudedo -c
<path> ...` points at a specific config; otherwise it searches `$CLAUDEDO_CONFIG`,
`~/.config/claudedo/config.toml`, then `./config.toml`.
- **`auto_target`** (default `false`): with no sticky target set and exactly one
`claude-*` session running, `false` makes a bare command do nothing and ask you to
`set` one; `true` auto-targets that single session.
- **`print_heard`** (default `false`, debug): prints non-wake transcripts to the
console so you can see how Whisper renders your wake word. Turn it on to debug
detection, then off. Whisper has no token for "claudedo" — it commonly emits
"claude do" or "claude due", both of which are in the default wake list.
## Requirements
+16 -1
View File
@@ -5,7 +5,7 @@
# wake phrases for listen mode. fuzzy-matched: case/space-insensitive, lenient on
# the coined word "claudedo" (whisper renders it inconsistently). number words are
# normalized to digits before command matching.
phrases = ["claudedo", "hey claude"]
phrases = ["claudedo", "claude do", "claude due", "hey claude", "ok claude", "okay claude"]
[input]
# "listen" (default): continuous capture; only acts on utterances that start with a
@@ -51,3 +51,18 @@ 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"]
# 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
+1 -1
View File
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
[project]
name = "claudedo"
version = "0.1.0"
version = "0.1.2"
description = "voice-control daemon for claude code (local STT -> tmux send-keys)"
readme = "README.md"
requires-python = ">=3.10"
+1 -1
View File
@@ -1,3 +1,3 @@
"""claudedo — voice-control daemon for claude code (local STT -> tmux send-keys)"""
__version__ = "0.1.0"
__version__ = "0.1.2"
+25 -5
View File
@@ -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
+7
View File
@@ -49,6 +49,9 @@ class Config:
max_utterance: float
type_autosend: bool
match_threshold: float
filler_words: tuple[str, ...]
auto_target: bool
print_heard: bool
source_path: Path | None = field(default=None)
@@ -115,6 +118,10 @@ 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"])),
auto_target=bool(_require(raw, "behavior", "auto_target", (bool,), False)),
print_heard=bool(_require(raw, "behavior", "print_heard", (bool,), False)),
source_path=path,
)
if not 0.0 < cfg.match_threshold <= 1.0:
+51
View File
@@ -0,0 +1,51 @@
"""colored, prefixed console output for the daemon's recognition/action feed.
every line is ``HH:MM:SS [PREFIX] message``. prefixes group the source: a session
name (e.g. ``[claude-libs]``) for anything injected into a tmux session, ``[SYSTEM]``
for daemon-control/state lines, and ``[VOICE]`` for STT/recognition lines. color is
opt-in via tty detection (or forced): green for successful injections, red for
drops/errors, dim for routine. falls back to plain text when stdout is not a tty.
"""
from __future__ import annotations
import sys
import time
RESET = "\033[0m"
_COLORS = {
"green": "\033[32m",
"red": "\033[31m",
"yellow": "\033[33m",
"cyan": "\033[36m",
"dim": "\033[2m",
"bold": "\033[1m",
}
SYSTEM = "SYSTEM"
VOICE = "VOICE"
class Console:
"""formats and prints daemon log lines with timestamp, prefix, and color"""
def __init__(self, color: bool | None = None, stream=None, clock=None) -> None:
self.stream = stream if stream is not None else sys.stdout
self._clock = clock or time.localtime
if color is None:
color = hasattr(self.stream, "isatty") and self.stream.isatty()
self.color = bool(color)
def _stamp(self) -> str:
t = self._clock()
return f"{t.tm_hour:02d}:{t.tm_min:02d}:{t.tm_sec:02d}"
def _paint(self, text: str, color: str | None) -> str:
if not self.color or not color or color not in _COLORS:
return text
return f"{_COLORS[color]}{text}{RESET}"
def emit(self, prefix: str, message: str, color: str | None = None) -> None:
"""print one line: ``HH:MM:SS [prefix] message`` (message optionally colored)"""
line = f"{self._stamp()} {self._paint(f'[{prefix}]', 'dim')} {self._paint(message, color)}"
print(line, file=self.stream, flush=True)
+76 -29
View File
@@ -18,6 +18,7 @@ from pathlib import Path
from . import audio, grammar, inject, target
from .config import Config
from .console import SYSTEM, VOICE, Console
from .stt import Transcriber
log = logging.getLogger(__name__)
@@ -114,6 +115,8 @@ class Daemon:
self._transcriber: Transcriber | None = None
self._device: int | None = None
self._ptt = _PTTKey()
self._pending: dict[str, int] = {}
self._console = Console()
def _install_signals(self) -> None:
signal.signal(signal.SIGTERM, self._on_signal)
@@ -160,36 +163,86 @@ 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:
self._emit(f'heard: "{transcript}" -> no command matched')
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._console.emit(VOICE, f'heard "{transcript}" -> no command matched', "yellow")
return
action = parsed.action
if action.name == "mode":
new_mode = str(action.arg)
if new_mode != self.mode:
self.mode = new_mode
self._emit(f"mode -> {new_mode}")
self._console.emit(SYSTEM, f"mode -> {new_mode}", "cyan")
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._pending.pop(session, None)
self._console.emit(SYSTEM, f"set sticky -> {session}", "cyan")
self._refresh_state()
return
if action.name == "unset":
target.unset_target()
self._console.emit(SYSTEM, "unset (cleared)", "cyan")
self._refresh_state()
return
if action.name == "list":
sessions = target.list_sessions()
self._console.emit(SYSTEM, "list -> " + (", ".join(sessions) if sessions else "(none running)"))
return
session = target.resolve_target()
session, reason = target.resolve(parsed.one_shot, auto_target=cfg.auto_target)
if session is None:
self._emit(f'heard: "{transcript}" -> matched: {self._describe(action)} '
f'-> ERROR no target session (did nothing)')
self._console.emit(VOICE, f'heard "{transcript}" -> {reason} -> '
f'{self._describe(action)} did nothing', "red")
return
self._emit(f'heard: "{transcript}" -> matched: {self._describe(action)} -> target {session}')
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._inject(session, transcript, reason, action)
def _inject(self, session: str, transcript: str, reason: str, action) -> None:
"""run a resolved command against `session`, tracking the uncommitted-input
buffer so backspace/erase delete only back to the last submit boundary"""
heard = f'heard "{transcript}" ({reason})'
name = action.name
if name == "type":
text = str(action.arg)
inject.send_literal(session, text)
self._pending[session] = self._pending.get(session, 0) + len(text)
if self.config.type_autosend:
inject.send_named(session, inject.keys.SUBMIT)
self._pending[session] = 0
self._console.emit(session, f"{heard} -> typed {text!r}"
+ (" + send" if self.config.type_autosend else ""), "green")
return
if name == "space":
n = int(action.arg)
inject.perform(session, action)
self._emit(f"injected: {self._describe(action)} -> {session}")
self._pending[session] = self._pending.get(session, 0) + n
self._console.emit(session, f"{heard} -> space x{n}", "green")
return
if name == "backspace":
have = self._pending.get(session, 0)
n = min(int(action.arg), have)
if n:
inject.perform(session, grammar.Action("backspace", n))
self._pending[session] = have - n
self._console.emit(session, f"{heard} -> backspace x{n}"
+ ("" if n == int(action.arg) else " (capped at boundary)"), "green")
return
if name == "erase":
n = self._pending.get(session, 0)
if n:
inject.perform(session, grammar.Action("erase", n))
self._pending[session] = 0
self._console.emit(session, f"{heard} -> erase x{n} (to last boundary)", "green")
return
inject.perform(session, action)
if name == "submit":
self._pending[session] = 0
self._console.emit(session, f"{heard} -> {self._describe(action)}", "green")
@staticmethod
def _describe(action) -> str:
@@ -197,11 +250,6 @@ class Daemon:
return action.name.upper()
return f"{action.name.upper()}({action.arg})"
@staticmethod
def _emit(line: str) -> None:
"""print a recognition/action line to the watched terminal"""
print(line, flush=True)
def _has_wake(self, transcript: str) -> bool:
"""true if the utterance starts with a wake phrase (listen-mode gate).
@@ -214,15 +262,11 @@ class Daemon:
def _print_startup(self) -> None:
cfg = self.config
dev = cfg.stt_device if cfg.stt_device != "auto" else "default"
target_now = target.read_active() or "(none — run cc to attach)"
self._emit("── claudedo ─────────────────────────────────")
self._emit(f" model: {cfg.stt_model} ({cfg.stt_language})")
self._emit(f" mic: {dev}")
self._emit(f" mode: {self.mode}")
self._emit(f" target: {target_now}")
self._emit(f" wake: {', '.join(cfg.wake_phrases)}")
self._emit(" Ctrl-C to stop")
self._emit("─────────────────────────────────────────────")
target_now = target.read_active() or "(none — run cc / set <name>)"
self._console.emit(SYSTEM, f"claudedo {self.mode} mode — Ctrl-C to stop", "bold")
self._console.emit(SYSTEM, f"model {cfg.stt_model} ({cfg.stt_language}) · mic {dev} · "
f"target {target_now}")
self._console.emit(SYSTEM, "wake: " + ", ".join(cfg.wake_phrases))
def _refresh_state(self) -> None:
write_state(os.getpid(), self.mode, target.read_active())
@@ -246,7 +290,10 @@ class Daemon:
if not transcript:
continue
if self.mode == "listen" and not self._has_wake(transcript):
self._emit("dropped: non-wake speech (not recorded)")
if self.config.print_heard:
self._console.emit(VOICE, f'heard (dropped) "{transcript}"', "red")
else:
self._console.emit(VOICE, "dropped: non-wake speech (not recorded)", "dim")
continue
self._handle(transcript)
finally:
+97 -12
View File
@@ -27,20 +27,47 @@ _NUMBER_WORDS = {
_INDEX_WORDS = {"1": 1, "2": 2, "3": 3, "4": 4}
_COUNT_WORDS = {
"five": 5, "six": 6, "seven": 7, "eight": 8, "nine": 9, "ten": 10,
"eleven": 11, "twelve": 12, "thirteen": 13, "fourteen": 14, "fifteen": 15,
"sixteen": 16, "seventeen": 17, "eighteen": 18, "nineteen": 19, "twenty": 20,
}
_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, space, backspace, erase,
cancel, mode, set, unset, list. arg carries the select index (int), the literal
text for ``type``, the count for ``space``/``backspace`` (int), 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 <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:
"""lowercase, strip punctuation, collapse whitespace, map number words to digits"""
text = text.lower().strip()
@@ -103,8 +130,29 @@ def _fuzzy_in(token: str, options: tuple[str, ...], threshold: float) -> bool:
return any(_ratio(token, opt) >= threshold for opt in options)
def _leading_count(rest: list[str], default: int = 1) -> int:
"""read a count from the first token (digit or number word), else the default.
'backspace 3' -> 3, 'backspace ten' -> 10 (normalize maps small words to digits;
larger words come from _COUNT_WORDS), 'backspace' -> default.
"""
if not rest:
return default
tok = rest[0]
if tok.isdigit():
return max(0, int(tok))
if tok in _COUNT_WORDS:
return _COUNT_WORDS[tok]
return default
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,17 +173,23 @@ 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:
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()
return Action("type", text) if text else None
if _fuzzy_in(head, ("backspace", "delete"), threshold):
return Action("backspace", _leading_count(rest, default=1))
if _fuzzy_in(head, ("space",), threshold):
return Action("space", _leading_count(rest, default=1))
if _fuzzy_in(head, ("erase", "clear", "wipe"), threshold):
return Action("erase")
if _fuzzy_in(head, ("mode",), threshold) and rest:
if _fuzzy_in(rest[0], ("ptt",), threshold) or "push" in rest[0]:
return Action("mode", "ptt")
@@ -143,17 +197,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)
+13 -2
View File
@@ -45,11 +45,18 @@ class OutputHandler(ABC):
def send_literal(self, session: str, text: str) -> None:
"""emit literal text into the input box without submitting (``type``)"""
def send_repeat(self, session: str, token: str, count: int) -> None:
"""emit a named key `count` times (e.g. BSpace x n). default impl loops."""
if count <= 0:
return
self.send_named(session, [token] * count)
def perform(self, session: str, action) -> bool:
"""resolve a grammar.Action to keystrokes and emit them. returns acted?.
``switch`` and ``mode`` are handled by the daemon (they change daemon state,
not the claude session), so they are ignored here.
``switch``/``set``/``mode`` etc. are handled by the daemon (they change daemon
state, not the claude session), so they are ignored here. ``erase`` arrives
with action.arg already set to the count the daemon wants backspaced.
"""
name = action.name
if name == "yes":
@@ -72,6 +79,10 @@ class OutputHandler(ABC):
self.send_named(session, seq)
elif name == "type":
self.send_literal(session, str(action.arg))
elif name == "space":
self.send_literal(session, " " * int(action.arg))
elif name in ("backspace", "erase"):
self.send_repeat(session, keys.BACKSPACE[0], int(action.arg))
else:
return False
return True
+6
View File
@@ -37,6 +37,12 @@ DENY = ["3"]
SUBMIT = ["Enter"]
CANCEL = ["Escape"]
# BACKSPACE deletes one char left; SPACE inserts one literal space. both are emitted
# repeatedly for `backspace <n>` / `space <n>` and for `erase` (n = the daemon's
# tracked uncommitted-input count). BSpace is tmux's name for the backspace key.
BACKSPACE = ["BSpace"]
SPACE = [" "]
SELECT_BY_INDEX = {
1: SELECT_1,
2: SELECT_2,
+75 -2
View File
@@ -6,12 +6,77 @@ short in-memory chunk; nothing is written to disk or sent anywhere.
from __future__ import annotations
import contextlib
import logging
import os
import re
import sys
import numpy as np
log = logging.getLogger(__name__)
_NOISE = re.compile(r"GPU device discovery failed|device_discovery\.cc|DiscoverDevicesForPlatform")
def _quiet_backends() -> None:
"""quiet onnxruntime/ctranslate2 chatter and the faster_whisper INFO log.
faster-whisper's VAD loads an onnx model whose device discovery prints a noisy
'GPU device discovery failed' warning on headless/WSL hosts with no GPU sysfs.
the env var + logger severity stop most onnx logging; the warning itself is
emitted at C++ init and is filtered out of stderr by _filter_stderr().
"""
os.environ.setdefault("ORT_LOGGING_LEVEL", "3")
os.environ.setdefault("OMP_NUM_THREADS", os.environ.get("OMP_NUM_THREADS", "4"))
logging.getLogger("faster_whisper").setLevel(logging.WARNING)
try:
import onnxruntime
onnxruntime.set_default_logger_severity(3)
except Exception:
pass
@contextlib.contextmanager
def _filter_stderr():
"""drop onnxruntime's GPU-discovery warning lines from stderr for this block.
a pipe temporarily replaces fd 2; a pump thread forwards every line to the real
stderr EXCEPT the known GPU-discovery noise, so real errors still surface. the
original fd is always restored on exit.
"""
import threading
try:
stderr_fd = sys.stderr.fileno()
except (AttributeError, OSError):
yield
return
saved_fd = os.dup(stderr_fd)
read_fd, write_fd = os.pipe()
os.dup2(write_fd, stderr_fd)
os.close(write_fd)
def pump():
with os.fdopen(read_fd, "rb") as reader, os.fdopen(saved_fd, "wb", closefd=False) as out:
for line in reader:
if not _NOISE.search(line.decode("utf-8", "replace")):
out.write(line)
out.flush()
thread = threading.Thread(target=pump, daemon=True)
thread.start()
try:
yield
finally:
import time
time.sleep(0.05)
os.dup2(saved_fd, stderr_fd)
os.close(saved_fd)
thread.join(timeout=1.0)
class Transcriber:
"""a loaded faster-whisper model that transcribes float32 mono audio chunks"""
@@ -20,18 +85,26 @@ class Transcriber:
compute_type: str = "auto") -> None:
self.language = language
self._model = self._load(model, device, compute_type)
self._warm()
@staticmethod
def _load(model: str, device: str, compute_type: str):
from faster_whisper import WhisperModel
if device == "auto":
device = "cpu"
if compute_type == "auto":
compute_type = "int8" if device == "cpu" else "float16"
log.info("loading faster-whisper model=%s device=%s compute=%s", model, device, compute_type)
with _filter_stderr():
_quiet_backends()
from faster_whisper import WhisperModel
return WhisperModel(model, device=device, compute_type=compute_type)
def _warm(self) -> None:
"""run one throwaway transcribe so the VAD onnx session inits now, under the
stderr filter — the GPU-discovery warning fires here once, not in the loop"""
with _filter_stderr():
list(self._model.transcribe(np.zeros(1600, dtype=np.float32), vad_filter=True)[0])
def transcribe(self, audio: np.ndarray, samplerate: int = 16000) -> str:
"""transcribe a mono float32 numpy array to a stripped text string.
+61 -23
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
(~/.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,51 @@ 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, auto_target: bool = False) -> 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-<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_target=True -> auto-use it;
auto_target=False -> require an explicit set/target, do nothing.
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:
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:
return None, f"no target set, {len(sessions)} sessions (set one)"
return None, "no claude sessions"