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