diff --git a/hosts/mars/hermes-agent.nix b/hosts/mars/hermes-agent.nix index 6ee060f..129b5de 100644 --- a/hosts/mars/hermes-agent.nix +++ b/hosts/mars/hermes-agent.nix @@ -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" ''; }; } diff --git a/hosts/mars/mnemosyne/set-provider.py b/hosts/mars/mnemosyne/set-provider.py deleted file mode 100644 index d028fc5..0000000 --- a/hosts/mars/mnemosyne/set-provider.py +++ /dev/null @@ -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])) diff --git a/pkgs/mnemosyne-env.nix b/pkgs/mnemosyne-env.nix new file mode 100644 index 0000000..7b95478 --- /dev/null +++ b/pkgs/mnemosyne-env.nix @@ -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 ])