Author SHA1 Message Date
darmanandClaude Opus 5 22caa06904 mars(kittentts): patch venv .so RPATHs so numpy/onnxruntime load
The venv's python is Nix python313 (also inside the container, via the
/nix/store mount), whose loader never searches /usr/lib, so PyPI manylinux
wheels failed with "libstdc++.so.6: cannot open shared object file".
auto-patchelf the staged venv against gcc-lib + zlib; RPATHs are store
paths, so the delivered copy works in the container too. The lib paths join
the input hash so a toolchain bump rebuilds.

Verified locally with mars's exact uv/python313: construct offline from the
staged hub layout and synthesize with voice "Luna".

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-19 15:05:01 +02:00
darmanandClaude Opus 5 812c1af1f2 mars(kittentts): install the wheel under its real filename
uv reads the version from the wheel filename, and the fetchurl store path's
hash prefix made it reject the file ("invalid version"). Expose it via a
linkFarm under kittentts-0.8.1-py3-none-any.whl.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-19 14:59:01 +02:00
darmanandClaude Opus 5 e0f5c6b37b mars(kittentts): fix review round 3 — eval, delivery and stale-venv bugs
- pkgs.setpriv does not exist (mars failed to eval); setpriv is in util-linux.
- `sh` is not on a unit's PATH; call ${pkgs.runtimeShell} directly.
- Drop `env HOME="$hermesHome"`: unset shell var under `set -u`, and unneeded.
- Staging dir 0700 -> 0755: the uid-986 delivery step could not read it.
- Rebuild the staging venv whenever the stamp misses, else an input bump
  delivered the old venv under the new stamp.
