From 2fa3abab63bb429705bd858282134cf4210c391d Mon Sep 17 00:00:00 2001 From: disqualifier Date: Fri, 26 Jun 2026 17:39:10 -0400 Subject: [PATCH] v0.2.0: context injection + system daemon-control namespace MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 -> Action("context", (name, dictation)). same-utterance dictation (everything after is literal, incl. "send"); bare context injects just the blurb. one-shot targeting composes: [target ] [context ] [filler] . - 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 --- README.md | 46 ++++++++++++- config.toml | 10 +++ contexts.toml | 18 +++++ install.sh | 12 ++++ pyproject.toml | 2 +- src/claudedo/__init__.py | 2 +- src/claudedo/__main__.py | 10 +++ src/claudedo/config.py | 4 ++ src/claudedo/contexts.py | 108 +++++++++++++++++++++++++++++ src/claudedo/daemon.py | 144 +++++++++++++++++++++++++++++++++++++-- src/claudedo/grammar.py | 62 +++++++++++++++-- src/claudedo/keys.py | 7 ++ 12 files changed, 412 insertions(+), 13 deletions(-) create mode 100644 contexts.toml create mode 100644 src/claudedo/contexts.py diff --git a/README.md b/README.md index 2334870..f86a61d 100644 --- a/README.md +++ b/README.md @@ -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 "" -> 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 # set the sticky target -> claude- (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 ` | **one-shot** override: run that command on `claude-` for this utterance only; sticky default unchanged | | `unset` (alias `unsticky`) | clear the sticky target | | `list` | list running `claude-*` sessions to the daemon console | +| `context ` (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 # kill claude- 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 `** 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: (safe to spam), live: (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 diff --git a/config.toml b/config.toml index c941c8d..3aa9e02 100644 --- a/config.toml +++ b/config.toml @@ -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 ` 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 = " — " diff --git a/contexts.toml b/contexts.toml new file mode 100644 index 0000000..e857859 --- /dev/null +++ b/contexts.toml @@ -0,0 +1,18 @@ +# claudedo contexts — named reference blurbs you can inject ahead of a dictated +# instruction with the `context ` 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: (safe to spam), live: (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" diff --git a/install.sh b/install.sh index 01eccd4..26b5163 100755 --- a/install.sh +++ b/install.sh @@ -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 diff --git a/pyproject.toml b/pyproject.toml index 250afef..f9ffbf2 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -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" diff --git a/src/claudedo/__init__.py b/src/claudedo/__init__.py index 0e768b4..20b3941 100644 --- a/src/claudedo/__init__.py +++ b/src/claudedo/__init__.py @@ -1,3 +1,3 @@ """claudedo — voice-control daemon for claude code (local STT -> tmux send-keys)""" -__version__ = "0.1.4" +__version__ = "0.2.0" diff --git a/src/claudedo/__main__.py b/src/claudedo/__main__.py index bdeacbe..fae3d17 100644 --- a/src/claudedo/__main__.py +++ b/src/claudedo/__main__.py @@ -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) diff --git a/src/claudedo/config.py b/src/claudedo/config.py index 2407d4f..662dea4 100644 --- a/src/claudedo/config.py +++ b/src/claudedo/config.py @@ -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), diff --git a/src/claudedo/contexts.py b/src/claudedo/contexts.py new file mode 100644 index 0000000..0c99f52 --- /dev/null +++ b/src/claudedo/contexts.py @@ -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) diff --git a/src/claudedo/daemon.py b/src/claudedo/daemon.py index 10149b8..6442916 100644 --- a/src/claudedo/daemon.py +++ b/src/claudedo/daemon.py @@ -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 `` (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 )" 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 diff --git a/src/claudedo/grammar.py b/src/claudedo/grammar.py index df95b73..a5abae3 100644 --- a/src/claudedo/grammar.py +++ b/src/claudedo/grammar.py @@ -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 ", "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 ", "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]) diff --git a/src/claudedo/keys.py b/src/claudedo/keys.py index b18ca5d..1845fe7 100644 --- a/src/claudedo/keys.py +++ b/src/claudedo/keys.py @@ -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 ` / `space ` and for `erase` (n = the daemon's # tracked uncommitted-input count). BSpace is tmux's name for the backspace key.