This repository has no description
31 kB
677 lines
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
45 # The gateway's webhook platform (settings.platforms.webhook below),
46 # loopback only.
47 webhookPort = 8644;
48
49 # Post a message to the Matrix home room through the running gateway: the
50 # "notify" webhook route below hands it to the gateway's own Matrix adapter.
51 # For the agent's shell scripts (phonetpm heads-ups, background deploys).
52 # `hermes send -t matrix` is no substitute: outside the gateway it opens a
53 # second Matrix client on the gateway's device and crypto store, and the two
54 # desync the Olm sessions until no client can decrypt Hermes any more.
55 hermes-notify = pkgs.writeShellApplication {
56 name = "hermes-notify";
57 runtimeInputs = [
58 pkgs.coreutils
59 pkgs.curl
60 pkgs.gnused
61 pkgs.jq
62 pkgs.openssl
63 ];
64 text = ''
65 if [ $# -eq 0 ]; then
66 echo "usage: hermes-notify <message>" >&2
67 exit 2
68 fi
69 secret=$(sed -n 's/^WEBHOOK_SECRET=//p' "''${HERMES_HOME:-/var/lib/hermes/.hermes}/.env")
70 body=$(jq -cn --arg text "$*" '{text: $text}')
71 stamp=$(date +%s)
72 signature=$(printf '%s.%s' "$stamp" "$body" | openssl dgst -sha256 -hmac "$secret" -r | cut -d' ' -f1)
73 curl -fsS --max-time 30 -o /dev/null \
74 -H 'Content-Type: application/json' \
75 -H "X-Webhook-Timestamp: $stamp" \
76 -H "X-Webhook-Signature-V2: $signature" \
77 --data-binary "$body" \
78 http://127.0.0.1:${toString webhookPort}/webhooks/notify
79 '';
80 };
81
82in
83{
84 imports = [
85 self.inputs.hermes-agent.nixosModules.default
86 ../../configs/hermes-mail-vars.nix
87 # Phone-approved ssh for the agent: the phonetpm user service runs in the
88 # hermes user manager (the module sets linger), see SSH_AUTH_SOCK below.
89 ../../configs/phonetpm.nix
90 ];
91
92 # Hermes Agent (Nous Research) — autonomous agent run as a native systemd
93 # gateway, reachable over Matrix (@hermes:lassul.us). Model backend is our
94 # shared llama cluster: one vLLM instance behind an OpenAI-compatible
95 # endpoint, so the model is whatever the cluster currently serves and a swap
96 # is a one-line settings.model.default change.
97 services.hermes-agent = {
98 enable = true;
99
100 # Build the Matrix platform dependency group into the sealed venv (mautrix
101 # SDK + liboqs for optional E2EE; Linux-only). "voice" adds faster-whisper,
102 # which is what `stt.provider = "local"` below runs — that group exists
103 # precisely because local STT pulls wheel-only transitive deps
104 # (ctranslate2, onnxruntime) that uv2nix has to resolve at build time.
105 # "anthropic" is the SDK the anthropic model provider imports; the venv is
106 # sealed (lazy installs disabled), so without it every turn fails with
107 # "The 'anthropic' package is required for the Anthropic provider".
108 extraDependencyGroups = [
109 "matrix"
110 "voice"
111 "anthropic"
112 ];
113
114 # Put the Claude Code CLI on the agent's PATH so its bundled "claude-code"
115 # skill can delegate coding via `terminal(command="claude -p '...'")`.
116 # extraPackages lands in both the systemd service PATH and the hermes
117 # user's profile. Auth is separate (run `claude` once as the hermes user).
118 extraPackages = [
119 self.legacyPackages.${pkgs.system}.llm.claude-code
120 # Voice both ways: the Matrix adapter shells out to ffmpeg to transcode
121 # piper's WAV to Ogg/Opus (native MSC3245 voice bubbles in Element) and
122 # to ffprobe for the duration + waveform metadata those bubbles carry.
123 pkgs.ffmpeg
124 hermes-tts
125 # CalDAV (calendar skill below) is plain HTTP; curl is not otherwise on
126 # the gateway PATH.
127 pkgs.curl
128 # ssh through the phonetpm agent (SSH_AUTH_SOCK below).
129 pkgs.openssh
130 hermes-notify
131 ];
132
133 # Home Assistant (acheron, configs/hass) left hermes core for the
134 # `homeassistant` catalog plugin: the ha_* tools plus a gateway platform
135 # for state-change events. Pinned to the sha plugin-catalog/homeassistant.yaml
136 # ships; its only Python dependency, aiohttp, is already in the venv via
137 # the "matrix" group. HASS_TOKEN comes from hermes-env.
138 #
139 # The flake builds hermes as version 0.0.0 (pyproject.toml; no release
140 # version is stamped), so the plugin's `requires_hermes: ">=0.21.5"` gate
141 # refused to load it. The pinned hermes-agent input already has Home
142 # Assistant moved out of core (hermes_cli/left_core_migration.py), so the
143 # gate is met in substance; drop it.
144 extraPlugins = [
145 (pkgs.applyPatches {
146 name = "homeassistant";
147 src = pkgs.fetchFromGitHub {
148 owner = "NousResearch";
149 repo = "hermes-homeassistant";
150 rev = "ba30cb0cf86c52bdb5cde98974bc062e97966529";
151 hash = "sha256-Pg4smvkiVpmcmUola0rziMBfq5V7UjzX8864Dem76pI=";
152 };
153 postPatch = ''
154 sed -i '/^requires_hermes:/d' plugin.yaml
155 '';
156 })
157 ];
158 # Non-bundled plugins are opt-in. The module links the plugin as
159 # plugins/nix-managed-homeassistant, but the left-core migration only
160 # recognizes plugins/homeassistant: without the marker it would see
161 # HASS_TOKEN, call the plugin missing, and warn on every start that it
162 # cannot fetch it (lazy installs are off in the sealed venv).
163 # settings does not merge lists, so hermes-voice.nix's plugin is enabled
164 # here as well.
165 settings.plugins.enabled = [
166 "homeassistant"
167 "hermes-voice"
168 ];
169 settings._left_core_installed = [ "homeassistant" ];
170
171 settings.model = {
172 # Claude Opus 5.5 on lass's Claude subscription: the anthropic provider
173 # takes the `claude setup-token` OAuth token (sk-ant-oat…) from
174 # ANTHROPIC_TOKEN (hermes-anthropic generator below) and sends it as a
175 # Bearer token. Anthropic bills third-party apps on subscription tokens
176 # as extra usage; hermes renames some tools on the OAuth wire to avoid
177 # that classification (agent/anthropic_adapter.py), which goes against
178 # Anthropic's usage policies and risks the account. Chosen knowingly.
179 #
180 # Context (1M) and output (128k) limits come from hermes's own model
181 # tables, so nothing is pinned here.
182 provider = "anthropic";
183 default = "claude-opus-5-5";
184 };
185
186 # The shared llama cluster stays configured as a named provider, so
187 # `/model Qwen3.6-27B-FP8 --provider custom:llama` switches back. Its key
188 # comes from hermes-env (key_env), never the nix store. It MUST be a
189 # *named* custom provider ("custom:<name>" + this entry), not bare
190 # "custom": bare custom resolves its credential from a host-gated list
191 # (hermes_cli/runtime_provider.py:1244) that only forwards OPENAI_API_KEY
192 # to openai.com and otherwise sends "no-key-required" (upstream #28660 /
193 # GHSA-76xc-57q6-vm5m). api_mode is pinned because auto-detection only
194 # runs as a fallback.
195 settings.custom_providers = [
196 {
197 name = "llama";
198 base_url = "https://inference.p0.contact/v1";
199 key_env = "LLAMA_API_TOKEN";
200 api_mode = "chat_completions";
201 }
202 ];
203
204 # Voice in. Element voice notes arrive flagged MSC3245; the Matrix adapter
205 # recognizes that flag and routes the audio to STT before the agent sees
206 # the turn, so a held-down mic button is just another prompt.
207 #
208 # "local" = faster-whisper in-process (from the "voice" dep group). An
209 # explicit provider is honored strictly: if it cannot run, transcription
210 # errors out loud instead of silently falling back to a cloud key.
211 settings.stt = {
212 provider = "local";
213 # "" = auto-detect per clip, instead of upstream's "en" hint — commands
214 # here switch between English and German mid-conversation.
215 language = "";
216 # Vocabulary bias (faster-whisper initial_prompt). Every name here is one
217 # the agent has to route on — a misheard host or tool name is a failed
218 # command, not a typo.
219 prompt = "Jargon: omp, nix, clan, retiolum, coaxmetal, neoprism, ignavia, lassulus.";
220 local = {
221 # small is the first size whose transcripts survive our jargon; a 4s
222 # clip takes ~3s warm on this CPU (int8), ~6s from cold weights.
223 model = "small";
224 # Release the weights after five idle minutes; this is a laptop, and
225 # the next voice note pays the reload instead of holding the RAM.
226 unload_after_idle_seconds = 300;
227 };
228 };
229
230 # Voice out. `voice_compatible` is the opt-in that makes the gateway treat
231 # this provider's output as a voice message (Opus-transcoding it on the
232 # way) rather than a file attachment, so replies land as playable bubbles.
233 settings.tts = {
234 provider = "piper-local";
235 providers.piper-local = {
236 type = "command";
237 command = "${hermes-tts}/bin/hermes-tts {input_path} {output_path}";
238 output_format = "wav";
239 voice_compatible = true;
240 };
241 };
242
243 # Text replies only: live voice moved to hermes-voice (assist.lassul.us,
244 # hermes-voice.nix). Voice notes still reach the agent through STT, and
245 # `/voice tts` in a chat still turns spoken replies on there.
246 settings.voice.auto_tts = false;
247
248 # In rooms Hermes requires an @mention by default; DMs always respond.
249 settings.matrix.session_scope = "room";
250
251 # Matrix is a Tier-2 platform whose default tool-progress preview caps
252 # commands at 40 chars (so a `terminal` call shows only `curl -X POST -H
253 # "Content-Type: applic...`). "verbose" makes the gateway render the FULL
254 # command — multi-line — as a fenced code block instead of the truncated
255 # one-liner. Trade-off: verbose also prints full JSON args for every other
256 # tool call, so the channel is chattier.
257 settings.display.platforms.matrix.tool_progress = "verbose";
258
259 # The EMAIL_* credentials below are for mail-sending skills only. Hermes
260 # turns the email gateway on by itself once all four of EMAIL_ADDRESS,
261 # EMAIL_PASSWORD, EMAIL_IMAP_HOST and EMAIL_SMTP_HOST are set
262 # (gateway/config_env.py); only an explicit `enabled: false` stops that.
263 settings.platforms.email.enabled = false;
264
265 # deliver_only routes post their rendered prompt to the Matrix home room
266 # through the gateway, no agent turn. Signed with WEBHOOK_SECRET, the API
267 # server key again (hermes-env). hermes-voice.nix adds voice-approval.
268 settings.platforms.webhook = {
269 enabled = true;
270 extra = {
271 host = "127.0.0.1";
272 port = webhookPort;
273 routes.notify = {
274 deliver = "matrix";
275 deliver_only = true;
276 prompt = "{text}";
277 };
278 };
279 };
280
281 # The one shared calendar (configs/radicale.nix): lass's own events, the
282 # book.lassul.us requests and bookings, and anything Hermes adds. Hermes
283 # authenticates as its own radicale user, which may only touch this
284 # collection. Booking events are read back by book, so deleting, moving
285 # or confirming them in CalDAV manages the booking (tools/book).
286 hermesHomeFiles."skills/calendar/SKILL.md" = ''
287 ---
288 name: calendar
289 description: Read and manage lass's calendar (CalDAV) and approve, decline or cancel book.lassul.us booking requests.
290 version: 1.0.0
291 license: MIT
292 platforms: [linux]
293 required_environment_variables:
294 - name: CALDAV_PASSWORD
295 prompt: Radicale password of the hermes user
296 help: Provisioned by clan vars (radicale-hermes) on coaxmetal.
297 required_for: any calendar access
298 prerequisites:
299 commands: [curl]
300 metadata:
301 hermes:
302 tags: [calendar, caldav, schedule, booking, appointments]
303 requires_toolsets: [terminal]
304 ---
305
306 # calendar
307
308 lass has exactly one calendar, at `$CALDAV_CALENDAR_URL`
309 (CalDAV on radicale). It holds his own events, booking requests and
310 bookings from https://book.lassul.us, and events you add. The booking
311 page treats every event in it as busy time.
312
313 Use it when the user asks what is on his schedule, whether he is free,
314 to add, move or remove an appointment, or about booking requests.
315
316 Authenticate every request with
317 `curl -sS -u "$CALDAV_USER:$CALDAV_PASSWORD"`. Never print, echo or
318 repeat the password. Times are Europe/Berlin unless the user says
319 otherwise; write DTSTART/DTEND with `;TZID=Europe/Berlin` or in UTC (`Z`).
320
321 ## Reading
322
323 Events overlapping a range (UTC, `YYYYMMDDTHHMMSSZ`):
324
325 ```sh
326 curl -sS -u "$CALDAV_USER:$CALDAV_PASSWORD" -X REPORT -H 'Depth: 1' \
327 -H 'Content-Type: application/xml; charset=utf-8' "$CALDAV_CALENDAR_URL" --data '
328 <C:calendar-query xmlns:D="DAV:" xmlns:C="urn:ietf:params:xml:ns:caldav">
329 <D:prop><D:getetag/><C:calendar-data/></D:prop>
330 <C:filter><C:comp-filter name="VCALENDAR"><C:comp-filter name="VEVENT">
331 <C:time-range start="20261010T000000Z" end="20261017T000000Z"/>
332 </C:comp-filter></C:comp-filter></C:filter>
333 </C:calendar-query>'
334 ```
335
336 Each `<D:response>` has the event's `href` (its URL path), `getetag` and
337 the iCalendar text. Recurring events (RRULE) come back once with their
338 rule; expand them yourself when answering "what is on Tuesday".
339
340 ## Adding, changing, removing your own events
341
342 - Add: pick a UID (`uuidgen`-style, suffix `@hermes`), then
343 `PUT "$CALDAV_CALENDAR_URL<uid>.ics"` with
344 `-H 'Content-Type: text/calendar; charset=utf-8' -H 'If-None-Match: *'`
345 and a body of one `VCALENDAR` (VERSION:2.0, PRODID) holding one
346 `VEVENT` with UID, DTSTAMP, DTSTART, DTEND (or DURATION) and SUMMARY.
347 Add a `VALARM` (ACTION:DISPLAY, TRIGGER:-PT15M) when the user wants a
348 reminder.
349 - Change: GET the event, edit it, PUT it back to the same href with
350 `-H 'If-Match: <etag>'` so you never overwrite a concurrent edit.
351 - Remove: `DELETE` the href with `-H 'If-Match: <etag>'`.
352 - Success is HTTP 201/204. 412 means it changed meanwhile: re-read and
353 retry. Confirm by reading the range again.
354
355 Before adding, check the range for overlaps and tell the user about
356 any. Do not change or delete events the user did not ask about.
357
358 ## Booking requests and bookings (book.lassul.us)
359
360 Events whose UID ends in `@book.lassul.us` come from the booking page.
361 It reads them back every minute (for bookings that have not started
362 yet) and mails the guest about what changed, so editing them is how
363 bookings are managed:
364
365 - Pending request: `STATUS:TENTATIVE`, SUMMARY starts with `? `.
366 - Booked: `STATUS:CONFIRMED`.
367 - The DESCRIPTION holds the guest (`Name <email>`), their note, and as
368 its last line the admin link `https://book.lassul.us/a/<token>`.
369
370 | Calendar edit | Effect, guest mail |
371 | --- | --- |
372 | DELETE a booked event (or set `STATUS:CANCELLED`) | cancelled, guest gets a cancellation |
373 | DELETE a pending request | declined, guest gets a decline |
374 | Change DTSTART/DTEND | rescheduled; a booked guest gets the updated invitation |
375 | Set a pending request to `STATUS:CONFIRMED` | approved, guest gets the invitation |
376
377 Use If-Match as for your own events. The booking page rewrites SUMMARY
378 and DESCRIPTION itself; edits to them are not kept. Act on a booking
379 only when the user decided to.
380
381 To send the guest a personal message with the decision, use the admin
382 link instead (an optional `message` is mailed along):
383
384 ```sh
385 curl -sS -o /dev/null -w '%{http_code}\n' -X POST \
386 --data-urlencode 'message=Looking forward to it' \
387 'https://book.lassul.us/a/<token>/approve' # or /decline, /cancel
388 ```
389
390 A 303 means it worked. `GET` on the admin link shows the request's
391 current state. Treat admin links as secrets: never paste one into a
392 chat unless the user asks for it.
393 '';
394
395 # Non-secret connection + SECURITY gate. Invites are auto-accepted and that
396 # cannot be disabled, so the agent may join any room it's invited to. Joining
397 # is harmless on its own — what matters is who can *trigger* the agent, which
398 # on this host has terminal / filesystem / web tools (chat-driven RCE).
399 # MATRIX_ALLOWED_USERS is the universal trigger gate (applies even in DMs);
400 # only these MXIDs can make the agent act, in any room it's joined.
401 environment = {
402 MATRIX_HOMESERVER = "https://matrix.lassul.us";
403 MATRIX_USER_ID = "@hermes:lassul.us";
404 MATRIX_ALLOWED_USERS = "@lassulus:lassul.us";
405 # The DM with lassulus: default target for deliver=matrix (cron jobs,
406 # the voice-approval webhook and the matrix_dm_send tool of
407 # hermes-voice.nix). Without it cron delivery fails with "no delivery
408 # target resolved for deliver=matrix".
409 MATRIX_HOME_ROOM = "!QSmQTYGdeExThpCqNh:lassul.us";
410
411 # The shared calendar (calendar skill above); CALDAV_PASSWORD comes from
412 # hermes-env.
413 CALDAV_USER = "hermes";
414 CALDAV_CALENDAR_URL = "https://cal.lassul.us/lass/calendar/";
415
416 # Home Assistant on acheron, over retiolum (configs/hass opens 8123 there).
417 HASS_URL = "http://acheron.r:8123";
418
419 # Element makes DMs end-to-end encrypted by default, so the bot must do
420 # E2EE or it can't read messages. The `matrix` dep group already bundles
421 # mautrix[encryption] (python-olm); "optional" initializes E2EE when those
422 # deps are present (they are) and keeps the crypto store under
423 # /var/lib/hermes/.hermes/platforms/matrix/store/.
424 MATRIX_E2EE_MODE = "optional";
425 # Element is moving to exclude devices their owner did not cross-sign
426 # (MSC4153). With this set the adapter bootstraps cross-signing once (no
427 # master key on the account yet) and signs its device; the key is
428 # written here 0600 and never overwritten. Move it into pass afterwards;
429 # it is needed only to cross-sign a new device id (MATRIX_RECOVERY_KEY).
430 MATRIX_RECOVERY_KEY_OUTPUT_FILE = "/var/lib/hermes/.hermes/matrix-recovery-key";
431 # Optional defense-in-depth (confine to specific rooms; DMs are exempt):
432 # MATRIX_ALLOWED_ROOMS = "!yourRoomId:lassul.us";
433
434 # The OpenAI-compatible API server: the assistant-button lane, reached
435 # both directly from massulus.r and through the public
436 # hermes.lassul.us proxy on neoprism. API_SERVER_KEY comes from clan
437 # vars via environmentFiles; API_SERVER_HOST is written at start by the
438 # ExecStartPre below (the retiolum address). Unlike Matrix there is no
439 # per-user allowlist here — the bearer token is the whole gate.
440 API_SERVER_ENABLED = "true";
441 API_SERVER_PORT = "8642";
442
443 # Mailbox hermes@lassul.us on neoprism (configs/mailserver.nix).
444 # EMAIL_PASSWORD comes from the shared hermes-mail var via hermes-env.
445 # 993 is implicit-TLS IMAP, 465 is implicit-TLS submission (smtps).
446 EMAIL_ADDRESS = "hermes@lassul.us";
447 EMAIL_IMAP_HOST = "mail.lassul.us";
448 EMAIL_IMAP_PORT = "993";
449 EMAIL_SMTP_HOST = "mail.lassul.us";
450 EMAIL_SMTP_PORT = "465";
451 };
452
453 # Secrets (MATRIX_ACCESS_TOKEN, LLAMA_API_TOKEN, API_SERVER_KEY,
454 # EMAIL_PASSWORD, HASS_TOKEN) come from clan vars below,
455 # merged into $HERMES_HOME/.env at activation. WEBHOOK_SECRET is the API
456 # server key again: hermes-voice signs its webhook posts with it
457 # (hermes-voice.nix).
458 environmentFiles = [
459 config.clan.core.vars.generators.hermes-env.files."hermes.env".path
460 ];
461 };
462
463 # ssh as hermes signs with the phone key (keys/ssh/phonetpm_massulus.pub,
464 # authorized on all hosts): every login asks for a fingerprint on the phone.
465 # The daemon only starts once paired:
466 # sudo -u hermes phonetpm pair <endpoint id>
467 # systemctl --user -M hermes@ start phonetpm
468 # %U is the hermes uid, so this is the user manager's %t/phonetpm/agent.sock.
469 systemd.services.hermes-agent.environment.SSH_AUTH_SOCK = "/run/user/%U/phonetpm/agent.sock";
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 # Bundled skills are copied out of the Nix store with shutil.copytree,
527 # which keeps the store's r-x/r-- modes, so skill_manage could not patch
528 # them ("Permission denied: skills/productivity/maps/.tmp_…"). Seed them
529 # here instead of at gateway start, then make them writable; the gateway's
530 # own sync then finds nothing to copy (its hash is content-only, so the
531 # chmod does not mark them user-modified). `opt-in` only drops the
532 # .no-bundled-skills marker, which this host never sets.
533 "${pkgs.writeShellScript "hermes-skills-writable" ''
534 set -eu
535 hermes skills opt-in --sync
536 ${pkgs.findutils}/bin/find ${config.services.hermes-agent.stateDir}/.hermes/skills \
537 ! -perm -u+w -exec ${pkgs.coreutils}/bin/chmod u+w {} +
538 ''}"
539 ];
540
541 # The packet filter drops INPUT by default here (configs/default.nix sets
542 # filter.INPUT.policy = "DROP"), so the API server's port has to be opened
543 # for the interface it binds. Without this neoprism's proxy_pass times out
544 # and hermes.lassul.us answers 504, while a local curl to the same
545 # address:port returns 200 — traffic to the host's own address goes through
546 # lo, which is accepted.
547 networking.firewall.interfaces.retiolum.allowedTCPPorts = [ 8642 ];
548
549 # "-" because the file is absent until the ExecStartPre above finds an
550 # address, and a missing bind override must not fail the unit.
551 systemd.services.hermes-agent.serviceConfig.EnvironmentFile = [
552 "-/run/hermes-api/host.env"
553 ];
554
555 # Matrix bot access token for @hermes:lassul.us (minted against the neoprism
556 # Synapse). persist=true so the prompt value is stored once.
557 clan.core.vars.generators.hermes-matrix.prompts.matrix-access-token = {
558 description = "Matrix access token for @hermes:lassul.us";
559 type = "hidden";
560 persist = true;
561 };
562
563 # API token for the shared llama cluster, surfaced to hermes as
564 # LLAMA_API_TOKEN (the custom_providers key_env above).
565 # persist=true so the prompt value is stored once.
566 clan.core.vars.generators.hermes-llama.prompts.llama-api-token = {
567 description = "API token for the shared llama cluster (inference.p0.contact)";
568 type = "hidden";
569 persist = true;
570 };
571
572 # Bearer token for the API server. Generated, not prompted, and kept in a
573 # separate file as well so it can be read back out for the phone:
574 # clan vars get coaxmetal hermes-api/api_key
575 clan.core.vars.generators.hermes-api = {
576 files."api_key" = { };
577 runtimeInputs = [
578 pkgs.coreutils
579 pkgs.openssl
580 ];
581 script = ''
582 openssl rand -hex 32 | tr -d '\n' > "$out/api_key"
583 '';
584 };
585
586 # Hermes's radicale login (configs/radicale.nix). Shared with neoprism, where
587 # radicale runs; a shared generator has to be declared identically on every
588 # machine that uses it, hence the common definition.
589 clan.core.vars.generators.radicale-hermes =
590 import ../../configs/radicale-user-generator.nix pkgs "hermes"
591 // {
592 share = true;
593 };
594
595 # Claude subscription OAuth token for the anthropic provider: run
596 # `claude setup-token` (long-lived, sk-ant-oat…) and paste it at the
597 # prompt. persist=true so it is asked once.
598 clan.core.vars.generators.hermes-anthropic.prompts.oauth-token = {
599 description = "Claude subscription OAuth token from `claude setup-token` (sk-ant-oat…)";
600 type = "hidden";
601 persist = true;
602 };
603
604 # Home Assistant long-lived access token for the homeassistant plugin
605 # (HA profile -> Security -> Long-lived access tokens). persist=true so the
606 # prompt value is stored once.
607 clan.core.vars.generators.hermes-homeassistant.prompts.token = {
608 description = "Home Assistant long-lived access token for hermes";
609 type = "hidden";
610 persist = true;
611 };
612
613 # Assemble the .env Hermes reads from the generators above.
614 clan.core.vars.generators.hermes-env = {
615 dependencies = [
616 "hermes-matrix"
617 "hermes-llama"
618 "hermes-api"
619 "radicale-hermes"
620 "hermes-anthropic"
621 "hermes-mail"
622 "hermes-homeassistant"
623 ];
624 files."hermes.env" = { };
625 # clan regenerates a var only when its validation changes, not when the
626 # script does: without this a key added below never reaches the .env.
627 validation.script = builtins.hashString "sha256" config.clan.core.vars.generators.hermes-env.script;
628 runtimeInputs = [ pkgs.coreutils ];
629 script = ''
630 cat > "$out/hermes.env" <<EOF
631 MATRIX_ACCESS_TOKEN=$(cat "$in"/hermes-matrix/matrix-access-token)
632 LLAMA_API_TOKEN=$(cat "$in"/hermes-llama/llama-api-token)
633 API_SERVER_KEY=$(cat "$in"/hermes-api/api_key)
634 WEBHOOK_SECRET=$(cat "$in"/hermes-api/api_key)
635 CALDAV_PASSWORD=$(cat "$in"/radicale-hermes/password)
636 ANTHROPIC_TOKEN=$(cat "$in"/hermes-anthropic/oauth-token)
637 EMAIL_PASSWORD=$(cat "$in"/hermes-mail/password)
638 HASS_TOKEN=$(cat "$in"/hermes-homeassistant/token)
639 EOF
640 '';
641 };
642
643 # The home without caches, toolchains and git clones; the SQLite databases
644 # under .hermes are dumped consistently instead of copied.
645 lass.backup.state.hermes =
646 let
647 home = config.services.hermes-agent.stateDir;
648 in
649 {
650 folders = [ home ];
651 exclude = [
652 "${home}/.cache"
653 "${home}/.cargo"
654 "${home}/.gradle"
655 "${home}/go"
656 "${home}/.hermes/cache"
657 "${home}/workspace"
658 "${home}/.local/share/Trash"
659 "${home}/*/node_modules"
660 "${home}/*/target"
661 "${home}/.hermes/*.db"
662 "${home}/.hermes/*.db-wal"
663 "${home}/.hermes/*.db-shm"
664 ];
665 preBackup = ''
666 rm -rf /var/backup/hermes
667 cd ${home}/.hermes
668 ${pkgs.findutils}/bin/find . -path ./cache -prune -o -type f -name '*.db' -printf '%P\n' |
669 while read -r db; do
670 mkdir -p "/var/backup/hermes/$(dirname "$db")"
671 ${pkgs.sqlite}/bin/sqlite3 -cmd ".timeout 60000" "$db" ".backup '/var/backup/hermes/$db'"
672 done
673 '';
674 # WAL-mode SQLite readers need to write the -shm file
675 readWritePaths = [ "${home}/.hermes" ];
676 };
677}