- Add diffutils: `cmp` in the fast path was missing, so every boot re-delivered.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-19 14:51:05 +02:00
luna bef5215bea mars(kittentts): review round 2 — setpriv delivery, staged hub tree, ordering fix
1: stageVenv declared with the other path vars before the early-exit
reads it (set -u no longer kills the self-heal path on later boots).
2: delivery is rm+cp as the container uid via setpriv — no mv -Tf
rename-replace on a non-empty directory (that failed every
re-provision); chown -R step gone with it.
3: network-online.target back in after= — this unit CAN download at
boot, so uv must not run pre-network.
4: root never writes into hermesHome at all. refs/main, snapshots and
venv staging happen under root-owned staging; delivery drops to uid 986
via setpriv, rm -rf + cp -a from the staging sources. Symlink-takeover
of refs/install targets and the rm/cp race on the delivered tree are
structurally gone.
Minor: sanity check now constructs KittenTTS against the staged hub
tree, not just imports; fetchurls pinned to the commit sha matching
refs/main; fast-path comment matches behavior (staging venv skips
rebuild on damaged delivery).
2026-09-19 12:46:14 +00:00
luna b2982c47a1 mars(kittentts): review fixes — root-owned staging, offline cache resolved, full-input stamp
Review round 1 fixes:
1+2 (security): build+stamp+uv-cache moved out of hermesHome into a
0700 root-owned staging dir (/var/lib/hermes-kittentts). Root executes
python only from the staging venv after an offline import check;
finished artifacts are copied into hermesHome and the stamp compares
delivered copy vs staging byte-for-byte. No root code path follows
anything the container can write, so no stamp symlink-takeover and no
planted-wheel-in-cache execution.
3: HF snapshot dir is the real commit sha with refs/main written, so
hf_hub_download resolves the seeded cache offline (was 'kitten', never
found).
4: container env pins HF_HOME=/opt/data/kittentts-hf + HF_HUB_OFFLINE=1.
5: stamp keyed on the full input set (requirements+wheel+stub+models),
not requirements alone; deletion of a delivered file drops out of the
fast path and re-provisions cheaply.
6: orders after/requires hermes-agent-prepare-dirs.
2026-09-19 12:33:55 +00:00
luna 524315f495 mars: add KittenTTS voice provider (CPU-only, offline mini model)
tts.provider: kittentts, voice Luna, mini-0.8 model (80M params) per
darman. CPU-only onnxruntime inference — no GPU on mars, validated ~1x
realtime on a weaker dev box. Upstream's misaki[en]+spacy declaration is
deliberately not honored (pulls torch→CUDA, 5.6 GB); a .pth-registered
stub satisfies the dead misaki import, failing loudly if it is ever
actually used. HF model cache pre-seeded from hash-pinned store paths so
HF_HUB_OFFLINE=1 runs with zero boot-time network. Provisioner shaped by
the Mnemosyne review: uv venv --clear, root never executes python from
the venv, ownership to the container uid before anything imports, stamp
checked against venv+model files.
2026-09-19 12:14:53 +00:00
darman 25e17f163e Merge pull request 'mars: provision the Mnemosyne memory provider for Hermes' (#6) from feat/mars-hermes-mnemosyne into master
Reviewed-on: #6
2026-09-19 02:16:35 +02:00
darmanandClaude Opus 5 9b96d3c6f8 mars(mnemosyne): fix env build and plugin symlink target
- fetchPypi: sdists are published underscore-named, so the hyphenated
  pname 404'd.
- sitePackages is relative; prefix the env path so the unit's -d check
  and the symlink point at the store.
- hermes_memory_provider ships in mnemosyne-memory, not mnemosyne-hermes;
  move the import check accordingly.
- Drop the unused /opt/data/mnemosyne-env mount, the orphaned
  requirements.txt, and trim comments.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-19 02:09:14 +02:00
3 changed files with 208 additions and 209 deletions
+180 -105
View File
@@ -74,11 +74,7 @@ let
builtins.readFile ./gitea-pr-review-prompt.md builtins.readFile ./gitea-pr-review-prompt.md
); );
# Mnemosyne memory provider (local SQLite, third-party plugin not bundled # Mnemosyne memory provider: third-party plugin, not in the image.
# with the official image). Fully built as a Nix derivation — see
# pkgs/mnemosyne-env.nix — and mounted READ-ONLY into the container at a
# fixed path. The oneshot near the bottom of this file only writes the
# plugin symlink Docker needs at $HERMES_HOME/plugins/mnemosyne.
mnemosyneEnv = pkgs.callPackage ../../pkgs/mnemosyne-env.nix { }; mnemosyneEnv = pkgs.callPackage ../../pkgs/mnemosyne-env.nix { };
# KittenTTS voice provider inputs (CPU-only; model + wheel hash-pinned). # KittenTTS voice provider inputs (CPU-only; model + wheel hash-pinned).
@@ -89,6 +85,11 @@ let
url = "https://github.com/KittenML/KittenTTS/releases/download/0.8.1/kittentts-0.8.1-py3-none-any.whl"; url = "https://github.com/KittenML/KittenTTS/releases/download/0.8.1/kittentts-0.8.1-py3-none-any.whl";
sha256 = "sha256-SCpDbE8fMZIVNxA3bkWf82iVF+vNp8KwUeL9QYe0GFE="; sha256 = "sha256-SCpDbE8fMZIVNxA3bkWf82iVF+vNp8KwUeL9QYe0GFE=";
}; };
# uv parses the version out of the filename; the store hash prefix breaks it.
kittenttsWheelFile = "${pkgs.linkFarm "kittentts-wheel" {
"kittentts-0.8.1-py3-none-any.whl" = kittenttsWheel;
}}/kittentts-0.8.1-py3-none-any.whl";
kittenttsRuntimeLibs = "${pkgs.stdenv.cc.cc.lib}/lib ${pkgs.zlib}/lib";
kittenttsReqs = pkgs.writeText "kittentts-requirements.txt" ( kittenttsReqs = pkgs.writeText "kittentts-requirements.txt" (
builtins.readFile ./kittentts/requirements.txt builtins.readFile ./kittentts/requirements.txt
); );
@@ -96,15 +97,15 @@ let
builtins.readFile ./kittentts/kitten-misaki-stub.py builtins.readFile ./kittentts/kitten-misaki-stub.py
); );
kittenttsModelOnnx = pkgs.fetchurl { kittenttsModelOnnx = pkgs.fetchurl {
url = "https://huggingface.co/KittenML/kitten-tts-mini-0.8/resolve/main/kitten_tts_mini_v0_8.onnx"; url = "https://huggingface.co/KittenML/kitten-tts-mini-0.8/resolve/c02725660cea441db4c383af69f1f26f5cd00947/kitten_tts_mini_v0_8.onnx";
sha256 = "sha256-D1u65PxIAMmNvFRKh+z6eVEN4vuCItsw0S5b/pF335E="; sha256 = "sha256-D1u65PxIAMmNvFRKh+z6eVEN4vuCItsw0S5b/pF335E=";
}; };
kittenttsModelVoices = pkgs.fetchurl { kittenttsModelVoices = pkgs.fetchurl {
url = "https://huggingface.co/KittenML/kitten-tts-mini-0.8/resolve/main/voices.npz"; url = "https://huggingface.co/KittenML/kitten-tts-mini-0.8/resolve/c02725660cea441db4c383af69f1f26f5cd00947/voices.npz";
sha256 = "sha256-QK0mOJUrd7ey8wEn4mCOFp/GndJWtTvYqqNAmjMZPEI="; sha256 = "sha256-QK0mOJUrd7ey8wEn4mCOFp/GndJWtTvYqqNAmjMZPEI=";
}; };
kittenttsModelConfig = pkgs.fetchurl { kittenttsModelConfig = pkgs.fetchurl {
url = "https://huggingface.co/KittenML/kitten-tts-mini-0.8/resolve/main/config.json"; url = "https://huggingface.co/KittenML/kitten-tts-mini-0.8/resolve/c02725660cea441db4c383af69f1f26f5cd00947/config.json";
sha256 = "sha256-axYLybGeJOyyHoS8FPin2iH99H7HLUJFC8XPUUthgEo="; sha256 = "sha256-axYLybGeJOyyHoS8FPin2iH99H7HLUJFC8XPUUthgEo=";
}; };
@@ -262,15 +263,6 @@ in
"${hermesHome}:/opt/data" "${hermesHome}:/opt/data"
"${dropboxDir}:/opt/data/dropbox" "${dropboxDir}:/opt/data/dropbox"
# Mnemosyne memory provider — a Nix-built python env, mounted :ro.
# Nothing fetched at boot, nothing writable from inside the container.
# The plugin symlink the oneshot at the bottom of this file writes
# points at the canonical store path (site-packages passthru), which
# is visible inside thanks to the existing /nix/store ro mount, so
# this /opt/data restatement is a readability alias, not a load
# bearing path.
"${mnemosyneEnv}:/opt/data/mnemosyne-env:ro"
# luna's Obsidian vault, synced with CouchDB on jupiter by # luna's Obsidian vault, synced with CouchDB on jupiter by
# livesync-bridge.nix. Under /opt/data so she can write notes, not just # livesync-bridge.nix. Under /opt/data so she can write notes, not just
# read them; the bridge runs as this same uid/gid so no chown is needed. # read them; the bridge runs as this same uid/gid so no chown is needed.
@@ -314,6 +306,11 @@ in
# Authentik rejects. Safe to trust any peer: 9119 is already scoped to # Authentik rejects. Safe to trust any peer: 9119 is already scoped to
# loopback + tailscale0 only. # loopback + tailscale0 only.
FORWARDED_ALLOW_IPS = "*"; FORWARDED_ALLOW_IPS = "*";
# KittenTTS: point huggingface_hub at the pre-seeded offline cache and
# forbid network — no model drift, no boot-time fetch (review #4).
HF_HOME = "/opt/data/kittentts-hf";
HF_HUB_OFFLINE = "1";
}; };
environmentFiles = [ config.sops.templates."hermes-agent.env".path ]; environmentFiles = [ config.sops.templates."hermes-agent.env".path ];
cmd = [ "gateway" "run" ]; cmd = [ "gateway" "run" ];
@@ -462,26 +459,9 @@ in
''; '';
}; };
# ---- Mnemosyne memory provider ---------------------------------------- # Hermes discovers memory providers under $HERMES_HOME/plugins; the target
# The provider's Python closure (mnemosyneEnv, callPackage'd # is a store path, readable in the container via the /nix/store ro mount.
# pkgs/mnemosyne-env.nix above) is a READ-ONLY nix store path mounted into # wantedBy, not requiredBy: a failure leaves Hermes on built-in memory.
# the container — nothing fetched at boot, nothing inside the container can
# write to it, and the mono-repo reproducibility story applies. Hermes only
# needs one mutable pickup point: $HERMES_HOME/plugins/mnemosyne, the
# symlink its discovery scan looks for. Both the plugin wrapper and its
# sibling `mnemosyne` core package live in that env's single site-packages,
# so one link covers them.
#
# Path handling: the symlink target is spelled in the CONTAINER's path
# space (/opt/data/...), because Hermes resolves the plugin from inside the
# container — the same host/container mismatch the webhook prompts already
# navigate via containerHome. Host-side the identical literal resolves
# onto the same store path through hermesHome's bind mount.
#
# Failure posture: after=, not requires= — a failed link write leaves the
# container running with whatever memory.provider falls back to Hermes's
# built-in memory, not a dead bot. The ro mount itself is evaluated at
# build time, so there is nothing provisionable to drift at runtime.
systemd.services.hermes-agent-mnemosyne-plugin = { systemd.services.hermes-agent-mnemosyne-plugin = {
description = "Link Mnemosyne provider into the Hermes plugin dir"; description = "Link Mnemosyne provider into the Hermes plugin dir";
before = [ "podman-hermes-agent.service" ]; before = [ "podman-hermes-agent.service" ];
@@ -495,27 +475,13 @@ in
}; };
script = '' script = ''
set -euo pipefail set -euo pipefail
pluginsDir=${hermesHome}/plugins pluginsDir=${hermesHome}/plugins
pluginDir=$pluginsDir/mnemosyne pluginDir=$pluginsDir/mnemosyne
# Target is the STORE path itself, not a /opt/data restatement: the target=${mnemosyneEnv}/${mnemosyneEnv.sitePackages}/hermes_memory_provider
# container already ro-mounts /nix/store for git/tea (see the volumes [ -d "$target" ] || { echo "$target missing" >&2; exit 1; }
# list), so the identical literal resolves on both sides of the bind
# mount. Using the canonical store path directly not the
# /opt/data/mnemosyne-env mount keeps one truth and still works
# whether Hermes resolves the link inside the container or host-side
# during debugging.
target="${mnemosyneEnv.sitePackages}/hermes_memory_provider"
if [ ! -d "$target" ]; then
echo "hermes_memory_provider not found in the mnemosyne env unit bug, not transient" >&2
exit 1
fi
mkdir -p "$pluginsDir" mkdir -p "$pluginsDir"
chown ${hermesUid}:${hermesGid} "$pluginsDir" chown ${hermesUid}:${hermesGid} "$pluginsDir"
# Atomic swap: write to a temp name, rename over the old link. `-T`
# errors loudly if the target turned into a directory for any reason.
ln -sfn "$target" "$pluginDir.new" ln -sfn "$target" "$pluginDir.new"
mv -Tf "$pluginDir.new" "$pluginDir" mv -Tf "$pluginDir.new" "$pluginDir"
chown -h ${hermesUid}:${hermesGid} "$pluginDir" chown -h ${hermesUid}:${hermesGid} "$pluginDir"
@@ -530,27 +496,51 @@ in
# .pth-registered stub (kitten-misaki-stub.py) that fails loudly if misaki # .pth-registered stub (kitten-misaki-stub.py) that fails loudly if misaki
# is ever actually used. # is ever actually used.
# #
# Provisioner invariants (shaped by the Mnemosyne-round review): # Provisioner invariants (shaped by the Mnemosyne + KittenTTS review rounds):
# - `uv venv --clear`: re-provision cannot wedge on an existing dir. # - Build in a ROOT-OWNED staging dir (/var/lib/hermes-kittentts, 0755 so
# - Root runs NO python from this venv: the unit itself does only fs # the uid-986 delivery step can read it; nothing in it is secret):
# writes; the venv is chowned to the container uid before Hermes ever # stamp, staging venv, uv cache and staging model copies all live there.
# imports from it. (Hermes executes provider code as uid 986.) # Root never reads or executes anything the container can write — the
# - HF model cache is PRE-SEEDED from hash-pinned store paths, so # container cannot symlink-takeover the stamp (review #1) or plant a
# HF_HUB_OFFLINE=1 gives zero boot-time network and no drift. # wheel in the uv cache (review #2); only FINISHED artifacts are copied
# - Idempotent via a stamp keyed on the requirements hash; checked # into hermesHome, and the stamp compares the delivered copy against
# against BOTH the venv python and model files being intact. # staging byte-for-byte.
# - Root runs python ONLY from the staging venv (never from the delivered
# uid-986-owned tree in hermesHome) — after a root-side offline import
# check; a failing build aborts before anything is delivered.
# - HF model cache delivered to hermesHome from staging (refs/main ->
# snapshots/<real commit sha>), and the container env pins
# HF_HOME=/opt/data/kittentts-hf + HF_HUB_OFFLINE=1: zero boot-time
# network, no drift.
# - Idempotency stamp keyed on the FULL input set (requirements + wheel +
# stub + model files), not just requirements (review #5); a deleted
# delivered file falls out of the fast path and re-provisions cheaply.
# - Order after prepare-dirs (review #6) — mirrors the mnemosyne unit.
# #
# Trust boundary: the venv lives inside hermesHome (HERMES_WRITE_SAFE_ROOT), # Trust boundary: the DELIVERED venv lives inside hermesHome
# so luna can technically alter her own TTS engine. Deliberate: it's her # (HERMES_WRITE_SAFE_ROOT), so luna can alter her own TTS engine — and a
# voice, not her jail — the webhook filter scripts remain the only # deleted/modified copy just triggers a fresh delivery from the root-owned
# staging area on next boot (self-heals instead of wedging). Deliberate: it's
# her voice, not her jail — the webhook filter scripts remain the only
# write-protected-but-load-bearing items. # write-protected-but-load-bearing items.
systemd.services.hermes-agent-kittentts-provision = { systemd.services.hermes-agent-kittentts-provision = {
description = "Provision KittenTTS voice provider (side venv + offline HF cache)"; description = "Provision KittenTTS voice provider (side venv + offline HF cache)";
before = [ "podman-hermes-agent.service" ]; before = [ "podman-hermes-agent.service" ];
wantedBy = [ "podman-hermes-agent.service" ]; wantedBy = [ "podman-hermes-agent.service" ];
wants = [ "network-online.target" ]; wants = [ "network-online.target" ];
after = [ "network-online.target" ]; # after prepare-dirs (review #6): on a fresh state dir this unit must not
path = [ pkgs.uv pkgs.coreutils ]; # create hermesHome root-owned before prepare-dirs sets the ownership
# layout — same ordering contract the mnemosyne unit has. PLUS
# network-online ordering (review2 #3): this unit CAN download at boot
# (unlike mnemosyne's store-path build), so uv must not run before the
# network is actually up.
after = [
"network-online.target"
"hermes-agent-prepare-dirs.service"
];
requires = [ "hermes-agent-prepare-dirs.service" ];
# diffutils: `cmp` in the fast path; missing, it silently re-delivers every boot.
path = [ pkgs.uv pkgs.coreutils pkgs.diffutils pkgs.util-linux pkgs.auto-patchelf pkgs.patchelf ];
serviceConfig = { serviceConfig = {
Type = "oneshot"; Type = "oneshot";
TimeoutStartSec = 600; TimeoutStartSec = 600;
@@ -560,51 +550,136 @@ in
venv=${hermesHome}/kittentts-venv venv=${hermesHome}/kittentts-venv
hubDir=${hermesHome}/kittentts-hf/hub/models--KittenML--kitten-tts-mini-0.8 hubDir=${hermesHome}/kittentts-hf/hub/models--KittenML--kitten-tts-mini-0.8
snap=$hubDir/snapshots/kitten # Real upstream commit SHA as snapshot dir: hf_hub_download resolves
stampFile=${hermesHome}/kittentts-provision.stamp # refs/main -> snapshots/<sha>; "kitten" (review #3) is never found
reqHash=$(sha256sum ${kittenttsReqs} | cut -d' ' -f1) # offline and silently triggers a re-download.
modelSha=c02725660cea441db4c383af69f1f26f5cd00947
snap=$hubDir/snapshots/$modelSha
# REVIEW #1/#2: nothing root touches lives in hermesHome. Stamp, staging
# venv and uv cache live root-owned under /var/lib/hermes-kittentts (a
# path the container can not pathwrite or symlinks into its own tree);
# the FINISHED staging venv and model files are the only things copied
# into hermesHome, and only after being validated. Root doesn't follow
# any uid-986-writable path while running as root.
stageDir=/var/lib/hermes-kittentts
# Root-side staging of the DELIVERED TREE (venv + full HF hub layout
# including refs/main) everything root writes lives here.
# review2 #4: root never writes into hermesHome; delivery happens as
# the container uid via setpriv, copying from these root-owned sources.
stageVenv=$stageDir/venv
stageHub=$stageDir/kittentts-hf
stageSnap=$stageDir/hf-model
# Input key: requirements + wheel + stub + model files + resolved script.
# Review #5 a miss on the old requirements-only stamp let a changed
# wheel/stub/model URL keep running the stale install forever.
inputHash=$({ cat ${kittenttsReqs} ${kittenttsWheel} ${kittenttsStub} \
${kittenttsModelOnnx} ${kittenttsModelVoices} ${kittenttsModelConfig}
echo ${kittenttsRuntimeLibs}; } | sha256sum | cut -d' ' -f1)
stampFile=$stageDir/provision.stamp
# Idempotent early exit: stamp + venv + all three model files intact. # Idempotent early exit: stamp matches the full input hash, staging venv
if [ -f "$stampFile" ] && [ "$(cat "$stampFile")" = "$reqHash" ] \ # validated, delivered copy intact check runs below.
&& [ -x "$venv/bin/python" ] \ if [ -f "$stampFile" ] && [ "$(cat "$stampFile")" = "$inputHash" ] \
&& [ -f "$snap/kitten_tts_mini_v0_8.onnx" ] \ && [ -x "$stageVenv/bin/python" ] \
&& [ -f "$snap/voices.npz" ] \ && [ -f "$stageSnap/config.json" ]; then
&& [ -f "$venv/lib/python3.13/site-packages/kitten_tts_stub.py" ]; then # Delivered artifacts in hermesHome must ALSO match the staging copy:
exit 0 # luna can rewrite her copy, that's fine but then the missing file
# forces a re-provision (cheap copy, not a rebuild) so deletions
# cannot wedge the gateway without a voice.
if [ -x "$venv/bin/python" ] \
&& cmp -s "$stageSnap/config.json" "$snap/config.json" 2>/dev/null \
&& cmp -s "$stageSnap/kitten_tts_mini_v0_8.onnx" "$snap/kitten_tts_mini_v0_8.onnx" 2>/dev/null \
&& cmp -s "$stageSnap/voices.npz" "$snap/voices.npz" 2>/dev/null; then
exit 0
fi
fi fi
# Venv (rebuilt rather than broken on --no-clear: uv exits 2 otherwise). # ---- staging venv + staged hub tree: root-owned path, uv cache
uv venv "$venv" --python ${pkgs.python313}/bin/python3 --clear --quiet # included. Root runs python from HERE (container can write nothing in
UV_CACHE_DIR=${hermesHome}/kittentts-uv-cache \ # this tree); the FINISHED result is copied into hermesHome AS THE
uv pip install --python "$venv/bin/python" --quiet \ # CONTAINER USER via setpriv (review2 #4) root never writes into
--requirement ${kittenttsReqs} # hermesHome, so no uid-986-controlled path is ever followed while
# kittentts --no-deps: its overlay of spacy/misaki[en] is what drags in # running as root; symlink-takeover of stamps/refs/install targets is
# the CUDA tree; the requirements freeze already covers its real needs. # structurally impossible. ----
UV_CACHE_DIR=${hermesHome}/kittentts-uv-cache \ mkdir -p "$stageDir" "$stageSnap" "$stageVenv" "$stageHub"
uv pip install --python "$venv/bin/python" --quiet --no-deps \ # 0755: the setpriv'd uid-986 delivery below must be able to read it.
${kittenttsWheel} chmod 0755 "$stageDir"
install -m 0444 ${kittenttsModelConfig} "$stageSnap/config.json"
install -m 0444 ${kittenttsModelOnnx} "$stageSnap/kitten_tts_mini_v0_8.onnx"
install -m 0444 ${kittenttsModelVoices} "$stageSnap/voices.npz"
# Dead-import shim: .pth auto-loads kitten_tts_stub at interpreter start # Skip the venv REBUILD when staging is still valid (review2 minor:
# so `from misaki import en, espeak` resolves without the real misaki.en. # damaged delivery should be a copy, not a rebuild) but only for the
siteDir=$("$venv/bin/python" -c 'import sysconfig; print(sysconfig.get_paths()["purelib"])') # CURRENT inputs, else a bump delivers the old venv under a new stamp.
cp ${kittenttsStub} "$siteDir/kitten_tts_stub.py" if [ "$(cat "$stampFile" 2>/dev/null)" != "$inputHash" ] \
printf 'import kitten_tts_stub\n' > "$siteDir/zz-kitten-stub.pth" || ! [ -x "$stageVenv/bin/python" ] \
|| ! [ -f "$stageVenv/lib/python3.13/site-packages/kitten_tts_stub.py" ]; then
rm -rf "$stageVenv"
UV_CACHE_DIR=$stageDir/uv-cache \
uv venv "$stageVenv" --python ${pkgs.python313}/bin/python3 --quiet
UV_CACHE_DIR=$stageDir/uv-cache \
uv pip install --python "$stageVenv/bin/python" --quiet \
--requirement ${kittenttsReqs}
# kittentts --no-deps: its overlay of spacy/misaki[en] is what drags in
# the CUDA tree; the requirements freeze already covers its real needs.
UV_CACHE_DIR=$stageDir/uv-cache \
uv pip install --python "$stageVenv/bin/python" --quiet --no-deps \
${kittenttsWheelFile}
# Seed the HF cache with the hashed model files (exact hub layout; # Dead-import shim: .pth auto-loads kitten_tts_stub at interpreter
# hf_hub_download scans refs/snapshots on disk offline). # start so `from misaki import en, espeak` resolves without the real
mkdir -p "$hubDir/refs" "$snap" # misaki.en.
install -m 0444 ${kittenttsModelOnnx} "$snap/kitten_tts_mini_v0_8.onnx" siteDir=$("$stageVenv/bin/python" -c 'import sysconfig; print(sysconfig.get_paths()["purelib"])')
install -m 0444 ${kittenttsModelVoices} "$snap/voices.npz" cp ${kittenttsStub} "$siteDir/kitten_tts_stub.py"
install -m 0444 ${kittenttsModelConfig} "$snap/config.json" printf 'import kitten_tts_stub\n' > "$siteDir/zz-kitten-stub.pth"
# Hand ownership to the container uid BEFORE any python runs in this # PyPI manylinux .so's (numpy, onnxruntime) need libstdc++, which the
# tree (root never imports from it that was Mnemosyne review #3). # Nix loader never finds, on the host or in the container.
chown -R ${hermesUid}:${hermesGid} "$venv" "$hubDir" auto-patchelf --paths "$stageVenv" --libs ${kittenttsRuntimeLibs}
fi
# Stamp LAST a half-provisioned venv fails the integrity check and # Stage the full delivered hub tree (exact hf_hub_download layout:
# re-provisions on next boot rather than being trusted. # refs/main -> snapshots/<sha>; review1 #3) under root-owned staging.
printf '%s' "$reqHash" > "$stampFile" stageModelDir=$stageHub/hub/models--KittenML--kitten-tts-mini-0.8
chown ${hermesUid}:${hermesGid} "$stampFile" mkdir -p "$stageModelDir/refs" "$stageModelDir/snapshots/$modelSha"
printf '%s' "$modelSha" > "$stageModelDir/refs/main"
install -m 0444 ${kittenttsModelOnnx} "$stageModelDir/snapshots/$modelSha/kitten_tts_mini_v0_8.onnx"
install -m 0444 ${kittenttsModelVoices} "$stageModelDir/snapshots/$modelSha/voices.npz"
install -m 0444 ${kittenttsModelConfig} "$stageModelDir/snapshots/$modelSha/config.json"
# No blobs/ indirection: kittentts reads paths RETURNED by
# hf_hub_download, which serves the resolved snapshot file directly
# (prefer-dir layout works offline for fully-materialized files).
# Root-side sanity: the staged interpreter must construct the model
# END-TO-END offline (review2 minor import alone doesn't exercise
# hf_hub_download; a broken cache layout must fail HERE, not in the
# gateway). Points HF_HOME at the staged hub tree itself.
HF_HOME=$stageHub \
HF_HUB_OFFLINE=1 \
PHONEMIZER_ESPEAK_LIBRARY="$("$stageVenv/bin/python" -c 'import espeakng_loader,pathlib;print(pathlib.Path(espeakng_loader.get_library_path()))')" \
PHONEMIZER_ESPEAK_DATA_PATH="$("$stageVenv/bin/python" -c 'import espeakng_loader,pathlib;print(pathlib.Path(espeakng_loader.get_data_path()))')" \
"$stageVenv/bin/python" -c 'from kittentts import KittenTTS; KittenTTS("KittenML/kitten-tts-mini-0.8"); print("kittentts offline build ok")' >/dev/null
# ---- deliver AS THE CONTAINER USER (review2 #4): root never writes
# into hermesHome, so no symlink race and no `chown` step. setpriv
# drops to uid 986, rm -rf's the old delivered copies and copies the
# fresh staging tree in. cp-as-986 also fixes review2 #2: rm+cp in one
# step, no mv -Tf rename-replace on a non-empty directory.
setpriv --reuid=${hermesUid} --regid=${hermesGid} --clear-groups \
${pkgs.runtimeShell} -c '
set -eu
rm -rf "$1" "$2"
cp -a "$3" "$1"
cp -a "$4" "$2"
' _ \
"${hermesHome}/kittentts-venv" \
"${hermesHome}/kittentts-hf" \
"$stageVenv" \
"$stageHub"
# Stamp LAST, root-owned outside hermesHome luna can delete it (which
# forces a cheap re-delivery on next boot), not tamper via symlink.
printf '%s' "$inputHash" > "$stampFile"
''; '';
}; };
} }
-46
View File
@@ -1,46 +0,0 @@
# Pinned requirements for a Mnemosyne side-venv on mars.
#
# Hermes vendors its own Python (the official image's venv) and deliberately
# stays minimal: no pip module inside it, PEP 668 external-management on top.
# Installing provider packages straight into that interpreter would fight the
# image on every rebuild, so Mnemosyne (and its plugin wrapper) live in their
# own venv instead — see the provisioning unit in hosts/mars/hermes-agent.nix.
#
# Freeze captured 2026-09-19 from a verified container-side install of
# `mnemosyne-memory[embeddings]` + `mnemosyne-hermes` — side venv at
# $HERMES_HOME/mnemosyne-venv, activated via $HERMES_HOME/plugins/mnemosyne.
# Versions pinned exactly; transitive deps frozen for reproducibility
# (onnxruntime/numpy drift under a long-lived SQLite state dir is what a
# freeze is here to prevent).
#
anyio==4.15.0
certifi==2026.7.22
charset-normalizer==3.5.1
click==8.5.0
fastembed==0.8.0
filelock==3.32.5
flatbuffers==25.12.19
fsspec==2026.7.0
h11==0.16.0
hf-xet==1.6.0
httpcore==1.0.9
httpx==0.28.1
huggingface-hub==1.32.0
idna==3.19
loguru==0.7.3
mmh3==5.3.0
mnemosyne-hermes==0.5.0
mnemosyne-memory==3.15.1
numpy==2.5.3
onnxruntime==1.30.0
packaging==26.3
pillow==12.3.0
protobuf==7.36.1
py-rust-stemmers==0.1.8
pyyaml==6.0.3
requests==2.34.2
sqlite-vec==0.1.9
tokenizers==0.23.2
tqdm==4.70.0
typing-extensions==4.16.0
urllib3==2.7.0
+28 -58
View File
@@ -1,86 +1,56 @@
# Mnemosyne memory provider for Hermes on mars — packaged for real (Nix). # Mnemosyne memory provider for Hermes. Built here rather than pip-installed:
# the image's Python has no pip and is PEP 668 managed.
# #
# Why derivations instead of a runtime side-venv: the official Hermes image # Core deps only: the embeddings extra (fastembed/onnxruntime) is optional at
# vendors its own Python and stays off-limits to pip (no pip module, PEP 668), # runtime, and recall falls back to FTS5.
# and a runtime venv built host-side breaks twice over inside the container:
# the venv's pyvenv.cfg records a /nix/store python home the container never
# mounts, and a plugins symlink with an absolute host path points nowhere
# from /opt/data. Building here means nothing is fetched at boot, nothing
# under the provider's control is writable from inside the container, and
# the closure is as reproducible as the rest of the host.
#
# Package set (one shared site-packages — the plugin wrapper imports its
# sibling `mnemosyne` core package, so withPackages, not separate envs):
#
# mnemosyne-memory core engine: SQLite/FTS5 storage, recall, tools.
# Base deps only (PyYAML); the optional extras (llm,
# embeddings via fastembed/onnxruntime, mcp, sync) are
# deliberately NOT pulled — recall uses the bundled FTS5
# lexical path, and the heavyweight ML stack (~hundreds of
# MB, live network on first vector use) buys nothing for
# a first deployment. Adding the embeddings extra later
# is pinning fastembed + sqlite-vec here.
# mnemosyne-hermes the wrapper Hermes discovers under $HERMES_HOME/plugins
# (installs itself as package `hermes_memory_provider`).
# Hard dependency: mnemosyne-memory, PyYAML.
#
# Platform note: both sdists are pure Python (build no C extensions), so
# nothing here constrains the host arch beyond the interpreter itself.
{ {
python3, python3,
fetchPypi, fetchPypi,
}: }:
let let
python = python3; mnemosyneMemory = python3.pkgs.buildPythonPackage rec {
mnemosyneMemory = python.pkgs.buildPythonPackage rec {
pname = "mnemosyne-memory"; pname = "mnemosyne-memory";
version = "3.15.1"; version = "3.15.1";
pyproject = true; pyproject = true;
src = fetchPypi { src = fetchPypi {
inherit pname version; pname = "mnemosyne_memory";
sha256 = "sha256-lspUMxc0pUSkhSUrNdiiO5OJ1NMC/S853EYSanXtXKM="; inherit version;
hash = "sha256-lspUMxc0pUSkhSUrNdiiO5OJ1NMC/S853EYSanXtXKM=";
}; };
build-system = with python.pkgs; [ setuptools ]; build-system = with python3.pkgs; [ setuptools ];
dependencies = with python3.pkgs; [ pyyaml ];
# Base dependency set — everything else in the upstream metadata is an doCheck = false; # tests want a live Hermes + LLM key
# optional extra (llm / embeddings / mcp / sync / test / dev) and is not # Ships the Hermes plugin package too, not just the core.
# installed; see the file-level comment. pythonImportsCheck = [
dependencies = with python.pkgs; [ pyyaml ]; "mnemosyne"
"hermes_memory_provider"
doCheck = false; # upstream tests want a live Hermes + LLM key present ];
pythonImportsCheck = [ "mnemosyne" ];
}; };
mnemosyneHermes = python.pkgs.buildPythonPackage rec { mnemosyneHermes = python3.pkgs.buildPythonPackage rec {
pname = "mnemosyne-hermes"; pname = "mnemosyne-hermes";
version = "0.5.0"; version = "0.5.0";
pyproject = true; pyproject = true;
src = fetchPypi { src = fetchPypi {
inherit pname version; pname = "mnemosyne_hermes";
sha256 = "sha256-CzEvnUw5oPFtT5bHQQ/GBdy2C/E7qShQn32irIRYKqw="; inherit version;
hash = "sha256-CzEvnUw5oPFtT5bHQQ/GBdy2C/E7qShQn32irIRYKqw=";
}; };
build-system = with python.pkgs; [ setuptools ]; build-system = with python3.pkgs; [ setuptools ];
# Upstream asks for mnemosyne-memory[embeddings]; see the header.
# The wrapper declares `mnemosyne-memory[embeddings]>=3.11.1` on PyPI, but dependencies = [ mnemosyneMemory ] ++ (with python3.pkgs; [ pyyaml ]);
# the embeddings extra is only consulted when vector recall is enabled
# (see above) — pass the core dependency explicitly rather than dragging
# in onnxruntime for nothing.
dependencies = [ mnemosyneMemory ] ++ (with python.pkgs; [ pyyaml ]);
doCheck = false; doCheck = false;
pythonImportsCheck = [ "hermes_memory_provider" ]; pythonImportsCheck = [ "mnemosyne_hermes" ];
}; };
# The exposed value is the python env itself (a store path mounted :ro).
# Hermes only needs the site-packages dir inside it; `sitePackages` is a
# passthru the python interpreter derivation (and hence withPackages envs)
# exposes, so the caller uses `${env.sitePackages}` instead of guessing
# the python version in a path literal.
in in
python.withPackages (_: [ mnemosyneMemory mnemosyneHermes ]) python3.withPackages (_: [
mnemosyneMemory
mnemosyneHermes
])