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 -> 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
``` ```
@@ -106,22 +108,39 @@ Wake phrases (listen mode), fuzzy-matched: **"claudedo"**, **"hey claude"**.
| `approve` / `deny` | allow / deny a permission prompt | | `approve` / `deny` | allow / deny a permission prompt |
| `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") |
| `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 | | `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.
@@ -155,11 +174,19 @@ If Claude Code changes its prompt UI, re-confirm against a live session and upda
## Config ## Config
Everything tunable lives in [`config.toml`](config.toml): wake phrases, mode + PTT Everything tunable lives in [`config.toml`](config.toml): wake phrases, mode + PTT
key, Whisper model/language/device, audio segmentation thresholds, and key, Whisper model/language/device, audio segmentation thresholds, and `[behavior]`
`type_autosend = false`. The default model is `small`; bump to `medium` if the coined (`type_autosend`, `filler_words`, `auto_target`, `print_heard`). The default model is
wake word is recognized poorly. `claudedo -c <path> ...` points at a specific config; `small`; bump to `medium` if the coined wake word is recognized poorly. `claudedo -c
otherwise it searches `$CLAUDEDO_CONFIG`, `~/.config/claudedo/config.toml`, then <path> ...` points at a specific config; otherwise it searches `$CLAUDEDO_CONFIG`,
`./config.toml`. `~/.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 ## Requirements
+16 -1
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
@@ -51,3 +51,18 @@ 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"]
# 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] [project]
name = "claudedo" name = "claudedo"
version = "0.1.0" version = "0.1.2"
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"
+1 -1
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.2"
+25 -5
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
+7
View File
@@ -49,6 +49,9 @@ class Config:
max_utterance: float max_utterance: float
type_autosend: bool type_autosend: bool
match_threshold: float match_threshold: float
filler_words: tuple[str, ...]
auto_target: bool
print_heard: bool
source_path: Path | None = field(default=None) 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)), 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"])),
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:
+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 . import audio, grammar, inject, target
from .config import Config from .config import Config
from .console import SYSTEM, VOICE, Console
from .stt import Transcriber from .stt import Transcriber
log = logging.getLogger(__name__) log = logging.getLogger(__name__)
@@ -114,6 +115,8 @@ class Daemon:
self._transcriber: Transcriber | None = None self._transcriber: Transcriber | None = None
self._device: int | None = None self._device: int | None = None
self._ptt = _PTTKey() self._ptt = _PTTKey()
self._pending: dict[str, int] = {}
self._console = Console()
def _install_signals(self) -> None: def _install_signals(self) -> None:
signal.signal(signal.SIGTERM, self._on_signal) signal.signal(signal.SIGTERM, self._on_signal)
@@ -160,36 +163,86 @@ 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)
self._emit(f'heard: "{transcript}" -> no command matched') if parsed is None or parsed.action is None:
self._console.emit(VOICE, f'heard "{transcript}" -> no command matched', "yellow")
return return
action = parsed.action
if action.name == "mode": if action.name == "mode":
new_mode = str(action.arg) new_mode = str(action.arg)
if new_mode != self.mode: if new_mode != self.mode:
self.mode = new_mode self.mode = new_mode
self._emit(f"mode -> {new_mode}") self._console.emit(SYSTEM, f"mode -> {new_mode}", "cyan")
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._pending.pop(session, None)
self._console.emit(SYSTEM, f"set sticky -> {session}", "cyan")
self._refresh_state() self._refresh_state()
return 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: if session is None:
self._emit(f'heard: "{transcript}" -> matched: {self._describe(action)} ' self._console.emit(VOICE, f'heard "{transcript}" -> {reason} -> '
f'-> ERROR no target session (did nothing)') f'{self._describe(action)} did nothing', "red")
return return
self._emit(f'heard: "{transcript}" -> matched: {self._describe(action)} -> target {session}') self._inject(session, transcript, reason, action)
if action.name == "type" and not cfg.type_autosend:
inject.send_literal(session, str(action.arg)) def _inject(self, session: str, transcript: str, reason: str, action) -> None:
self._emit(f"injected: literal {str(action.arg)!r} -> {session}") """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 return
if name == "space":
n = int(action.arg)
inject.perform(session, action) 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 @staticmethod
def _describe(action) -> str: def _describe(action) -> str:
@@ -197,11 +250,6 @@ class Daemon:
return action.name.upper() return action.name.upper()
return f"{action.name.upper()}({action.arg})" 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: def _has_wake(self, transcript: str) -> bool:
"""true if the utterance starts with a wake phrase (listen-mode gate). """true if the utterance starts with a wake phrase (listen-mode gate).
@@ -214,15 +262,11 @@ class Daemon:
def _print_startup(self) -> None: def _print_startup(self) -> None:
cfg = self.config cfg = self.config
dev = cfg.stt_device if cfg.stt_device != "auto" else "default" dev = cfg.stt_device if cfg.stt_device != "auto" else "default"
target_now = target.read_active() or "(none — run cc to attach)" target_now = target.read_active() or "(none — run cc / set <name>)"
self._emit("── claudedo ─────────────────────────────────") self._console.emit(SYSTEM, f"claudedo {self.mode} mode — Ctrl-C to stop", "bold")
self._emit(f" model: {cfg.stt_model} ({cfg.stt_language})") self._console.emit(SYSTEM, f"model {cfg.stt_model} ({cfg.stt_language}) · mic {dev} · "
self._emit(f" mic: {dev}") f"target {target_now}")
self._emit(f" mode: {self.mode}") self._console.emit(SYSTEM, "wake: " + ", ".join(cfg.wake_phrases))
self._emit(f" target: {target_now}")
self._emit(f" wake: {', '.join(cfg.wake_phrases)}")
self._emit(" Ctrl-C to stop")
self._emit("─────────────────────────────────────────────")
def _refresh_state(self) -> None: def _refresh_state(self) -> None:
write_state(os.getpid(), self.mode, target.read_active()) write_state(os.getpid(), self.mode, target.read_active())
@@ -246,7 +290,10 @@ class Daemon:
if not transcript: if not transcript:
continue continue
if self.mode == "listen" and not self._has_wake(transcript): 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 continue
self._handle(transcript) self._handle(transcript)
finally: finally:
+97 -12
View File
@@ -27,20 +27,47 @@ _NUMBER_WORDS = {
_INDEX_WORDS = {"1": 1, "2": 2, "3": 3, "4": 4} _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) @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, space, backspace, erase,
arg carries the select index (int), the literal text for ``type``, the mode for cancel, mode, set, unset, list. arg carries the select index (int), the literal
``mode``, or the session short-name for ``switch``. text for ``type``, the count for ``space``/``backspace`` (int), the mode 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()
@@ -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) 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: 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,17 +173,23 @@ 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):
text = " ".join(rest).strip() text = " ".join(rest).strip()
return Action("type", text) if text else None 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(head, ("mode",), threshold) and rest:
if _fuzzy_in(rest[0], ("ptt",), threshold) or "push" in rest[0]: if _fuzzy_in(rest[0], ("ptt",), threshold) or "push" in rest[0]:
return Action("mode", "ptt") return Action("mode", "ptt")
@@ -143,17 +197,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)
+13 -2
View File
@@ -45,11 +45,18 @@ class OutputHandler(ABC):
def send_literal(self, session: str, text: str) -> None: def send_literal(self, session: str, text: str) -> None:
"""emit literal text into the input box without submitting (``type``)""" """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: def perform(self, session: str, action) -> bool:
"""resolve a grammar.Action to keystrokes and emit them. returns acted?. """resolve a grammar.Action to keystrokes and emit them. returns acted?.
``switch`` and ``mode`` are handled by the daemon (they change daemon state, ``switch``/``set``/``mode`` etc. are handled by the daemon (they change daemon
not the claude session), so they are ignored here. 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 name = action.name
if name == "yes": if name == "yes":
@@ -72,6 +79,10 @@ class OutputHandler(ABC):
self.send_named(session, seq) self.send_named(session, seq)
elif name == "type": elif name == "type":
self.send_literal(session, str(action.arg)) 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: else:
return False return False
return True return True
+6
View File
@@ -37,6 +37,12 @@ DENY = ["3"]
SUBMIT = ["Enter"] SUBMIT = ["Enter"]
CANCEL = ["Escape"] 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 = { SELECT_BY_INDEX = {
1: SELECT_1, 1: SELECT_1,
2: SELECT_2, 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 from __future__ import annotations
import contextlib
import logging import logging
import os
import re
import sys
import numpy as np import numpy as np
log = logging.getLogger(__name__) 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: class Transcriber:
"""a loaded faster-whisper model that transcribes float32 mono audio chunks""" """a loaded faster-whisper model that transcribes float32 mono audio chunks"""
@@ -20,18 +85,26 @@ class Transcriber:
compute_type: str = "auto") -> None: compute_type: str = "auto") -> None:
self.language = language self.language = language
self._model = self._load(model, device, compute_type) self._model = self._load(model, device, compute_type)
self._warm()
@staticmethod @staticmethod
def _load(model: str, device: str, compute_type: str): def _load(model: str, device: str, compute_type: str):
from faster_whisper import WhisperModel
if device == "auto": if device == "auto":
device = "cpu" device = "cpu"
if compute_type == "auto": if compute_type == "auto":
compute_type = "int8" if device == "cpu" else "float16" compute_type = "int8" if device == "cpu" else "float16"
log.info("loading faster-whisper model=%s device=%s compute=%s", model, device, compute_type) 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) 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: def transcribe(self, audio: np.ndarray, samplerate: int = 16000) -> str:
"""transcribe a mono float32 numpy array to a stripped text string. """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 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,51 @@ 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, auto_target: bool = False) -> 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_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 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:
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"