mars: build Mnemosyne env as a Nix derivation (review rework)
Supersedes the venv-based fix attempt in 694317a entirely: replaces the
runtime side-venv with a python3.withPackages derivation
(pkgs/mnemosyne-env.nix: mnemosyne-memory 3.15.1 + mnemosyne-hermes 0.5.0
via fetchPypi, base deps only). Env mounted :ro into the container; the
oneshot only writes the plugins/mnemosyne symlink to the env's
site-packages passthru — a canonical store path valid on both sides. No
runtime fetch, no stamp, no root-executes-luna-writable-code, no
host/container path mismatch, no config.yaml sed.
This commit is contained in:
+55
-142
@@ -75,24 +75,11 @@ let
|
||||
);
|
||||
|
||||
# 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. The venv lives at
|
||||
# ${hermesHome}/mnemosyne-venv and the plugin discovery symlink
|
||||
# (${hermesHome}/plugins/mnemosyne) is made RELATIVE — ../mnemosyne-venv/…
|
||||
# — so it resolves identically on the host and inside the container, where
|
||||
# /opt/data is a bind mount of hermesHome and hosts/containers see
|
||||
# different mountpoint prefixes for the same tree.
|
||||
mnemosyneReqs = pkgs.writeText "mnemosyne-requirements.txt" (
|
||||
builtins.readFile ./mnemosyne/requirements.txt
|
||||
);
|
||||
|
||||
# config.yaml provider-cue setter (PyYAML, preserves all other keys) — run
|
||||
# host-side by the provisioning unit against the just-built, still
|
||||
# root-owned venv's interpreter, BEFORE that venv is chowned to the
|
||||
# container uid (root never executes from a luna-writable tree).
|
||||
mnemosyneSetProvider = pkgs.writeText "mnemosyne-set-provider.py" (
|
||||
builtins.readFile ./mnemosyne/set-provider.py
|
||||
);
|
||||
# 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 { };
|
||||
|
||||
# Wire event names (X-GitHub-Event) each route accepts — NOT the
|
||||
# subscription names the gitea hooks in services/dev/gitea.nix use. The two
|
||||
@@ -248,6 +235,15 @@ in
|
||||
"${hermesHome}:/opt/data"
|
||||
"${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
|
||||
# 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.
|
||||
@@ -440,145 +436,62 @@ in
|
||||
};
|
||||
|
||||
# ---- 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:
|
||||
# The provider's Python closure (mnemosyneEnv, callPackage'd
|
||||
# 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.
|
||||
#
|
||||
# 1. ${hermesHome}/plugins/mnemosyne — a RELATIVE symlink to the plugin
|
||||
# package inside the side venv (../mnemosyne-venv/lib/…/site-packages/
|
||||
# hermes_memory_provider). Hermes discovers providers by scanning
|
||||
# $HERMES_HOME/plugins; resolve() keeps the traversal inside the
|
||||
# bind-mounted tree, so it lands on the same files whether read
|
||||
# host-side (/var/lib/hermes/…) or container-side (/opt/data/…).
|
||||
# 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.
|
||||
#
|
||||
# 2. The side venv (${hermesHome}/mnemosyne-venv), built with the pinned
|
||||
# pins in ./mnemosyne/requirements.txt. Inside hermesHome so it lives
|
||||
# under the container's HERMES_WRITE_SAFE_ROOT and survives image
|
||||
# rebuilds.
|
||||
#
|
||||
# Import mechanics (verified against the plugin, not assumed): Hermes's own
|
||||
# interpreter imports hermes_memory_provider OFF THE SYMLINK; that module's
|
||||
# __init__.py itself inserts Path(__file__).resolve().parent.parent — the
|
||||
# venv's site-packages — into sys.path before importing `mnemosyne.*`. So
|
||||
# nothing needs to EXECUTE the venv's interpreter inside the container: the
|
||||
# interpreter is only used host-side, by this unit, at provisioning time.
|
||||
#
|
||||
# Idempotent: marked done by a stamp file keyed by the hash of the
|
||||
# requirements text; the console script is verified before the stamp is
|
||||
# written, so a half-install (venv present but install died) re-provisions
|
||||
# rather than exiting on a stale stamp. A pin change also re-provisions.
|
||||
#
|
||||
# Re-provisioning always wipes and recreates the venv (`uv venv --clear`,
|
||||
# plus an explicit rm -f for a leftover non-directory): `uv venv` refuses
|
||||
# to reuse an existing dir, and a wipe-and-rebuild is precisely what a
|
||||
# changed stamp is supposed to mean. Working under root against a
|
||||
# luna-writable ${hermesHome} means Python must never be made to IMPORT
|
||||
# from a tree she has written to — that's why siteDir is composed here
|
||||
# (python3.13 is pinned in the uv venv path) instead of executing
|
||||
# $venv/bin/python to ask it, and why the rm -f/ln -sfn pair cannot leave
|
||||
# stale venv content behind. Deleting first is what guarantees the new
|
||||
# venv is hermetic to root, not incremental.
|
||||
#
|
||||
# Ordering: before podman-hermes-agent (the gateway needs the plugin at
|
||||
# import time), after network (uv fetches wheels on first provision,
|
||||
# ~largest payload is onnxruntime), with a bounded timeout so a broken
|
||||
# proxy cannot hang boot.
|
||||
#
|
||||
# Deliberately NOT Required= / requiredBy: a failed provision leaves the
|
||||
# container running as before, without mnemosyne (RETAINED on purpose —
|
||||
# hermes-webhook-routes and the gateway keep working, and config.yaml
|
||||
# stays untouched, so a plain retry after fixing the network/mirror is
|
||||
# enough). If mnemosyne activation itself should hard-fail boot, that
|
||||
# needs an explicit decision from darman — the default here errs toward
|
||||
# "don't take memory down along with everything else".
|
||||
#
|
||||
# No RequiresMountsFor: this unit only touches ${stateDir}, which is on
|
||||
# the local filesystem (not a mount) on mars.
|
||||
systemd.services.hermes-agent-mnemosyne-provision = {
|
||||
description = "Provision Mnemosyne memory provider (side venv + plugin symlink)";
|
||||
# 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 = {
|
||||
description = "Link Mnemosyne provider into the Hermes plugin dir";
|
||||
before = [ "podman-hermes-agent.service" ];
|
||||
wantedBy = [ "podman-hermes-agent.service" ];
|
||||
wants = [ "network-online.target" ];
|
||||
after = [ "network-online.target" ];
|
||||
path = [ pkgs.uv pkgs.coreutils ];
|
||||
after = [ "hermes-agent-prepare-dirs.service" ];
|
||||
requires = [ "hermes-agent-prepare-dirs.service" ];
|
||||
path = [ pkgs.coreutils ];
|
||||
serviceConfig = {
|
||||
Type = "oneshot";
|
||||
TimeoutStartSec = 600;
|
||||
};
|
||||
environment = {
|
||||
UV_CACHE_DIR = "${hermesHome}/mnemosyne-uv/cache";
|
||||
UV_COMPILE_BYTECODE = "1";
|
||||
RemainAfterExit = true;
|
||||
};
|
||||
script = ''
|
||||
set -euo pipefail
|
||||
venv=${hermesHome}/mnemosyne-venv
|
||||
|
||||
pluginsDir=${hermesHome}/plugins
|
||||
pluginDir=$pluginsDir/mnemosyne
|
||||
stampFile=${hermesHome}/mnemosyne-provision.stamp
|
||||
reqHash=$(sha256sum ${mnemosyneReqs} | cut -d" " -f1)
|
||||
# Target is the STORE path itself, not a /opt/data restatement: the
|
||||
# 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 [ -x "$venv/bin/mnemosyne-hermes" ] && [ -f "$stampFile" ] \
|
||||
&& [ "$(cat "$stampFile")" = "$reqHash" ] \
|
||||
&& [ -e "$pluginDir" ]; then
|
||||
exit 0
|
||||
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"
|
||||
# Recreate from scratch so she cannot place anything into it that
|
||||
# provisioning would then execute or trust (root/luna trust boundary:
|
||||
# root only ever IMPORTS from a venv IT just built).
|
||||
rm -rf "$venv"
|
||||
uv venv "$venv" --python ${pkgs.python313}/bin/python3 --quiet --clear
|
||||
|
||||
uv pip install \
|
||||
--python "$venv/bin/python" \
|
||||
--requirement ${mnemosyneReqs} --quiet
|
||||
|
||||
# Console script EXISTENCE is the success marker — verified before the
|
||||
# stamp, so a half-install cannot be trusted on the next run.
|
||||
[ -x "$venv/bin/mnemosyne-hermes" ] || {
|
||||
echo "mnemosyne-hermes console script missing after install" >&2; exit 1;
|
||||
}
|
||||
|
||||
# The plugin package dir name hermes_memory_provider is fixed by the
|
||||
# upstream wheel; catching a rename here costs one ls per provision
|
||||
# and is cheaper than importing from a mid-name drift.
|
||||
siteDir="$venv/lib/python3.13/site-packages"
|
||||
target="hermes_memory_provider"
|
||||
[ -d "$siteDir/$target" ] || {
|
||||
echo "mnemosyne plugin package not found in venv" >&2; exit 1;
|
||||
}
|
||||
|
||||
# RELATIVE symlink: unambiguous across the bind mount (host prefix
|
||||
# /var/lib/hermes vs container prefix /opt/data point at the same
|
||||
# tree; build the traversal from plugins/mnemosyne, not from any
|
||||
# absolute path baked in either direction).
|
||||
rm -f "$pluginDir"
|
||||
ln -sfn "../mnemosyne-venv/lib/python3.13/site-packages/$target" "$pluginDir"
|
||||
|
||||
chmod 0755 "$(dirname "$pluginDir")" "$pluginsDir"
|
||||
chown ${hermesUid}:${hermesGid} "$(dirname "$pluginDir")"
|
||||
|
||||
# Config cue FIRST — the venv is still root-owned here, so root is
|
||||
# executing its own freshly built interpreter, not a container-uid
|
||||
# tree (the chown below hands that tree over; nothing executes from
|
||||
# it after that point).
|
||||
cfg=${hermesHome}/config.yaml
|
||||
# A pre-existing wrong `provider:` value, `memory: null` / `memory: {}`,
|
||||
# a missing memory section and a missing config.yaml are all handled
|
||||
# inside the helper (see its header) — never a bare sed on YAML.
|
||||
if [ -f "$cfg" ]; then
|
||||
"$venv/bin/python" ${mnemosyneSetProvider} "$cfg"
|
||||
chown ${hermesUid}:${hermesGid} "$cfg"
|
||||
else
|
||||
# Keep the cue out of first-run's way; just logged, not fatal.
|
||||
echo "WARNING: $cfg not found; skipping provider cue (first-run will seed it)" >&2
|
||||
fi
|
||||
|
||||
printf '%s' "$reqHash" > "$stampFile"
|
||||
chown -R ${hermesUid}:${hermesGid} \
|
||||
"$venv" "$pluginsDir" "$stampFile" \
|
||||
${hermesHome}/mnemosyne-uv
|
||||
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"
|
||||
mv -Tf "$pluginDir.new" "$pluginDir"
|
||||
chown -h ${hermesUid}:${hermesGid} "$pluginDir"
|
||||
'';
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,45 +0,0 @@
|
||||
# Set memory.provider = mnemosyne in Hermes's config.yaml, preserving every
|
||||
# other key, comment-free but value-faithful. Written as a separate file so
|
||||
# the provisioning unit runs it from the nix store (never inline) and ALWAYS
|
||||
# before the venv is chowned to the container uid — root must not execute an
|
||||
# interpreter inside a tree luna can write to.
|
||||
#
|
||||
# Behaviour per config.yaml state:
|
||||
# existing `memory:` mapping (incl. an old `provider:` value) → merge/replace
|
||||
# `memory: null` or `memory: {}` or key missing → create mapping
|
||||
# top-level not a mapping → abort loudly
|
||||
# file missing → SKIP: Hermes's
|
||||
# first-run seeding must create it; a one-key stub would stop that.
|
||||
import sys
|
||||
|
||||
import yaml
|
||||
|
||||
|
||||
def set_provider(path: str) -> int:
|
||||
try:
|
||||
with open(path) as f:
|
||||
data = yaml.safe_load(f) or {}
|
||||
except FileNotFoundError:
|
||||
print(
|
||||
f"WARNING: {path} not found; skipping provider cue (first-run will seed it)",
|
||||
file=sys.stderr,
|
||||
)
|
||||
return 0
|
||||
if not isinstance(data, dict):
|
||||
print(
|
||||
f"ERROR: {path} is a {type(data).__name__}, not a mapping; not touched",
|
||||
file=sys.stderr,
|
||||
)
|
||||
return 1
|
||||
mem = data.get("memory")
|
||||
if isinstance(mem, dict):
|
||||
mem["provider"] = "mnemosyne"
|
||||
else:
|
||||
data["memory"] = {"provider": "mnemosyne"}
|
||||
with open(path, "w") as f:
|
||||
yaml.safe_dump(data, f, sort_keys=False)
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(set_provider(sys.argv[1]))
|
||||
@@ -0,0 +1,86 @@
|
||||
# Mnemosyne memory provider for Hermes on mars — packaged for real (Nix).
|
||||
#
|
||||
# Why derivations instead of a runtime side-venv: the official Hermes image
|
||||
# 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,
|
||||
fetchPypi,
|
||||
}:
|
||||
|
||||
let
|
||||
python = python3;
|
||||
|
||||
mnemosyneMemory = python.pkgs.buildPythonPackage rec {
|
||||
pname = "mnemosyne-memory";
|
||||
version = "3.15.1";
|
||||
pyproject = true;
|
||||
|
||||
src = fetchPypi {
|
||||
inherit pname version;
|
||||
sha256 = "sha256-lspUMxc0pUSkhSUrNdiiO5OJ1NMC/S853EYSanXtXKM=";
|
||||
};
|
||||
|
||||
build-system = with python.pkgs; [ setuptools ];
|
||||
|
||||
# Base dependency set — everything else in the upstream metadata is an
|
||||
# optional extra (llm / embeddings / mcp / sync / test / dev) and is not
|
||||
# installed; see the file-level comment.
|
||||
dependencies = with python.pkgs; [ pyyaml ];
|
||||
|
||||
doCheck = false; # upstream tests want a live Hermes + LLM key present
|
||||
pythonImportsCheck = [ "mnemosyne" ];
|
||||
};
|
||||
|
||||
mnemosyneHermes = python.pkgs.buildPythonPackage rec {
|
||||
pname = "mnemosyne-hermes";
|
||||
version = "0.5.0";
|
||||
pyproject = true;
|
||||
|
||||
src = fetchPypi {
|
||||
inherit pname version;
|
||||
sha256 = "sha256-CzEvnUw5oPFtT5bHQQ/GBdy2C/E7qShQn32irIRYKqw=";
|
||||
};
|
||||
|
||||
build-system = with python.pkgs; [ setuptools ];
|
||||
|
||||
# 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;
|
||||
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
|
||||
python.withPackages (_: [ mnemosyneMemory mnemosyneHermes ])
|
||||
Reference in New Issue
Block a user