v0.2.0: context injection + system daemon-control namespace

context injection — named reference blurbs from contexts.toml injected ahead of a
dictated instruction, read-before-send (never auto-submits):
- new contexts.py mirrors config.py: [contexts] name = "blurb"; missing file = empty
  set; names validated as simple words, looked up on a despaced/lowercased key so
  "web hooks"/"web-hooks"/"webhooks" all resolve the same block.
- grammar: context|prepare <name> <instruction> -> Action("context", (name, dictation)).
  same-utterance dictation (everything after <name> is literal, incl. "send"); bare
  context <name> injects just the blurb. one-shot targeting composes:
  [target <name>] [context <ctx>] [filler] <dictation>.
- daemon assembles blurb + (Shift+Enter soft newline | flattened separator) + dictation
  via the existing send_literal/type path, tracks the uncommitted-input buffer, and
  WAITS. config-gated by behavior.context_multiline / context_separator. unknown context
  name announces and injects nothing.

system daemon-control namespace — lands the pass-through vs control split the router was
structured for. reserved leading "system" routes to _do_system (never injects to
claude): system status (mode/target/model/contexts) and system reload [config|contexts].

live reload — voice reload + CLI claudedo reload (SIGHUP) re-read config.toml +
contexts.toml without reinitializing the loaded whisper model. customs now lists loaded
contexts. install.sh installs the contexts.toml template copy-if-absent (else .new).

keys.NEWLINE (S-Enter) added for the soft-newline assembly. wake list unchanged.

Signed-off-by: disqualifier <dev@disqualifier.me>
This commit is contained in:
disqualifier 2026-06-26 17:39:10 -04:00
parent f177b46a4b
commit bd083b1a8d
12 changed files with 412 additions and 13 deletions

View File

@ -21,7 +21,7 @@ mic (WSLg/PulseAudio RDPSource)
-> faster-whisper (local STT, on-device)
-> wake gate: utterance must start with a wake phrase, else DISCARD locally
-> grammar match (yes/no/one..four/approve/deny/send/type/space/backspace/erase/
mode/set/target/unset/list/cancel)
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>"
-> log the action to the watched terminal ([session]/[SYSTEM]/[VOICE], colored)
@ -79,6 +79,7 @@ claudedo start --check # run a mic check before listening
claudedo start --mode ptt # push-to-talk instead (desk-only — see Modes)
claudedo status # running? mode? target session?
claudedo stop # stop a running daemon
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
@ -127,8 +128,12 @@ said "okay clouds"), the heard line notes which phrase it assumed —
| `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`) | custom commands — arriving in v0.2.0 (stub for now) |
| `customs` (alias `custom`) | list the loaded context names |
| `version` | print the claudedo version to the console |
| `cancel` / `escape` | back out of a prompt |
@ -172,6 +177,40 @@ cck <name> # kill claude-<name>
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 keystrokes in [`keys.py`](src/claudedo/keys.py) were confirmed **empirically**
@ -218,6 +257,9 @@ it searches
`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

View File

@ -78,3 +78,13 @@ auto_target = false
# 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 = " — "

18
contexts.toml Normal file
View File

@ -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"

View File

@ -106,6 +106,18 @@ 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

View File

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

View File

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

View File

@ -97,6 +97,14 @@ def cmd_stop(_args: argparse.Namespace) -> int:
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:
@ -222,6 +230,8 @@ def build_parser() -> argparse.ArgumentParser:
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)

View File

@ -56,6 +56,8 @@ class Config:
filler_words: tuple[str, ...]
auto_target: bool
print_heard: bool
context_multiline: bool
context_separator: str
source_path: Path | None = field(default=None)
@ -128,6 +130,8 @@ def load_config(explicit: str | os.PathLike | None = None) -> Config:
["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,
)
for label, val in (("wake_fuzzy_threshold", cfg.wake_fuzzy_threshold),

108
src/claudedo/contexts.py Normal file
View File

@ -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)

View File

@ -16,9 +16,10 @@ import sys
import time
from pathlib import Path
from . import __version__, audio, grammar, inject, target
from .config import Config
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__)
@ -76,6 +77,16 @@ def stop_running() -> bool:
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
@ -112,22 +123,34 @@ class Daemon:
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
@ -140,11 +163,21 @@ class Daemon:
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":
@ -217,7 +250,9 @@ class Daemon:
self._console.line(f" {self._console.paint(f'{usage:<26}', 'brightblue')} {desc}")
return
if action.name == "customs":
self._console.emit(SYSTEM, "custom commands arrive in v0.2.0 (contexts.toml)")
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__}")
@ -225,12 +260,26 @@ class Daemon:
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:
@ -247,7 +296,7 @@ class Daemon:
inject.send_literal(session, text)
self._pending[session] = self._pending.get(session, 0) + len(text)
if self.config.type_autosend:
inject.send_named(session, inject.keys.SUBMIT)
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")
@ -278,12 +327,94 @@ class Daemon:
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})"
@ -304,7 +435,7 @@ class Daemon:
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}")
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}")
@ -321,6 +452,9 @@ class Daemon:
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

View File

@ -54,6 +54,10 @@ _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 = (
@ -61,6 +65,7 @@ _COMMAND_WORDS = (
+ _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")
)
@ -72,9 +77,12 @@ 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. arg carries the select index (int), the literal
text for ``type``, the count for ``space``/``backspace`` (int), the mode for
``mode``, or the session short-name for ``set``.
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
@ -149,7 +157,11 @@ def command_menu() -> list[tuple[str, str]]:
("target <name> <cmd>", "one-shot to another session"),
("unset / list", "clear sticky / list sessions"),
("mode ptt|listen", "switch input mode"),
("commands / customs", "this menu / custom commands (v0.2.0)"),
("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"),
]
@ -232,6 +244,39 @@ def _leading_count(rest: list[str], default: int = 1) -> int:
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.
@ -246,6 +291,15 @@ def match_command(remainder: str, threshold: float) -> Action | None:
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])

View File

@ -37,6 +37,13 @@ DENY = ["3"]
SUBMIT = ["Enter"]
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.