diff --git a/flake.lock b/flake.lock index 348ca23..a61a702 100644 --- a/flake.lock +++ b/flake.lock @@ -14,11 +14,11 @@ "uv2nix": "uv2nix" }, "locked": { - "lastModified": 1785761909, - "narHash": "sha256-M8FNG0aca0/JsETvpYSJgwIrgJ4kpgxo9i7SDS0LfHs=", + "lastModified": 1786986906, + "narHash": "sha256-DJ1oU9szQJNdEM0dysh4NnKOB1HwOKtNukrUYKpawVs=", "owner": "nix-community", "repo": "authentik-nix", - "rev": "db4a267f345338659a290a76e5b28da31e9923df", + "rev": "afdb2eeca1e0b38fabb93c4a8944be73d3581268", "type": "github" }, "original": { @@ -101,11 +101,11 @@ "nixpkgs-lib": "nixpkgs-lib" }, "locked": { - "lastModified": 1782949081, - "narHash": "sha256-vp6Y/Grm98ESt6ceOkWiHWyZRDV3J1RID4w+6NWK9yA=", + "lastModified": 1785627969, + "narHash": "sha256-4dtXQk/NMePegK/nWp5NSeuZKLATItOq61lpEvmXqGw=", "owner": "hercules-ci", "repo": "flake-parts", - "rev": "17c9d6cdfc60c64f4ee8d306f9bc0b4ccb51481e", + "rev": "427bf4bd9435fdf21321c8cc628c24efc14c0f7a", "type": "github" }, "original": { @@ -142,11 +142,11 @@ ] }, "locked": { - "lastModified": 1785119570, - "narHash": "sha256-Rgs2xKnGLFWQscxUaXX07oyZeuMDOHEbqDOsgliLFGM=", + "lastModified": 1786924861, + "narHash": "sha256-hftabkb+73OcGzvwFAjCiQorAhprs9TnU1+FkGO5CIw=", "owner": "nix-community", "repo": "home-manager", - "rev": "d4fd24667c8cbef124bb70a20380cab75ec8474d", + "rev": "09ae1b85a6db412d841d60f924b23f881f0d0a38", "type": "github" }, "original": { @@ -270,11 +270,11 @@ "treefmt-nix": "treefmt-nix" }, "locked": { - "lastModified": 1785356851, - "narHash": "sha256-UYKTcayxYPE3yIruCv525WMbcYDLlkv79bRfkOuFNLs=", + "lastModified": 1786520020, + "narHash": "sha256-9b0hAKM8UtNsm4lD3aySuXPjPpSibh6yfYEcNvr4fU0=", "owner": "nix-community", "repo": "nixos-anywhere", - "rev": "5887f1c72fbf0e88000716237194de414d2299ee", + "rev": "4cf3b82df8422f82657ae98c3b8374a5fd74f9a1", "type": "github" }, "original": { @@ -291,11 +291,11 @@ "nixos-unstable": "nixos-unstable" }, "locked": { - "lastModified": 1785407732, - "narHash": "sha256-iv6OYbZgMqvudEFB4LvhpZDHyNqD+lx0WubIk6BdAVg=", + "lastModified": 1786617555, + "narHash": "sha256-r3brKkBWm34wltszIeoZw2VCFUEoS2nNAe3W2Zgzc9k=", "owner": "nix-community", "repo": "nixos-images", - "rev": "e2a34c0ce9dcbb5169f2a233b49211f5f30b7d69", + "rev": "f01878e82602ad3d0a2e74a14440521924e57bc5", "type": "github" }, "original": { @@ -323,11 +323,11 @@ }, "nixpkgs": { "locked": { - "lastModified": 1785692966, - "narHash": "sha256-vUfIeBEfpbAfZ5zjgIkYk7eHBeVfCYVjLbWnMkseYnk=", + "lastModified": 1786862985, + "narHash": "sha256-FBJRXmbGXiSUDvYEbfLYRkckayyZ6SK1UEqhCrIZ2Cs=", "owner": "NixOS", "repo": "nixpkgs", - "rev": "643809054d65fdd466a63e3155b8c498cb483c04", + "rev": "e5bdc4a41d4c072fe1e3787eaa0320a384741d44", "type": "github" }, "original": { @@ -339,11 +339,11 @@ }, "nixpkgs-lib": { "locked": { - "lastModified": 1782614948, - "narHash": "sha256-ePjCwr1sNm9NYUqywL7QfK3JnlS015msC+eBu2zKlp8=", + "lastModified": 1785031560, + "narHash": "sha256-OmshNvn2vupOFpYinLUu+1Dnpu4n7Q5N3ggGVNHpkUI=", "owner": "nix-community", "repo": "nixpkgs.lib", - "rev": "db3f255737b94216eb71cce308e2912cf6bc2d7c", + "rev": "0e79af5e3d4dcfcd676ab5ba3f95d2e3352e078c", "type": "github" }, "original": { @@ -354,11 +354,11 @@ }, "nixpkgs-unstable": { "locked": { - "lastModified": 1785747939, - "narHash": "sha256-D740uKsMbgsfK2oaDenJLLPIZfq7W0/g4KN/Fls8eKs=", + "lastModified": 1786963906, + "narHash": "sha256-3tkeMWSvHPo3tYljfXbPC/TgknikU1GvdVr/DkdfvE0=", "owner": "NixOS", "repo": "nixpkgs", - "rev": "104240a772428cc2e20d8fd86c9ddbb886bbaff2", + "rev": "f4b6996c4e8b9ee06ce147ec344c885f51071b14", "type": "github" }, "original": { @@ -370,11 +370,11 @@ }, "nixpkgs_2": { "locked": { - "lastModified": 1785734586, - "narHash": "sha256-ODZkEK9Gy50yg6h98u7KkitZ3oc/uuTFK00bh1CRdNA=", + "lastModified": 1786943417, + "narHash": "sha256-b4qgjdFtlz5TAZ1Hi7DFJeqX3sjaDUnrwr9OO+O1rM0=", "owner": "NixOS", "repo": "nixpkgs", - "rev": "531670d871c0e29724a02f3cbcac170adc65b58c", + "rev": "0dd31db7e6dbf9ce05697c4545f6fe01accec994", "type": "github" }, "original": { @@ -400,11 +400,11 @@ ] }, "locked": { - "lastModified": 1785115949, - "narHash": "sha256-8AM37BfyGaL2v/SZyg4PupRxJ01Y4htvM+WrTjWrPpo=", + "lastModified": 1785730568, + "narHash": "sha256-NjSPsgjJ7MSpBtTkUcmNhRe6AFZ96+zsca2M8YuQi8Y=", "owner": "pyproject-nix", "repo": "build-system-pkgs", - "rev": "62c0d86027edb1c4f39a5facc09876348144f7c9", + "rev": "90fde00db3687922d39d95fc591475fd0bbbcd72", "type": "github" }, "original": { @@ -450,11 +450,11 @@ ] }, "locked": { - "lastModified": 1784591072, - "narHash": "sha256-zP/WaDxrRu8GANZM61+V2LT/7ycEEdoyLWn7M6WzU7M=", + "lastModified": 1786031528, + "narHash": "sha256-cROiHKO3UbIKqF5FG5NikvydzlfIj4EcR1Cty9qOVt4=", "owner": "pyproject-nix", "repo": "pyproject.nix", - "rev": "e3b599ca2e7fcf93d4edf65d7f19bbf6491724f3", + "rev": "1b1485546d85f6f6c7aadb10c4923dbc09633263", "type": "github" }, "original": { @@ -507,11 +507,11 @@ ] }, "locked": { - "lastModified": 1783174389, - "narHash": "sha256-aCWC8ngycU7OdJrU2+Je3qf+1a2ykuBvpPhZT/9tXMc=", + "lastModified": 1786629091, + "narHash": "sha256-gkig4nPi1CWc4Z50GBsjE4ygSE7hMpl/TwID2an2Cck=", "owner": "Mic92", "repo": "sops-nix", - "rev": "f1406619a3884cd5c47992a70b8b35c9c0fcb4c9", + "rev": "a8627b21b9107c5711c96b84f32a9a4b3d45295f", "type": "github" }, "original": { @@ -559,11 +559,11 @@ ] }, "locked": { - "lastModified": 1784369104, - "narHash": "sha256-47cxbcZODibHv3rELFQ9vZly0vUNkND/atn/U7HLeb0=", + "lastModified": 1785945821, + "narHash": "sha256-NLSyTCW4K4ofhNBllt3omPasm6QpralXH1DBZOc91Dw=", "owner": "numtide", "repo": "treefmt-nix", - "rev": "df3c0640565d04a0261253cdd89fce78ec50168a", + "rev": "ae7910970dddc408fe6ab1c8e4b277bb21d72dc0", "type": "github" }, "original": { @@ -584,11 +584,11 @@ ] }, "locked": { - "lastModified": 1785277507, - "narHash": "sha256-9Tq3UDX2hD/aveW/HvkBlAmEwJTOlY5HQXJM+L5BGmE=", + "lastModified": 1786615403, + "narHash": "sha256-U++y7nM/6xiEcWI7q4fQoPZjPvRaTwkSqzBOVoEBjUE=", "owner": "pyproject-nix", "repo": "uv2nix", - "rev": "5a836d395cbf5fc22670eb98dd4aa4fc4d406977", + "rev": "4b59abb2ae1896d2a0e1abfc47fbc9bf985ea730", "type": "github" }, "original": { diff --git a/hosts/terra/configuration.nix b/hosts/terra/configuration.nix index 20b5b4f..206f6bc 100644 --- a/hosts/terra/configuration.nix +++ b/hosts/terra/configuration.nix @@ -17,6 +17,7 @@ in ../../services/vpn/tailscale.nix ../../services/desktop/desktop-hyprland.nix ../../services/desktop/desktop-apps.nix + ../../services/desktop/librechat.nix ]; networking.hostName = "terra"; @@ -69,6 +70,34 @@ in # whisper.cpp/llama.cpp containers in ~/Data/Dev/repos/content-trigger-scanner. users.users.darman.extraGroups = [ "render" "video" ]; + # ---- ollama (local LLM server, ROCm on the 6800 XT) ---- + # Navi 21 is gfx1030 — officially supported by ROCm, so no + # rocmOverrideGfx/HSA_OVERRIDE_GFX_VERSION needed (that's for gpus ROCm + # doesn't recognize, e.g. RDNA1/gfx101x). The upstream module runs the + # service under DynamicUser with SupplementaryGroups=["render"] and + # DeviceAllow for char-kfd/char-drm/char-fb already, so unlike jellyfin's + # static user it needs no extraGroups wiring here. + services.ollama = { + enable = true; + package = pkgs.ollama-rocm; + # keep in sync with services/desktop/librechat.nix's endpoints.custom + # default model — LibreChat's config schema needs a non-empty default + # even though fetch=true replaces it with whatever's actually pulled. + # gemma4:12b: general chat/coding daily driver, fits fully in 16G VRAM — + # also doubles as the memory-extraction agent (see librechat.nix): a + # 3b model (llama3.2:3b, dropped) couldn't reliably tell the user's + # stated facts apart from its own boilerplate, e.g. saving "I am an AI + # assistant with tool calling capabilities" as the user's personal_info + # after "Hi I'm Erik Simon". Reusing gemma4:12b for both roles also means + # no second model needs to swap into VRAM while it's already the active + # chat model. + # qwen3.6:35b-a3b: MoE (3B active/36B total), ~24GB Q4_K_M — doesn't fit + # in VRAM alone, so ollama offloads the inactive experts to CPU RAM. + # Sparse activation makes that far less painful than it'd be for a dense + # model this size, but still expect it to run slower than the two above. + loadModels = [ "gemma4:12b" "qwen3.6:35b-a3b" ]; + }; + # ---- Dev-data disks — NOT in disko, mounted read-write, never wiped ---- fileSystems."/mnt/hdd_01" = { device = "/dev/disk/by-uuid/b8445126-ec6d-4f88-818a-d9e13031d9a4"; diff --git a/hosts/terra/secrets.nix b/hosts/terra/secrets.nix index 3edce2a..a39ba2b 100644 --- a/hosts/terra/secrets.nix +++ b/hosts/terra/secrets.nix @@ -21,4 +21,14 @@ username=darman password=${config.sops.placeholder.samba_password} ''; + + # LibreChat's CREDS_KEY/IV encrypt stored user credentials (linked 3rd-party + # API keys etc) at rest in mongo; JWT_SECRET/JWT_REFRESH_SECRET sign session + # tokens. All four are random, generated once with `sops --set` (see + # CLAUDE.md) — losing/rotating them just invalidates existing sessions and + # any saved per-user API keys, nothing else depends on their value. + sops.secrets.librechat_creds_key = { }; + sops.secrets.librechat_creds_iv = { }; + sops.secrets.librechat_jwt_secret = { }; + sops.secrets.librechat_jwt_refresh_secret = { }; } diff --git a/secrets/terra.yaml b/secrets/terra.yaml index 5b3fc27..9253b3f 100644 --- a/secrets/terra.yaml +++ b/secrets/terra.yaml @@ -1,6 +1,10 @@ tailscale_authkey: ENC[AES256_GCM,data:DgxbMrKTxC//cWd+ZckCbwZ9j/FgjYwNwadU5cWnnPz+nPEFhXLla2TuUE0pJKpcx9pKZCQmxcGtzQvIB+mbgxjgB0X2CRTp4Yy3WR+PBYMghKx36F8KBg==,iv:hJr6O41OTdMa4wkBXEsRUXgV4zWt21h1kx/CWArLU7o=,tag:IEpMIwKBHakvVYEFdEUe7A==,type:str] darman_password: ENC[AES256_GCM,data:G3ZM+NMxvKq5twblcBvyC+MiUX+X7nz+s1GqBHBkJQy1YgW4KN6rpbdj10F9jWxHph5dJ9j9fG6L7UlEg5W5zUYeLFdEx2xJGE1fxksch+6BB9FT6LKkhJ7MmbJmhNHYwJOZO3LM1f/oRw==,iv:abQTRe9kRyYj+TL0rtoxM6JFBaIBmxu7y77qt1h4eME=,tag:OgKrB3SUgSNJjmtJyrmh/Q==,type:str] samba_password: ENC[AES256_GCM,data:UkJLUa2hW1iZ++sfJAcg6G1RJMM=,iv:/HbZ9F+GxCydUP50PNBtJknPlmDWh1DAE26N9FJUyb0=,tag:z1cbwUaVdyfwEIU8LINEcw==,type:str] +librechat_creds_key: ENC[AES256_GCM,data:e2Ptf41yHu0KxfzjW4DP04CSJfbtdsJ8bwrgJyv9up4/JSCbBzjPFmOi7jsHUL4jT0AGVuHG5w3y+YOil9EeNA==,iv:zneozNSkXsb4Vy/sq21b8HWCKpDkXVTxyLY2Zh0bwP0=,tag:6CxR5sIspbfzHlfdx+45Iw==,type:str] +librechat_creds_iv: ENC[AES256_GCM,data:iuW1Rxfu7Ei8zHVG7wsBTYYznbtErj7DC9B71OiiWGA=,iv:Ngs88C44gcSzxQZU1lMiy5kn9mM/ckuWKRRGOFJeeoQ=,tag:Q9B8vM4YMkIP3RyYVWSQqg==,type:str] +librechat_jwt_secret: ENC[AES256_GCM,data:f/gljrQPZIXeLHXtqKCCYGEu2pXgbZd69CjyEhSVJ+AMuaVj7DWVclD5IMsEVERBHw5a7OndBSfLvgXRaKN6Gg==,iv:DG5CwCbQLCTv1++APCdFzAiWGKiEJl+9MhKIM2ykJQM=,tag:wdpY6l+5h4UtsluIncsyhA==,type:str] +librechat_jwt_refresh_secret: ENC[AES256_GCM,data:N/yAPtaodJX4t2C2A0bZ/LQaVc8SBMgg3KYJrWRyXCM6HAmgHtSQ9nWJO2fl+7A966mMtYvpATYcBIPj/K5nig==,iv:k5ZYi2OKjL9Z+lhVgdNsmGVQm+iCqmF15H3TUTbofWk=,tag:NVTdvlL1lpwVhjo2c2phpg==,type:str] sops: age: - enc: | @@ -21,7 +25,7 @@ sops: sHjKfw8VrrmAR4pQf1dsY+wcyh4FsZxhP3Q+QIVq3eCIXS9PeJkGAg== -----END AGE ENCRYPTED FILE----- recipient: age1rfcmu6zh40v4260l9hnf8ajs9vly0s06rx3ey76eu78dp9t7getqyhmkut - lastmodified: "2026-07-28T20:18:44Z" - mac: ENC[AES256_GCM,data:TRh/Idd8MEq/5QRb7r+g6msdaObtVzsX5hK5gacJI6seZ5BLW5tcBO/E2m8ya4t50lTofxkwObWskMbCOucLDYt+lnYwHSqDvAeQRvswxaCZgiFOn0upqCi8H4OomvIK475SIH6bniyN9bCQ2gaSS0D9aecyCSmdUARkmcZEYLc=,iv:PBoEUZG0QNyj4B0c6zuWr6Y39EdL1CoAsVExU86P6Tw=,tag:NPxsWkxdojXNsoxVVMb5TQ==,type:str] + lastmodified: "2026-08-17T21:14:30Z" + mac: ENC[AES256_GCM,data:vbD0W0zN5WV4UonOWreZWvUrAP7A/PllGxTS29L0qSc8HdV+IzVtHfknl3QSrAx1cAgd0exz1mr2yVsBX5Ez4rzdyXO63KE1uwT/A5ZdZW8iEv/HP4I1dxuDN0U2nd5aFxAFqckYXQd24u/2JKZhtiwOG9QHlwm5bAr8+7o3r2k=,iv:BZ/8m4Gsbj/eg77qbcTFe7e6wqEBH2NVON/4zH74yAM=,tag:WumJmaIcIhm4nyouLGf7og==,type:str] unencrypted_suffix: _unencrypted version: 3.13.2 diff --git a/services/desktop/desktop-apps.nix b/services/desktop/desktop-apps.nix index 630a7b3..ef14506 100644 --- a/services/desktop/desktop-apps.nix +++ b/services/desktop/desktop-apps.nix @@ -18,6 +18,7 @@ in "claude-code" "proton-pass-cli" "vivaldi" + "mongodb" # librechat's local db (services/desktop/librechat.nix) — SSPL ]; programs.steam = { diff --git a/services/desktop/librechat.nix b/services/desktop/librechat.nix new file mode 100644 index 0000000..379fc42 --- /dev/null +++ b/services/desktop/librechat.nix @@ -0,0 +1,96 @@ +{ config, ... }: + +# LibreChat — web chat UI, talking to the local ollama server (see +# hosts/terra/configuration.nix) over its OpenAI-compatible /v1 route. +# Only reachable over the tailnet (networking.firewall.trustedInterfaces = +# [ "tailscale0" ] in services/vpn/tailscale.nix) — openFirewall stays off. +{ + services.librechat = { + enable = true; + enableLocalDB = true; # spins up a local, unauthenticated-on-localhost mongodb + + # LibreChat's isEnabled() treats an UNSET var as false, not true — so + # registration is closed unless this is explicit, despite .env.example + # suggesting true is the default. Only reachable over the tailnet + # (trusted interface, see module comment below), so leaving it open is + # fine; flip to false once your account exists if you want it locked down. + env.ALLOW_REGISTRATION = true; + + credentials = { + CREDS_KEY = config.sops.secrets.librechat_creds_key.path; + CREDS_IV = config.sops.secrets.librechat_creds_iv.path; + JWT_SECRET = config.sops.secrets.librechat_jwt_secret.path; + JWT_REFRESH_SECRET = config.sops.secrets.librechat_jwt_refresh_secret.path; + }; + + settings = { + version = "1.2.1"; + endpoints.custom = [ + { + name = "Ollama"; + # required field but unchecked by ollama's OpenAI-compat shim + apiKey = "ollama"; + baseURL = "http://127.0.0.1:11434/v1"; + models = { + # schema requires >=1 entry even though fetch=true overwrites it + # at runtime with whatever's pulled (see loadModels in + # hosts/terra/configuration.nix) — kept roughly in sync anyway + # so the UI has sane names before the first fetch completes. + default = [ "gemma4:12b" "qwen3.6:35b-a3b" ]; + fetch = true; # pull the model list from ollama at startup + }; + titleConvo = true; + } + ]; + + # Persistent memory is opt-in at the CONFIG level — omitting this block + # (as before) leaves the feature entirely off, no matter what a user + # toggles in Settings > Personalization. `agent.provider` must match + # endpoints.custom[].name above exactly ("Ollama"), which is how the + # memory-extraction agent picks a backend/model. + memory = { + personalize = true; # still needs a per-user opt-in toggle in the UI + # instructions REPLACES the default extraction prompt entirely (not + # appended to it) — the 3b model (llama3.2:3b, dropped) was + # defaulting to saving things like its own "I am a helpful + # assistant..." boilerplate under an invented "user_conversation" + # key, and even after adding this prompt, still saved "I am an AI + # assistant with tool calling capabilities" as personal_info after + # the user introduced THEMSELVES — a capability ceiling, not a + # prompting problem. validKeys constrains it to a fixed whitelist + # and instructions spells out the bar for each one. + validKeys = [ "user_preferences" "personal_info" "ongoing_projects" "technical_context" ]; + agent = { + enabled = true; + provider = "Ollama"; + # same model as the chat endpoint's primary driver — when that's + # the active chat model, extraction needs no second model swapped + # into VRAM alongside it. + model = "gemma4:12b"; + instructions = '' + Save memory ONLY using the keys below, and only when the user's + message states something durable and genuinely useful to recall + in a LATER, unrelated conversation. Small talk, greetings, and + anything about what the assistant said or is capable of are NOT + memories — if nothing meets the bar, save nothing. + + set_memory REPLACES the entire value stored at a key — it does + NOT append to it. Before calling set_memory for a key, check the + "Existing memory" section below. If that key already has a + value, your new value MUST merge the old and new information + into one complete sentence or short paragraph — calling + set_memory with only the newest fact silently ERASES everything + already stored under that key. Only drop prior details if the + user is explicitly correcting or replacing them. + + - user_preferences: explicitly stated preferences (tools, formats, style). + - personal_info: durable facts about the user (name, role, timezone). + - ongoing_projects: projects or tasks the user is actively working on. + - technical_context: durable facts about the user's setup/stack + relevant to future answers (e.g. "runs NixOS", "GPU is AMD ROCm"). + ''; + }; + }; + }; + }; +}