This repository has no description
21 kB
458 lines
1{
2 self,
3 config,
4 lib,
5 pkgs,
6 ...
7}:
8let
9 # Pinned Piper voice for the bot's spoken replies. Single-speaker on purpose:
10 # the radio's libritts-high (configs/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 };
74in
75{
76 imports = [
77 self.inputs.hermes-agent.nixosModules.default
78 ];
79
80 # Hermes Agent (Nous Research) — autonomous agent run as a native systemd
81 # gateway, reachable over Matrix (@hermes:lassul.us). Model backend is our
82 # shared llama cluster: one vLLM instance behind an OpenAI-compatible
83 # endpoint, so the model is whatever the cluster currently serves and a swap
84 # is a one-line settings.model.default change.
85 services.hermes-agent = {
86 enable = true;
87
88 # Build the Matrix platform dependency group into the sealed venv (mautrix
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 ];
97
98 # Put the Claude Code CLI on the agent's PATH so its bundled "claude-code"
99 # skill can delegate coding via `terminal(command="claude -p '...'")`.
100 # extraPackages lands in both the systemd service PATH and the hermes
101 # user's profile. Auth is separate (run `claude` once as the hermes user).
102 extraPackages = [
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
112 ];
113
114 settings.model = {
115 # The cluster serves Qwen3.6-27B-FP8 (also aliased as "default" in
116 # /v1/models — both resolve to the same weights); pin the explicit id so
117 # logs and 400s name the actual model. Verified against the live endpoint
118 # that it emits native tool_calls, which Hermes Agent requires.
119 default = "Qwen3.6-27B-FP8";
120 # hermes's built-in "nous" provider is OAuth-only and hardcodes the dead
121 # host inference.nousresearch.com (NXDOMAIN) — true on both 0.17.0 and
122 # main, so we drive the cluster's OpenAI-compatible endpoint instead.
123 #
124 # It MUST be a *named* custom provider ("custom:<name>" + a
125 # custom_providers entry), not bare "custom". Bare custom resolves its
126 # credential from a host-gated candidate list
127 # (hermes_cli/runtime_provider.py:1244) that only forwards
128 # OPENAI_API_KEY when base_url's host is openai.com — a deliberate
129 # anti-credential-leak measure (upstream #28660 / GHSA-76xc-57q6-vm5m).
130 # For any other host it silently substitutes the literal placeholder
131 # "no-key-required" and the endpoint 401s. A named entry instead reads
132 # the key from its declared key_env (runtime_provider.py:692), which is
133 # not host-gated. Named entries also ignore config.yaml's model.api_key,
134 # so a stray `hermes model` run can no longer shadow this with a stale
135 # key (that is exactly how the Nous→cluster switch broke).
136 provider = "custom:llama";
137 base_url = "https://inference.p0.contact/v1";
138 # The custom provider can't auto-detect limits, so unset it leaves
139 # context uncompressed and defaults max_tokens to 65536 — once history
140 # outgrows the window, input + max_tokens exceeds it and the endpoint
141 # 400s ("check the model name and other parameters"). vLLM reports
142 # max_model_len 262144 for this model: pin that as the window so hermes
143 # compresses in time, and keep a sane output cap (covers thinking +
144 # reply) so input + max_tokens always stays under it.
145 context_length = 262144;
146 max_tokens = 16384;
147 };
148
149 # The endpoint hermes actually authenticates against. key_env names the
150 # env var carrying the token; it comes from the hermes-env generator below
151 # via environmentFiles, so the secret never enters the nix store.
152 # api_mode is pinned because auto-detection only runs as a fallback.
153 settings.custom_providers = [
154 {
155 name = "llama";
156 base_url = "https://inference.p0.contact/v1";
157 key_env = "LLAMA_API_TOKEN";
158 api_mode = "chat_completions";
159 }
160 ];
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
209 # In rooms Hermes requires an @mention by default; DMs always respond.
210 settings.matrix.session_scope = "room";
211
212 # Matrix is a Tier-2 platform whose default tool-progress preview caps
213 # commands at 40 chars (so a `terminal` call shows only `curl -X POST -H
214 # "Content-Type: applic...`). "verbose" makes the gateway render the FULL
215 # command — multi-line — as a fenced code block instead of the truncated
216 # one-liner. Trade-off: verbose also prints full JSON args for every other
217 # tool call, so the channel is chattier.
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 '';
265
266 # Non-secret connection + SECURITY gate. Invites are auto-accepted and that
267 # cannot be disabled, so the agent may join any room it's invited to. Joining
268 # is harmless on its own — what matters is who can *trigger* the agent, which
269 # on this host has terminal / filesystem / web tools (chat-driven RCE).
270 # MATRIX_ALLOWED_USERS is the universal trigger gate (applies even in DMs);
271 # only these MXIDs can make the agent act, in any room it's joined.
272 environment = {
273 MATRIX_HOMESERVER = "https://matrix.lassul.us";
274 MATRIX_USER_ID = "@hermes:lassul.us";
275 MATRIX_ALLOWED_USERS = "@lassulus:lassul.us";
276 # Element makes DMs end-to-end encrypted by default, so the bot must do
277 # E2EE or it can't read messages. The `matrix` dep group already bundles
278 # mautrix[encryption] (python-olm); "optional" initializes E2EE when those
279 # deps are present (they are) and keeps the crypto store under
280 # /var/lib/hermes/.hermes/platforms/matrix/store/.
281 MATRIX_E2EE_MODE = "optional";
282 # Optional defense-in-depth (confine to specific rooms; DMs are exempt):
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";
293 };
294
295 # Secrets (MATRIX_ACCESS_TOKEN + LLAMA_API_TOKEN) come from clan vars below,
296 # merged into $HERMES_HOME/.env at activation.
297 environmentFiles = [
298 config.clan.core.vars.generators.hermes-env.files."hermes.env".path
299 ];
300 };
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
338 # Boot-race guard. matrix.lassul.us is IPv6-only; on this desktop
339 # network-online.target can fire before v6 DNS is ready, so the gateway's
340 # first Matrix connect fails ("Name or service not known") and it does not
341 # retry — leaving the bot silent until a manual restart. Block startup until
342 # the homeserver resolves (best-effort, ~60s cap) so reboots come up
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.
359 systemd.services.hermes-agent.serviceConfig.ExecStartPre = [
360 "${pkgs.writeShellScript "hermes-wait-matrix-dns" ''
361 i=0
362 while [ "$i" -lt 30 ]; do
363 ${pkgs.getent}/bin/getent ahosts matrix.lassul.us >/dev/null 2>&1 && exit 0
364 ${pkgs.coreutils}/bin/sleep 2
365 i=$((i + 1))
366 done
367 echo "matrix.lassul.us did not resolve within 60s; starting anyway" >&2
368 exit 0
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 (configs/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"
407 ];
408
409 # Matrix bot access token for @hermes:lassul.us (minted against the neoprism
410 # Synapse). persist=true so the prompt value is stored once.
411 clan.core.vars.generators.hermes-matrix.prompts.matrix-access-token = {
412 description = "Matrix access token for @hermes:lassul.us";
413 type = "hidden";
414 persist = true;
415 };
416
417 # API token for the shared llama cluster, surfaced to hermes as
418 # LLAMA_API_TOKEN (the custom_providers key_env above).
419 # persist=true so the prompt value is stored once.
420 clan.core.vars.generators.hermes-llama.prompts.llama-api-token = {
421 description = "API token for the shared llama cluster (inference.p0.contact)";
422 type = "hidden";
423 persist = true;
424 };
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
441 # Assemble the .env Hermes reads (mirrors the opencrow-env pattern).
442 clan.core.vars.generators.hermes-env = {
443 dependencies = [
444 "hermes-matrix"
445 "hermes-llama"
446 "hermes-api"
447 ];
448 files."hermes.env" = { };
449 runtimeInputs = [ pkgs.coreutils ];
450 script = ''
451 cat > "$out/hermes.env" <<EOF
452 MATRIX_ACCESS_TOKEN=$(cat "$in"/hermes-matrix/matrix-access-token)
453 LLAMA_API_TOKEN=$(cat "$in"/hermes-llama/llama-api-token)
454 API_SERVER_KEY=$(cat "$in"/hermes-api/api_key)
455 EOF
456 '';
457 };
458}