This repository has no description
0

Configure Feed

Select the types of activity you want to include in your feed.

machines/coaxmetal: hermes voice loop, herdr bridge, api server

Voice both ways over Matrix: local faster-whisper (the "voice" dep group)
transcribes Element voice notes, and replies are spoken by a piper command
provider against a pinned en_US-lessac-medium voice. The built-in piper and
edge backends cannot work here, they lazily pip-install into a read-only
sealed venv. stt.prompt biases the jargon: without it "herdr" transcribes
as "Herter".

herdr runs as lass and keeps its socket under lass's HOME, so the agent
reaches the herd through a sudo wrapper plus a SKILL.md that documents it.
That needs NoNewPrivileges off, sudo is setuid.

The OpenAI-compatible API server is the assistant-button lane for the
WakeHermesClaw app, bound to the retiolum address resolved at start.

+327 -2
+36
2configs/hermes-proxy.nix
··· 1 + { 2 + # Public door to the Hermes API server on coaxmetal (machines/coaxmetal/hermes.nix). 3 + # This is what the WakeHermesClaw app on massulus points at when it is not 4 + # on retiolum: the assistant button posts to /v1/chat/completions here. 5 + # 6 + # SECURITY: behind this vhost sits an agent with terminal tools and, through 7 + # the herdr wrapper, code execution as lass. The gateway's only check on 8 + # this lane is the API_SERVER_KEY bearer token — there is no per-user 9 + # allowlist like MATRIX_ALLOWED_USERS on the Matrix lane. So the token is a 10 + # root-equivalent credential for that laptop; rotate it by regenerating the 11 + # hermes-api var, and expect to see internet background noise in the access 12 + # log (unauthenticated requests get 401 from hermes itself). 13 + services.nginx.virtualHosts."hermes.lassul.us" = { 14 + enableACME = true; 15 + forceSSL = true; 16 + locations."/" = { 17 + recommendedProxySettings = true; 18 + proxyPass = "http://coaxmetal.r:8642"; 19 + extraConfig = '' 20 + # Agent turns run for minutes and /v1/chat/completions streams SSE 21 + # chunks the whole time. nginx's default 60s proxy_read_timeout would 22 + # cut a long turn off mid-answer, and its response buffering would 23 + # hold the stream back until the turn ended, so the phone would sit 24 + # silent and then dump everything at once. 25 + proxy_read_timeout 1h; 26 + proxy_send_timeout 1h; 27 + proxy_buffering off; 28 + proxy_cache off; 29 + # coaxmetal is a laptop: asleep or off the mesh most of the day. Fail 30 + # fast on connect so the app reports "unreachable" instead of hanging 31 + # on the assistant button. 32 + proxy_connect_timeout 5s; 33 + ''; 34 + }; 35 + }; 36 + }
+291 -2
machines/coaxmetal/hermes.nix
··· 1 1 { 2 2 self, 3 3 config, 4 + lib, 4 5 pkgs, 5 6 ... 6 7 }: 8 + let 9 + # Pinned Piper voice for the bot's spoken replies. Single-speaker on purpose: 10 + # the radio's libritts-high (2configs/services/radio/tts.nix) is multi-speaker 11 + # and needs an -s speaker id per call. 12 + piperVoice = pkgs.runCommand "piper-voice-en_US-lessac-medium" { } '' 13 + mkdir -p $out 14 + ln -s ${ 15 + pkgs.fetchurl { 16 + url = "https://huggingface.co/rhasspy/piper-voices/resolve/v1.0.0/en/en_US/lessac/medium/en_US-lessac-medium.onnx?download=true"; 17 + hash = "sha256-Xv4J5pkCGHgnr2RuGm6dJp3udp+Yd9F7FrG0buqvAZ8="; 18 + } 19 + } $out/model.onnx 20 + ln -s ${ 21 + pkgs.fetchurl { 22 + url = "https://huggingface.co/rhasspy/piper-voices/resolve/v1.0.0/en/en_US/lessac/medium/en_US-lessac-medium.onnx.json?download=true"; 23 + hash = "sha256-7+GcQXvtBV8taZCCSMa6ZQ+hNbyGiw5quz2hgdq2kKA="; 24 + } 25 + } $out/model.onnx.json 26 + ''; 27 + 28 + # TTS backend for the gateway, wired as a `type: command` provider below. 29 + # 30 + # Hermes's built-in `tts.provider = "piper"` cannot work on this deployment: 31 + # it imports the `piper` Python module, which is in neither the sealed uv2nix 32 + # venv nor tools/lazy_deps.py's install allowlist, and the venv is a 33 + # read-only store path — so its lazy `pip install` has nowhere to land. Same 34 + # for the upstream default "edge" (lazy edge-tts). A command provider calls 35 + # nixpkgs' piper instead: no runtime pip, no runtime voice download. 36 + # 37 + # Contract: hermes writes the reply text to $1 and expects audio at $2. 38 + hermes-tts = pkgs.writeShellApplication { 39 + name = "hermes-tts"; 40 + runtimeInputs = [ pkgs.piper-tts ]; 41 + text = '' 42 + piper --model ${piperVoice}/model.onnx --output-file "$2" < "$1" 43 + ''; 44 + }; 45 + 46 + # herdr keeps its API socket under the session owner's 47 + # $HOME/.config/herdr/herdr.sock (verified on this host: the running server 48 + # has HOME=/home/lass and listens on /home/lass/.config/herdr/herdr.sock), 49 + # and the herd runs as lass — so the hermes user cannot see any pane without 50 + # crossing the user boundary. sudo's env_reset leaves HOME pointing at the 51 + # *calling* user, which made the CLI look for a socket under 52 + # /var/lib/hermes/.config and report "no herdr server is running", so set it 53 + # here. Keeping the env setup inside a store script also means sudoers 54 + # matches one fixed path instead of an argv template. 55 + herdr-as-lass = pkgs.writeShellApplication { 56 + name = "herdr-as-lass"; 57 + text = '' 58 + export HOME=${config.users.users.mainUser.home} 59 + exec ${self.inputs.llm-agents.packages.${pkgs.system}.herdr}/bin/herdr "$@" 60 + ''; 61 + }; 62 + 63 + # What the agent actually calls: `herdr agent list`, `herdr agent read <id>`, 64 + # `herdr agent prompt <id> '...'`. Named plainly because the gateway's PATH 65 + # is hermes + bash + coreutils + git + extraPackages — /run/current-system/sw 66 + # is absent, so there is no real `herdr` to shadow. 67 + herdr = pkgs.writeShellApplication { 68 + name = "herdr"; 69 + text = '' 70 + exec /run/wrappers/bin/sudo -u ${config.users.users.mainUser.name} \ 71 + ${herdr-as-lass}/bin/herdr-as-lass "$@" 72 + ''; 73 + }; 74 + in 7 75 { 8 76 imports = [ 9 77 self.inputs.hermes-agent.nixosModules.default ··· 18 86 enable = true; 19 87 20 88 # Build the Matrix platform dependency group into the sealed venv (mautrix 21 - # SDK + liboqs for optional E2EE; Linux-only). 22 - extraDependencyGroups = [ "matrix" ]; 89 + # SDK + liboqs for optional E2EE; Linux-only). "voice" adds faster-whisper, 90 + # which is what `stt.provider = "local"` below runs — that group exists 91 + # precisely because local STT pulls wheel-only transitive deps 92 + # (ctranslate2, onnxruntime) that uv2nix has to resolve at build time. 93 + extraDependencyGroups = [ 94 + "matrix" 95 + "voice" 96 + ]; 23 97 24 98 # Put the Claude Code CLI on the agent's PATH so its bundled "claude-code" 25 99 # skill can delegate coding via `terminal(command="claude -p '...'")`. ··· 27 101 # user's profile. Auth is separate (run `claude` once as the hermes user). 28 102 extraPackages = [ 29 103 self.legacyPackages.${pkgs.system}.llm.claude-code 104 + # Voice both ways: the Matrix adapter shells out to ffmpeg to transcode 105 + # piper's WAV to Ogg/Opus (native MSC3245 voice bubbles in Element) and 106 + # to ffprobe for the duration + waveform metadata those bubbles carry. 107 + pkgs.ffmpeg 108 + hermes-tts 109 + # Drive the herd from a voice note: "read me what the agent on herdr is 110 + # stuck on" -> herdr agent list / read / prompt, as lass. 111 + herdr 30 112 ]; 31 113 32 114 settings.model = { ··· 77 159 } 78 160 ]; 79 161 162 + # Voice in. Element voice notes arrive flagged MSC3245; the Matrix adapter 163 + # recognizes that flag and routes the audio to STT before the agent sees 164 + # the turn, so a held-down mic button is just another prompt. 165 + # 166 + # "local" = faster-whisper in-process (from the "voice" dep group). An 167 + # explicit provider is honored strictly: if it cannot run, transcription 168 + # errors out loud instead of silently falling back to a cloud key. 169 + settings.stt = { 170 + provider = "local"; 171 + # "" = auto-detect per clip, instead of upstream's "en" hint — commands 172 + # here switch between English and German mid-conversation. 173 + language = ""; 174 + # Vocabulary bias (faster-whisper initial_prompt). Measured on this host: 175 + # "the agent on herdr is blocked" transcribes as "the agent on Herter" 176 + # without it and verbatim with it. Every name here is one the agent has 177 + # to route on — a misheard host or tool name is a failed command, not a 178 + # typo. 179 + prompt = "Jargon: herdr, omp, nix, clan, retiolum, coaxmetal, neoprism, ignavia, lassulus."; 180 + local = { 181 + # small is the first size whose transcripts survive our jargon; a 4s 182 + # clip takes ~3s warm on this CPU (int8), ~6s from cold weights. 183 + model = "small"; 184 + # Release the weights after five idle minutes; this is a laptop, and 185 + # the next voice note pays the reload instead of holding the RAM. 186 + unload_after_idle_seconds = 300; 187 + }; 188 + }; 189 + 190 + # Voice out. `voice_compatible` is the opt-in that makes the gateway treat 191 + # this provider's output as a voice message (Opus-transcoding it on the 192 + # way) rather than a file attachment, so replies land as playable bubbles. 193 + settings.tts = { 194 + provider = "piper-local"; 195 + providers.piper-local = { 196 + type = "command"; 197 + command = "${hermes-tts}/bin/hermes-tts {input_path} {output_path}"; 198 + output_format = "wav"; 199 + voice_compatible = true; 200 + }; 201 + }; 202 + 203 + # Speak every gateway reply, not just ones requested with /voice tts. The 204 + # toggle also takes a lease on the engine, so the first spoken reply after 205 + # startup does not pay model load as silence. Turn it off per chat with 206 + # /voice off. 207 + settings.voice.auto_tts = true; 208 + 80 209 # In rooms Hermes requires an @mention by default; DMs always respond. 81 210 settings.matrix.session_scope = "room"; 82 211 ··· 87 216 # one-liner. Trade-off: verbose also prints full JSON args for every other 88 217 # tool call, so the channel is chattier. 89 218 settings.display.platforms.matrix.tool_progress = "verbose"; 219 + 220 + # Teach the agent the herd. Without this the `herdr` wrapper is just an 221 + # unadvertised binary on PATH: nothing in the model's context would make it 222 + # reach for the herd when a voice note says "what is the agent stuck on". 223 + # Skills are loaded from $HERMES_HOME/skills/<name>/SKILL.md. 224 + hermesHomeFiles."skills/herdr/SKILL.md" = '' 225 + --- 226 + name: herdr 227 + description: Inspect and steer the coding agents running in lass's herdr session on this machine. 228 + version: 1.0.0 229 + license: MIT 230 + platforms: [linux] 231 + metadata: 232 + hermes: 233 + tags: [herdr, agents, terminal, coding, inspect] 234 + --- 235 + 236 + # herdr 237 + 238 + `herdr` on this host is a wrapper that talks to lass's running herdr 239 + server (the agent multiplexer holding the coding-agent panes). Every 240 + invocation prints JSON. 241 + 242 + Use it whenever the user asks about "the agents", "the herd", "the 243 + session on herdr", what something is "stuck on", or wants a prompt or 244 + answer delivered to a running agent. 245 + 246 + | Command | Use | 247 + | --- | --- | 248 + | `herdr agent list` | Every agent with its state: working, blocked, idle, done. Start here. | 249 + | `herdr agent get <agent>` | One agent's detail (workspace, tab, pane, cwd). | 250 + | `herdr agent read <agent>` | Recent terminal output. This is how you answer "what is it doing/asking". | 251 + | `herdr agent prompt <agent> '<text>'` | Submit a prompt to that agent, e.g. to unblock an approval. | 252 + | `herdr agent wait <agent> --state idle` | Block until it reaches a state. | 253 + | `herdr workspace list` / `herdr tab list` / `herdr pane list` | Structure, when agent-level views are not enough. | 254 + | `herdr --skill` | herdr's own, fuller usage reference — read it before improvising flags. | 255 + 256 + Answering by voice: the reply is spoken, so summarize what `agent read` 257 + shows in a sentence or two instead of reciting scrollback, and say the 258 + agent's name and state first. Read a diff or a stack trace aloud only if 259 + asked. 260 + 261 + Blocked agents are the interesting case: report what the agent is waiting 262 + for, and only run `herdr agent prompt` when the user actually decides. 263 + Never close panes or kill agents on your own. 264 + ''; 90 265 91 266 # Non-secret connection + SECURITY gate. Invites are auto-accepted and that 92 267 # cannot be disabled, so the agent may join any room it's invited to. Joining ··· 106 281 MATRIX_E2EE_MODE = "optional"; 107 282 # Optional defense-in-depth (confine to specific rooms; DMs are exempt): 108 283 # MATRIX_ALLOWED_ROOMS = "!yourRoomId:lassul.us"; 284 + 285 + # The OpenAI-compatible API server: the assistant-button lane, reached 286 + # both directly from massulus.r and through the public 287 + # hermes.lassul.us proxy on neoprism. API_SERVER_KEY comes from clan 288 + # vars via environmentFiles; API_SERVER_HOST is written at start by the 289 + # ExecStartPre below (the retiolum address). Unlike Matrix there is no 290 + # per-user allowlist here — the bearer token is the whole gate. 291 + API_SERVER_ENABLED = "true"; 292 + API_SERVER_PORT = "8642"; 109 293 }; 110 294 111 295 # Secrets (MATRIX_ACCESS_TOKEN + LLAMA_API_TOKEN) come from clan vars below, ··· 115 299 ]; 116 300 }; 117 301 302 + # The herd/hermes user boundary. herdr keeps its API socket in the session 303 + # owner's runtime dir, so reaching lass's panes means running as lass. 304 + # 305 + # Be clear about what this grants: `herdr pane run` / `agent prompt` execute 306 + # commands inside lass's panes, so this is lass-level code execution for 307 + # anything that can drive the agent — it does not narrow the existing threat 308 + # model (the gateway already has terminal tools as hermes), but it widens the 309 + # blast radius from the hermes user to lass. The gate stays 310 + # MATRIX_ALLOWED_USERS above. Restricting the command to one store path only 311 + # buys auditability, not containment. 312 + security.sudo.extraRules = [ 313 + { 314 + users = [ config.services.hermes-agent.user ]; 315 + runAs = config.users.users.mainUser.name; 316 + commands = [ 317 + { 318 + command = "${herdr-as-lass}/bin/herdr-as-lass"; 319 + options = [ 320 + "NOPASSWD" 321 + "SETENV" 322 + ]; 323 + } 324 + ]; 325 + } 326 + ]; 327 + 328 + # sudo is setuid, and the upstream unit sets NoNewPrivileges=yes, which makes 329 + # every setuid exec fail — so the wrapper above returned "sudo: The "no new 330 + # privileges" flag is set, which prevents sudo from running as root" and the 331 + # agent reported the herd as unreachable. Turning it off for this unit is the 332 + # price of the bridge; it only re-enables what the sudoers rule already 333 + # allows (one store path, runas lass), since sudo itself is the only setuid 334 + # binary the agent is pointed at. 335 + # The module hardens the unit with NoNewPrivileges = true, hence mkForce. 336 + systemd.services.hermes-agent.serviceConfig.NoNewPrivileges = lib.mkForce false; 337 + 118 338 # Boot-race guard. matrix.lassul.us is IPv6-only; on this desktop 119 339 # network-online.target can fire before v6 DNS is ready, so the gateway's 120 340 # first Matrix connect fails ("Name or service not known") and it does not 121 341 # retry — leaving the bot silent until a manual restart. Block startup until 122 342 # the homeserver resolves (best-effort, ~60s cap) so reboots come up 123 343 # connected. 344 + # 345 + # The second entry resolves the API server's bind address (see the 346 + # API_SERVER_* environment above). Retiolum, because both consumers live 347 + # there: massulus.r talks to it directly, and neoprism reverse-proxies it 348 + # to hermes.lassul.us. This host joins untrusted networks, so binding the 349 + # mesh address rather than "::" keeps the listener off café wifi even 350 + # before the packet filter sees it; the public door is nginx on neoprism, 351 + # which we control. 352 + # 353 + # It is resolved at start rather than hardcoded because the address derives 354 + # from the retiolum key in clan vars, so a regenerated key would otherwise 355 + # leave the unit bound to a stale literal. It waits because the interface 356 + # gets its address after network-online, and aiohttp cannot bind an address 357 + # that does not exist yet. "+" runs it as root: /run is not writable by 358 + # hermes. 124 359 systemd.services.hermes-agent.serviceConfig.ExecStartPre = [ 125 360 "${pkgs.writeShellScript "hermes-wait-matrix-dns" '' 126 361 i=0 ··· 132 367 echo "matrix.lassul.us did not resolve within 60s; starting anyway" >&2 133 368 exit 0 134 369 ''}" 370 + "+${pkgs.writeShellScript "hermes-api-bind-retiolum" '' 371 + set -eu 372 + i=0 373 + while [ "$i" -lt 30 ]; do 374 + addr=$(${pkgs.iproute2}/bin/ip -6 -o addr show dev retiolum scope global \ 375 + | ${pkgs.gawk}/bin/awk '{print $4}' | ${pkgs.coreutils}/bin/cut -d/ -f1 \ 376 + | ${pkgs.coreutils}/bin/head -n1) 377 + if [ -n "$addr" ]; then 378 + ${pkgs.coreutils}/bin/mkdir -p /run/hermes-api 379 + echo "API_SERVER_HOST=$addr" > /run/hermes-api/host.env 380 + ${pkgs.coreutils}/bin/chmod 0444 /run/hermes-api/host.env 381 + exit 0 382 + fi 383 + ${pkgs.coreutils}/bin/sleep 2 384 + i=$((i + 1)) 385 + done 386 + # No retiolum address: leave the file absent so the API server falls 387 + # back to its 127.0.0.1 default rather than silently listening on every 388 + # interface. The Matrix lane keeps working either way. 389 + ${pkgs.coreutils}/bin/rm -f /run/hermes-api/host.env 390 + echo "retiolum has no global address after 60s; API server stays on localhost" >&2 391 + exit 0 392 + ''}" 393 + ]; 394 + 395 + # The packet filter drops INPUT by default here (2configs/default.nix sets 396 + # filter.INPUT.policy = "DROP"), so the API server's port has to be opened 397 + # for the interface it binds. Without this neoprism's proxy_pass times out 398 + # and hermes.lassul.us answers 504, while a local curl to the same 399 + # address:port returns 200 — traffic to the host's own address goes through 400 + # lo, which is accepted. 401 + networking.firewall.interfaces.retiolum.allowedTCPPorts = [ 8642 ]; 402 + 403 + # "-" because the file is absent until the ExecStartPre above finds an 404 + # address, and a missing bind override must not fail the unit. 405 + systemd.services.hermes-agent.serviceConfig.EnvironmentFile = [ 406 + "-/run/hermes-api/host.env" 135 407 ]; 136 408 137 409 # Matrix bot access token for @hermes:lassul.us (minted against the neoprism ··· 151 423 persist = true; 152 424 }; 153 425 426 + # Bearer token for the API server. Generated, not prompted, and kept in a 427 + # separate file as well so it can be read back out for the phone: 428 + # clan vars get coaxmetal hermes-api/api_key 429 + # (same shape as the covibe api_key generator). 430 + clan.core.vars.generators.hermes-api = { 431 + files."api_key" = { }; 432 + runtimeInputs = [ 433 + pkgs.coreutils 434 + pkgs.openssl 435 + ]; 436 + script = '' 437 + openssl rand -hex 32 | tr -d '\n' > "$out/api_key" 438 + ''; 439 + }; 440 + 154 441 # Assemble the .env Hermes reads (mirrors the opencrow-env pattern). 155 442 clan.core.vars.generators.hermes-env = { 156 443 dependencies = [ 157 444 "hermes-matrix" 158 445 "hermes-llama" 446 + "hermes-api" 159 447 ]; 160 448 files."hermes.env" = { }; 161 449 runtimeInputs = [ pkgs.coreutils ]; ··· 163 451 cat > "$out/hermes.env" <<EOF 164 452 MATRIX_ACCESS_TOKEN=$(cat "$in"/hermes-matrix/matrix-access-token) 165 453 LLAMA_API_TOKEN=$(cat "$in"/hermes-llama/llama-api-token) 454 + API_SERVER_KEY=$(cat "$in"/hermes-api/api_key) 166 455 EOF 167 456 ''; 168 457 };