From ee0a2f39e9fd213e27a60fdf376d487c0885d003 Mon Sep 17 00:00:00 2001 From: luna Date: Fri, 18 Sep 2026 23:11:12 +0000 Subject: [PATCH] mars: provision the Mnemosyne memory provider for Hermes Mnemosyne isn't bundled with the official image; third-party PyPI plugin. Built as a side venv + plugin symlink inside hermesHome so it lands inside HERMES_WRITE_SAFE_ROOT and survives image rebuilds. Pinned requirements captured from a validated live install. --- hosts/mars/hermes-agent.nix | 123 ++++++++++++++++++++++++++ hosts/mars/mnemosyne/requirements.txt | 46 ++++++++++ 2 files changed, 169 insertions(+) create mode 100644 hosts/mars/mnemosyne/requirements.txt diff --git a/hosts/mars/hermes-agent.nix b/hosts/mars/hermes-agent.nix index 417c32d..2959e45 100644 --- a/hosts/mars/hermes-agent.nix +++ b/hosts/mars/hermes-agent.nix @@ -74,6 +74,17 @@ let builtins.readFile ./gitea-pr-review-prompt.md ); + # Mnemosyne memory provider (local SQLite, third-party plugin — not bundled + # with Hermes). Requirements pins live in ./mnemosyne/requirements.txt; see + # the provisioning unit near the bottom of this file for the layout mapped + # into hermesHome. The symlink target below must be the SAME path the + # side venv was built with (hermesHome/mnemosyne-venv) — Hermes resolves + # plugin modules through it, so relative traversal after the bind mount + # still resolves inside the container identically. + mnemosyneReqs = pkgs.writeText "mnemosyne-requirements.txt" ( + builtins.readFile ./mnemosyne/requirements.txt + ); + # Wire event names (X-GitHub-Event) each route accepts — NOT the # subscription names the gitea hooks in services/dev/gitea.nix use. The two # namespaces collide; see the long comment on the route unit below. @@ -418,4 +429,116 @@ in mv -f "$tmp" "$conf" ''; }; + + # ---- Mnemosyne memory provider ---------------------------------------- + # Third-party plugin (PyPI: mnemosyne-hermes + mnemosyne-memory), not + # bundled with the official image. Two pieces must exist before the gateway + # starts for memory.provider = mnemosyne to activate: + # + # 1. ${hermesHome}/plugins/mnemosyne — a symlink to the plugin package + # inside the side venv. Hermes discovers providers by scanning + # $HERMES_HOME/plugins (see its plugins/memory discovery code), reads + # __init__.py/. For it to IMPORT cleanly the plugin's sibling + # `mnemosyne` core package must be importable too — which is exactly + # why the plugin code lives inside the side venv's site-packages + # rather than as a bare writable copy. + # + # 2. The side venv itself (${hermesHome}/mnemosyne-venv), built with the + # pinned pins in ./mnemosyne/requirements.txt. Inside hermesHome so + # it lands inside HERMES_WRITE_SAFE_ROOT=/opt/data (visible to the + # container at /opt/data/mnemosyne-venv) and survives image rebuilds. + # + # The venv's absolute paths embed ${hermesHome}: uv venv records the + # creation prefix, which is by construction identical inside and outside + # the container thanks to /opt/data being a bind mount of hermesHome. + # + # Idempotent: marked done by a stamp file keyed by the hash of the + # requirements text, so a changed pin re-provisions. Never deletes — + # removing memory.provider from config is what retires it. + # + # Ordering: before podman-hermes-agent (the gateway needs the plugin at + # import time), after network (uv may fetch wheels on first provision), + # with a bounded timeout so a broken proxy cannot hang boot. + systemd.services.hermes-agent-mnemosyne-provision = { + description = "Provision Mnemosyne memory provider (side venv + plugin symlink)"; + before = [ "podman-hermes-agent.service" ]; + wantedBy = [ "podman-hermes-agent.service" ]; + wants = [ "network-online.target" ]; + after = [ "network-online.target" ]; + unitConfig.RequiresMountsFor = [ "/mnt/jupiter" ]; + path = [ pkgs.python3 pkgs.uv pkgs.coreutils ]; + serviceConfig = { + Type = "oneshot"; + TimeoutStartSec = 600; + }; + environment = { + UV_PYTHON_INSTALL_DIR = "${hermesHome}/mnemosyne-uv/python"; + UV_CACHE_DIR = "${hermesHome}/mnemosyne-uv/cache"; + UV_COMPILE_BYTECODE = "1"; + }; + script = '' + set -euo pipefail + venv=${hermesHome}/mnemosyne-venv + pluginDir=${hermesHome}/plugins/mnemosyne + stampFile=${hermesHome}/mnemosyne-provision.stamp + reqHash=$(sha256sum ${mnemosyneReqs} | cut -d" " -f1) + + if [ -x "$venv/bin/python" ] && [ -f "$stampFile" ] \ + && [ "$(cat "$stampFile")" = "$reqHash" ] \ + && [ -e "$pluginDir" ] \ + && [ -x "$venv/bin/mnemosyne-hermes" ]; then + exit 0 + fi + + mkdir -p ${hermesHome}/plugins ${hermesHome}/mnemosyne + uv venv "$venv" --python ${pkgs.python313}/bin/python3 --quiet + UV_VENV="$venv" uv pip install \ + --python "$venv/bin/python" \ + --requirement ${mnemosyneReqs} --quiet + + # The plugin wrapper lands as hermes_memory_provider inside site-packages; + # uv installs the exact entry point scripts shown below. Symlink the + # discovered package dir (never a fixed guess — find it by marker). + siteDir=$("$venv/bin/python" -c 'import site; print(site.getsitepackages()[0])') + target="$siteDir/hermes_memory_provider" + [ -d "$target" ] || { echo "mnemosyne plugin package not found in venv" >&2; exit 1; } + + install -d -m 0755 -o ${hermesUid} -g ${hermesGid} \ + "$(dirname "$pluginDir")" + rm -f "$pluginDir" + ln -s "$target" "$pluginDir" + + printf '%s' "$reqHash" > "$stampFile" + chown -R ${hermesUid}:${hermesGid} \ + "$venv" "$(dirname "$pluginDir")" "$stampFile" \ + ${hermesHome}/mnemosyne-uv + + # Mirror the "active provider" cue into config.yaml — equivalent to + # `hermes config set memory.provider mnemosyne`, but idempotent and + # non-interactive. Only touches the one key, never rewrites the file. + cfg=${hermesHome}/config.yaml + if [ -f "$cfg" ]; then + if ! grep -q '^ provider: mnemosyne' "$cfg"; then + if grep -q '^memory:' "$cfg"; then + sed -i 's/^memory:$/memory:\n provider: mnemosyne/' "$cfg" + else + printf '\nmemory:\n provider: mnemosyne\n' >> "$cfg" + fi + chown ${hermesUid}:${hermesGid} "$cfg" + fi + else + printf 'memory:\n provider: mnemosyne\n' > "$cfg" + chown ${hermesUid}:${hermesGid} "$cfg" + fi + ''; + }; + + # Also assert mnemosyne as the active provider so the container's own + # config.yaml says the same thing statelessly — mirrored from the docs' + # `hermes config set memory.provider mnemosyne`. Done here (not a separate + # unit) so venv and config-cue stay in lockstep; never edits anything else + # in the file. Runs at the tail of the provisioning oneshot, after a + # successful venv, so a half-provision never flips the provider on. + # (system.activationScripts is NOT used — the file must exist first, and + # activation would run before hermesHome's own cont-init has created it.) } diff --git a/hosts/mars/mnemosyne/requirements.txt b/hosts/mars/mnemosyne/requirements.txt new file mode 100644 index 0000000..8d687f0 --- /dev/null +++ b/hosts/mars/mnemosyne/requirements.txt @@ -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