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