# AGENTS.md Always-on speech-to-text daemon plus a separate voice-assistant layer. Python 3.11+ (dev env is 3.13, but `requires-python = ">=3.11"` and ruff targets `py311` — don't use 3.12+ syntax), `uv`, `src/` layout. `stt_daemon/` is the only package; console scripts live in `pyproject.toml` (`stt-daemon`, `stt-assistant`, `stt-ctl`, `stt-tui`). ## Commands ```bash uv sync # install into .venv (see pywhispercpp below) uv sync --extra portaudio # sounddevice capture backend only uv sync --extra triage # optional: in-process Laya triage lane uv sync --extra speaker # optional: in-process speaker embedding lane # extras are exclusive under uv sync: pass both flags (--extra speaker --extra triage) to keep both uv run pytest -v # full suite (hardware- and network-free) uv run pytest tests/test_segmenter.py::test_name -v # single test uv run ruff check . # only static check: E,F,W, line-length 100, no typechecker uv build # sdist + wheel (hatchling) uv run stt-daemon up --with-tui # supervise all three processes (main launcher) uv run stt-daemon setup && uv run stt-daemon run # manual flow without supervisor ``` There is no CI, pre-commit, Makefile, or task runner. Run ruff + pytest manually before finishing. ## ROCm environment (this host) The repo's lock/vendor pins CUDA wheels (`torch 2.14.0+cu130`, vendored `pywhispercpp`). After any `uv sync`, reinstall the ROCm stack; otherwise the venv silently goes back to CUDA. One command: ```bash scripts/install-rocm.sh ``` It fetches the torch ROCm wheels and the CTranslate2 ROCm wheel. The manual equivalent, if the script cannot run on this host: ```bash TMPDIR=$HOME/.tmp .venv/bin/pip install --force-reinstall torch torchvision torchaudio \ --index-url https://download.pytorch.org/whl/rocm7.2 \ --extra-index-url https://pypi.org/simple TMPDIR=$HOME/.tmp .venv/bin/pip install --force-reinstall ~/.tmp/ct2-rocm/ctranslate2-*-cp313-*.whl ``` - Use the CTranslate2 **ROCm** wheel (release v4.8.4 `rocm-python-wheels-Linux.zip`; the script fetches it); PyPI's `ctranslate2` is CUDA-only. - Plain `uv run` re-syncs the venv implicitly, so it also reverts these wheels (`uv sync` is not required to lose them). After installing the ROCm stack, run the suite as `.venv/bin/python -m pytest` (or `uv run --no-sync ...`), and re-run `scripts/install-rocm.sh` after any deliberate sync. - On HIP the default `hipMallocAsync` allocator silently corrupts CTranslate2 buffers (OpenNMT/CTranslate2 #2090): wrong language detection, empty transcripts, hangs, or segfaults. The transcriber sets `CT2_CUDA_ALLOCATOR=cub_caching` automatically on ROCm hosts (CTranslate2 4.8.4 makes it the HIP default); an explicit user-set value is respected. - STT backend: `[whisper] backend = "faster-whisper"`, `device = "auto"`, `compute_type = "int8"`. `auto` resolves to the discrete R9700 (verified by VRAM delta) while the integrated GPU stays idle, and auto language detection works on ROCm once the allocator is configured. The vendored `pywhispercpp` wheel is CUDA-only and cannot import on this host. - **Do not set `HIP_VISIBLE_DEVICES=0` for CTranslate2** — it selects the wrong device and transcribes garbage. Leave both devices visible; `device = "auto"` picks the R9700. - `TMPDIR=$HOME/.tmp` for pip/uv: `/tmp` is a 1 GiB tmpfs and large wheels fail with ENOSPC. ## Process architecture (don't break this) Three processes wired by UNIX sockets under `$XDG_RUNTIME_DIR` (fallback `/tmp`): - `stt-daemon` (`cli.py`) — capture → pre-roll/VAD → segment → transcribe → stdout + JSONL. **Publish-only** on `stt-daemon.sock`: it never accepts inbound messages; lifecycle is signals only. - `stt-assistant` (`assistant/runner.py`) — subscribes to that bus, owns all state (modes, command buffer, chat history, LLM, tools, audit), serves `stt-assistant.sock`. - `stt-tui` / `stt-ctl` — clients of the assistant control socket only. `stt-daemon up [--with-tui]` (`supervisor.py`) launches these as child processes. With `--with-tui`, TUI startup is mandatory (no silent headless fallback: socket timeout or early child exit kills all), and the supervisor restores the tty's `ISIG` on exit so a killed TUI doesn't leave Ctrl+C dead. Stateful logic belongs in the assistant (the speaker embedding lane is the daemon-side exception: it owns the prototype store). Four single-worker-thread lanes exist by design, and none may be parallelized: transcription (faster-whisper isn't thread-safe), the **speaker embedding lane** (in-process in `stt-daemon`: `src/stt_daemon/speakers.py` + `src/stt_daemon/embedder.py`, owns the `SpeakerStore`, drains the rename inbox), all LLM calls (`assistant/inference.py` `InferenceQueue`; commands submit with priority `-1` to preempt chat), and the **Laya triage lane** (`assistant/laya_lane.py`: one background thread plus one loader, running in-process in the assistant). Neither the speaker embedding lane nor the Laya lane is a process or a socket, and neither changes supervisor spawn counts (2 without the TUI, 3 with it); the daemon socket stays publish-only. `stt-daemon` with no subcommand runs the daemon (legacy `--check` / `--list-sources` still work). ## Config - Loaded from `config.toml` in the **current working directory** only (no env var, no XDG lookup); `--config` overrides. A missing file means dataclass defaults. - `config.example.toml` is the shipped template (values only); `docs/CONFIGURATION.md` is the annotated reference. Keep the dataclass, example, and docs in sync when adding a key. - Mapping is **manual** — no pydantic/dynaconf. Adding a key means editing the dataclass *and* `load_config()` in `config.py`. Unknown top-level sections warn and are ignored; unknown keys *inside* a known section are silently ignored (`dict.get` per field). - Env vars: `STT_DAEMON_STATE` (provisioning state; default `audio.json` in the cwd), `XDG_RUNTIME_DIR` (sockets), `HF_HOME` (Whisper cache, honored by faster-whisper/huggingface). - `[triage]` is the display-only signal panel, off by default (`enabled = false`). Its `questions_file` (`questions.json` in the cwd) is private user state and is gitignored, like `config.toml`. `dump_io = true` writes one JSON per triage under `troubleshoot/triage/` (also gitignored); a coalesced brief update writes two files. The brief engine is driven by `[llm].triage_system_prompt` and returns two plain-text sections (`NEW FACTS:` + `BRIEF:`); code owns the append-only `FactLedger` (dedupe/eviction/render, `ledger_max_facts`), so the model never rewrites recorded facts. The optional fail-open `substantive_update` gate is `brief_gate_enabled` (default `true`); `periodic_s` re-runs triage every N seconds while speech accumulates without a settled turn. - `[speaker]` is opt-in per-utterance speaker identification, off by default, backed by the `SpeakerConfig` dataclass (12th known section; 14 keys: `enabled`, `model_path`, `provider`, `threshold_id`, `threshold_new`, `min_enroll_s`, `rms_floor`, `margin`, `mint_cooldown_s`, `confirm_s`, `max_prototypes`, `provisional_ttl_h`, `store_dir`, `join_timeout_s`). Needs `uv sync --extra speaker` plus a model under `models/`. Full reference: `docs/SPEAKERS.md`. - `[brief]` is opt-in durable-brief persistence, backed by the `BriefConfig` dataclass (13th known section; 1 key: `write` (default `false`, gates disk persistence only)). The writer defaults to `brief/brief.md` (cwd-relative, gitignored; `directory`/`filename` are `BriefFile` ctor defaults, not config keys) and `set_directory()` repoints it at the active session folder; with `write = false` the brief stays in the TUI only. Full reference: `docs/TRIAGE.md`. - Never treat the live `config.toml` as a source of defaults; it is gitignored user state. ## Transcription backends - `[whisper].backend` picks `faster-whisper` (default) or `pywhispercpp` through `build_transcriber()` in `transcriber.py`. `device`/`compute_type` apply to faster-whisper only; pywhispercpp ignores them (GPU comes from the built wheel, quantization from the ggml model). - pywhispercpp is a **local, uncommitted CUDA build**: `vendor/.gitignore` is `*`, the cp313-only wheel is not tracked, and `[tool.uv.sources]` points at that path. A fresh clone on Python 3.13 fails `uv sync` until the wheel is rebuilt — see `docs/PYWHISPERCPP.md`. Never delete `vendor/`. ## Testing - `tests/` has 1038 tests, all hardware- and network-free. Preserve that: inject fakes instead of touching real `pactl`, audio devices, an LLM, or `sherpa_onnx` (the speaker lane uses a fake embedder; no ONNX in tests). - Key seams: `Daemon(...)` accepts `capture_factory`, `vad_factory`, `transcriber_factory`, `embedder_factory`, `sink_factory`; `PulseAudio` takes an injectable `runner`; `AssistantService` takes `time_fn`, `executor`, `audit`. - Speaker coverage lives in `tests/test_speakers.py`, `tests/test_daemon_speaker.py`, `tests/test_assistant_speaker.py`, `tests/test_enroll_assist.py` (the offline seed helper `src/stt_daemon/enroll_assist.py`), and `tests/test_evaluate.py` (the `speaker-eval` scorer `src/stt_daemon/evaluate.py`). - No `conftest.py` — per-file fakes are the convention. Tests are the behavioral spec; read them before changing behavior. - The default suite is green without the optional `triage` and `speaker` extras. Install `triage` (`uv sync --extra triage`) only to exercise the Laya measurement test, which otherwise skips; `speaker` (`uv sync --extra speaker`) is needed only for real ONNX embeddings, never the suite. ## Conventions & gotchas - Never commit gitignored runtime output: `config.toml`, `transcripts/`, `audio.json`, `audio/`, `troubleshoot/` (audit/debug/triage), `brief/`, `speakers/` (biometric), `models/`, `.omo/`. - New assistant tools: register in `assistant/tools.py`. Model-supplied arguments are JSON-parsed and schema-validated before running; set `side_effect` and `requires_confirmation` for anything non-read-only. - Whisper models must be multilingual — do not use `.en` variants (defaults auto-detect es/en). - `docs/` holds the full references (config, CLI, audio setup, assistant, speaker identification, output, troubleshooting, development); `.omo/plans/` holds local design plans (gitignored).