Compare commits
29
Commits
732bad4c8d
..
v0.2.0
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2fa3abab63 | ||
|
|
f177b46a4b | ||
|
|
252385fb67 | ||
|
|
97591eb24d | ||
|
|
5f05a01423 | ||
|
|
e84ef91e7b | ||
|
|
2cbbabfaa1 | ||
|
|
4357b14fad | ||
|
|
8e20b7eb0b | ||
|
|
4abdfd56bc | ||
|
|
e6dadab143 | ||
|
|
5064f912a4 | ||
|
|
a51c2fbdd4 | ||
|
|
bd6597352a | ||
|
|
08bbe3ce58 | ||
|
|
d96dc3898f | ||
|
|
d734161c97 | ||
|
|
b05f6256c1 | ||
|
|
43b36d2a0b | ||
|
|
17db65858e | ||
|
|
eb587692e1 | ||
|
|
84c74603e5 | ||
|
|
d43004e4b9 | ||
|
|
66b08d290c | ||
|
|
7f4a6f6699 | ||
|
|
bf516143b5 | ||
|
|
7780a8d47c | ||
|
|
947b30c22e | ||
|
|
da7c39c4f2 |
@@ -1,4 +1,5 @@
|
|||||||
CLAUDE.md
|
CLAUDE.md
|
||||||
|
COMPACT.md
|
||||||
|
|
||||||
__pycache__/
|
__pycache__/
|
||||||
*.pyc
|
*.pyc
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ hands-free while another window (a game) is focused.
|
|||||||
It exists because Claude Code's native `/voice` is hardcoded-blocked in WSL (it
|
It exists because Claude Code's native `/voice` is hardcoded-blocked in WSL (it
|
||||||
assumes WSL has no audio). Modern WSL2 + WSLg *does* have working mic input via
|
assumes WSL has no audio). Modern WSL2 + WSLg *does* have working mic input via
|
||||||
PulseAudio/RDP. `claudedo` captures the mic itself, transcribes on-device, and drives
|
PulseAudio/RDP. `claudedo` captures the mic itself, transcribes on-device, and drives
|
||||||
Claude Code over tmux — fully local, private, backgroundable.
|
Claude Code over tmux — fully local and private. You run it in a terminal you watch.
|
||||||
|
|
||||||
## How it works
|
## How it works
|
||||||
|
|
||||||
@@ -20,9 +20,11 @@ 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/space/backspace/erase/
|
||||||
-> resolve target session (~/.claude-active)
|
mode/set/target/unset/list/context/reload/system/cancel)
|
||||||
|
-> resolve target session (one-shot > sticky ~/.claude-active > auto/none)
|
||||||
-> tmux send-keys -t <session> "<keys>"
|
-> tmux send-keys -t <session> "<keys>"
|
||||||
|
-> log the action to the watched terminal ([session]/[SYSTEM]/[VOICE], colored)
|
||||||
```
|
```
|
||||||
|
|
||||||
**Privacy by construction.** STT runs on-device. In listen mode, any speech that
|
**Privacy by construction.** STT runs on-device. In listen mode, any speech that
|
||||||
@@ -61,37 +63,29 @@ claudedo test-audio
|
|||||||
|
|
||||||
## Usage
|
## Usage
|
||||||
|
|
||||||
|
**Run it in a terminal you watch — that's the product.** You launch `claudedo
|
||||||
|
start` and it drops into a visible listen loop (pass `--check` to run a mic check
|
||||||
|
first). Each utterance prints a timestamped, colored line — `HH:MM:SS [claude-libs]
|
||||||
|
heard "…" →
|
||||||
|
typed 'fix'` (green for injected, red for drops, `[SYSTEM]`/`[VOICE]` for state and
|
||||||
|
recognition). That terminal is your recognition/action console; you attach to the
|
||||||
|
`claude-<name>` session in another pane to watch the keystrokes land. It runs in the
|
||||||
|
foreground by design — the console is the point — though `claudedo stop` can signal a
|
||||||
|
stray instance.
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
claudedo start # run the daemon (foreground; listen mode by default)
|
claudedo start # the visible listen loop (listen mode default; no mic check)
|
||||||
|
claudedo start --check # run a mic check before listening
|
||||||
claudedo start --mode ptt # push-to-talk instead (desk-only — see Modes)
|
claudedo start --mode ptt # push-to-talk instead (desk-only — see Modes)
|
||||||
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 reload # reload config.toml + contexts.toml in a running daemon
|
||||||
|
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
|
||||||
```
|
```
|
||||||
|
|
||||||
Background it in its own tmux session:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
tmux new-session -d -s claudedo 'claudedo start'
|
|
||||||
```
|
|
||||||
|
|
||||||
### Autostart
|
|
||||||
|
|
||||||
WSL has no real boot, so autostart is rc-based and **opt-in**. `install.sh` ships
|
|
||||||
`~/.config/claudedo/autostart.sh`, which starts the daemon in a `claudedo-daemon`
|
|
||||||
tmux session once per WSL session — but only when `CLAUDEDO_AUTOSTART=1` is set.
|
|
||||||
Enable it by uncommenting the `export CLAUDEDO_AUTOSTART=1` line in the cc-kit marker
|
|
||||||
block of your rc; disable it by re-commenting (or deleting the file). Watch its logs
|
|
||||||
with `tmux attach -t claudedo-daemon`.
|
|
||||||
|
|
||||||
If your WSL runs systemd (`systemd=true` in `/etc/wsl.conf`), `install.sh` also
|
|
||||||
installs an optional user unit — enable it instead with:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
systemctl --user enable --now claudedo
|
|
||||||
```
|
|
||||||
|
|
||||||
### Modes
|
### Modes
|
||||||
|
|
||||||
- **listen (default)** — continuous capture; only acts on utterances that **start
|
- **listen (default)** — continuous capture; only acts on utterances that **start
|
||||||
@@ -109,9 +103,14 @@ Switch at runtime by voice: "claudedo mode listen" / "claudedo mode ptt".
|
|||||||
|
|
||||||
## Command grammar
|
## Command grammar
|
||||||
|
|
||||||
Wake phrases (listen mode), fuzzy-matched: **"claudedo"**, **"hey claude"**.
|
Wake phrases (listen mode), fuzzy-matched. The default list is **"claudedo"**,
|
||||||
"claudedo" is a coined word, so the matcher is lenient (accepts "claude do",
|
**"claude do"**, **"hey claude"**, **"ok claude"**, **"okay claude"** — Whisper has
|
||||||
"clauddo", "cloud do", …). In PTT mode the wake phrase is optional.
|
no token for the coined word "claudedo" and renders it as real words ("claude do"),
|
||||||
|
so that spelling is listed explicitly. Matching is lenient (case/space-insensitive).
|
||||||
|
Add the spellings you actually see (turn on `print_heard` to find them). In PTT mode
|
||||||
|
the wake phrase is optional. When a command's wake phrase matched loosely (e.g. you
|
||||||
|
said "okay clouds"), the heard line notes which phrase it assumed —
|
||||||
|
`heard "okay clouds list" -> LIST (wake: okay claude)`.
|
||||||
|
|
||||||
| Say | Does |
|
| Say | Does |
|
||||||
|---|---|
|
|---|---|
|
||||||
@@ -120,22 +119,49 @@ 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>]` (also `add [a] space`, `insert <n> spaces`) | 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 |
|
||||||
|
| `debug <text>` (alias `echo`) | just print what you said to the console (test wake/STT; injects nothing) |
|
||||||
| `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 |
|
||||||
|
| `context <name> <instruction>` (alias `prepare`) | inject a `contexts.toml` blurb as a preamble + the dictated instruction, then **wait** (no submit — say "send") |
|
||||||
|
| `reload` | re-read `config.toml` + `contexts.toml` live (no daemon restart, model stays loaded) |
|
||||||
|
| `system status` | print mode / target / model / context count to the console (daemon-control; never injects) |
|
||||||
|
| `system reload [config\|contexts]` | reload one or both config files |
|
||||||
|
| `commands` (alias `help`/`menu`) | print the voice-command menu to the console |
|
||||||
|
| `customs` (alias `custom`) | list the loaded context names |
|
||||||
|
| `version` | print the claudedo version to the 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 does nothing and asks you to `set` one
|
||||||
|
(the default). Set `auto_target = true` to instead auto-use the single running
|
||||||
|
`claude-*` session when there's exactly one; with several running it always 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, only if `auto_target = true`, the single
|
||||||
|
running `claude-*` session → else (default, or zero/several sessions) 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.
|
||||||
@@ -151,6 +177,40 @@ cck <name> # kill claude-<name>
|
|||||||
cckl # kill all claude-* sessions
|
cckl # kill all claude-* sessions
|
||||||
```
|
```
|
||||||
|
|
||||||
|
## Contexts (named reference blurbs)
|
||||||
|
|
||||||
|
`contexts.toml` holds named reference snippets you can inject ahead of a dictated
|
||||||
|
instruction with the **`context <name> <instruction>`** voice command (alias
|
||||||
|
`prepare`). It lives next to `config.toml`
|
||||||
|
(`$CLAUDEDO_CONTEXTS` → `~/.config/claudedo/contexts.toml` → `./contexts.toml`); a
|
||||||
|
missing file just means no contexts (the feature is opt-in).
|
||||||
|
|
||||||
|
```toml
|
||||||
|
[contexts]
|
||||||
|
webhooks = "discord webhooks — test: <url> (safe to spam), live: <url> (real, careful)"
|
||||||
|
testing = "use the test/staging resources only, never touch prod"
|
||||||
|
```
|
||||||
|
|
||||||
|
Saying `context webhooks send a test message` injects the `webhooks` blurb as a
|
||||||
|
preamble, then the dictated instruction, and **waits** — nothing is auto-submitted. You
|
||||||
|
say `send` to submit (**read-before-send**; Claude's own permission prompt is the
|
||||||
|
backstop for anything consequential). A bare `context webhooks` injects just the blurb.
|
||||||
|
One context per command (no stacking yet); an unknown name announces and injects
|
||||||
|
nothing.
|
||||||
|
|
||||||
|
Names are **spoken and fuzzy-matched**, so keep them simple and distinct — they're
|
||||||
|
looked up on a despaced/lowercased key, so `web hooks` / `web-hooks` / `webhooks` all
|
||||||
|
resolve the same block. Assembly is config-gated: `behavior.context_multiline` (default
|
||||||
|
`true`) puts the blurb and instruction on separate lines via a Shift+Enter soft newline;
|
||||||
|
set it `false` to flatten onto one line with `context_separator` (default `" — "`) if
|
||||||
|
Shift+Enter is unreliable in your terminal.
|
||||||
|
|
||||||
|
Edit `contexts.toml`, then say **`reload`** (or run `claudedo reload`) — it re-reads
|
||||||
|
`config.toml` and `contexts.toml` live without restarting the daemon or reloading the
|
||||||
|
Whisper model. The **`system`** namespace gives daemon-control by voice without touching
|
||||||
|
Claude: `system status` (mode / target / model / context count) and `system reload
|
||||||
|
[config|contexts]`.
|
||||||
|
|
||||||
## The confirmed Claude Code keymap
|
## The confirmed Claude Code keymap
|
||||||
|
|
||||||
The keystrokes in [`keys.py`](src/claudedo/keys.py) were confirmed **empirically**
|
The keystrokes in [`keys.py`](src/claudedo/keys.py) were confirmed **empirically**
|
||||||
@@ -169,11 +229,37 @@ 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, `[vad]` endpointing, and `[behavior]`
|
||||||
`type_autosend = false`. The default model is `small`; bump to `medium` if the coined
|
(`type_autosend`, fuzzy thresholds, `filler_words`, `auto_target`, `print_heard`).
|
||||||
wake word is recognized poorly. `claudedo -c <path> ...` points at a specific config;
|
The default model is **`small.en`** (the English-only small model — ~1s/command on a
|
||||||
otherwise it searches `$CLAUDEDO_CONFIG`, `~/.config/claudedo/config.toml`, then
|
strong CPU, more accurate on English than multilingual `small` at the same speed);
|
||||||
`./config.toml`.
|
`medium`/`medium.en` are more accurate but ~3× slower (noticeable lag), `base.en` is
|
||||||
|
snappier/less accurate, `large-v3` most accurate/slowest. Every `heard` line shows the
|
||||||
|
STT latency as `(<ms>/<audio>s)` so you can see what a model change costs. VAD
|
||||||
|
endpointing ends a capture after `[vad].silence_ms` (700) of trailing silence, capped
|
||||||
|
at `max_seconds` (15). `claudedo -c <path> ...` points at a specific config; otherwise
|
||||||
|
it searches
|
||||||
|
`$CLAUDEDO_CONFIG`, `~/.config/claudedo/config.toml`, then `./config.toml`.
|
||||||
|
|
||||||
|
- **STT biasing.** The transcriber is seeded with an `initial_prompt` built from the
|
||||||
|
configured wake phrases + command vocabulary (one source — `grammar.vocabulary()`),
|
||||||
|
so Whisper is conditioned to expect "claudedo" and the command words.
|
||||||
|
- **Split fuzzy thresholds.** `wake_fuzzy_threshold` (default `0.65`, lenient) vs
|
||||||
|
`command_fuzzy_threshold` (default `0.8`, tight). The asymmetry is deliberate: a
|
||||||
|
false *wake* is cheap (it wakes, finds no command, does nothing), but a false
|
||||||
|
*command* fires the wrong action. Prefer expanding command synonyms over loosening
|
||||||
|
the command threshold.
|
||||||
|
- **`[vad]` endpointing.** Capture starts on speech and ends after `silence_ms`
|
||||||
|
(default 700) of trailing silence — Alexa-style record-until-pause — capped at
|
||||||
|
`max_seconds` (default 15). The pause both ends a command and separates it from
|
||||||
|
following chatter (the chatter is a separate capture the wake gate discards).
|
||||||
|
- **`auto_target`** (default `false`): with no sticky target and one session running,
|
||||||
|
`false` does nothing and asks you to `set`; `true` auto-uses that session.
|
||||||
|
- **`print_heard`** (default `false`, debug): prints non-wake transcripts so you can
|
||||||
|
see how Whisper renders your wake word, then tune the wake list/threshold.
|
||||||
|
- **`context_multiline`** (default `true`) / **`context_separator`** (default `" — "`):
|
||||||
|
how the `context` command assembles the blurb and instruction — a Shift+Enter soft
|
||||||
|
newline between them, or (when `false`) flattened onto one line with the separator.
|
||||||
|
|
||||||
## Requirements
|
## Requirements
|
||||||
|
|
||||||
|
|||||||
+49
-12
@@ -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", "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
|
||||||
@@ -21,10 +21,12 @@ mode = "listen"
|
|||||||
ptt_key = "space"
|
ptt_key = "space"
|
||||||
|
|
||||||
[stt]
|
[stt]
|
||||||
# faster-whisper model size. "small" is a good accuracy/latency balance for the
|
# faster-whisper model size. "small.en" is the default — the English-only small model
|
||||||
# short command grammar (~sub-second per chunk on a strong cpu). if the coined wake
|
# (~1s/command on a strong cpu, more accurate on english than multilingual "small" at
|
||||||
# word "claudedo" is recognized poorly, bump to "medium" (slower per chunk).
|
# the same speed). "medium"/"medium.en" are more accurate but ~3x slower (noticeable
|
||||||
model = "small"
|
# lag); "large-v3" is most accurate and slowest. drop to "base.en" for max snappiness
|
||||||
|
# (less accurate). bump only if recognition is poor.
|
||||||
|
model = "small.en"
|
||||||
language = "en"
|
language = "en"
|
||||||
# mic device: "auto", or a sounddevice device index (integer) / substring of a
|
# mic device: "auto", or a sounddevice device index (integer) / substring of a
|
||||||
# device name. run `claudedo test-audio` to list devices.
|
# device name. run `claudedo test-audio` to list devices.
|
||||||
@@ -36,18 +38,53 @@ compute = "auto"
|
|||||||
# capture parameters. 16 kHz mono is what whisper expects.
|
# capture parameters. 16 kHz mono is what whisper expects.
|
||||||
samplerate = 16000
|
samplerate = 16000
|
||||||
channels = 1
|
channels = 1
|
||||||
# listen-mode silence segmentation: an utterance ends after this many seconds below
|
# rms energy below this counts as silence (the VAD onset/endpoint floor).
|
||||||
# the rms threshold. keeps latency low without streaming.
|
|
||||||
silence_threshold = 0.012
|
silence_threshold = 0.012
|
||||||
silence_duration = 0.8
|
|
||||||
# ignore utterances shorter than this (clicks, coughs).
|
# ignore utterances shorter than this (clicks, coughs).
|
||||||
min_utterance = 0.3
|
min_utterance = 0.3
|
||||||
# hard cap on a single utterance so a stuck stream can't grow unbounded.
|
|
||||||
max_utterance = 15.0
|
[vad]
|
||||||
|
# Alexa-style record-until-pause endpointing (listen mode). capture starts on speech
|
||||||
|
# onset and ends after this much trailing silence — the natural end of an utterance.
|
||||||
|
# a real pause both ends the command AND separates it from following chatter (the
|
||||||
|
# chatter becomes a separate capture that the wake gate then discards).
|
||||||
|
silence_ms = 700
|
||||||
|
# hard cap so continuous noise can't record forever (also the ceiling for a long
|
||||||
|
# dictated `type` phrase).
|
||||||
|
max_seconds = 15.0
|
||||||
|
|
||||||
[behavior]
|
[behavior]
|
||||||
# dictation never auto-submits: "type <phrase>" inserts literal text only; you say
|
# dictation never auto-submits: "type <phrase>" inserts literal text only; you say
|
||||||
# "send" separately to submit (read-before-send).
|
# "send" separately to submit (read-before-send).
|
||||||
type_autosend = false
|
type_autosend = false
|
||||||
# fuzzy match ratio (0..1) required to accept a wake phrase / command token.
|
# fuzzy match ratios (0..1). the asymmetry is deliberate: a false WAKE is cheap (it
|
||||||
match_threshold = 0.8
|
# wakes, finds no command, does nothing), so wake is lenient; a false COMMAND fires
|
||||||
|
# the WRONG action, so commands stay tight. lower = more lenient = more matches.
|
||||||
|
# prefer expanding command synonyms over loosening command_fuzzy_threshold.
|
||||||
|
wake_fuzzy_threshold = 0.65
|
||||||
|
command_fuzzy_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
|
||||||
|
|
||||||
|
# how the `context <name> <dictation>` command assembles the blurb + instruction.
|
||||||
|
# true (default): blurb, a soft newline (Shift+Enter — needs the extended-keys tmux
|
||||||
|
# settings install.sh appends), then the instruction. if Shift+Enter is at all flaky
|
||||||
|
# in your terminal (it submits or does nothing), set false to flatten onto one line
|
||||||
|
# with context_separator between blurb and instruction — the blank line is cosmetic,
|
||||||
|
# not worth a submit risk. either way the assembled text is NEVER auto-submitted.
|
||||||
|
context_multiline = true
|
||||||
|
# separator inserted between blurb and instruction when context_multiline = false.
|
||||||
|
context_separator = " — "
|
||||||
|
|||||||
@@ -0,0 +1,18 @@
|
|||||||
|
# claudedo contexts — named reference blurbs you can inject ahead of a dictated
|
||||||
|
# instruction with the `context <name> <instruction>` voice command (alias `prepare`).
|
||||||
|
#
|
||||||
|
# the named blurb is injected as a preamble, then your dictated instruction, and the
|
||||||
|
# daemon WAITS — nothing is auto-submitted. you say "send" to submit (read-before-send;
|
||||||
|
# claude's own permission prompt is the backstop for anything consequential).
|
||||||
|
#
|
||||||
|
# names are SPOKEN and fuzzy-matched, so keep them simple, distinct, single words
|
||||||
|
# (a-z, 0-9; spaces/hyphens/underscores are stripped for matching, so "web hooks",
|
||||||
|
# "web-hooks" and "webhooks" all resolve the same block). values are free-form text.
|
||||||
|
#
|
||||||
|
# edit this file, then say "reload" (or run `claudedo reload`) — no daemon restart,
|
||||||
|
# the whisper model is not reloaded.
|
||||||
|
|
||||||
|
[contexts]
|
||||||
|
webhooks = "discord webhooks — test: <url> (safe to spam), live: <url> (real, careful)"
|
||||||
|
testing = "use the test/staging resources only, never touch prod"
|
||||||
|
discord = "discord.py 2.x; bot token in .env as BOT_TOKEN; guild id 12345"
|
||||||
Executable
+180
@@ -0,0 +1,180 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# claudedo bootstrap — does the system setup pip can't. idempotent: re-running is
|
||||||
|
# safe and won't duplicate the shell-rc cc kit. run from the repo root.
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
REPO_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||||
|
ASOUNDRC="$HOME/.asoundrc"
|
||||||
|
MARKER_BEGIN="# >>> claudedo cc kit >>>"
|
||||||
|
MARKER_END="# <<< claudedo cc kit <<<"
|
||||||
|
|
||||||
|
say() { printf '\n\033[1;36m==> %s\033[0m\n' "$*"; }
|
||||||
|
warn() { printf '\033[1;33m!! %s\033[0m\n' "$*" >&2; }
|
||||||
|
die() { printf '\033[1;31mxx %s\033[0m\n' "$*" >&2; exit 1; }
|
||||||
|
|
||||||
|
# 1. windows-side checks (cannot automate — check and instruct) -----------------
|
||||||
|
say "checking WSLg audio bridge"
|
||||||
|
if [ ! -e /mnt/wslg/PulseServer ]; then
|
||||||
|
die "WSLg PulseServer missing (/mnt/wslg/PulseServer). claudedo needs WSLg audio.
|
||||||
|
update WSL ('wsl --update' in Windows) or install WSL from the Microsoft Store,
|
||||||
|
then restart WSL ('wsl --shutdown') and re-run this script."
|
||||||
|
fi
|
||||||
|
echo " /mnt/wslg/PulseServer present"
|
||||||
|
|
||||||
|
cat <<'EOF'
|
||||||
|
|
||||||
|
MANUAL WINDOWS STEP (this script cannot do it for you):
|
||||||
|
Windows Settings -> Privacy & security -> Microphone ->
|
||||||
|
enable "Let desktop apps access your microphone".
|
||||||
|
Without this, the mic is silent inside WSL. Do it now if you haven't.
|
||||||
|
|
||||||
|
EOF
|
||||||
|
|
||||||
|
# 2. WSL audio deps (apt) -------------------------------------------------------
|
||||||
|
say "installing WSL audio dependencies (apt)"
|
||||||
|
sudo apt-get update
|
||||||
|
sudo apt-get install -y libportaudio2 libasound2t64 libasound2-plugins \
|
||||||
|
alsa-utils pulseaudio-utils
|
||||||
|
|
||||||
|
# 3. ALSA -> Pulse routing ------------------------------------------------------
|
||||||
|
say "configuring ALSA -> Pulse routing (~/.asoundrc)"
|
||||||
|
if [ -f "$ASOUNDRC" ] && grep -q "type pulse" "$ASOUNDRC"; then
|
||||||
|
echo " ~/.asoundrc already routes to pulse"
|
||||||
|
else
|
||||||
|
{
|
||||||
|
echo "pcm.!default { type pulse }"
|
||||||
|
echo "ctl.!default { type pulse }"
|
||||||
|
} >> "$ASOUNDRC"
|
||||||
|
echo " wrote pulse default to ~/.asoundrc"
|
||||||
|
fi
|
||||||
|
if [ -z "${PULSE_SERVER:-}" ] && [ -e /mnt/wslg/PulseServer ]; then
|
||||||
|
export PULSE_SERVER="unix:/mnt/wslg/PulseServer"
|
||||||
|
echo " exported PULSE_SERVER=$PULSE_SERVER (WSLg usually sets this already)"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# 4. verify audio (fail loudly with guidance) -----------------------------------
|
||||||
|
say "verifying audio path"
|
||||||
|
if pactl info >/dev/null 2>&1; then
|
||||||
|
DEFAULT_SRC="$(pactl info | sed -n 's/^Default Source: //p')"
|
||||||
|
echo " Default Source: ${DEFAULT_SRC:-<none>}"
|
||||||
|
if ! pactl list sources short 2>/dev/null | grep -q RDPSource; then
|
||||||
|
warn "RDPSource not listed by pactl — mic may not be bridged. check Windows mic permission."
|
||||||
|
fi
|
||||||
|
else
|
||||||
|
warn "pactl info failed — pulseaudio-utils installed but no server reachable yet."
|
||||||
|
fi
|
||||||
|
|
||||||
|
TESTWAV="/tmp/claudedo_test.wav"
|
||||||
|
if arecord -D default -f S16_LE -c 1 -r 16000 -d 2 "$TESTWAV" >/dev/null 2>&1 && [ -s "$TESTWAV" ]; then
|
||||||
|
echo " arecord captured 2s -> $TESTWAV ($(stat -c%s "$TESTWAV") bytes)"
|
||||||
|
else
|
||||||
|
warn "arecord could not capture. fix-chain: apt deps above + ~/.asoundrc + Windows mic permission.
|
||||||
|
debug anytime with: claudedo test-audio"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# 5. python install + model prime -----------------------------------------------
|
||||||
|
say "installing the claudedo python package"
|
||||||
|
PIP="${PIP:-pip3}"
|
||||||
|
"$PIP" install -e "$REPO_DIR"
|
||||||
|
|
||||||
|
say "priming the faster-whisper model (so first run isn't slow)"
|
||||||
|
MODEL="$(sed -n 's/^model *= *"\(.*\)".*/\1/p' "$REPO_DIR/config.toml" | head -1)"
|
||||||
|
MODEL="${MODEL:-small}"
|
||||||
|
python3 - "$MODEL" <<'PY' || warn "model prime failed — first run will download it"
|
||||||
|
import sys
|
||||||
|
from faster_whisper import WhisperModel
|
||||||
|
WhisperModel(sys.argv[1], device="cpu", compute_type="int8")
|
||||||
|
print(" primed faster-whisper model:", sys.argv[1])
|
||||||
|
PY
|
||||||
|
|
||||||
|
# 6. cc kit as a sourced file + rc wiring (idempotent) --------------------------
|
||||||
|
say "installing the cc kit (~/.config/claudedo/cc.sh)"
|
||||||
|
CONF_DIR="$HOME/.config/claudedo"
|
||||||
|
mkdir -p "$CONF_DIR"
|
||||||
|
install -m 0644 "$REPO_DIR/shell/cc.sh" "$CONF_DIR/cc.sh"
|
||||||
|
echo " wrote $CONF_DIR/cc.sh"
|
||||||
|
|
||||||
|
# install config.toml to the standard location so the daemon finds it from any dir.
|
||||||
|
# never clobber an edited user config: copy only if absent, else drop a .new to diff.
|
||||||
|
if [ ! -f "$CONF_DIR/config.toml" ]; then
|
||||||
|
install -m 0644 "$REPO_DIR/config.toml" "$CONF_DIR/config.toml"
|
||||||
|
echo " wrote $CONF_DIR/config.toml"
|
||||||
|
elif ! cmp -s "$REPO_DIR/config.toml" "$CONF_DIR/config.toml"; then
|
||||||
|
install -m 0644 "$REPO_DIR/config.toml" "$CONF_DIR/config.toml.new"
|
||||||
|
echo " kept your $CONF_DIR/config.toml; new default written to config.toml.new (diff to merge)"
|
||||||
|
else
|
||||||
|
echo " $CONF_DIR/config.toml already current"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# install the contexts.toml template (named blurbs for the `context` voice command).
|
||||||
|
# same policy: copy only if absent, else drop a .new — never clobber edited contexts.
|
||||||
|
if [ ! -f "$CONF_DIR/contexts.toml" ]; then
|
||||||
|
install -m 0644 "$REPO_DIR/contexts.toml" "$CONF_DIR/contexts.toml"
|
||||||
|
echo " wrote $CONF_DIR/contexts.toml"
|
||||||
|
elif ! cmp -s "$REPO_DIR/contexts.toml" "$CONF_DIR/contexts.toml"; then
|
||||||
|
install -m 0644 "$REPO_DIR/contexts.toml" "$CONF_DIR/contexts.toml.new"
|
||||||
|
echo " kept your $CONF_DIR/contexts.toml; new default written to contexts.toml.new (diff to merge)"
|
||||||
|
else
|
||||||
|
echo " $CONF_DIR/contexts.toml already current"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# wire EVERY rc that exists (the user may have both zsh and bash).
|
||||||
|
wired_any=0
|
||||||
|
for RC in "$HOME/.zshrc" "$HOME/.bashrc"; do
|
||||||
|
[ -f "$RC" ] || continue
|
||||||
|
wired_any=1
|
||||||
|
if grep -qF "$MARKER_BEGIN" "$RC"; then
|
||||||
|
echo " cc kit marker already in $RC (not duplicating)"
|
||||||
|
continue
|
||||||
|
fi
|
||||||
|
cp "$RC" "$RC.claudedo.bak"
|
||||||
|
echo " backed up $RC -> $RC.claudedo.bak"
|
||||||
|
cat >> "$RC" <<'CCKIT'
|
||||||
|
|
||||||
|
# >>> claudedo cc kit >>>
|
||||||
|
[ -f ~/.config/claudedo/cc.sh ] && source ~/.config/claudedo/cc.sh
|
||||||
|
# <<< claudedo cc kit <<<
|
||||||
|
CCKIT
|
||||||
|
echo " wired source-line block into $RC (open a new shell or 'source $RC')"
|
||||||
|
done
|
||||||
|
[ "$wired_any" = 1 ] || warn "no ~/.zshrc or ~/.bashrc found — add the marker block from README.md manually."
|
||||||
|
|
||||||
|
# warn about any OLD loose cc defs outside our markers (do not auto-delete).
|
||||||
|
for RC in "$HOME/.zshrc" "$HOME/.bashrc"; do
|
||||||
|
[ -f "$RC" ] || continue
|
||||||
|
loose="$(grep -nE '^[[:space:]]*(cc|ccr|ccl|cck|cckl|_cc_name)[[:space:]]*\(\)' "$RC" \
|
||||||
|
| grep -v 'claudedo' || true)"
|
||||||
|
if [ -n "$loose" ]; then
|
||||||
|
warn "old cc-function defs found in $RC (outside the claudedo markers):"
|
||||||
|
echo "$loose" | sed 's/^/ /'
|
||||||
|
echo " review and remove them by hand — the new sourced kit overrides them, but"
|
||||||
|
echo " they are dead code. a backup is at $RC.claudedo.bak"
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
|
||||||
|
# 7. tmux settings for reliable send-keys (idempotent ~/.tmux.conf append) -------
|
||||||
|
say "configuring tmux for reliable send-keys (~/.tmux.conf)"
|
||||||
|
TMUX_CONF="$HOME/.tmux.conf"
|
||||||
|
TMUX_MARKER="# >>> claudedo tmux >>>"
|
||||||
|
touch "$TMUX_CONF"
|
||||||
|
if grep -qF "$TMUX_MARKER" "$TMUX_CONF"; then
|
||||||
|
echo " claudedo tmux block already present (not duplicating)"
|
||||||
|
else
|
||||||
|
cat >> "$TMUX_CONF" <<'TMUXCONF'
|
||||||
|
|
||||||
|
# >>> claudedo tmux >>>
|
||||||
|
# settings for reliable keystroke injection + notifications (do not edit inside the
|
||||||
|
# markers; re-run install.sh to refresh). escape-time 0 stops injected Escape from
|
||||||
|
# being misread; allow-passthrough + extended-keys let notifications and modified
|
||||||
|
# keys (Shift+Enter) reach the claude pane; the larger history-limit keeps scrollback.
|
||||||
|
set -g escape-time 0
|
||||||
|
set -g history-limit 50000
|
||||||
|
set -g allow-passthrough on
|
||||||
|
set -s extended-keys on
|
||||||
|
set -as terminal-features 'xterm*:extkeys'
|
||||||
|
# <<< claudedo tmux <<<
|
||||||
|
TMUXCONF
|
||||||
|
echo " appended claudedo tmux settings to $TMUX_CONF (reload: tmux source-file ~/.tmux.conf)"
|
||||||
|
fi
|
||||||
|
|
||||||
|
say "done. next: 'claudedo test-audio' then 'claudedo start'"
|
||||||
+1
-1
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
|||||||
|
|
||||||
[project]
|
[project]
|
||||||
name = "claudedo"
|
name = "claudedo"
|
||||||
version = "0.1.0"
|
version = "0.2.0"
|
||||||
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"
|
||||||
|
|||||||
+67
@@ -0,0 +1,67 @@
|
|||||||
|
# claudedo cc kit — claude-code-in-tmux session helpers.
|
||||||
|
# POSIX sh; sources cleanly under bash and zsh. side-effect-free on source
|
||||||
|
# (function definitions only — nothing runs at source time).
|
||||||
|
#
|
||||||
|
# every command REQUIRES an explicit project name. the session is always
|
||||||
|
# "claude-<name>", a stable speakable handle: "cc libs" -> claude-libs, which the
|
||||||
|
# voice daemon targets with "claudedo target libs" / "switch libs". the name->session
|
||||||
|
# mapping here MUST match target.py's session_name() in the daemon.
|
||||||
|
#
|
||||||
|
# cc <name> start or reattach to claude-<name>; writes ~/.claude-active
|
||||||
|
# ccr <name> reattach only (error if it doesn't exist); writes ~/.claude-active
|
||||||
|
# ccl list running claude- sessions
|
||||||
|
# cck <name> kill claude-<name>
|
||||||
|
# cckl kill ALL claude- sessions
|
||||||
|
|
||||||
|
cc() {
|
||||||
|
if [ -z "$1" ]; then
|
||||||
|
echo "usage: cc <project-name>" >&2
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
session="claude-$1"
|
||||||
|
echo "$session" > "$HOME/.claude-active"
|
||||||
|
if tmux has-session -t "$session" 2>/dev/null; then
|
||||||
|
tmux attach -t "$session"
|
||||||
|
else
|
||||||
|
tmux new-session -s "$session" "claude"
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
ccr() {
|
||||||
|
if [ -z "$1" ]; then
|
||||||
|
echo "usage: ccr <project-name>" >&2
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
session="claude-$1"
|
||||||
|
if tmux has-session -t "$session" 2>/dev/null; then
|
||||||
|
echo "$session" > "$HOME/.claude-active"
|
||||||
|
tmux attach -t "$session"
|
||||||
|
else
|
||||||
|
echo "no session '$session' — run 'cc $1' to start one" >&2
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
ccl() {
|
||||||
|
tmux ls 2>/dev/null | grep '^claude-' || echo "no claude sessions running"
|
||||||
|
}
|
||||||
|
|
||||||
|
cck() {
|
||||||
|
if [ -z "$1" ]; then
|
||||||
|
echo "usage: cck <project-name>" >&2
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
session="claude-$1"
|
||||||
|
if tmux kill-session -t "$session" 2>/dev/null; then
|
||||||
|
echo "killed $session"
|
||||||
|
else
|
||||||
|
echo "no session '$session'" >&2
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
cckl() {
|
||||||
|
tmux ls 2>/dev/null | grep '^claude-' | cut -d: -f1 | while read -r s; do
|
||||||
|
tmux kill-session -t "$s" && echo "killed $s"
|
||||||
|
done
|
||||||
|
}
|
||||||
@@ -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.2.0"
|
||||||
|
|||||||
@@ -0,0 +1,256 @@
|
|||||||
|
"""claudedo CLI: start | stop | status | test-audio | install"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import logging
|
||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
|
import wave
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from . import __version__, daemon, target
|
||||||
|
from .config import Config, ConfigError, load_config
|
||||||
|
|
||||||
|
|
||||||
|
def _setup_logging(verbose: bool) -> None:
|
||||||
|
logging.basicConfig(
|
||||||
|
level=logging.DEBUG if verbose else logging.INFO,
|
||||||
|
format="%(asctime)s %(levelname)s %(name)s: %(message)s",
|
||||||
|
datefmt="%H:%M:%S",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _load_or_die(path: str | None) -> Config:
|
||||||
|
try:
|
||||||
|
return load_config(path)
|
||||||
|
except ConfigError as exc:
|
||||||
|
print(f"config error: {exc}", file=sys.stderr)
|
||||||
|
raise SystemExit(2)
|
||||||
|
|
||||||
|
|
||||||
|
def cmd_start(args: argparse.Namespace) -> int:
|
||||||
|
config = _load_or_die(args.config)
|
||||||
|
if args.mode:
|
||||||
|
config.mode = args.mode
|
||||||
|
if args.check:
|
||||||
|
print("checking mic before listening (speak briefly) ...")
|
||||||
|
peak = _probe_mic(config, seconds=2.0, verbose=False)
|
||||||
|
if peak is None or peak < 0.02:
|
||||||
|
print("mic check failed — no usable input.", file=sys.stderr)
|
||||||
|
print("run `claudedo test-audio` to debug, or `claudedo start` to skip the check",
|
||||||
|
file=sys.stderr)
|
||||||
|
return 1
|
||||||
|
print(f"mic OK (peak {peak:.3f}).")
|
||||||
|
try:
|
||||||
|
daemon.run_daemon(config)
|
||||||
|
except RuntimeError as exc:
|
||||||
|
print(str(exc), file=sys.stderr)
|
||||||
|
return 1
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
def _probe_mic(config: Config, seconds: float, verbose: bool):
|
||||||
|
"""warm up the mic then capture for `seconds`; return peak amplitude or None.
|
||||||
|
|
||||||
|
None signals a hard capture failure (no PortAudio / device error) with guidance
|
||||||
|
already printed; a float (possibly ~0) is a successful capture whose level the
|
||||||
|
caller judges. shared by `start`'s precheck and `test-audio`.
|
||||||
|
"""
|
||||||
|
from . import audio as audio_mod
|
||||||
|
|
||||||
|
try:
|
||||||
|
device = audio_mod.resolve_device(config.stt_device)
|
||||||
|
if verbose:
|
||||||
|
print("priming mic (RDPSource resumes from suspend) ...")
|
||||||
|
audio_mod.warm_up(config.samplerate, config.channels, device)
|
||||||
|
if verbose:
|
||||||
|
print(f"capturing {seconds:.0f}s from "
|
||||||
|
f"device={device if device is not None else 'default'} — speak now ...")
|
||||||
|
chunk = audio_mod.record_while(
|
||||||
|
config.samplerate, config.channels, device,
|
||||||
|
held=_timed_hold(seconds), max_utterance=seconds + 1.0, min_utterance=0.0,
|
||||||
|
)
|
||||||
|
except Exception as exc:
|
||||||
|
print(f"audio capture FAILED: {exc}", file=sys.stderr)
|
||||||
|
print("fix-chain: install.sh apt deps + ~/.asoundrc pulse shim + Windows mic permission",
|
||||||
|
file=sys.stderr)
|
||||||
|
return None
|
||||||
|
|
||||||
|
if chunk is None or chunk.size == 0:
|
||||||
|
print("captured no audio — check mic permission + RDPSource", file=sys.stderr)
|
||||||
|
return None
|
||||||
|
|
||||||
|
peak = float(abs(chunk).max())
|
||||||
|
if verbose:
|
||||||
|
out = Path("/tmp/claudedo_test.wav")
|
||||||
|
_write_wav(out, chunk, config.samplerate)
|
||||||
|
print(f"captured {chunk.size / config.samplerate:.1f}s, peak amplitude {peak:.3f} -> {out}")
|
||||||
|
return peak
|
||||||
|
|
||||||
|
|
||||||
|
def cmd_stop(_args: argparse.Namespace) -> int:
|
||||||
|
if daemon.stop_running():
|
||||||
|
print("sent stop signal to claudedo")
|
||||||
|
return 0
|
||||||
|
print("claudedo is not running")
|
||||||
|
return 1
|
||||||
|
|
||||||
|
|
||||||
|
def cmd_reload(_args: argparse.Namespace) -> int:
|
||||||
|
if daemon.reload_running():
|
||||||
|
print("signalled claudedo to reload config + contexts")
|
||||||
|
return 0
|
||||||
|
print("claudedo is not running")
|
||||||
|
return 1
|
||||||
|
|
||||||
|
|
||||||
|
def cmd_status(_args: argparse.Namespace) -> int:
|
||||||
|
pid = daemon.read_pid()
|
||||||
|
if pid is None:
|
||||||
|
print("claudedo: not running")
|
||||||
|
return 1
|
||||||
|
state = daemon.read_state() or {}
|
||||||
|
print(f"claudedo: running (pid {pid})")
|
||||||
|
print(f" mode: {state.get('mode', '?')}")
|
||||||
|
print(f" target: {state.get('target') or '(none — run cc to attach)'}")
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
def _check_audio_tools() -> None:
|
||||||
|
for tool in ("pactl", "arecord"):
|
||||||
|
path = subprocess.run(["which", tool], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
|
||||||
|
mark = "ok" if path.returncode == 0 else "MISSING (run install.sh)"
|
||||||
|
print(f" {tool}: {mark}")
|
||||||
|
|
||||||
|
|
||||||
|
def cmd_test_audio(args: argparse.Namespace) -> int:
|
||||||
|
config = _load_or_die(args.config)
|
||||||
|
print("== claudedo test-audio ==")
|
||||||
|
print("WSLg PulseServer:", "present" if Path("/mnt/wslg/PulseServer").exists() else "MISSING")
|
||||||
|
_check_audio_tools()
|
||||||
|
|
||||||
|
try:
|
||||||
|
pactl = subprocess.run(["pactl", "info"], stdout=subprocess.PIPE, stderr=subprocess.DEVNULL)
|
||||||
|
if pactl.returncode == 0:
|
||||||
|
for line in pactl.stdout.decode("utf-8", "replace").splitlines():
|
||||||
|
if line.startswith("Default Source"):
|
||||||
|
print(" ", line.strip())
|
||||||
|
except FileNotFoundError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
from . import audio as audio_mod
|
||||||
|
print("\nsounddevice input devices:")
|
||||||
|
try:
|
||||||
|
for idx, dev in enumerate(audio_mod.list_devices()):
|
||||||
|
if dev.get("max_input_channels", 0) > 0:
|
||||||
|
print(f" [{idx}] {dev['name']} ({dev['max_input_channels']}ch)")
|
||||||
|
except Exception as exc:
|
||||||
|
print(f" could not list devices: {exc}", file=sys.stderr)
|
||||||
|
|
||||||
|
peak = _probe_mic(config, seconds=3.0, verbose=True)
|
||||||
|
if peak is None:
|
||||||
|
return 1
|
||||||
|
if peak < 0.02:
|
||||||
|
print("WARNING: near-silent capture — is the mic muted / permission denied?")
|
||||||
|
print("fix-chain: Windows mic permission for desktop apps + a non-Krisp default input;")
|
||||||
|
print(" if still silent, `wsl --shutdown` then reopen to re-attach RDPSource.")
|
||||||
|
return 1
|
||||||
|
print("mic OK.")
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
def _timed_hold(seconds: float):
|
||||||
|
import time
|
||||||
|
|
||||||
|
end = [None]
|
||||||
|
|
||||||
|
def held() -> bool:
|
||||||
|
now = time.monotonic()
|
||||||
|
if end[0] is None:
|
||||||
|
end[0] = now + seconds
|
||||||
|
return now < end[0]
|
||||||
|
|
||||||
|
return held
|
||||||
|
|
||||||
|
|
||||||
|
def _write_wav(path: Path, chunk, samplerate: int) -> None:
|
||||||
|
import numpy as np
|
||||||
|
|
||||||
|
pcm = (np.clip(chunk, -1.0, 1.0) * 32767).astype("<i2")
|
||||||
|
with wave.open(str(path), "wb") as wf:
|
||||||
|
wf.setnchannels(1)
|
||||||
|
wf.setsampwidth(2)
|
||||||
|
wf.setframerate(samplerate)
|
||||||
|
wf.writeframes(pcm.tobytes())
|
||||||
|
|
||||||
|
|
||||||
|
def cmd_install(_args: argparse.Namespace) -> int:
|
||||||
|
script = Path(__file__).resolve().parents[2] / "install.sh"
|
||||||
|
if not script.is_file():
|
||||||
|
print(f"install.sh not found at {script}", file=sys.stderr)
|
||||||
|
return 1
|
||||||
|
return subprocess.call(["bash", str(script)])
|
||||||
|
|
||||||
|
|
||||||
|
def cmd_set(args: argparse.Namespace) -> int:
|
||||||
|
session = target.set_target(args.name)
|
||||||
|
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
|
||||||
|
|
||||||
|
|
||||||
|
def build_parser() -> argparse.ArgumentParser:
|
||||||
|
p = argparse.ArgumentParser(prog="claudedo", description="voice control for claude code")
|
||||||
|
p.add_argument("--version", action="version", version=f"claudedo {__version__}")
|
||||||
|
p.add_argument("-v", "--verbose", action="store_true", help="debug logging")
|
||||||
|
p.add_argument("-c", "--config", help="path to config.toml")
|
||||||
|
sub = p.add_subparsers(dest="command", required=True)
|
||||||
|
|
||||||
|
sp = sub.add_parser("start", help="run the daemon (foreground)")
|
||||||
|
sp.add_argument("--mode", choices=("listen", "ptt"), help="override input mode")
|
||||||
|
sp.add_argument("--check", action="store_true",
|
||||||
|
help="run a mic check before listening (off by default)")
|
||||||
|
sp.set_defaults(func=cmd_start)
|
||||||
|
|
||||||
|
sub.add_parser("stop", help="stop a running daemon").set_defaults(func=cmd_stop)
|
||||||
|
sub.add_parser("reload", help="reload config + contexts in a running daemon"
|
||||||
|
).set_defaults(func=cmd_reload)
|
||||||
|
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)
|
||||||
|
|
||||||
|
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
|
||||||
|
|
||||||
|
|
||||||
|
def main(argv: list[str] | None = None) -> int:
|
||||||
|
parser = build_parser()
|
||||||
|
args = parser.parse_args(argv)
|
||||||
|
_setup_logging(getattr(args, "verbose", False))
|
||||||
|
return args.func(args)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
sys.exit(main())
|
||||||
@@ -0,0 +1,179 @@
|
|||||||
|
"""mic capture via sounddevice — the WSL-hard part.
|
||||||
|
|
||||||
|
device selection resolves config's stt.device ("auto" | index | name substring) to
|
||||||
|
a concrete sounddevice input device. two capture paths:
|
||||||
|
- record_until_silence(): listen mode — stream until trailing silence segments the
|
||||||
|
utterance (no streaming STT; chunk-on-silence is enough for commands).
|
||||||
|
- record_while(predicate): ptt mode — capture while predicate() is true (key held).
|
||||||
|
|
||||||
|
the WSLg/PulseAudio path is verified separately by `claudedo test-audio`; if capture
|
||||||
|
fails here the fix-chain is the apt deps + ~/.asoundrc + Windows mic permission.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import logging
|
||||||
|
import queue
|
||||||
|
import time
|
||||||
|
from typing import Callable
|
||||||
|
|
||||||
|
import numpy as np
|
||||||
|
|
||||||
|
log = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
class AudioError(Exception):
|
||||||
|
"""raised when no usable input device is found or capture fails"""
|
||||||
|
|
||||||
|
|
||||||
|
def list_devices() -> list[dict]:
|
||||||
|
"""return sounddevice's device table (for test-audio / debugging)"""
|
||||||
|
import sounddevice as sd
|
||||||
|
|
||||||
|
return list(sd.query_devices())
|
||||||
|
|
||||||
|
|
||||||
|
def resolve_device(spec: str) -> int | None:
|
||||||
|
"""resolve a device spec to a sounddevice input index, or None for default.
|
||||||
|
|
||||||
|
spec: "auto" -> default input; a digit string -> that index; otherwise a
|
||||||
|
case-insensitive substring of a device name with input channels.
|
||||||
|
"""
|
||||||
|
import sounddevice as sd
|
||||||
|
|
||||||
|
if spec in ("", "auto", "default"):
|
||||||
|
return None
|
||||||
|
if spec.isdigit():
|
||||||
|
return int(spec)
|
||||||
|
spec_low = spec.lower()
|
||||||
|
for idx, dev in enumerate(sd.query_devices()):
|
||||||
|
if dev.get("max_input_channels", 0) > 0 and spec_low in dev["name"].lower():
|
||||||
|
return idx
|
||||||
|
raise AudioError(f"no input device matching {spec!r}")
|
||||||
|
|
||||||
|
|
||||||
|
def _rms(block: np.ndarray) -> float:
|
||||||
|
if block.size == 0:
|
||||||
|
return 0.0
|
||||||
|
return float(np.sqrt(np.mean(np.square(block, dtype=np.float64))))
|
||||||
|
|
||||||
|
|
||||||
|
def warm_up(samplerate: int, channels: int, device: int | None,
|
||||||
|
timeout: float = 3.0) -> bool:
|
||||||
|
"""open a short stream and read until the source produces audio.
|
||||||
|
|
||||||
|
WSLg's RDPSource suspends when idle and emits ~1-2s of silence while it resumes
|
||||||
|
on the next read. priming here means the first real capture isn't lost to that
|
||||||
|
warm-up gap. returns whether any non-silent block arrived before timeout (still
|
||||||
|
safe to proceed either way — a truly silent mic just returns False).
|
||||||
|
"""
|
||||||
|
import sounddevice as sd
|
||||||
|
|
||||||
|
block_dur = 0.05
|
||||||
|
blocksize = int(samplerate * block_dur)
|
||||||
|
deadline = time.monotonic() + timeout
|
||||||
|
with sd.InputStream(samplerate=samplerate, channels=channels, device=device,
|
||||||
|
dtype="float32", blocksize=blocksize) as stream:
|
||||||
|
while time.monotonic() < deadline:
|
||||||
|
block, _overflowed = stream.read(blocksize)
|
||||||
|
mono = block.reshape(-1) if channels == 1 else block.mean(axis=1)
|
||||||
|
if _rms(mono) > 0.0:
|
||||||
|
return True
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def record_until_silence(samplerate: int, channels: int, device: int | None,
|
||||||
|
silence_threshold: float, silence_duration: float,
|
||||||
|
min_utterance: float, max_utterance: float,
|
||||||
|
stop: Callable[[], bool] | None = None) -> np.ndarray | None:
|
||||||
|
"""capture one utterance, ending after trailing silence. returns mono float32.
|
||||||
|
|
||||||
|
blocks until speech is detected and then trailing silence segments it, or until
|
||||||
|
stop() returns true (clean shutdown). returns None if stopped before any speech
|
||||||
|
or if the captured utterance is shorter than min_utterance.
|
||||||
|
"""
|
||||||
|
import sounddevice as sd
|
||||||
|
|
||||||
|
block_dur = 0.05
|
||||||
|
blocksize = int(samplerate * block_dur)
|
||||||
|
q: "queue.Queue[np.ndarray]" = queue.Queue()
|
||||||
|
|
||||||
|
def _cb(indata, _frames, _time, status):
|
||||||
|
if status:
|
||||||
|
log.debug("audio status: %s", status)
|
||||||
|
q.put(indata.copy())
|
||||||
|
|
||||||
|
collected: list[np.ndarray] = []
|
||||||
|
speaking = False
|
||||||
|
silence_run = 0.0
|
||||||
|
started_at = time.monotonic()
|
||||||
|
|
||||||
|
with sd.InputStream(samplerate=samplerate, channels=channels, device=device,
|
||||||
|
dtype="float32", blocksize=blocksize, callback=_cb):
|
||||||
|
while True:
|
||||||
|
if stop is not None and stop():
|
||||||
|
break
|
||||||
|
try:
|
||||||
|
block = q.get(timeout=0.2)
|
||||||
|
except queue.Empty:
|
||||||
|
if not speaking and time.monotonic() - started_at > 600:
|
||||||
|
started_at = time.monotonic()
|
||||||
|
continue
|
||||||
|
mono = block.reshape(-1) if channels == 1 else block.mean(axis=1)
|
||||||
|
level = _rms(mono)
|
||||||
|
if level >= silence_threshold:
|
||||||
|
speaking = True
|
||||||
|
silence_run = 0.0
|
||||||
|
collected.append(mono)
|
||||||
|
elif speaking:
|
||||||
|
silence_run += block_dur
|
||||||
|
collected.append(mono)
|
||||||
|
if silence_run >= silence_duration:
|
||||||
|
break
|
||||||
|
if speaking and (time.monotonic() - started_at) > max_utterance:
|
||||||
|
log.debug("utterance hit max_utterance cap")
|
||||||
|
break
|
||||||
|
|
||||||
|
if not collected:
|
||||||
|
return None
|
||||||
|
audio = np.concatenate(collected).astype(np.float32)
|
||||||
|
if audio.size / samplerate < min_utterance:
|
||||||
|
return None
|
||||||
|
return audio
|
||||||
|
|
||||||
|
|
||||||
|
def record_while(samplerate: int, channels: int, device: int | None,
|
||||||
|
held: Callable[[], bool], max_utterance: float,
|
||||||
|
min_utterance: float) -> np.ndarray | None:
|
||||||
|
"""capture while held() is true (push-to-talk). returns mono float32 or None"""
|
||||||
|
import sounddevice as sd
|
||||||
|
|
||||||
|
block_dur = 0.05
|
||||||
|
blocksize = int(samplerate * block_dur)
|
||||||
|
q: "queue.Queue[np.ndarray]" = queue.Queue()
|
||||||
|
|
||||||
|
def _cb(indata, _frames, _time, status):
|
||||||
|
if status:
|
||||||
|
log.debug("audio status: %s", status)
|
||||||
|
q.put(indata.copy())
|
||||||
|
|
||||||
|
collected: list[np.ndarray] = []
|
||||||
|
started_at = time.monotonic()
|
||||||
|
with sd.InputStream(samplerate=samplerate, channels=channels, device=device,
|
||||||
|
dtype="float32", blocksize=blocksize, callback=_cb):
|
||||||
|
while held():
|
||||||
|
try:
|
||||||
|
block = q.get(timeout=0.1)
|
||||||
|
except queue.Empty:
|
||||||
|
continue
|
||||||
|
mono = block.reshape(-1) if channels == 1 else block.mean(axis=1)
|
||||||
|
collected.append(mono)
|
||||||
|
if (time.monotonic() - started_at) > max_utterance:
|
||||||
|
break
|
||||||
|
|
||||||
|
if not collected:
|
||||||
|
return None
|
||||||
|
audio = np.concatenate(collected).astype(np.float32)
|
||||||
|
if audio.size / samplerate < min_utterance:
|
||||||
|
return None
|
||||||
|
return audio
|
||||||
+37
-16
@@ -1,4 +1,4 @@
|
|||||||
"""load and validate config.toml into a typed Config object with clear errors."""
|
"""load and validate config.toml into a typed Config object with clear errors"""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
@@ -10,14 +10,17 @@ from pathlib import Path
|
|||||||
try:
|
try:
|
||||||
import tomllib as _toml
|
import tomllib as _toml
|
||||||
_TOML_BINARY = True
|
_TOML_BINARY = True
|
||||||
except ModuleNotFoundError: # python < 3.11
|
except ModuleNotFoundError:
|
||||||
import tomli as _toml
|
import tomli as _toml
|
||||||
_TOML_BINARY = True
|
_TOML_BINARY = True
|
||||||
|
|
||||||
log = logging.getLogger(__name__)
|
log = logging.getLogger(__name__)
|
||||||
|
|
||||||
_VALID_MODES = ("listen", "ptt")
|
_VALID_MODES = ("listen", "ptt")
|
||||||
_VALID_MODELS = ("tiny", "base", "small", "medium", "large-v2", "large-v3")
|
_VALID_MODELS = (
|
||||||
|
"tiny", "base", "small", "medium", "large-v1", "large-v2", "large-v3",
|
||||||
|
"tiny.en", "base.en", "small.en", "medium.en",
|
||||||
|
)
|
||||||
|
|
||||||
DEFAULT_CONFIG_PATHS = (
|
DEFAULT_CONFIG_PATHS = (
|
||||||
Path(os.environ.get("CLAUDEDO_CONFIG", "")) if os.environ.get("CLAUDEDO_CONFIG") else None,
|
Path(os.environ.get("CLAUDEDO_CONFIG", "")) if os.environ.get("CLAUDEDO_CONFIG") else None,
|
||||||
@@ -27,12 +30,12 @@ DEFAULT_CONFIG_PATHS = (
|
|||||||
|
|
||||||
|
|
||||||
class ConfigError(Exception):
|
class ConfigError(Exception):
|
||||||
"""raised on a missing or invalid configuration value."""
|
"""raised on a missing or invalid configuration value"""
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
class Config:
|
class Config:
|
||||||
"""validated claudedo configuration."""
|
"""validated claudedo configuration"""
|
||||||
|
|
||||||
wake_phrases: list[str]
|
wake_phrases: list[str]
|
||||||
mode: str
|
mode: str
|
||||||
@@ -44,16 +47,22 @@ class Config:
|
|||||||
samplerate: int
|
samplerate: int
|
||||||
channels: int
|
channels: int
|
||||||
silence_threshold: float
|
silence_threshold: float
|
||||||
silence_duration: float
|
vad_silence_ms: int
|
||||||
|
vad_max_seconds: float
|
||||||
min_utterance: float
|
min_utterance: float
|
||||||
max_utterance: float
|
|
||||||
type_autosend: bool
|
type_autosend: bool
|
||||||
match_threshold: float
|
wake_fuzzy_threshold: float
|
||||||
|
command_fuzzy_threshold: float
|
||||||
|
filler_words: tuple[str, ...]
|
||||||
|
auto_target: bool
|
||||||
|
print_heard: bool
|
||||||
|
context_multiline: bool
|
||||||
|
context_separator: str
|
||||||
source_path: Path | None = field(default=None)
|
source_path: Path | None = field(default=None)
|
||||||
|
|
||||||
|
|
||||||
def find_config_path(explicit: str | os.PathLike | None = None) -> Path:
|
def find_config_path(explicit: str | os.PathLike | None = None) -> Path:
|
||||||
"""resolve the config file path, raising ConfigError if none is found."""
|
"""resolve the config file path, raising ConfigError if none is found"""
|
||||||
candidates: list[Path] = []
|
candidates: list[Path] = []
|
||||||
if explicit:
|
if explicit:
|
||||||
candidates.append(Path(explicit))
|
candidates.append(Path(explicit))
|
||||||
@@ -79,7 +88,7 @@ def _require(table: dict, section: str, key: str, types: tuple, default=None):
|
|||||||
|
|
||||||
|
|
||||||
def load_config(explicit: str | os.PathLike | None = None) -> Config:
|
def load_config(explicit: str | os.PathLike | None = None) -> Config:
|
||||||
"""load config.toml from the first existing default path (or an explicit one)."""
|
"""load config.toml from the first existing default path (or an explicit one)"""
|
||||||
path = find_config_path(explicit)
|
path = find_config_path(explicit)
|
||||||
try:
|
try:
|
||||||
with open(path, "rb") as fh:
|
with open(path, "rb") as fh:
|
||||||
@@ -95,7 +104,7 @@ def load_config(explicit: str | os.PathLike | None = None) -> Config:
|
|||||||
if mode not in _VALID_MODES:
|
if mode not in _VALID_MODES:
|
||||||
raise ConfigError(f"[input].mode must be one of {_VALID_MODES}, got {mode!r}")
|
raise ConfigError(f"[input].mode must be one of {_VALID_MODES}, got {mode!r}")
|
||||||
|
|
||||||
model = _require(raw, "stt", "model", (str,), "small")
|
model = _require(raw, "stt", "model", (str,), "small.en")
|
||||||
if model not in _VALID_MODELS:
|
if model not in _VALID_MODELS:
|
||||||
log.warning("unknown stt model %r — passing through to faster-whisper", model)
|
log.warning("unknown stt model %r — passing through to faster-whisper", model)
|
||||||
|
|
||||||
@@ -110,15 +119,27 @@ def load_config(explicit: str | os.PathLike | None = None) -> Config:
|
|||||||
samplerate=int(_require(raw, "audio", "samplerate", (int,), 16000)),
|
samplerate=int(_require(raw, "audio", "samplerate", (int,), 16000)),
|
||||||
channels=int(_require(raw, "audio", "channels", (int,), 1)),
|
channels=int(_require(raw, "audio", "channels", (int,), 1)),
|
||||||
silence_threshold=float(_require(raw, "audio", "silence_threshold", (int, float), 0.012)),
|
silence_threshold=float(_require(raw, "audio", "silence_threshold", (int, float), 0.012)),
|
||||||
silence_duration=float(_require(raw, "audio", "silence_duration", (int, float), 0.8)),
|
vad_silence_ms=int(_require(raw, "vad", "silence_ms", (int,), 700)),
|
||||||
|
vad_max_seconds=float(_require(raw, "vad", "max_seconds", (int, float), 15.0)),
|
||||||
min_utterance=float(_require(raw, "audio", "min_utterance", (int, float), 0.3)),
|
min_utterance=float(_require(raw, "audio", "min_utterance", (int, float), 0.3)),
|
||||||
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)),
|
wake_fuzzy_threshold=float(_require(raw, "behavior", "wake_fuzzy_threshold", (int, float), 0.65)),
|
||||||
|
command_fuzzy_threshold=float(_require(raw, "behavior", "command_fuzzy_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)),
|
||||||
|
context_multiline=bool(_require(raw, "behavior", "context_multiline", (bool,), True)),
|
||||||
|
context_separator=str(_require(raw, "behavior", "context_separator", (str,), " — ")),
|
||||||
source_path=path,
|
source_path=path,
|
||||||
)
|
)
|
||||||
if not 0.0 < cfg.match_threshold <= 1.0:
|
for label, val in (("wake_fuzzy_threshold", cfg.wake_fuzzy_threshold),
|
||||||
raise ConfigError("[behavior].match_threshold must be in (0, 1]")
|
("command_fuzzy_threshold", cfg.command_fuzzy_threshold)):
|
||||||
|
if not 0.0 < val <= 1.0:
|
||||||
|
raise ConfigError(f"[behavior].{label} must be in (0, 1]")
|
||||||
|
if cfg.vad_silence_ms <= 0 or cfg.vad_max_seconds <= 0:
|
||||||
|
raise ConfigError("[vad].silence_ms and max_seconds must be positive")
|
||||||
if cfg.samplerate <= 0 or cfg.channels <= 0:
|
if cfg.samplerate <= 0 or cfg.channels <= 0:
|
||||||
raise ConfigError("[audio].samplerate and channels must be positive")
|
raise ConfigError("[audio].samplerate and channels must be positive")
|
||||||
return cfg
|
return cfg
|
||||||
|
|||||||
@@ -0,0 +1,65 @@
|
|||||||
|
"""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",
|
||||||
|
"blue": "\033[34m",
|
||||||
|
"brightblue": "\033[94m",
|
||||||
|
"magenta": "\033[35m",
|
||||||
|
"dim": "\033[2m",
|
||||||
|
"bold": "\033[1m",
|
||||||
|
}
|
||||||
|
|
||||||
|
SYSTEM = "SYSTEM"
|
||||||
|
VOICE = "VOICE"
|
||||||
|
HELP = "HELP"
|
||||||
|
|
||||||
|
|
||||||
|
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 paint(self, text: str, color: str | None) -> str:
|
||||||
|
"""public colorizer for pre-coloring a fragment of a message (e.g. a command
|
||||||
|
word) before passing it to emit() with color=None"""
|
||||||
|
return self._paint(text, color)
|
||||||
|
|
||||||
|
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)
|
||||||
|
|
||||||
|
def line(self, message: str, color: str | None = None) -> None:
|
||||||
|
"""print a bare continuation line (no timestamp/prefix) — for multi-row blocks
|
||||||
|
like the help menu, indented under a preceding header"""
|
||||||
|
print(self._paint(message, color), file=self.stream, flush=True)
|
||||||
@@ -0,0 +1,108 @@
|
|||||||
|
"""load named context blocks from contexts.toml into a typed lookup.
|
||||||
|
|
||||||
|
contexts are user-edited reference blurbs (claude.md-style snippets) keyed by simple
|
||||||
|
spoken names. the ``context``/``prepare`` voice command injects a named blurb ahead of
|
||||||
|
a dictated instruction (read-before-send: never auto-submitted). mirrors config.py's
|
||||||
|
load/validate pattern; a missing file is an empty set, not an error.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import logging
|
||||||
|
import os
|
||||||
|
import re
|
||||||
|
from dataclasses import dataclass, field
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
try:
|
||||||
|
import tomllib as _toml
|
||||||
|
except ModuleNotFoundError:
|
||||||
|
import tomli as _toml
|
||||||
|
|
||||||
|
log = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
_NAME_RE = re.compile(r"^[a-z0-9][a-z0-9 _-]*$")
|
||||||
|
|
||||||
|
DEFAULT_CONTEXTS_PATHS = (
|
||||||
|
Path(os.environ.get("CLAUDEDO_CONTEXTS", "")) if os.environ.get("CLAUDEDO_CONTEXTS") else None,
|
||||||
|
Path.home() / ".config" / "claudedo" / "contexts.toml",
|
||||||
|
Path.cwd() / "contexts.toml",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class ContextsError(Exception):
|
||||||
|
"""raised on an unparseable or invalid contexts.toml"""
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class Contexts:
|
||||||
|
"""validated named context blocks (name -> blurb), normalized for spoken lookup"""
|
||||||
|
|
||||||
|
blocks: dict[str, str] = field(default_factory=dict)
|
||||||
|
source_path: Path | None = field(default=None)
|
||||||
|
|
||||||
|
def __len__(self) -> int:
|
||||||
|
return len(self.blocks)
|
||||||
|
|
||||||
|
def names(self) -> list[str]:
|
||||||
|
"""the context names, sorted (for status / listing)"""
|
||||||
|
return sorted(self.blocks)
|
||||||
|
|
||||||
|
def get(self, name: str) -> str | None:
|
||||||
|
"""look up a blurb by its normalized (lowercased, despaced) name, or None.
|
||||||
|
|
||||||
|
names are matched on a lowercase, space/underscore/hyphen-stripped key so a
|
||||||
|
spoken "web hooks" resolves the configured ``webhooks``/``web-hooks`` block.
|
||||||
|
"""
|
||||||
|
return self.blocks.get(_key(name))
|
||||||
|
|
||||||
|
|
||||||
|
def _key(name: str) -> str:
|
||||||
|
return re.sub(r"[ _-]+", "", name.strip().lower())
|
||||||
|
|
||||||
|
|
||||||
|
def find_contexts_path(explicit: str | os.PathLike | None = None) -> Path | None:
|
||||||
|
"""resolve the contexts.toml path, or None if no file exists (not an error)"""
|
||||||
|
candidates: list[Path] = []
|
||||||
|
if explicit:
|
||||||
|
candidates.append(Path(explicit))
|
||||||
|
candidates.extend(p for p in DEFAULT_CONTEXTS_PATHS if p)
|
||||||
|
for path in candidates:
|
||||||
|
if path.is_file():
|
||||||
|
return path
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def load_contexts(explicit: str | os.PathLike | None = None) -> Contexts:
|
||||||
|
"""load contexts.toml from the first existing default path (or an explicit one).
|
||||||
|
|
||||||
|
a missing file yields an empty Contexts (the feature is opt-in). names must be
|
||||||
|
simple words (matchable) and values must be non-empty strings; a bad entry raises
|
||||||
|
ContextsError so the user sees a clear message rather than a silent drop.
|
||||||
|
"""
|
||||||
|
path = find_contexts_path(explicit)
|
||||||
|
if path is None:
|
||||||
|
return Contexts(blocks={}, source_path=None)
|
||||||
|
|
||||||
|
try:
|
||||||
|
with open(path, "rb") as fh:
|
||||||
|
raw = _toml.load(fh)
|
||||||
|
except _toml.TOMLDecodeError as exc:
|
||||||
|
raise ContextsError(f"could not parse {path}: {exc}") from exc
|
||||||
|
|
||||||
|
table = raw.get("contexts", {})
|
||||||
|
if not isinstance(table, dict):
|
||||||
|
raise ContextsError("[contexts] must be a table of name = \"blurb\" entries")
|
||||||
|
|
||||||
|
blocks: dict[str, str] = {}
|
||||||
|
for name, value in table.items():
|
||||||
|
if not isinstance(name, str) or not _NAME_RE.match(name.lower()):
|
||||||
|
raise ContextsError(f"context name {name!r} must be simple words (a-z, 0-9, space/-/_)")
|
||||||
|
if not isinstance(value, str) or not value.strip():
|
||||||
|
raise ContextsError(f"context {name!r} must be a non-empty string")
|
||||||
|
key = _key(name)
|
||||||
|
if key in blocks:
|
||||||
|
raise ContextsError(f"context {name!r} collides with another name on the spoken key {key!r}")
|
||||||
|
blocks[key] = value.strip()
|
||||||
|
|
||||||
|
return Contexts(blocks=blocks, source_path=path)
|
||||||
@@ -0,0 +1,486 @@
|
|||||||
|
"""the capture -> stt -> match -> inject loop.
|
||||||
|
|
||||||
|
privacy invariant: in listen mode, any utterance that does not start with a wake
|
||||||
|
phrase is discarded the instant grammar.parse() returns None — the transcript text
|
||||||
|
is dropped and never stored or transmitted. nothing about non-command speech is
|
||||||
|
persisted.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
import logging
|
||||||
|
import os
|
||||||
|
import signal
|
||||||
|
import sys
|
||||||
|
import time
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from . import __version__, audio, grammar, inject, keys, target
|
||||||
|
from .config import Config, ConfigError, load_config
|
||||||
|
from .console import HELP, SYSTEM, VOICE, Console
|
||||||
|
from .contexts import Contexts, ContextsError, load_contexts
|
||||||
|
from .stt import Transcriber
|
||||||
|
|
||||||
|
log = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
STATE_DIR = Path(os.environ.get("XDG_CACHE_HOME", str(Path.home() / ".cache"))) / "claudedo"
|
||||||
|
PIDFILE = STATE_DIR / "claudedo.pid"
|
||||||
|
STATEFILE = STATE_DIR / "state.json"
|
||||||
|
|
||||||
|
|
||||||
|
def _ensure_state_dir() -> None:
|
||||||
|
STATE_DIR.mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
|
|
||||||
|
def write_state(pid: int, mode: str, target_session: str | None) -> None:
|
||||||
|
"""write the running daemon's status for `claudedo status` to read"""
|
||||||
|
_ensure_state_dir()
|
||||||
|
STATEFILE.write_text(json.dumps({
|
||||||
|
"pid": pid,
|
||||||
|
"mode": mode,
|
||||||
|
"target": target_session,
|
||||||
|
"since": time.time(),
|
||||||
|
}), encoding="utf-8")
|
||||||
|
|
||||||
|
|
||||||
|
def read_state() -> dict | None:
|
||||||
|
"""read the daemon status file, or None if absent/unreadable"""
|
||||||
|
try:
|
||||||
|
return json.loads(STATEFILE.read_text(encoding="utf-8"))
|
||||||
|
except (FileNotFoundError, json.JSONDecodeError, OSError):
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def read_pid() -> int | None:
|
||||||
|
"""return the pid of a running daemon, or None (also clears stale pidfiles)"""
|
||||||
|
try:
|
||||||
|
pid = int(PIDFILE.read_text(encoding="utf-8").strip())
|
||||||
|
except (FileNotFoundError, ValueError, OSError):
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
os.kill(pid, 0)
|
||||||
|
except ProcessLookupError:
|
||||||
|
PIDFILE.unlink(missing_ok=True)
|
||||||
|
return None
|
||||||
|
except PermissionError:
|
||||||
|
return pid
|
||||||
|
return pid
|
||||||
|
|
||||||
|
|
||||||
|
def stop_running() -> bool:
|
||||||
|
"""signal a running daemon to stop. returns whether one was found"""
|
||||||
|
pid = read_pid()
|
||||||
|
if pid is None:
|
||||||
|
return False
|
||||||
|
os.kill(pid, signal.SIGTERM)
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
def reload_running() -> bool:
|
||||||
|
"""signal a running daemon (SIGHUP) to reload config + contexts. returns whether
|
||||||
|
one was found. no-op on platforms without SIGHUP."""
|
||||||
|
pid = read_pid()
|
||||||
|
if pid is None or not hasattr(signal, "SIGHUP"):
|
||||||
|
return False
|
||||||
|
os.kill(pid, signal.SIGHUP)
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
class _PTTKey:
|
||||||
|
"""desk-only push-to-talk: 'held' while the configured key is down in the
|
||||||
|
daemon's own terminal. there is deliberately NO global hotkey — a system-wide
|
||||||
|
keyboard hook is the keylogger/cheat silhouette claudedo refuses to install. for
|
||||||
|
hands-free-while-gaming use listen mode (voice trigger over the mic bridge).
|
||||||
|
|
||||||
|
implementation reads stdin in raw mode: press the key to start capture, press it
|
||||||
|
again (or Enter) to stop. (terminals don't deliver key-up events, so true
|
||||||
|
hold-to-talk isn't possible from a tty — this is press-toggle, documented.)
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self) -> None:
|
||||||
|
self._tty = sys.stdin.isatty()
|
||||||
|
|
||||||
|
def wait_press(self, stop) -> bool:
|
||||||
|
import select
|
||||||
|
|
||||||
|
if not self._tty:
|
||||||
|
log.warning("ptt mode needs a tty; falling back to a 3s timed capture")
|
||||||
|
time.sleep(0.2)
|
||||||
|
return not stop()
|
||||||
|
while not stop():
|
||||||
|
r, _, _ = select.select([sys.stdin], [], [], 0.2)
|
||||||
|
if r:
|
||||||
|
sys.stdin.read(1)
|
||||||
|
return True
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
class Daemon:
|
||||||
|
"""owns the capture/transcribe/inject loop and runtime mode switching"""
|
||||||
|
|
||||||
|
def __init__(self, config: Config) -> None:
|
||||||
|
self.config = config
|
||||||
|
self.mode = config.mode
|
||||||
|
self._stop = False
|
||||||
|
self._reload_pending = False
|
||||||
|
self._transcriber: Transcriber | None = None
|
||||||
|
self._device: int | None = None
|
||||||
|
self._ptt = _PTTKey()
|
||||||
|
self._pending: dict[str, int] = {}
|
||||||
|
self._console = Console()
|
||||||
|
self._contexts = Contexts()
|
||||||
|
self._last_stt_ms = 0.0
|
||||||
|
self._last_audio_s = 0.0
|
||||||
|
|
||||||
|
def _install_signals(self) -> None:
|
||||||
|
signal.signal(signal.SIGTERM, self._on_signal)
|
||||||
|
signal.signal(signal.SIGINT, self._on_signal)
|
||||||
|
if hasattr(signal, "SIGHUP"):
|
||||||
|
signal.signal(signal.SIGHUP, self._on_reload_signal)
|
||||||
|
|
||||||
|
def _on_signal(self, _signum, _frame) -> None:
|
||||||
|
log.info("stop requested")
|
||||||
|
self._stop = True
|
||||||
|
|
||||||
|
def _on_reload_signal(self, _signum, _frame) -> None:
|
||||||
|
"""SIGHUP from `claudedo reload` -> reload both config files on the next tick.
|
||||||
|
|
||||||
|
the actual reload runs in the loop (not the handler) so it never races a
|
||||||
|
capture/transcribe; the handler only sets the flag.
|
||||||
|
"""
|
||||||
|
self._reload_pending = True
|
||||||
|
|
||||||
|
def stopped(self) -> bool:
|
||||||
|
return self._stop
|
||||||
|
|
||||||
|
def _load(self) -> None:
|
||||||
|
cfg = self.config
|
||||||
|
self._device = audio.resolve_device(cfg.stt_device)
|
||||||
|
self._transcriber = Transcriber(
|
||||||
|
model=cfg.stt_model, language=cfg.stt_language,
|
||||||
|
device=cfg.stt_compute if cfg.stt_compute in ("cpu", "cuda") else "auto",
|
||||||
|
compute_type="auto",
|
||||||
|
initial_prompt=grammar.initial_prompt(cfg.wake_phrases),
|
||||||
|
)
|
||||||
|
self._load_contexts()
|
||||||
|
if audio.warm_up(cfg.samplerate, cfg.channels, self._device):
|
||||||
|
log.info("mic warmed up (source live)")
|
||||||
|
else:
|
||||||
|
log.warning("mic warm-up saw only silence — check mic permission / RDPSource")
|
||||||
|
|
||||||
|
def _load_contexts(self) -> None:
|
||||||
|
"""(re)load contexts.toml, leaving the loaded model untouched. a parse error is
|
||||||
|
logged and leaves the previous set in place rather than crashing the loop."""
|
||||||
|
try:
|
||||||
|
self._contexts = load_contexts()
|
||||||
|
except ContextsError as exc:
|
||||||
|
log.warning("contexts.toml invalid, keeping previous set: %s", exc)
|
||||||
|
self._console.emit(SYSTEM, f"contexts.toml error (kept previous): {exc}", "red")
|
||||||
|
|
||||||
|
def _capture(self):
|
||||||
|
cfg = self.config
|
||||||
|
if self.mode == "ptt":
|
||||||
|
print("[ptt] press the capture key in this terminal, speak, then press again to stop")
|
||||||
|
if not self._ptt.wait_press(self.stopped):
|
||||||
|
return None
|
||||||
|
return audio.record_while(
|
||||||
|
cfg.samplerate, cfg.channels, self._device,
|
||||||
|
held=lambda: not self._ptt.wait_press(self.stopped),
|
||||||
|
max_utterance=cfg.vad_max_seconds, min_utterance=cfg.min_utterance,
|
||||||
|
)
|
||||||
|
return audio.record_until_silence(
|
||||||
|
cfg.samplerate, cfg.channels, self._device,
|
||||||
|
silence_threshold=cfg.silence_threshold, silence_duration=cfg.vad_silence_ms / 1000.0,
|
||||||
|
min_utterance=cfg.min_utterance, max_utterance=cfg.vad_max_seconds,
|
||||||
|
stop=self.stopped,
|
||||||
|
)
|
||||||
|
|
||||||
|
def _handle(self, transcript: str) -> None:
|
||||||
|
cfg = self.config
|
||||||
|
require_wake = self.mode == "listen"
|
||||||
|
parsed = grammar.parse(transcript, cfg.wake_phrases, cfg.wake_fuzzy_threshold,
|
||||||
|
cfg.command_fuzzy_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 {self._timing()}',
|
||||||
|
"yellow")
|
||||||
|
return
|
||||||
|
action = parsed.action
|
||||||
|
|
||||||
|
# a command was recognized — echo what we heard (green) before acting. note the
|
||||||
|
# matched wake phrase (magenta) when the transcript didn't literally contain it
|
||||||
|
# (so a loose match like "okay clouds" -> "okay claude" is visible).
|
||||||
|
head = self._console.paint(f'heard "{transcript}" -> {self._describe(action)}', "green")
|
||||||
|
note = ""
|
||||||
|
if parsed.wake and parsed.wake.replace(" ", "") not in transcript.lower().replace(" ", ""):
|
||||||
|
note = (self._console.paint(" (wake: ", "green")
|
||||||
|
+ self._console.paint(parsed.wake, "magenta")
|
||||||
|
+ self._console.paint(")", "green"))
|
||||||
|
tail = self._console.paint(f" {self._timing()}", "green")
|
||||||
|
self._console.emit(VOICE, f"{head}{note}{tail}")
|
||||||
|
|
||||||
|
def blue(s):
|
||||||
|
return self._console.paint(s, "brightblue")
|
||||||
|
if action.name == "mode":
|
||||||
|
new_mode = str(action.arg)
|
||||||
|
if new_mode != self.mode:
|
||||||
|
self.mode = new_mode
|
||||||
|
self._console.emit(SYSTEM, f"{blue('mode')} -> {new_mode}")
|
||||||
|
self._refresh_state()
|
||||||
|
return
|
||||||
|
if action.name == "set":
|
||||||
|
session = target.set_target(str(action.arg))
|
||||||
|
self._pending.pop(session, None)
|
||||||
|
self._console.emit(SYSTEM, f"{blue('set sticky')} -> {session}")
|
||||||
|
self._refresh_state()
|
||||||
|
return
|
||||||
|
if action.name == "unset":
|
||||||
|
target.unset_target()
|
||||||
|
self._console.emit(SYSTEM, f"{blue('unset')} (cleared)")
|
||||||
|
self._refresh_state()
|
||||||
|
return
|
||||||
|
if action.name == "list":
|
||||||
|
sessions = target.list_sessions()
|
||||||
|
self._console.emit(SYSTEM, f"{blue('list')} -> "
|
||||||
|
+ (", ".join(sessions) if sessions else "(none running)"))
|
||||||
|
return
|
||||||
|
if action.name == "commands":
|
||||||
|
self._console.emit(HELP, "voice commands:")
|
||||||
|
for usage, desc in grammar.command_menu():
|
||||||
|
self._console.line(f" {self._console.paint(f'{usage:<26}', 'brightblue')} {desc}")
|
||||||
|
return
|
||||||
|
if action.name == "customs":
|
||||||
|
names = self._contexts.names()
|
||||||
|
listed = ", ".join(names) if names else "(none — edit contexts.toml)"
|
||||||
|
self._console.emit(SYSTEM, f"contexts: {listed}")
|
||||||
|
return
|
||||||
|
if action.name == "version":
|
||||||
|
self._console.emit(SYSTEM, f"claudedo {__version__}")
|
||||||
|
return
|
||||||
|
if action.name == "debug":
|
||||||
|
self._console.emit(VOICE, f'debug: "{action.arg}"', "yellow")
|
||||||
|
return
|
||||||
|
if action.name == "reload":
|
||||||
|
self._do_reload(str(action.arg))
|
||||||
|
return
|
||||||
|
if action.name == "system":
|
||||||
|
self._do_system(action.arg)
|
||||||
|
return
|
||||||
|
if action.name == "context":
|
||||||
|
name = str(action.arg[0])
|
||||||
|
if self._contexts.get(name) is None:
|
||||||
|
self._console.emit(VOICE, f"no context named '{name}' -> did nothing", "red")
|
||||||
|
return
|
||||||
|
|
||||||
|
session, reason = target.resolve(parsed.one_shot, auto_target=cfg.auto_target)
|
||||||
|
if session is None:
|
||||||
|
self._console.emit(VOICE, f'heard "{transcript}" -> {reason} -> '
|
||||||
|
f'{self._describe(action)} did nothing', "red")
|
||||||
|
return
|
||||||
|
if action.name == "context":
|
||||||
|
self._inject_context(session, action)
|
||||||
|
return
|
||||||
|
self._inject(session, action)
|
||||||
|
|
||||||
|
def _inject(self, session: 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.
|
||||||
|
|
||||||
|
the 'heard ...' echo is already printed by _handle and the [session] prefix
|
||||||
|
names the target, so these lines just report the keystrokes injected.
|
||||||
|
"""
|
||||||
|
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, keys.SUBMIT)
|
||||||
|
self._pending[session] = 0
|
||||||
|
self._console.emit(session, f"typed {text!r}"
|
||||||
|
+ (" + send" if self.config.type_autosend else ""), "green")
|
||||||
|
return
|
||||||
|
if name == "space":
|
||||||
|
n = int(action.arg)
|
||||||
|
inject.perform(session, action)
|
||||||
|
self._pending[session] = self._pending.get(session, 0) + n
|
||||||
|
self._console.emit(session, f"space x{n}", "green")
|
||||||
|
return
|
||||||
|
if name == "backspace":
|
||||||
|
n = int(action.arg)
|
||||||
|
if n:
|
||||||
|
inject.perform(session, action)
|
||||||
|
self._pending[session] = max(0, self._pending.get(session, 0) - n)
|
||||||
|
self._console.emit(session, f"backspace x{n}", "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"erase x{n} (to last boundary)", "green")
|
||||||
|
return
|
||||||
|
|
||||||
|
inject.perform(session, action)
|
||||||
|
if name == "submit":
|
||||||
|
self._pending[session] = 0
|
||||||
|
self._console.emit(session, f"injected {self._describe(action)}", "green")
|
||||||
|
|
||||||
|
def _inject_context(self, session: str, action) -> None:
|
||||||
|
"""inject a named context blurb ahead of the dictated instruction, then WAIT.
|
||||||
|
|
||||||
|
read-before-send: never auto-submits — the user says ``send`` separately, and
|
||||||
|
claude's own permission prompt is the backstop for anything consequential.
|
||||||
|
routes through inject.send_literal (the same path as ``type``) and tracks the
|
||||||
|
uncommitted-input buffer so backspace/erase still bound to the last boundary.
|
||||||
|
|
||||||
|
assembly (config behavior.context_multiline): true -> blurb, a soft Shift+Enter
|
||||||
|
newline, then the instruction; false -> blurb + context_separator + instruction
|
||||||
|
flattened onto one line. a bare ``context <name>`` (no dictation) injects just
|
||||||
|
the blurb. the soft newline does not count toward the editable-char buffer.
|
||||||
|
"""
|
||||||
|
cfg = self.config
|
||||||
|
name, dictation = str(action.arg[0]), str(action.arg[1])
|
||||||
|
blurb = self._contexts.get(name) or ""
|
||||||
|
|
||||||
|
inject.send_literal(session, blurb)
|
||||||
|
chars = len(blurb)
|
||||||
|
if dictation:
|
||||||
|
if cfg.context_multiline:
|
||||||
|
inject.send_named(session, keys.NEWLINE)
|
||||||
|
else:
|
||||||
|
inject.send_literal(session, cfg.context_separator)
|
||||||
|
chars += len(cfg.context_separator)
|
||||||
|
inject.send_literal(session, dictation)
|
||||||
|
chars += len(dictation)
|
||||||
|
self._pending[session] = self._pending.get(session, 0) + chars
|
||||||
|
|
||||||
|
shape = "blurb" if not dictation else "blurb + dictation"
|
||||||
|
self._console.emit(session, f"context '{name}' -> {shape} (waiting for send)", "green")
|
||||||
|
|
||||||
|
def _do_reload(self, scope: str) -> None:
|
||||||
|
"""re-read config.toml and/or contexts.toml live without reinitializing the
|
||||||
|
loaded whisper model (the slow part). scope: all|config|contexts."""
|
||||||
|
did = []
|
||||||
|
if scope in ("all", "config"):
|
||||||
|
try:
|
||||||
|
new_cfg = load_config()
|
||||||
|
self._apply_config(new_cfg)
|
||||||
|
did.append("config")
|
||||||
|
except ConfigError as exc:
|
||||||
|
self._console.emit(SYSTEM, f"config reload failed (kept previous): {exc}", "red")
|
||||||
|
if scope in ("all", "contexts"):
|
||||||
|
self._load_contexts()
|
||||||
|
did.append("contexts")
|
||||||
|
what = " + ".join(did) if did else "nothing"
|
||||||
|
blue = self._console.paint("reloaded", "brightblue")
|
||||||
|
self._console.emit(SYSTEM, f"{blue} {what} ({len(self._contexts)} contexts)")
|
||||||
|
|
||||||
|
def _apply_config(self, new_cfg: Config) -> None:
|
||||||
|
"""swap in a reloaded config, preserving the runtime mode the user may have
|
||||||
|
toggled by voice and leaving the already-loaded transcriber untouched."""
|
||||||
|
new_cfg.mode = self.mode
|
||||||
|
self.config = new_cfg
|
||||||
|
|
||||||
|
def _do_system(self, arg) -> None:
|
||||||
|
"""daemon-control namespace (never injects to claude): status / reload."""
|
||||||
|
if isinstance(arg, tuple) and arg and arg[0] == "reload":
|
||||||
|
self._do_reload(str(arg[1]))
|
||||||
|
return
|
||||||
|
if isinstance(arg, tuple) and arg and arg[0] == "unknown":
|
||||||
|
self._console.emit(SYSTEM, f"unknown system command '{arg[1]}'", "red")
|
||||||
|
return
|
||||||
|
if arg == "status":
|
||||||
|
cfg = self.config
|
||||||
|
sticky = target.read_active() or "(none)"
|
||||||
|
blue = self._console.paint("status", "brightblue")
|
||||||
|
self._console.emit(SYSTEM, f"{blue}: mode {self.mode}, sticky {sticky}, "
|
||||||
|
f"model {cfg.stt_model}, {len(self._contexts)} contexts")
|
||||||
|
return
|
||||||
|
self._console.emit(SYSTEM, f"unknown system command {arg!r}", "red")
|
||||||
|
|
||||||
|
def _timing(self) -> str:
|
||||||
|
"""compact STT latency suffix for heard lines (transcribe ms on audio secs)"""
|
||||||
|
return f"({self._last_stt_ms:.0f}ms/{self._last_audio_s:.1f}s)"
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _describe(action) -> str:
|
||||||
|
if action.name == "context":
|
||||||
|
name, dictation = action.arg
|
||||||
|
tail = " + dictation" if dictation else ""
|
||||||
|
return f"CONTEXT('{name}'{tail})"
|
||||||
|
if action.name == "system":
|
||||||
|
arg = action.arg
|
||||||
|
if isinstance(arg, tuple):
|
||||||
|
return f"SYSTEM({arg[0]} {arg[1]})"
|
||||||
|
return f"SYSTEM({arg})"
|
||||||
|
if action.arg is None:
|
||||||
|
return action.name.upper()
|
||||||
|
return f"{action.name.upper()}({action.arg})"
|
||||||
|
|
||||||
|
def _has_wake(self, transcript: str) -> bool:
|
||||||
|
"""true if the utterance starts with a wake phrase (listen-mode gate).
|
||||||
|
|
||||||
|
non-wake speech is dropped without ever printing the transcript — the privacy
|
||||||
|
invariant: non-command speech is discarded, never recorded.
|
||||||
|
"""
|
||||||
|
cfg = self.config
|
||||||
|
return grammar.strip_wake(transcript, cfg.wake_phrases,
|
||||||
|
cfg.wake_fuzzy_threshold, True) is not None
|
||||||
|
|
||||||
|
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 / 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} · {len(self._contexts)} contexts")
|
||||||
|
wakes = ", ".join(self._console.paint(p, "magenta") for p in cfg.wake_phrases)
|
||||||
|
self._console.emit(SYSTEM, f"wake: {wakes}")
|
||||||
|
|
||||||
|
def _refresh_state(self) -> None:
|
||||||
|
write_state(os.getpid(), self.mode, target.read_active())
|
||||||
|
|
||||||
|
def run(self) -> None:
|
||||||
|
"""run the daemon loop until a stop signal arrives"""
|
||||||
|
_ensure_state_dir()
|
||||||
|
PIDFILE.write_text(str(os.getpid()), encoding="utf-8")
|
||||||
|
self._install_signals()
|
||||||
|
try:
|
||||||
|
self._load()
|
||||||
|
self._refresh_state()
|
||||||
|
self._print_startup()
|
||||||
|
while not self._stop:
|
||||||
|
if self._reload_pending:
|
||||||
|
self._reload_pending = False
|
||||||
|
self._do_reload("all")
|
||||||
|
audio_chunk = self._capture()
|
||||||
|
if self._stop:
|
||||||
|
break
|
||||||
|
if audio_chunk is None:
|
||||||
|
continue
|
||||||
|
t0 = time.monotonic()
|
||||||
|
transcript = self._transcriber.transcribe(audio_chunk, self.config.samplerate)
|
||||||
|
self._last_stt_ms = (time.monotonic() - t0) * 1000.0
|
||||||
|
self._last_audio_s = audio_chunk.size / self.config.samplerate
|
||||||
|
if not transcript:
|
||||||
|
continue
|
||||||
|
if self.mode == "listen" and not self._has_wake(transcript):
|
||||||
|
if self.config.print_heard:
|
||||||
|
self._console.emit(VOICE, f'heard (dropped) "{transcript}" {self._timing()}', "red")
|
||||||
|
else:
|
||||||
|
self._console.emit(VOICE, "dropped: non-wake speech (not recorded)", "dim")
|
||||||
|
continue
|
||||||
|
self._handle(transcript)
|
||||||
|
finally:
|
||||||
|
PIDFILE.unlink(missing_ok=True)
|
||||||
|
STATEFILE.unlink(missing_ok=True)
|
||||||
|
log.info("claudedo stopped")
|
||||||
|
|
||||||
|
|
||||||
|
def run_daemon(config: Config) -> None:
|
||||||
|
"""entry point used by the CLI ``start`` command"""
|
||||||
|
if read_pid() is not None:
|
||||||
|
raise RuntimeError("claudedo is already running (see `claudedo status`)")
|
||||||
|
Daemon(config).run()
|
||||||
@@ -0,0 +1,402 @@
|
|||||||
|
"""wake-phrase gate + command grammar matching (fuzzy, data-driven).
|
||||||
|
|
||||||
|
the matcher is lenient by design: whisper renders the coined word "claudedo"
|
||||||
|
inconsistently, so wake-phrase detection normalizes case, strips spaces/punctuation,
|
||||||
|
and accepts close variants. number words are normalized to digits before matching.
|
||||||
|
|
||||||
|
flow: transcript -> strip_wake() returns the command remainder (or None if no wake
|
||||||
|
phrase in listen mode) -> match_command() maps the remainder to an Action.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import re
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from difflib import SequenceMatcher
|
||||||
|
|
||||||
|
_PUNCT = re.compile(r"[^a-z0-9 ]+")
|
||||||
|
_WS = re.compile(r"\s+")
|
||||||
|
|
||||||
|
_NUMBER_WORDS = {
|
||||||
|
"zero": "0", "oh": "0",
|
||||||
|
"one": "1", "won": "1",
|
||||||
|
"two": "2", "to": "2", "too": "2",
|
||||||
|
"three": "3", "tree": "3",
|
||||||
|
"four": "4", "for": "4", "fore": "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,
|
||||||
|
}
|
||||||
|
|
||||||
|
_YES_VERBS = ("yes", "yeah", "yep", "yup")
|
||||||
|
_NO_VERBS = ("no", "nope", "nah")
|
||||||
|
_APPROVE_VERBS = ("approve", "allow")
|
||||||
|
_DENY_VERBS = ("deny", "reject")
|
||||||
|
_SUBMIT_VERBS = ("send", "enter", "submit")
|
||||||
|
_CANCEL_VERBS = ("cancel", "escape")
|
||||||
|
_TYPE_VERBS = ("type", "dictate", "write")
|
||||||
|
_BACKSPACE_VERBS = ("backspace", "delete")
|
||||||
|
_SPACE_VERBS = ("space", "spacebar")
|
||||||
|
_ADD_VERBS = ("add", "insert")
|
||||||
|
_ERASE_VERBS = ("erase", "clear", "wipe")
|
||||||
|
_DEBUG_VERBS = ("debug", "echo")
|
||||||
|
_MODE_VERBS = ("mode",)
|
||||||
|
_STICKY_VERBS = ("set", "sticky", "switch")
|
||||||
|
_ONESHOT_VERBS = ("target",)
|
||||||
|
_UNSET_VERBS = ("unset", "unsticky")
|
||||||
|
_LIST_VERBS = ("list", "sessions")
|
||||||
|
_COMMANDS_VERBS = ("commands", "help", "menu")
|
||||||
|
_CUSTOMS_VERBS = ("customs", "custom")
|
||||||
|
_VERSION_VERBS = ("version",)
|
||||||
|
_SELECT_VERBS = ("select", "option", "choose", "number")
|
||||||
|
_CONTEXT_VERBS = ("context", "prepare")
|
||||||
|
_RELOAD_VERBS = ("reload",)
|
||||||
|
_SYSTEM_VERBS = ("system",)
|
||||||
|
_RELOAD_SCOPES = ("config", "contexts")
|
||||||
|
|
||||||
|
# every command/synonym word, for biasing the STT toward the vocabulary we expect.
|
||||||
|
_COMMAND_WORDS = (
|
||||||
|
_YES_VERBS + _NO_VERBS + _APPROVE_VERBS + _DENY_VERBS + _SUBMIT_VERBS
|
||||||
|
+ _CANCEL_VERBS + _TYPE_VERBS + _BACKSPACE_VERBS + _SPACE_VERBS + _ADD_VERBS
|
||||||
|
+ _ERASE_VERBS + _DEBUG_VERBS + _MODE_VERBS + _STICKY_VERBS + _ONESHOT_VERBS + _UNSET_VERBS
|
||||||
|
+ _LIST_VERBS + _COMMANDS_VERBS + _CUSTOMS_VERBS + _VERSION_VERBS
|
||||||
|
+ _CONTEXT_VERBS + _RELOAD_VERBS + _SYSTEM_VERBS + _RELOAD_SCOPES
|
||||||
|
+ _SELECT_VERBS + ("ptt", "listen")
|
||||||
|
+ ("one", "two", "three", "four")
|
||||||
|
)
|
||||||
|
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, space, backspace, erase,
|
||||||
|
cancel, mode, set, unset, list, context, reload, system. arg carries the select
|
||||||
|
index (int), the literal text for ``type``, the count for ``space``/``backspace``
|
||||||
|
(int), the mode for ``mode``, the session short-name for ``set``, a
|
||||||
|
``(name, dictation)`` tuple for ``context``, the scope string for ``reload``
|
||||||
|
(``"all"``/``"config"``/``"contexts"``), or the system control for ``system``
|
||||||
|
(``"status"`` or a ``("reload", scope)`` tuple).
|
||||||
|
"""
|
||||||
|
|
||||||
|
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. wake is the
|
||||||
|
configured wake phrase that matched (e.g. "okay claude" for a heard "okay clouds"),
|
||||||
|
or None.
|
||||||
|
"""
|
||||||
|
|
||||||
|
one_shot: str | None
|
||||||
|
action: Action | None
|
||||||
|
wake: str | None = None
|
||||||
|
|
||||||
|
|
||||||
|
def normalize(text: str) -> str:
|
||||||
|
"""lowercase, strip punctuation, collapse whitespace, map number words to digits"""
|
||||||
|
text = text.lower().strip()
|
||||||
|
text = _PUNCT.sub(" ", text)
|
||||||
|
text = _WS.sub(" ", text).strip()
|
||||||
|
if not text:
|
||||||
|
return ""
|
||||||
|
tokens = [_NUMBER_WORDS.get(tok, tok) for tok in text.split(" ")]
|
||||||
|
return " ".join(tokens)
|
||||||
|
|
||||||
|
|
||||||
|
def vocabulary(wake_phrases: list[str]) -> list[str]:
|
||||||
|
"""the wake + command vocabulary, deduped in first-seen order.
|
||||||
|
|
||||||
|
single source for biasing the STT: the same wake phrases the matcher uses plus
|
||||||
|
every command/synonym word in _COMMAND_WORDS. no separate hardcoded copy.
|
||||||
|
"""
|
||||||
|
seen: dict[str, None] = {}
|
||||||
|
for word in list(wake_phrases) + list(_COMMAND_WORDS):
|
||||||
|
key = word.strip()
|
||||||
|
if key and key not in seen:
|
||||||
|
seen[key] = None
|
||||||
|
return list(seen)
|
||||||
|
|
||||||
|
|
||||||
|
def initial_prompt(wake_phrases: list[str]) -> str:
|
||||||
|
"""a comma-joined vocabulary string to pass faster-whisper as initial_prompt,
|
||||||
|
conditioning transcription toward the words we expect (esp. the coined wake)"""
|
||||||
|
return ", ".join(vocabulary(wake_phrases))
|
||||||
|
|
||||||
|
|
||||||
|
def command_menu() -> list[tuple[str, str]]:
|
||||||
|
"""the voice command menu as (usage, description) rows, for the `commands` cmd.
|
||||||
|
|
||||||
|
a small curated list keyed off the verb groups — the speakable command surface,
|
||||||
|
NOT the cc shell kit.
|
||||||
|
"""
|
||||||
|
return [
|
||||||
|
("yes / no", "answer a yes/no prompt"),
|
||||||
|
("one..four", "pick numbered option 1-4"),
|
||||||
|
("approve / deny", "allow / deny a permission prompt"),
|
||||||
|
("send", "submit (Enter)"),
|
||||||
|
("cancel", "back out (Escape)"),
|
||||||
|
("type <text>", "insert literal text (no submit)"),
|
||||||
|
("space [n] / add a space", "insert n spaces"),
|
||||||
|
("backspace [n]", "delete n chars (to last submit)"),
|
||||||
|
("erase", "wipe the current input"),
|
||||||
|
("debug <text>", "echo to console (no inject)"),
|
||||||
|
("set <name>", "sticky target -> claude-<name>"),
|
||||||
|
("target <name> <cmd>", "one-shot to another session"),
|
||||||
|
("unset / list", "clear sticky / list sessions"),
|
||||||
|
("mode ptt|listen", "switch input mode"),
|
||||||
|
("context <name> <text>", "inject a contexts.toml blurb + dictation (no submit)"),
|
||||||
|
("reload", "re-read config.toml + contexts.toml live"),
|
||||||
|
("system status", "print mode/target/model/contexts to the console"),
|
||||||
|
("system reload [config|contexts]", "reload one or both config files"),
|
||||||
|
("commands / customs", "this menu / list loaded contexts"),
|
||||||
|
("version", "print the claudedo version"),
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def _ratio(a: str, b: str) -> float:
|
||||||
|
return SequenceMatcher(None, a, b).ratio()
|
||||||
|
|
||||||
|
|
||||||
|
def _wake_variants(phrase: str) -> set[str]:
|
||||||
|
"""spaced and despaced forms of a wake phrase for lenient matching"""
|
||||||
|
norm = normalize(phrase)
|
||||||
|
return {norm, norm.replace(" ", "")}
|
||||||
|
|
||||||
|
|
||||||
|
def strip_wake_match(transcript: str, wake_phrases: list[str], threshold: float,
|
||||||
|
require_wake: bool) -> tuple[str | None, str | None]:
|
||||||
|
"""return (command remainder, matched wake phrase).
|
||||||
|
|
||||||
|
if ``require_wake`` (listen mode) and no wake phrase is found at the start, the
|
||||||
|
remainder is None so the daemon discards the utterance. if not required (ptt
|
||||||
|
mode), a leading wake phrase is stripped when present but its absence is fine.
|
||||||
|
the matched phrase is the configured wake phrase that best matched (e.g. "okay
|
||||||
|
claude" for a heard "okay clouds"), or None when none matched.
|
||||||
|
|
||||||
|
matches leniently on a despaced prefix (whisper splits/joins the coined word
|
||||||
|
inconsistently) but always slices the remainder on a WORD boundary of the
|
||||||
|
spaced, normalized transcript — so the command portion keeps its spaces.
|
||||||
|
"""
|
||||||
|
norm = normalize(transcript)
|
||||||
|
if not norm:
|
||||||
|
return (None, None) if require_wake else ("", None)
|
||||||
|
words = norm.split(" ")
|
||||||
|
|
||||||
|
best_remainder: str | None = None
|
||||||
|
best_phrase: str | None = None
|
||||||
|
best_score = 0.0
|
||||||
|
for phrase in wake_phrases:
|
||||||
|
variants = _wake_variants(phrase)
|
||||||
|
max_words = phrase.count(" ") + 2
|
||||||
|
for take in range(1, min(max_words, len(words)) + 1):
|
||||||
|
head_despaced = "".join(words[:take])
|
||||||
|
for variant in variants:
|
||||||
|
if not variant:
|
||||||
|
continue
|
||||||
|
score = _ratio(head_despaced, variant)
|
||||||
|
if score >= threshold and score > best_score:
|
||||||
|
best_score = score
|
||||||
|
best_remainder = " ".join(words[take:]).strip()
|
||||||
|
best_phrase = phrase
|
||||||
|
|
||||||
|
if best_remainder is not None:
|
||||||
|
return best_remainder, best_phrase
|
||||||
|
return (None, None) if require_wake else (norm, None)
|
||||||
|
|
||||||
|
|
||||||
|
def strip_wake(transcript: str, wake_phrases: list[str], threshold: float,
|
||||||
|
require_wake: bool) -> str | None:
|
||||||
|
"""return the command remainder after the wake phrase (None if no wake in listen
|
||||||
|
mode). thin wrapper over strip_wake_match for callers that don't need the phrase"""
|
||||||
|
return strip_wake_match(transcript, wake_phrases, threshold, require_wake)[0]
|
||||||
|
|
||||||
|
|
||||||
|
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_reload(rest: list[str], threshold: float, bare_default: str) -> Action | None:
|
||||||
|
"""map the tokens after a ``reload`` verb to a reload Action.
|
||||||
|
|
||||||
|
bare reload -> the caller's default scope ("all" for the bare command, the
|
||||||
|
``("reload", scope)`` tuple for ``system reload``). a trailing ``config``/
|
||||||
|
``contexts`` narrows the scope; an unrecognized scope falls back to the default.
|
||||||
|
"""
|
||||||
|
scope = bare_default
|
||||||
|
if rest and _fuzzy_in(rest[0], ("config", "configuration"), threshold):
|
||||||
|
scope = "config"
|
||||||
|
elif rest and _fuzzy_in(rest[0], ("contexts", "context"), threshold):
|
||||||
|
scope = "contexts"
|
||||||
|
return Action("reload", scope)
|
||||||
|
|
||||||
|
|
||||||
|
def _match_system(rest: list[str], threshold: float) -> Action | None:
|
||||||
|
"""map the tokens after the reserved ``system`` word to a daemon-control Action.
|
||||||
|
|
||||||
|
the ``system`` namespace never injects into claude. v0.2.0 scope: ``status`` and
|
||||||
|
``reload [config|contexts]``. unknown controls return a ``system`` Action with an
|
||||||
|
``("unknown", word)`` arg so the daemon can report it rather than silently drop.
|
||||||
|
"""
|
||||||
|
if not rest:
|
||||||
|
return Action("system", "status")
|
||||||
|
head = rest[0]
|
||||||
|
if _fuzzy_in(head, _RELOAD_VERBS, threshold):
|
||||||
|
inner = _match_reload(rest[1:], threshold, bare_default="all")
|
||||||
|
return Action("system", ("reload", inner.arg))
|
||||||
|
if _fuzzy_in(head, ("status", "state"), threshold):
|
||||||
|
return Action("system", "status")
|
||||||
|
return Action("system", ("unknown", head))
|
||||||
|
|
||||||
|
|
||||||
|
def match_command(remainder: str, threshold: float) -> Action | None:
|
||||||
|
"""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
|
||||||
|
tokens = remainder.split(" ")
|
||||||
|
head = tokens[0]
|
||||||
|
rest = tokens[1:]
|
||||||
|
|
||||||
|
if _fuzzy_in(head, _SYSTEM_VERBS, threshold):
|
||||||
|
return _match_system(rest, threshold)
|
||||||
|
if _fuzzy_in(head, _RELOAD_VERBS, threshold):
|
||||||
|
return _match_reload(rest, threshold, bare_default="all")
|
||||||
|
if _fuzzy_in(head, _CONTEXT_VERBS, threshold) and rest:
|
||||||
|
name = rest[0]
|
||||||
|
dictation = " ".join(rest[1:]).strip()
|
||||||
|
return Action("context", (name, dictation))
|
||||||
|
|
||||||
|
if head in _INDEX_WORDS:
|
||||||
|
return Action("select", _INDEX_WORDS[head])
|
||||||
|
|
||||||
|
if _fuzzy_in(head, _YES_VERBS, threshold):
|
||||||
|
return Action("yes")
|
||||||
|
if _fuzzy_in(head, _NO_VERBS, threshold):
|
||||||
|
return Action("no")
|
||||||
|
if _fuzzy_in(head, _APPROVE_VERBS, threshold):
|
||||||
|
return Action("approve")
|
||||||
|
if _fuzzy_in(head, _DENY_VERBS, threshold):
|
||||||
|
return Action("deny")
|
||||||
|
if _fuzzy_in(head, _SUBMIT_VERBS, threshold):
|
||||||
|
return Action("submit")
|
||||||
|
if _fuzzy_in(head, _CANCEL_VERBS, threshold):
|
||||||
|
return Action("cancel")
|
||||||
|
|
||||||
|
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_VERBS, threshold):
|
||||||
|
text = " ".join(rest).strip()
|
||||||
|
return Action("type", text) if text else None
|
||||||
|
|
||||||
|
if _fuzzy_in(head, _BACKSPACE_VERBS, threshold):
|
||||||
|
return Action("backspace", _leading_count(rest, default=1))
|
||||||
|
if _fuzzy_in(head, _SPACE_VERBS, threshold):
|
||||||
|
return Action("space", _leading_count(rest, default=1))
|
||||||
|
if _fuzzy_in(head, _ADD_VERBS, threshold) and rest:
|
||||||
|
tail = [t for t in rest if t not in ("a", "an")]
|
||||||
|
if any(_fuzzy_in(t, ("space", "spaces"), threshold) for t in tail):
|
||||||
|
count = next((int(t) for t in tail if t.isdigit()),
|
||||||
|
next((_COUNT_WORDS[t] for t in tail if t in _COUNT_WORDS), 1))
|
||||||
|
return Action("space", count)
|
||||||
|
if _fuzzy_in(head, _ERASE_VERBS, threshold):
|
||||||
|
return Action("erase")
|
||||||
|
if _fuzzy_in(head, _DEBUG_VERBS, threshold):
|
||||||
|
return Action("debug", " ".join(rest).strip())
|
||||||
|
|
||||||
|
if _fuzzy_in(head, _MODE_VERBS, threshold) and rest:
|
||||||
|
if _fuzzy_in(rest[0], ("ptt",), threshold) or "push" in rest[0]:
|
||||||
|
return Action("mode", "ptt")
|
||||||
|
if _fuzzy_in(rest[0], ("listen",), threshold):
|
||||||
|
return Action("mode", "listen")
|
||||||
|
return None
|
||||||
|
|
||||||
|
if _fuzzy_in(head, _STICKY_VERBS, threshold) and rest:
|
||||||
|
name = "".join(rest)
|
||||||
|
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, _CUSTOMS_VERBS, threshold):
|
||||||
|
return Action("customs")
|
||||||
|
if _fuzzy_in(head, _COMMANDS_VERBS, threshold):
|
||||||
|
return Action("commands")
|
||||||
|
if _fuzzy_in(head, _LIST_VERBS, threshold):
|
||||||
|
return Action("list")
|
||||||
|
if _fuzzy_in(head, _VERSION_VERBS, threshold):
|
||||||
|
return Action("version")
|
||||||
|
|
||||||
|
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], wake_threshold: float,
|
||||||
|
command_threshold: float, require_wake: bool,
|
||||||
|
filler: tuple[str, ...] = DEFAULT_FILLER) -> ParsedCommand | None:
|
||||||
|
"""full parse: wake gate -> optional one-shot target -> filler -> command.
|
||||||
|
|
||||||
|
wake_threshold gates the wake phrase (lenient — a false wake is cheap, it just
|
||||||
|
finds no command); command_threshold gates the command words (stricter — a false
|
||||||
|
command fires the wrong action). 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, wake = strip_wake_match(transcript, wake_phrases, wake_threshold, require_wake)
|
||||||
|
if remainder is None:
|
||||||
|
return None
|
||||||
|
|
||||||
|
tokens = remainder.split(" ") if remainder else []
|
||||||
|
one_shot: str | None = None
|
||||||
|
if tokens and _fuzzy_in(tokens[0], _ONESHOT_VERBS, command_threshold) and len(tokens) >= 2:
|
||||||
|
one_shot = tokens[1]
|
||||||
|
tokens = tokens[2:]
|
||||||
|
|
||||||
|
tokens = _strip_filler(tokens, filler, command_threshold)
|
||||||
|
action = match_command(" ".join(tokens), command_threshold)
|
||||||
|
return ParsedCommand(one_shot=one_shot, action=action, wake=wake)
|
||||||
+128
-43
@@ -1,15 +1,24 @@
|
|||||||
"""inject keystrokes into a tmux session via ``tmux send-keys``.
|
"""output handlers: resolve a grammar.Action to keystrokes and emit them.
|
||||||
|
|
||||||
this is the ONLY mechanism by which claudedo affects claude code — PTY injection,
|
the production handler (TmuxOutputHandler) injects via ``tmux send-keys`` — the ONLY
|
||||||
never OS-level keyboard input. it works regardless of which window is focused and
|
mechanism by which claudedo affects claude code. PTY injection, never OS-level
|
||||||
never touches Windows input or a game/anticheat's view (it is text into a linux
|
keyboard input: it works regardless of which window is focused and never touches
|
||||||
pseudo-terminal). do not replace this with OS keystroke injection.
|
Windows input or a game/anticheat's view (it is text into a linux pseudo-terminal).
|
||||||
|
do not replace this with OS keystroke injection. this is also why claudedo is a
|
||||||
|
standalone daemon and not an MCP server — MCP tools can only return content to claude,
|
||||||
|
not inject into its input stream.
|
||||||
|
|
||||||
|
StdoutOutputHandler prints what WOULD be injected instead of touching tmux, so the
|
||||||
|
grammar + keymap can be exercised end-to-end without a live claude session — the
|
||||||
|
deterministic test path. both implement the same OutputHandler seam and are
|
||||||
|
interchangeable.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import logging
|
import logging
|
||||||
import subprocess
|
import subprocess
|
||||||
|
from abc import ABC, abstractmethod
|
||||||
|
|
||||||
from . import keys, target
|
from . import keys, target
|
||||||
|
|
||||||
@@ -17,10 +26,73 @@ log = logging.getLogger(__name__)
|
|||||||
|
|
||||||
|
|
||||||
class InjectError(Exception):
|
class InjectError(Exception):
|
||||||
"""raised when a tmux send-keys call fails."""
|
"""raised when a tmux send-keys call fails"""
|
||||||
|
|
||||||
|
|
||||||
def _send_keys(session: str, args: list[str], literal: bool) -> None:
|
class OutputHandler(ABC):
|
||||||
|
"""abstract sink for resolved keystrokes.
|
||||||
|
|
||||||
|
concretes implement send_named (a sequence of named tmux keys) and send_literal
|
||||||
|
(literal text, no submit). perform() maps a grammar.Action onto these and is shared
|
||||||
|
by all handlers.
|
||||||
|
"""
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
|
def send_named(self, session: str, key_tokens: list[str]) -> None:
|
||||||
|
"""emit a sequence of named keys (e.g. ['1'] or ['Down', 'Enter'])"""
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
|
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``/``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":
|
||||||
|
self.send_named(session, keys.YES)
|
||||||
|
elif name == "no":
|
||||||
|
self.send_named(session, keys.NO)
|
||||||
|
elif name == "approve":
|
||||||
|
self.send_named(session, keys.APPROVE)
|
||||||
|
elif name == "deny":
|
||||||
|
self.send_named(session, keys.DENY)
|
||||||
|
elif name == "submit":
|
||||||
|
self.send_named(session, keys.SUBMIT)
|
||||||
|
elif name == "cancel":
|
||||||
|
self.send_named(session, keys.CANCEL)
|
||||||
|
elif name == "select":
|
||||||
|
seq = keys.SELECT_BY_INDEX.get(int(action.arg))
|
||||||
|
if seq is None:
|
||||||
|
log.warning("no keymap for select index %r", action.arg)
|
||||||
|
return False
|
||||||
|
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
|
||||||
|
|
||||||
|
|
||||||
|
class TmuxOutputHandler(OutputHandler):
|
||||||
|
"""production handler — injects keystrokes into a tmux session via send-keys"""
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _send_keys(session: str, args: list[str], literal: bool) -> None:
|
||||||
cmd = ["tmux", "send-keys", "-t", session]
|
cmd = ["tmux", "send-keys", "-t", session]
|
||||||
if literal:
|
if literal:
|
||||||
cmd.append("-l")
|
cmd.append("-l")
|
||||||
@@ -30,55 +102,68 @@ def _send_keys(session: str, args: list[str], literal: bool) -> None:
|
|||||||
err = result.stderr.decode("utf-8", "replace").strip()
|
err = result.stderr.decode("utf-8", "replace").strip()
|
||||||
raise InjectError(f"tmux send-keys failed: {err}")
|
raise InjectError(f"tmux send-keys failed: {err}")
|
||||||
|
|
||||||
|
def send_named(self, session: str, key_tokens: list[str]) -> None:
|
||||||
def send_named(session: str, key_tokens: list[str]) -> None:
|
|
||||||
"""send a sequence of named tmux keys (e.g. ['1'] or ['Down', 'Enter'])."""
|
|
||||||
if not target.session_exists(session):
|
if not target.session_exists(session):
|
||||||
log.warning("refusing to inject — session %r does not exist", session)
|
log.warning("refusing to inject — session %r does not exist", session)
|
||||||
return
|
return
|
||||||
for token in key_tokens:
|
for token in key_tokens:
|
||||||
_send_keys(session, [token], literal=False)
|
self._send_keys(session, [token], literal=False)
|
||||||
log.info("injected keys %s -> %s", key_tokens, session)
|
log.info("injected keys %s -> %s", key_tokens, session)
|
||||||
|
|
||||||
|
def send_literal(self, session: str, text: str) -> None:
|
||||||
def send_literal(session: str, text: str) -> None:
|
|
||||||
"""insert literal text into the input box without submitting (``type``)."""
|
|
||||||
if not text:
|
if not text:
|
||||||
return
|
return
|
||||||
if not target.session_exists(session):
|
if not target.session_exists(session):
|
||||||
log.warning("refusing to inject — session %r does not exist", session)
|
log.warning("refusing to inject — session %r does not exist", session)
|
||||||
return
|
return
|
||||||
_send_keys(session, [text], literal=True)
|
self._send_keys(session, [text], literal=True)
|
||||||
log.info("injected literal text (%d chars) -> %s", len(text), session)
|
log.info("injected literal text (%d chars) -> %s", len(text), session)
|
||||||
|
|
||||||
|
|
||||||
def perform(session: str, action) -> bool:
|
class StdoutOutputHandler(OutputHandler):
|
||||||
"""resolve a grammar.Action to keystrokes and inject them. returns acted?.
|
"""test handler — prints what would be injected instead of touching tmux.
|
||||||
|
|
||||||
``switch`` and ``mode`` are handled by the daemon (they change daemon state, not
|
no session existence check (there is no real session); lets grammar + keymap be
|
||||||
the claude session), so they are ignored here.
|
exercised end-to-end without a live claude session. records the last emission on
|
||||||
|
``self.last`` for assertions.
|
||||||
"""
|
"""
|
||||||
name = action.name
|
|
||||||
if name == "yes":
|
def __init__(self, stream=None) -> None:
|
||||||
send_named(session, keys.YES)
|
import sys
|
||||||
elif name == "no":
|
|
||||||
send_named(session, keys.NO)
|
self.stream = stream if stream is not None else sys.stdout
|
||||||
elif name == "approve":
|
self.last: tuple[str, object] | None = None
|
||||||
send_named(session, keys.APPROVE)
|
|
||||||
elif name == "deny":
|
def send_named(self, session: str, key_tokens: list[str]) -> None:
|
||||||
send_named(session, keys.DENY)
|
self.last = ("named", list(key_tokens))
|
||||||
elif name == "submit":
|
print(f"[stdout] keys {key_tokens} -> {session}", file=self.stream)
|
||||||
send_named(session, keys.SUBMIT)
|
|
||||||
elif name == "cancel":
|
def send_literal(self, session: str, text: str) -> None:
|
||||||
send_named(session, keys.CANCEL)
|
if not text:
|
||||||
elif name == "select":
|
return
|
||||||
seq = keys.SELECT_BY_INDEX.get(int(action.arg))
|
self.last = ("literal", text)
|
||||||
if seq is None:
|
print(f"[stdout] literal {text!r} -> {session}", file=self.stream)
|
||||||
log.warning("no keymap for select index %r", action.arg)
|
|
||||||
return False
|
|
||||||
send_named(session, seq)
|
_default_handler: OutputHandler = TmuxOutputHandler()
|
||||||
elif name == "type":
|
|
||||||
send_literal(session, str(action.arg))
|
|
||||||
else:
|
def set_default_handler(handler: OutputHandler) -> None:
|
||||||
return False
|
"""swap the module-level handler the daemon drives (tmux in prod, stdout in tests)"""
|
||||||
return True
|
global _default_handler
|
||||||
|
_default_handler = handler
|
||||||
|
|
||||||
|
|
||||||
|
def send_named(session: str, key_tokens: list[str]) -> None:
|
||||||
|
"""module-level shim delegating to the default handler"""
|
||||||
|
_default_handler.send_named(session, key_tokens)
|
||||||
|
|
||||||
|
|
||||||
|
def send_literal(session: str, text: str) -> None:
|
||||||
|
"""module-level shim delegating to the default handler"""
|
||||||
|
_default_handler.send_literal(session, text)
|
||||||
|
|
||||||
|
|
||||||
|
def perform(session: str, action) -> bool:
|
||||||
|
"""module-level shim delegating to the default handler"""
|
||||||
|
return _default_handler.perform(session, action)
|
||||||
|
|||||||
@@ -37,6 +37,19 @@ DENY = ["3"]
|
|||||||
SUBMIT = ["Enter"]
|
SUBMIT = ["Enter"]
|
||||||
CANCEL = ["Escape"]
|
CANCEL = ["Escape"]
|
||||||
|
|
||||||
|
# NEWLINE is a soft newline inside the input box that does NOT submit — Shift+Enter,
|
||||||
|
# which tmux names ``S-Enter`` (requires the extended-keys / xterm extkeys tmux
|
||||||
|
# settings install.sh appends). used to separate a context blurb from the dictated
|
||||||
|
# instruction in multiline assembly; if it proves flaky the daemon flattens to one
|
||||||
|
# line with a separator instead (behavior.context_multiline = false).
|
||||||
|
NEWLINE = ["S-Enter"]
|
||||||
|
|
||||||
|
# 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,
|
||||||
|
|||||||
@@ -0,0 +1,127 @@
|
|||||||
|
"""faster-whisper wrapper: load a model once, transcribe audio chunks locally.
|
||||||
|
|
||||||
|
privacy invariant: transcription runs entirely on-device. audio handed here is a
|
||||||
|
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"""
|
||||||
|
|
||||||
|
def __init__(self, model: str = "small", language: str = "en", device: str = "auto",
|
||||||
|
compute_type: str = "auto", initial_prompt: str | None = None) -> None:
|
||||||
|
self.language = language
|
||||||
|
self.initial_prompt = initial_prompt
|
||||||
|
self._model = self._load(model, device, compute_type)
|
||||||
|
self._warm()
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _load(model: str, device: str, compute_type: str):
|
||||||
|
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.
|
||||||
|
|
||||||
|
the audio must be 16 kHz mono float32 in [-1, 1]; resample upstream if not.
|
||||||
|
"""
|
||||||
|
if audio.dtype != np.float32:
|
||||||
|
audio = audio.astype(np.float32)
|
||||||
|
if audio.ndim > 1:
|
||||||
|
audio = audio.reshape(-1)
|
||||||
|
segments, _info = self._model.transcribe(
|
||||||
|
audio,
|
||||||
|
language=self.language,
|
||||||
|
beam_size=1,
|
||||||
|
vad_filter=True,
|
||||||
|
condition_on_previous_text=False,
|
||||||
|
initial_prompt=self.initial_prompt,
|
||||||
|
)
|
||||||
|
text = " ".join(seg.text for seg in segments).strip()
|
||||||
|
return text
|
||||||
+63
-28
@@ -1,4 +1,4 @@
|
|||||||
"""resolve the active claude code tmux session from ~/.claude-active."""
|
"""resolve the active claude code tmux session from ~/.claude-active"""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
@@ -18,15 +18,16 @@ 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}"
|
||||||
|
|
||||||
|
|
||||||
def read_active() -> str | None:
|
def read_active() -> str | None:
|
||||||
"""return the target session name from ~/.claude-active, or None if unset."""
|
"""return the target session name from ~/.claude-active, or None if unset"""
|
||||||
try:
|
try:
|
||||||
name = ACTIVE_FILE.read_text(encoding="utf-8").strip()
|
name = ACTIVE_FILE.read_text(encoding="utf-8").strip()
|
||||||
except FileNotFoundError:
|
except FileNotFoundError:
|
||||||
@@ -38,20 +39,30 @@ 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:
|
||||||
return False
|
return False
|
||||||
result = subprocess.run(
|
result = subprocess.run(
|
||||||
@@ -62,27 +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.
|
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 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
|
|
||||||
return name
|
|
||||||
|
|
||||||
|
sticky = read_active()
|
||||||
|
if sticky:
|
||||||
|
if session_exists(sticky):
|
||||||
|
return sticky, f"sticky {sticky}"
|
||||||
|
return None, f"sticky {sticky} no longer exists (set one)"
|
||||||
|
|
||||||
# TODO: most-recently-active targeting (preferred over attached). today the target
|
sessions = list_sessions()
|
||||||
# is "the project most recently ATTACHED to" (the cc kit writes ~/.claude-active on
|
if len(sessions) == 1:
|
||||||
# attach). upgrade to "the session claude most recently asked a question / produced
|
if auto_target:
|
||||||
# output in" via tmux session_activity timestamps:
|
return sessions[0], f"auto-target {sessions[0]} (only session)"
|
||||||
# tmux list-sessions -F '#{session_name} #{session_activity}'
|
return None, f"no target set ({sessions[0]} running — set one)"
|
||||||
# pick the highest-activity claude-* session; or scrape panes
|
if len(sessions) > 1:
|
||||||
# (tmux capture-pane -p -t <s>) for a waiting-prompt UI and target the session whose
|
return None, f"no target set, {len(sessions)} sessions (set one)"
|
||||||
# pane currently shows one.
|
return None, "no claude sessions"
|
||||||
|
|||||||
Reference in New Issue
Block a user