This repository has no description
0

Configure Feed

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

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-amy-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/amy/medium/en_US-amy-medium.onnx?download=true"; 17 hash = "sha256-s6bke1e4x/vmoM4lGBYaUPWanN2KUINcAssCvdYgbBg="; 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/amy/medium/en_US-amy-medium.onnx.json?download=true"; 23 hash = "sha256-laI+tNQpCdON9zu5rH9F9Zfb/N4tG/lSb96vVGaXfXc="; 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 ../../configs/hermes-mail-vars.nix 79 ]; 80 81 # Hermes Agent (Nous Research) — autonomous agent run as a native systemd 82 # gateway, reachable over Matrix (@hermes:lassul.us). Model backend is our 83 # shared llama cluster: one vLLM instance behind an OpenAI-compatible 84 # endpoint, so the model is whatever the cluster currently serves and a swap 85 # is a one-line settings.model.default change. 86 services.hermes-agent = { 87 enable = true; 88 89 # Build the Matrix platform dependency group into the sealed venv (mautrix 90 # SDK + liboqs for optional E2EE; Linux-only). "voice" adds faster-whisper, 91 # which is what `stt.provider = "local"` below runs — that group exists 92 # precisely because local STT pulls wheel-only transitive deps 93 # (ctranslate2, onnxruntime) that uv2nix has to resolve at build time. 94 # "anthropic" is the SDK the anthropic model provider imports; the venv is 95 # sealed (lazy installs disabled), so without it every turn fails with 96 # "The 'anthropic' package is required for the Anthropic provider". 97 extraDependencyGroups = [ 98 "matrix" 99 "voice" 100 "anthropic" 101 ]; 102 103 # Put the Claude Code CLI on the agent's PATH so its bundled "claude-code" 104 # skill can delegate coding via `terminal(command="claude -p '...'")`. 105 # extraPackages lands in both the systemd service PATH and the hermes 106 # user's profile. Auth is separate (run `claude` once as the hermes user). 107 extraPackages = [ 108 self.legacyPackages.${pkgs.system}.llm.claude-code 109 # Voice both ways: the Matrix adapter shells out to ffmpeg to transcode 110 # piper's WAV to Ogg/Opus (native MSC3245 voice bubbles in Element) and 111 # to ffprobe for the duration + waveform metadata those bubbles carry. 112 pkgs.ffmpeg 113 hermes-tts 114 # Drive the herd from a voice note: "read me what the agent on herdr is 115 # stuck on" -> herdr agent list / read / prompt, as lass. 116 herdr 117 # CalDAV (calendar skill below) is plain HTTP; curl is not otherwise on 118 # the gateway PATH. 119 pkgs.curl 120 ]; 121 122 settings.model = { 123 # Claude Opus 5.5 on lass's Claude subscription: the anthropic provider 124 # takes the `claude setup-token` OAuth token (sk-ant-oat…) from 125 # ANTHROPIC_TOKEN (hermes-anthropic generator below) and sends it as a 126 # Bearer token. Anthropic bills third-party apps on subscription tokens 127 # as extra usage; hermes renames some tools on the OAuth wire to avoid 128 # that classification (agent/anthropic_adapter.py), which goes against 129 # Anthropic's usage policies and risks the account. Chosen knowingly. 130 # 131 # Context (1M) and output (128k) limits come from hermes's own model 132 # tables, so nothing is pinned here. 133 provider = "anthropic"; 134 default = "claude-opus-5-5"; 135 }; 136 137 # The shared llama cluster stays configured as a named provider, so 138 # `/model Qwen3.6-27B-FP8 --provider custom:llama` switches back. Its key 139 # comes from hermes-env (key_env), never the nix store. It MUST be a 140 # *named* custom provider ("custom:<name>" + this entry), not bare 141 # "custom": bare custom resolves its credential from a host-gated list 142 # (hermes_cli/runtime_provider.py:1244) that only forwards OPENAI_API_KEY 143 # to openai.com and otherwise sends "no-key-required" (upstream #28660 / 144 # GHSA-76xc-57q6-vm5m). api_mode is pinned because auto-detection only 145 # runs as a fallback. 146 settings.custom_providers = [ 147 { 148 name = "llama"; 149 base_url = "https://inference.p0.contact/v1"; 150 key_env = "LLAMA_API_TOKEN"; 151 api_mode = "chat_completions"; 152 } 153 ]; 154 155 # Voice in. Element voice notes arrive flagged MSC3245; the Matrix adapter 156 # recognizes that flag and routes the audio to STT before the agent sees 157 # the turn, so a held-down mic button is just another prompt. 158 # 159 # "local" = faster-whisper in-process (from the "voice" dep group). An 160 # explicit provider is honored strictly: if it cannot run, transcription 161 # errors out loud instead of silently falling back to a cloud key. 162 settings.stt = { 163 provider = "local"; 164 # "" = auto-detect per clip, instead of upstream's "en" hint — commands 165 # here switch between English and German mid-conversation. 166 language = ""; 167 # Vocabulary bias (faster-whisper initial_prompt). Measured on this host: 168 # "the agent on herdr is blocked" transcribes as "the agent on Herter" 169 # without it and verbatim with it. Every name here is one the agent has 170 # to route on — a misheard host or tool name is a failed command, not a 171 # typo. 172 prompt = "Jargon: herdr, omp, nix, clan, retiolum, coaxmetal, neoprism, ignavia, lassulus."; 173 local = { 174 # small is the first size whose transcripts survive our jargon; a 4s 175 # clip takes ~3s warm on this CPU (int8), ~6s from cold weights. 176 model = "small"; 177 # Release the weights after five idle minutes; this is a laptop, and 178 # the next voice note pays the reload instead of holding the RAM. 179 unload_after_idle_seconds = 300; 180 }; 181 }; 182 183 # Voice out. `voice_compatible` is the opt-in that makes the gateway treat 184 # this provider's output as a voice message (Opus-transcoding it on the 185 # way) rather than a file attachment, so replies land as playable bubbles. 186 settings.tts = { 187 provider = "piper-local"; 188 providers.piper-local = { 189 type = "command"; 190 command = "${hermes-tts}/bin/hermes-tts {input_path} {output_path}"; 191 output_format = "wav"; 192 voice_compatible = true; 193 }; 194 }; 195 196 # Text replies only: live voice moved to hermes-voice (voice.lassul.us, 197 # hermes-voice.nix). Voice notes still reach the agent through STT, and 198 # `/voice tts` in a chat still turns spoken replies on there. 199 settings.voice.auto_tts = false; 200 201 # In rooms Hermes requires an @mention by default; DMs always respond. 202 settings.matrix.session_scope = "room"; 203 204 # Matrix is a Tier-2 platform whose default tool-progress preview caps 205 # commands at 40 chars (so a `terminal` call shows only `curl -X POST -H 206 # "Content-Type: applic...`). "verbose" makes the gateway render the FULL 207 # command — multi-line — as a fenced code block instead of the truncated 208 # one-liner. Trade-off: verbose also prints full JSON args for every other 209 # tool call, so the channel is chattier. 210 settings.display.platforms.matrix.tool_progress = "verbose"; 211 212 # The EMAIL_* credentials below are for mail-sending skills only. Hermes 213 # turns the email gateway on by itself once all four of EMAIL_ADDRESS, 214 # EMAIL_PASSWORD, EMAIL_IMAP_HOST and EMAIL_SMTP_HOST are set 215 # (gateway/config_env.py); only an explicit `enabled: false` stops that. 216 settings.platforms.email.enabled = false; 217 218 # Teach the agent the herd. Without this the `herdr` wrapper is just an 219 # unadvertised binary on PATH: nothing in the model's context would make it 220 # reach for the herd when a voice note says "what is the agent stuck on". 221 # Skills are loaded from $HERMES_HOME/skills/<name>/SKILL.md. 222 hermesHomeFiles."skills/herdr/SKILL.md" = '' 223 --- 224 name: herdr 225 description: Inspect and steer the coding agents running in lass's herdr session on this machine. 226 version: 1.0.0 227 license: MIT 228 platforms: [linux] 229 metadata: 230 hermes: 231 tags: [herdr, agents, terminal, coding, inspect] 232 --- 233 234 # herdr 235 236 `herdr` on this host is a wrapper that talks to lass's running herdr 237 server (the agent multiplexer holding the coding-agent panes). Every 238 invocation prints JSON. 239 240 Use it whenever the user asks about "the agents", "the herd", "the 241 session on herdr", what something is "stuck on", or wants a prompt or 242 answer delivered to a running agent. 243 244 | Command | Use | 245 | --- | --- | 246 | `herdr agent list` | Every agent with its state: working, blocked, idle, done. Start here. | 247 | `herdr agent get <agent>` | One agent's detail (workspace, tab, pane, cwd). | 248 | `herdr agent read <agent>` | Recent terminal output. This is how you answer "what is it doing/asking". | 249 | `herdr agent prompt <agent> '<text>'` | Submit a prompt to that agent, e.g. to unblock an approval. | 250 | `herdr agent wait <agent> --state idle` | Block until it reaches a state. | 251 | `herdr workspace list` / `herdr tab list` / `herdr pane list` | Structure, when agent-level views are not enough. | 252 | `herdr --skill` | herdr's own, fuller usage reference — read it before improvising flags. | 253 254 Answering by voice: the reply is spoken, so summarize what `agent read` 255 shows in a sentence or two instead of reciting scrollback, and say the 256 agent's name and state first. Read a diff or a stack trace aloud only if 257 asked. 258 259 Blocked agents are the interesting case: report what the agent is waiting 260 for, and only run `herdr agent prompt` when the user actually decides. 261 Never close panes or kill agents on your own. 262 ''; 263 264 # The one shared calendar (configs/radicale.nix): lass's own events, the 265 # book.lassul.us requests and bookings, and anything Hermes adds. Hermes 266 # authenticates as its own radicale user, which may only touch this 267 # collection. Booking events are read back by book, so deleting, moving 268 # or confirming them in CalDAV manages the booking (tools/book). 269 hermesHomeFiles."skills/calendar/SKILL.md" = '' 270 --- 271 name: calendar 272 description: Read and manage lass's calendar (CalDAV) and approve, decline or cancel book.lassul.us booking requests. 273 version: 1.0.0 274 license: MIT 275 platforms: [linux] 276 required_environment_variables: 277 - name: CALDAV_PASSWORD 278 prompt: Radicale password of the hermes user 279 help: Provisioned by clan vars (radicale-hermes) on coaxmetal. 280 required_for: any calendar access 281 prerequisites: 282 commands: [curl] 283 metadata: 284 hermes: 285 tags: [calendar, caldav, schedule, booking, appointments] 286 requires_toolsets: [terminal] 287 --- 288 289 # calendar 290 291 lass has exactly one calendar, at `$CALDAV_CALENDAR_URL` 292 (CalDAV on radicale). It holds his own events, booking requests and 293 bookings from https://book.lassul.us, and events you add. The booking 294 page treats every event in it as busy time. 295 296 Use it when the user asks what is on his schedule, whether he is free, 297 to add, move or remove an appointment, or about booking requests. 298 299 Authenticate every request with 300 `curl -sS -u "$CALDAV_USER:$CALDAV_PASSWORD"`. Never print, echo or 301 repeat the password. Times are Europe/Berlin unless the user says 302 otherwise; write DTSTART/DTEND with `;TZID=Europe/Berlin` or in UTC (`Z`). 303 304 ## Reading 305 306 Events overlapping a range (UTC, `YYYYMMDDTHHMMSSZ`): 307 308 ```sh 309 curl -sS -u "$CALDAV_USER:$CALDAV_PASSWORD" -X REPORT -H 'Depth: 1' \ 310 -H 'Content-Type: application/xml; charset=utf-8' "$CALDAV_CALENDAR_URL" --data ' 311 <C:calendar-query xmlns:D="DAV:" xmlns:C="urn:ietf:params:xml:ns:caldav"> 312 <D:prop><D:getetag/><C:calendar-data/></D:prop> 313 <C:filter><C:comp-filter name="VCALENDAR"><C:comp-filter name="VEVENT"> 314 <C:time-range start="20261010T000000Z" end="20261017T000000Z"/> 315 </C:comp-filter></C:comp-filter></C:filter> 316 </C:calendar-query>' 317 ``` 318 319 Each `<D:response>` has the event's `href` (its URL path), `getetag` and 320 the iCalendar text. Recurring events (RRULE) come back once with their 321 rule; expand them yourself when answering "what is on Tuesday". 322 323 ## Adding, changing, removing your own events 324 325 - Add: pick a UID (`uuidgen`-style, suffix `@hermes`), then 326 `PUT "$CALDAV_CALENDAR_URL<uid>.ics"` with 327 `-H 'Content-Type: text/calendar; charset=utf-8' -H 'If-None-Match: *'` 328 and a body of one `VCALENDAR` (VERSION:2.0, PRODID) holding one 329 `VEVENT` with UID, DTSTAMP, DTSTART, DTEND (or DURATION) and SUMMARY. 330 Add a `VALARM` (ACTION:DISPLAY, TRIGGER:-PT15M) when the user wants a 331 reminder. 332 - Change: GET the event, edit it, PUT it back to the same href with 333 `-H 'If-Match: <etag>'` so you never overwrite a concurrent edit. 334 - Remove: `DELETE` the href with `-H 'If-Match: <etag>'`. 335 - Success is HTTP 201/204. 412 means it changed meanwhile: re-read and 336 retry. Confirm by reading the range again. 337 338 Before adding, check the range for overlaps and tell the user about 339 any. Do not change or delete events the user did not ask about. 340 341 ## Booking requests and bookings (book.lassul.us) 342 343 Events whose UID ends in `@book.lassul.us` come from the booking page. 344 It reads them back every minute (for bookings that have not started 345 yet) and mails the guest about what changed, so editing them is how 346 bookings are managed: 347 348 - Pending request: `STATUS:TENTATIVE`, SUMMARY starts with `? `. 349 - Booked: `STATUS:CONFIRMED`. 350 - The DESCRIPTION holds the guest (`Name <email>`), their note, and as 351 its last line the admin link `https://book.lassul.us/a/<token>`. 352 353 | Calendar edit | Effect, guest mail | 354 | --- | --- | 355 | DELETE a booked event (or set `STATUS:CANCELLED`) | cancelled, guest gets a cancellation | 356 | DELETE a pending request | declined, guest gets a decline | 357 | Change DTSTART/DTEND | rescheduled; a booked guest gets the updated invitation | 358 | Set a pending request to `STATUS:CONFIRMED` | approved, guest gets the invitation | 359 360 Use If-Match as for your own events. The booking page rewrites SUMMARY 361 and DESCRIPTION itself; edits to them are not kept. Act on a booking 362 only when the user decided to. 363 364 To send the guest a personal message with the decision, use the admin 365 link instead (an optional `message` is mailed along): 366 367 ```sh 368 curl -sS -o /dev/null -w '%{http_code}\n' -X POST \ 369 --data-urlencode 'message=Looking forward to it' \ 370 'https://book.lassul.us/a/<token>/approve' # or /decline, /cancel 371 ``` 372 373 A 303 means it worked. `GET` on the admin link shows the request's 374 current state. Treat admin links as secrets: never paste one into a 375 chat unless the user asks for it. 376 ''; 377 378 # Non-secret connection + SECURITY gate. Invites are auto-accepted and that 379 # cannot be disabled, so the agent may join any room it's invited to. Joining 380 # is harmless on its own — what matters is who can *trigger* the agent, which 381 # on this host has terminal / filesystem / web tools (chat-driven RCE). 382 # MATRIX_ALLOWED_USERS is the universal trigger gate (applies even in DMs); 383 # only these MXIDs can make the agent act, in any room it's joined. 384 environment = { 385 MATRIX_HOMESERVER = "https://matrix.lassul.us"; 386 MATRIX_USER_ID = "@hermes:lassul.us"; 387 MATRIX_ALLOWED_USERS = "@lassulus:lassul.us"; 388 # The DM with lassulus: default target for deliver=matrix (cron jobs, and 389 # sessions without a Matrix origin such as the voice agent on the API 390 # server). Without it cron delivery fails with "no delivery target 391 # resolved for deliver=matrix". 392 MATRIX_HOME_ROOM = "!QSmQTYGdeExThpCqNh:lassul.us"; 393 394 # The shared calendar (calendar skill above); CALDAV_PASSWORD comes from 395 # hermes-env. 396 CALDAV_USER = "hermes"; 397 CALDAV_CALENDAR_URL = "https://cal.lassul.us/lass/calendar/"; 398 399 # Element makes DMs end-to-end encrypted by default, so the bot must do 400 # E2EE or it can't read messages. The `matrix` dep group already bundles 401 # mautrix[encryption] (python-olm); "optional" initializes E2EE when those 402 # deps are present (they are) and keeps the crypto store under 403 # /var/lib/hermes/.hermes/platforms/matrix/store/. 404 MATRIX_E2EE_MODE = "optional"; 405 # Optional defense-in-depth (confine to specific rooms; DMs are exempt): 406 # MATRIX_ALLOWED_ROOMS = "!yourRoomId:lassul.us"; 407 408 # The OpenAI-compatible API server: the assistant-button lane, reached 409 # both directly from massulus.r and through the public 410 # hermes.lassul.us proxy on neoprism. API_SERVER_KEY comes from clan 411 # vars via environmentFiles; API_SERVER_HOST is written at start by the 412 # ExecStartPre below (the retiolum address). Unlike Matrix there is no 413 # per-user allowlist here — the bearer token is the whole gate. 414 API_SERVER_ENABLED = "true"; 415 API_SERVER_PORT = "8642"; 416 417 # Mailbox hermes@lassul.us on neoprism (configs/mailserver.nix). 418 # EMAIL_PASSWORD comes from the shared hermes-mail var via hermes-env. 419 # 993 is implicit-TLS IMAP, 465 is implicit-TLS submission (smtps). 420 EMAIL_ADDRESS = "hermes@lassul.us"; 421 EMAIL_IMAP_HOST = "mail.lassul.us"; 422 EMAIL_IMAP_PORT = "993"; 423 EMAIL_SMTP_HOST = "mail.lassul.us"; 424 EMAIL_SMTP_PORT = "465"; 425 }; 426 427 # Secrets (MATRIX_ACCESS_TOKEN, LLAMA_API_TOKEN, API_SERVER_KEY, 428 # EMAIL_PASSWORD) come from clan vars below, 429 # merged into $HERMES_HOME/.env at activation. 430 environmentFiles = [ 431 config.clan.core.vars.generators.hermes-env.files."hermes.env".path 432 ]; 433 }; 434 435 # The herd/hermes user boundary. herdr keeps its API socket in the session 436 # owner's runtime dir, so reaching lass's panes means running as lass. 437 # 438 # Be clear about what this grants: `herdr pane run` / `agent prompt` execute 439 # commands inside lass's panes, so this is lass-level code execution for 440 # anything that can drive the agent — it does not narrow the existing threat 441 # model (the gateway already has terminal tools as hermes), but it widens the 442 # blast radius from the hermes user to lass. The gate stays 443 # MATRIX_ALLOWED_USERS above. Restricting the command to one store path only 444 # buys auditability, not containment. 445 security.sudo.extraRules = [ 446 { 447 users = [ config.services.hermes-agent.user ]; 448 runAs = config.users.users.mainUser.name; 449 commands = [ 450 { 451 command = "${herdr-as-lass}/bin/herdr-as-lass"; 452 options = [ 453 "NOPASSWD" 454 "SETENV" 455 ]; 456 } 457 ]; 458 } 459 ]; 460 461 # sudo is setuid, and the upstream unit sets NoNewPrivileges=yes, which makes 462 # every setuid exec fail — so the wrapper above returned "sudo: The "no new 463 # privileges" flag is set, which prevents sudo from running as root" and the 464 # agent reported the herd as unreachable. Turning it off for this unit is the 465 # price of the bridge; it only re-enables what the sudoers rule already 466 # allows (one store path, runas lass), since sudo itself is the only setuid 467 # binary the agent is pointed at. 468 # The module hardens the unit with NoNewPrivileges = true, hence mkForce. 469 systemd.services.hermes-agent.serviceConfig.NoNewPrivileges = lib.mkForce false; 470 471 # Boot-race guard. matrix.lassul.us is IPv6-only; on this desktop 472 # network-online.target can fire before v6 DNS is ready, so the gateway's 473 # first Matrix connect fails ("Name or service not known") and it does not 474 # retry — leaving the bot silent until a manual restart. Block startup until 475 # the homeserver resolves (best-effort, ~60s cap) so reboots come up 476 # connected. 477 # 478 # The second entry resolves the API server's bind address (see the 479 # API_SERVER_* environment above). Retiolum, because both consumers live 480 # there: massulus.r talks to it directly, and neoprism reverse-proxies it 481 # to hermes.lassul.us. This host joins untrusted networks, so binding the 482 # mesh address rather than "::" keeps the listener off café wifi even 483 # before the packet filter sees it; the public door is nginx on neoprism, 484 # which we control. 485 # 486 # It is resolved at start rather than hardcoded because the address derives 487 # from the retiolum key in clan vars, so a regenerated key would otherwise 488 # leave the unit bound to a stale literal. It waits because the interface 489 # gets its address after network-online, and aiohttp cannot bind an address 490 # that does not exist yet. "+" runs it as root: /run is not writable by 491 # hermes. 492 systemd.services.hermes-agent.serviceConfig.ExecStartPre = [ 493 "${pkgs.writeShellScript "hermes-wait-matrix-dns" '' 494 i=0 495 while [ "$i" -lt 30 ]; do 496 ${pkgs.getent}/bin/getent ahosts matrix.lassul.us >/dev/null 2>&1 && exit 0 497 ${pkgs.coreutils}/bin/sleep 2 498 i=$((i + 1)) 499 done 500 echo "matrix.lassul.us did not resolve within 60s; starting anyway" >&2 501 exit 0 502 ''}" 503 "+${pkgs.writeShellScript "hermes-api-bind-retiolum" '' 504 set -eu 505 i=0 506 while [ "$i" -lt 30 ]; do 507 addr=$(${pkgs.iproute2}/bin/ip -6 -o addr show dev retiolum scope global \ 508 | ${pkgs.gawk}/bin/awk '{print $4}' | ${pkgs.coreutils}/bin/cut -d/ -f1 \ 509 | ${pkgs.coreutils}/bin/head -n1) 510 if [ -n "$addr" ]; then 511 ${pkgs.coreutils}/bin/mkdir -p /run/hermes-api 512 echo "API_SERVER_HOST=$addr" > /run/hermes-api/host.env 513 ${pkgs.coreutils}/bin/chmod 0444 /run/hermes-api/host.env 514 exit 0 515 fi 516 ${pkgs.coreutils}/bin/sleep 2 517 i=$((i + 1)) 518 done 519 # No retiolum address: leave the file absent so the API server falls 520 # back to its 127.0.0.1 default rather than silently listening on every 521 # interface. The Matrix lane keeps working either way. 522 ${pkgs.coreutils}/bin/rm -f /run/hermes-api/host.env 523 echo "retiolum has no global address after 60s; API server stays on localhost" >&2 524 exit 0 525 ''}" 526 ]; 527 528 # The packet filter drops INPUT by default here (configs/default.nix sets 529 # filter.INPUT.policy = "DROP"), so the API server's port has to be opened 530 # for the interface it binds. Without this neoprism's proxy_pass times out 531 # and hermes.lassul.us answers 504, while a local curl to the same 532 # address:port returns 200 — traffic to the host's own address goes through 533 # lo, which is accepted. 534 networking.firewall.interfaces.retiolum.allowedTCPPorts = [ 8642 ]; 535 536 # "-" because the file is absent until the ExecStartPre above finds an 537 # address, and a missing bind override must not fail the unit. 538 systemd.services.hermes-agent.serviceConfig.EnvironmentFile = [ 539 "-/run/hermes-api/host.env" 540 ]; 541 542 # Matrix bot access token for @hermes:lassul.us (minted against the neoprism 543 # Synapse). persist=true so the prompt value is stored once. 544 clan.core.vars.generators.hermes-matrix.prompts.matrix-access-token = { 545 description = "Matrix access token for @hermes:lassul.us"; 546 type = "hidden"; 547 persist = true; 548 }; 549 550 # API token for the shared llama cluster, surfaced to hermes as 551 # LLAMA_API_TOKEN (the custom_providers key_env above). 552 # persist=true so the prompt value is stored once. 553 clan.core.vars.generators.hermes-llama.prompts.llama-api-token = { 554 description = "API token for the shared llama cluster (inference.p0.contact)"; 555 type = "hidden"; 556 persist = true; 557 }; 558 559 # Bearer token for the API server. Generated, not prompted, and kept in a 560 # separate file as well so it can be read back out for the phone: 561 # clan vars get coaxmetal hermes-api/api_key 562 # (same shape as the covibe api_key generator). 563 clan.core.vars.generators.hermes-api = { 564 files."api_key" = { }; 565 runtimeInputs = [ 566 pkgs.coreutils 567 pkgs.openssl 568 ]; 569 script = '' 570 openssl rand -hex 32 | tr -d '\n' > "$out/api_key" 571 ''; 572 }; 573 574 # Hermes's radicale login (configs/radicale.nix). Shared with neoprism, where 575 # radicale runs; a shared generator has to be declared identically on every 576 # machine that uses it, hence the common definition. 577 clan.core.vars.generators.radicale-hermes = 578 import ../../configs/radicale-user-generator.nix pkgs "hermes" 579 // { 580 share = true; 581 }; 582 583 # Claude subscription OAuth token for the anthropic provider: run 584 # `claude setup-token` (long-lived, sk-ant-oat…) and paste it at the 585 # prompt. persist=true so it is asked once. 586 clan.core.vars.generators.hermes-anthropic.prompts.oauth-token = { 587 description = "Claude subscription OAuth token from `claude setup-token` (sk-ant-oat…)"; 588 type = "hidden"; 589 persist = true; 590 }; 591 592 # Assemble the .env Hermes reads (mirrors the opencrow-env pattern). 593 clan.core.vars.generators.hermes-env = { 594 dependencies = [ 595 "hermes-matrix" 596 "hermes-llama" 597 "hermes-api" 598 "radicale-hermes" 599 "hermes-anthropic" 600 "hermes-mail" 601 ]; 602 files."hermes.env" = { }; 603 runtimeInputs = [ pkgs.coreutils ]; 604 script = '' 605 cat > "$out/hermes.env" <<EOF 606 MATRIX_ACCESS_TOKEN=$(cat "$in"/hermes-matrix/matrix-access-token) 607 LLAMA_API_TOKEN=$(cat "$in"/hermes-llama/llama-api-token) 608 API_SERVER_KEY=$(cat "$in"/hermes-api/api_key) 609 CALDAV_PASSWORD=$(cat "$in"/radicale-hermes/password) 610 ANTHROPIC_TOKEN=$(cat "$in"/hermes-anthropic/oauth-token) 611 EMAIL_PASSWORD=$(cat "$in"/hermes-mail/password) 612 EOF 613 ''; 614 }; 615}