Author SHA1 Message Date
luna 76ebc8c822 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:09:03 +00:00
4 changed files with 209 additions and 248 deletions
+99 -197
View File
@@ -74,7 +74,11 @@ let
builtins.readFile ./gitea-pr-review-prompt.md builtins.readFile ./gitea-pr-review-prompt.md
); );
# Mnemosyne memory provider: third-party plugin, not in the image. # Mnemosyne memory provider (local SQLite, third-party plugin not bundled
# 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).
@@ -85,33 +89,22 @@ 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
); );
# STT add-on: faster-whisper (CPU) for Telegram voice-note transcription,
# same side-venv, appended by the provisioning unit. Keeps one venv + one
# stamp for the whole local-audio stack. See kittentts/stt-requirements.txt.
kittenttsSttReqs = pkgs.writeText "kittentts-stt-requirements.txt" (
builtins.readFile ./kittentts/stt-requirements.txt
);
kittenttsStub = pkgs.writeText "kitten-tts-stub.py" ( kittenttsStub = pkgs.writeText "kitten-tts-stub.py" (
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/c02725660cea441db4c383af69f1f26f5cd00947/kitten_tts_mini_v0_8.onnx"; url = "https://huggingface.co/KittenML/kitten-tts-mini-0.8/resolve/main/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/c02725660cea441db4c383af69f1f26f5cd00947/voices.npz"; url = "https://huggingface.co/KittenML/kitten-tts-mini-0.8/resolve/main/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/c02725660cea441db4c383af69f1f26f5cd00947/config.json"; url = "https://huggingface.co/KittenML/kitten-tts-mini-0.8/resolve/main/config.json";
sha256 = "sha256-axYLybGeJOyyHoS8FPin2iH99H7HLUJFC8XPUUthgEo="; sha256 = "sha256-axYLybGeJOyyHoS8FPin2iH99H7HLUJFC8XPUUthgEo=";
}; };
@@ -269,6 +262,15 @@ 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.
@@ -312,18 +314,6 @@ 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";
# STT (local whisper): expose the side-venv's site-packages to Hermes's
# own interpreter so faster-whisper imports in-process. PYTHONPATH dirs
# don't run .pth auto-imports, but faster-whisper has no dead imports —
# it imports cleanly from a plain path injection (unlike kittentts,
# which is driven through the command wrapper for exactly that reason).
PYTHONPATH = "/opt/data/kittentts-venv/lib/python3.13/site-packages";
}; };
environmentFiles = [ config.sops.templates."hermes-agent.env".path ]; environmentFiles = [ config.sops.templates."hermes-agent.env".path ];
cmd = [ "gateway" "run" ]; cmd = [ "gateway" "run" ];
@@ -472,9 +462,26 @@ in
''; '';
}; };
# Hermes discovers memory providers under $HERMES_HOME/plugins; the target # ---- Mnemosyne memory provider ----------------------------------------
# is a store path, readable in the container via the /nix/store ro mount. # The provider's Python closure (mnemosyneEnv, callPackage'd
# wantedBy, not requiredBy: a failure leaves Hermes on built-in memory. # pkgs/mnemosyne-env.nix above) is a READ-ONLY nix store path mounted into
# 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" ];
@@ -488,13 +495,27 @@ in
}; };
script = '' script = ''
set -euo pipefail set -euo pipefail
pluginsDir=${hermesHome}/plugins pluginsDir=${hermesHome}/plugins
pluginDir=$pluginsDir/mnemosyne pluginDir=$pluginsDir/mnemosyne
target=${mnemosyneEnv}/${mnemosyneEnv.sitePackages}/hermes_memory_provider # Target is the STORE path itself, not a /opt/data restatement: the
[ -d "$target" ] || { echo "$target missing" >&2; exit 1; } # container already ro-mounts /nix/store for git/tea (see the volumes
# 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"
@@ -509,51 +530,27 @@ 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 + KittenTTS review rounds): # Provisioner invariants (shaped by the Mnemosyne-round review):
# - Build in a ROOT-OWNED staging dir (/var/lib/hermes-kittentts, 0755 so # - `uv venv --clear`: re-provision cannot wedge on an existing dir.
# the uid-986 delivery step can read it; nothing in it is secret): # - Root runs NO python from this venv: the unit itself does only fs
# stamp, staging venv, uv cache and staging model copies all live there. # writes; the venv is chowned to the container uid before Hermes ever
# Root never reads or executes anything the container can write — the # imports from it. (Hermes executes provider code as uid 986.)
# container cannot symlink-takeover the stamp (review #1) or plant a # - HF model cache is PRE-SEEDED from hash-pinned store paths, so
# wheel in the uv cache (review #2); only FINISHED artifacts are copied # HF_HUB_OFFLINE=1 gives zero boot-time network and no drift.
# into hermesHome, and the stamp compares the delivered copy against # - Idempotent via a stamp keyed on the requirements hash; checked
# staging byte-for-byte. # against BOTH the venv python and model files being intact.
# - 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 DELIVERED venv lives inside hermesHome # Trust boundary: the venv lives inside hermesHome (HERMES_WRITE_SAFE_ROOT),
# (HERMES_WRITE_SAFE_ROOT), so luna can alter her own TTS engine — and a # so luna can technically alter her own TTS engine. Deliberate: it's her
# deleted/modified copy just triggers a fresh delivery from the root-owned # voice, not her jail — the webhook filter scripts remain the only
# 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 prepare-dirs (review #6): on a fresh state dir this unit must not after = [ "network-online.target" ];
# create hermesHome root-owned before prepare-dirs sets the ownership path = [ pkgs.uv pkgs.coreutils ];
# 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;
@@ -563,146 +560,51 @@ 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
# Real upstream commit SHA as snapshot dir: hf_hub_download resolves snap=$hubDir/snapshots/kitten
# refs/main -> snapshots/<sha>; "kitten" (review #3) is never found stampFile=${hermesHome}/kittentts-provision.stamp
# offline and silently triggers a re-download. reqHash=$(sha256sum ${kittenttsReqs} | cut -d' ' -f1)
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} ${kittenttsSttReqs} ${kittenttsWheel} ${kittenttsStub} \
${kittenttsModelOnnx} ${kittenttsModelVoices} ${kittenttsModelConfig}
echo ${kittenttsRuntimeLibs}; } | sha256sum | cut -d' ' -f1)
stampFile=$stageDir/provision.stamp
# Idempotent early exit: stamp matches the full input hash, staging venv # Idempotent early exit: stamp + venv + all three model files intact.
# validated, delivered copy intact check runs below. if [ -f "$stampFile" ] && [ "$(cat "$stampFile")" = "$reqHash" ] \
if [ -f "$stampFile" ] && [ "$(cat "$stampFile")" = "$inputHash" ] \ && [ -x "$venv/bin/python" ] \
&& [ -x "$stageVenv/bin/python" ] \ && [ -f "$snap/kitten_tts_mini_v0_8.onnx" ] \
&& [ -f "$stageSnap/config.json" ]; then && [ -f "$snap/voices.npz" ] \
# Delivered artifacts in hermesHome must ALSO match the staging copy: && [ -f "$venv/lib/python3.13/site-packages/kitten_tts_stub.py" ]; then
# 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 exit 0
fi fi
fi
# ---- staging venv + staged hub tree: root-owned path, uv cache # Venv (rebuilt rather than broken on --no-clear: uv exits 2 otherwise).
# included. Root runs python from HERE (container can write nothing in uv venv "$venv" --python ${pkgs.python313}/bin/python3 --clear --quiet
# this tree); the FINISHED result is copied into hermesHome AS THE UV_CACHE_DIR=${hermesHome}/kittentts-uv-cache \
# CONTAINER USER via setpriv (review2 #4) root never writes into uv pip install --python "$venv/bin/python" --quiet \
# hermesHome, so no uid-986-controlled path is ever followed while
# running as root; symlink-takeover of stamps/refs/install targets is
# structurally impossible. ----
mkdir -p "$stageDir" "$stageSnap" "$stageVenv" "$stageHub"
# 0755: the setpriv'd uid-986 delivery below must be able to read it.
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"
# Skip the venv REBUILD when staging is still valid (review2 minor:
# damaged delivery should be a copy, not a rebuild) but only for the
# CURRENT inputs, else a bump delivers the old venv under a new stamp.
if [ "$(cat "$stampFile" 2>/dev/null)" != "$inputHash" ] \
|| ! [ -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} --requirement ${kittenttsReqs}
# kittentts --no-deps: its overlay of spacy/misaki[en] is what drags in # kittentts --no-deps: its overlay of spacy/misaki[en] is what drags in
# the CUDA tree; the requirements freeze already covers its real needs. # the CUDA tree; the requirements freeze already covers its real needs.
UV_CACHE_DIR=$stageDir/uv-cache \ UV_CACHE_DIR=${hermesHome}/kittentts-uv-cache \
uv pip install --python "$stageVenv/bin/python" --quiet --no-deps \ uv pip install --python "$venv/bin/python" --quiet --no-deps \
${kittenttsWheelFile} ${kittenttsWheel}
# Dead-import shim: .pth auto-loads kitten_tts_stub at interpreter # Dead-import shim: .pth auto-loads kitten_tts_stub at interpreter start
# start so `from misaki import en, espeak` resolves without the real # so `from misaki import en, espeak` resolves without the real misaki.en.
# misaki.en. siteDir=$("$venv/bin/python" -c 'import sysconfig; print(sysconfig.get_paths()["purelib"])')
siteDir=$("$stageVenv/bin/python" -c 'import sysconfig; print(sysconfig.get_paths()["purelib"])')
cp ${kittenttsStub} "$siteDir/kitten_tts_stub.py" cp ${kittenttsStub} "$siteDir/kitten_tts_stub.py"
printf 'import kitten_tts_stub\n' > "$siteDir/zz-kitten-stub.pth" printf 'import kitten_tts_stub\n' > "$siteDir/zz-kitten-stub.pth"
# PyPI manylinux .so's (numpy, onnxruntime) need libstdc++, which the # Seed the HF cache with the hashed model files (exact hub layout;
# Nix loader never finds, on the host or in the container. # hf_hub_download scans refs/snapshots on disk offline).
auto-patchelf --paths "$stageVenv" --libs ${kittenttsRuntimeLibs} mkdir -p "$hubDir/refs" "$snap"
install -m 0444 ${kittenttsModelOnnx} "$snap/kitten_tts_mini_v0_8.onnx"
install -m 0444 ${kittenttsModelVoices} "$snap/voices.npz"
install -m 0444 ${kittenttsModelConfig} "$snap/config.json"
# STT add-on: faster-whisper into the SAME venv (shares the # Hand ownership to the container uid BEFORE any python runs in this
# numpy/onnxruntime pins; see kittentts/stt-requirements.txt). # tree (root never imports from it that was Mnemosyne review #3).
UV_CACHE_DIR=$stageDir/uv-cache \ chown -R ${hermesUid}:${hermesGid} "$venv" "$hubDir"
uv pip install --python "$stageVenv/bin/python" --quiet \
--requirement ${kittenttsSttReqs}
# av's bundled ffmpeg libs also need the stdc++/zlib link (already
# covered by the auto-patchelf pass above rerun it so the newly
# installed ctranslate2/av binaries get RPATHs too).
auto-patchelf --paths "$stageVenv" --libs ${kittenttsRuntimeLibs}
fi
# Stage the full delivered hub tree (exact hf_hub_download layout: # Stamp LAST a half-provisioned venv fails the integrity check and
# refs/main -> snapshots/<sha>; review1 #3) under root-owned staging. # re-provisions on next boot rather than being trusted.
stageModelDir=$stageHub/hub/models--KittenML--kitten-tts-mini-0.8 printf '%s' "$reqHash" > "$stampFile"
mkdir -p "$stageModelDir/refs" "$stageModelDir/snapshots/$modelSha" chown ${hermesUid}:${hermesGid} "$stampFile"
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"
''; '';
}; };
} }
-17
View File
@@ -1,17 +0,0 @@
# STT add-on pins for the KittenTTS side-venv on mars (faster-whisper CPU).
#
# APPENDED to hosts/mars/kittentts/requirements.txt by the provisioning unit
# (see hermes-agent.nix, KittenTTS section) so ONE venv covers the entire
# local-audio stack: TTS (KittenTTS) + STT (faster-whisper). Shares the
# numpy/onnxruntime pins already frozen there — no version conflicts.
#
# faster-whisper runs CTranslate2 on CPU; the tiny/base model (~150 MB)
# downloads once from the HF hub then works fully offline. Validated
# 2026-09-19 with a live Telegram voice note transcribed on i5-4460 CPU.
#
# NOTE on av: PyP AV 18.x needs libz/libstdc++ from the host. On NixOS those
# come via the nix store — the provisioning unit exports LD_LIBRARY_PATH with
# them at runtime (see hermes-agent.nix's kittenttsLDLibraryPath).
faster-whisper==1.2.1
ctranslate2==4.8.2
av==18.1.0
+46
View File
@@ -0,0 +1,46 @@
# 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
+58 -28
View File
@@ -1,56 +1,86 @@
# Mnemosyne memory provider for Hermes. Built here rather than pip-installed: # Mnemosyne memory provider for Hermes on mars — packaged for real (Nix).
# the image's Python has no pip and is PEP 668 managed.
# #
# Core deps only: the embeddings extra (fastembed/onnxruntime) is optional at # Why derivations instead of a runtime side-venv: the official Hermes image
# runtime, and recall falls back to FTS5. # vendors its own Python and stays off-limits to pip (no pip module, PEP 668),
# 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
mnemosyneMemory = python3.pkgs.buildPythonPackage rec { python = python3;
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 {
pname = "mnemosyne_memory"; inherit pname version;
inherit version; sha256 = "sha256-lspUMxc0pUSkhSUrNdiiO5OJ1NMC/S853EYSanXtXKM=";
hash = "sha256-lspUMxc0pUSkhSUrNdiiO5OJ1NMC/S853EYSanXtXKM=";
}; };
build-system = with python3.pkgs; [ setuptools ]; build-system = with python.pkgs; [ setuptools ];
dependencies = with python3.pkgs; [ pyyaml ];
doCheck = false; # tests want a live Hermes + LLM key # Base dependency set — everything else in the upstream metadata is an
# Ships the Hermes plugin package too, not just the core. # optional extra (llm / embeddings / mcp / sync / test / dev) and is not
pythonImportsCheck = [ # installed; see the file-level comment.
"mnemosyne" dependencies = with python.pkgs; [ pyyaml ];
"hermes_memory_provider"
]; doCheck = false; # upstream tests want a live Hermes + LLM key present
pythonImportsCheck = [ "mnemosyne" ];
}; };
mnemosyneHermes = python3.pkgs.buildPythonPackage rec { mnemosyneHermes = python.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 {
pname = "mnemosyne_hermes"; inherit pname version;
inherit version; sha256 = "sha256-CzEvnUw5oPFtT5bHQQ/GBdy2C/E7qShQn32irIRYKqw=";
hash = "sha256-CzEvnUw5oPFtT5bHQQ/GBdy2C/E7qShQn32irIRYKqw=";
}; };
build-system = with python3.pkgs; [ setuptools ]; build-system = with python.pkgs; [ setuptools ];
# Upstream asks for mnemosyne-memory[embeddings]; see the header.
dependencies = [ mnemosyneMemory ] ++ (with python3.pkgs; [ pyyaml ]); # The wrapper declares `mnemosyne-memory[embeddings]>=3.11.1` on PyPI, but
# 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 = [ "mnemosyne_hermes" ]; pythonImportsCheck = [ "hermes_memory_provider" ];
}; };
# 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
python3.withPackages (_: [ python.withPackages (_: [ mnemosyneMemory mnemosyneHermes ])
mnemosyneMemory
mnemosyneHermes
])