From: Svjatoslav Agejenko Date: Sat, 24 Oct 2026 23:14:08 +0000 (+0300) Subject: feat(installer): support clean installs and seed initial memories X-Git-Url: http://www2.svjatoslav.eu/gitweb/?a=commitdiff_plain;p=retinue.git feat(installer): support clean installs and seed initial memories - resolve current Hermes runtimes through PM and retain the legacy fallback - exclude the unsupported Android sqlite-vec branch from universal resolution - add idempotent explanation and installation-source memories - add Docker clean-install and standalone seed regression tests - document supported platforms and bump the plugin version to 1.3.0 --- diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..96ba3cb --- /dev/null +++ b/.dockerignore @@ -0,0 +1,8 @@ +# Keep host-specific runtimes and models out of the clean-install test context. +.git +.idea +.venv +.hf-cache +**/__pycache__ +**/*.py[cod] +**/*.html diff --git a/AGENTS.org b/AGENTS.org index 1f19e46..0536e4e 100644 --- a/AGENTS.org +++ b/AGENTS.org @@ -13,7 +13,7 @@ | Runtime deps | =model2vec=, =sqlite-vec= | | Recall | =prefetch()= injects up to 30 memories per turn; ids already returned in the session are filtered out | | Embedding model | =minishlab/potion-multilingual-128M= (~540 MB, cached offline; the repo's unused ONNX export is skipped) | -| Plugin metadata | plugin.yaml (version 1.2.0) | +| Plugin metadata | plugin.yaml (version 1.3.0) | | Public docs site | https://www.svjatoslav.eu/projects/ (served from Documentation/) | * Purpose @@ -78,15 +78,19 @@ retinue/ │ ├── __init__.py │ ├── memory.py # MemoryStore: model2vec embed + sqlite-vec CRUD │ ├── provider.py # RetinueMemoryProvider: MemoryProvider ABC impl +│ ├── bootstrap.py # Idempotent installer seed memories │ └── system_prompt_block.txt # Injected into the Hermes system prompt ├── Install Retinue # Deploy into $HERMES_HOME/plugins/retinue/ -├── test/test_standalone.py # End-to-end MemoryStore + provider tests +├── test/ +│ ├── test_standalone.py # End-to-end MemoryStore + provider tests +│ └── clean-install/ # Disposable Docker install + activation test ├── Documentation/ │ ├── index.org # Public user documentation (exports to HTML) │ └── Development/index.org # Architecture & integration documentation ├── Tools/ # Development helpers (filenames contain spaces) │ ├── lib/common.sh # Shared bash helpers: RETINUE_PY resolution (venv → uv → Hermes venv) │ ├── Run tests # Standalone smoke test runner (no Hermes needed) +│ ├── Test clean installation # Official Hermes + Retinue in clean Docker │ ├── Show Retinue memory contents # Debug CLI: dump/search all memories │ ├── Import Holographic memories # Migrate facts from Holographic memory_store.db │ ├── Open with IntelliJ IDEA @@ -177,12 +181,12 @@ hermes memory status # verify The installer is idempotent: it wipes everything under =$HERMES_HOME/plugins/retinue/= except =.venv= / =.hf-cache=, copies the -whitelist =(plugin.yaml __init__.py src)=, installs missing Python deps -into the interpreter Hermes actually runs, pre-downloads the embedding -model into =.hf-cache/= (no first-use stall), sets =memory.provider: -retinue=, enables the =memory= toolset for the CLI platform (clean -"Blank Slate" installs disable it — see pitfalls), and validates the -plugin imports. +whitelist =(plugin.yaml __init__.py src)=, sets =memory.provider: retinue=, +and prepares dependencies through Hermes PM on current Hermes versions +(legacy pip / ensurepip / uv fallbacks remain for older versions). It +pre-downloads the embedding model, adds two non-duplicating example memories +(Retinue's operation and the quoted installation source), enables the =memory= +toolset for the CLI platform, and validates the plugin imports. To target a non-default Hermes home: @@ -201,6 +205,24 @@ hermes chat -q "Remember that my default shell is fish." hermes chat -q "What shell do I use?" #+end_src +** Test clean installation in Docker +:PROPERTIES: +:CUSTOM_ID: test-clean-installation-in-docker +:ID: 931163de-ffb6-4c24-a46f-0b0e3a23b724 +:END: + +#+begin_src sh +cd /path/to/retinue +bash "Tools/Test clean installation" +#+end_src + +Builds a fresh Debian 13 image, installs the official stable Hermes release +as a non-root user, then installs Retinue twice. The verifier uses Hermes' real +plugin loader and checks automatic activation, the CLI memory toolset, all +four Retinue tools, two non-duplicating seed memories, and offline semantic +search. No host Hermes state or LLM credentials are used. Forward =--no-cache= +to repeat the official Hermes installation from scratch as well. + ** Inspect the memory store from the command line #+begin_src sh @@ -239,14 +261,19 @@ Never commit the generated =*.html= — they are gitignored. :ID: 6f0702f2-2957-4083-a602-caa709ed046e :END: -- *Python on Debian 13 is PEP 668.* System pip is blocked. The installer - deliberately installs deps into the Python that runs Hermes - (=PYTHON= env override, =$HERMES_HOME/hermes-agent/venv/bin/python3=, - or the =hermes= launcher shebang). That interpreter is a uv-created venv - without pip (see pitfall below), so dependency installation tries pip, - then =ensurepip=-bootstrapped pip, then =uv= from =PATH= or - =$HERMES_HOME/bin/uv=. Do not pip-install into the system Python from - this project. +- *Python and dependencies on current Hermes.* Set =memory.provider= first, + then run =hermes pm install= so PM builds Retinue's declared requirements + into the managed generation. Activate the result with + =hermes --run-module pm.environments --format sh= and use that Python and + =PYTHONPATH= for model preload, seeding, and validation. Do not pip-install + into a guessed PM generation. The legacy pip → ensurepip → uv chain remains + for older Hermes versions and explicit =PYTHON= overrides; Debian 13 system + Python is still PEP 668 and is never the install target. +- *Platform support.* The installer supports Linux (including WSL2) and + Apple-Silicon macOS. Native Windows and Android/Termux are unsupported. + The =sqlite-vec= dependency excludes Android in =plugin.yaml= because its + missing Android wheel otherwise makes Hermes PM's universal lock fail on + every host. - *Filenames with spaces.* Everything under =Tools/= contains spaces — quote paths in shell commands. - *The =Tools/Open with IntelliJ IDEA= helper* opens this tree as an @@ -298,6 +325,11 @@ Never commit the generated =*.html= — they are gitignored. hermes= fails even though Hermes is installed. The installer probes =~/.local/bin/hermes=, =~/bin/hermes=, =/usr/local/bin=, =/usr/bin= explicitly; keep that probing intact. +- *Pitfall: current Hermes launchers are shell scripts.* A launcher whose + first line is =#!/bin/sh= is not a Python interpreter. Reading that shebang + made the old installer run dependency commands as =/bin/sh -m pip= and fail + every clean install. Current versions must go through Hermes PM and its + activation environment instead of launcher/shebang guessing. - *Pitfall: bare Debian 13 lacks python3-venv.* =python3 -m venv .venv= fails without the =python3.X-venv= apt package (no ensurepip) and — worse — leaves a half-created =.venv= (python binary present, pip and diff --git a/Documentation/Development/index.org b/Documentation/Development/index.org index c2e5620..1834fdb 100644 --- a/Documentation/Development/index.org +++ b/Documentation/Development/index.org @@ -84,14 +84,17 @@ Retinue/ │ ├── __init__.py # Package init │ ├── memory.py # Memory engine: embeddings + sqlite-vec │ ├── provider.py # MemoryProvider subclass +│ ├── bootstrap.py # Idempotent installer seed memories │ ├── cli.py # Debug CLI behind "Show Retinue memory contents" │ ├── import_holographic.py # Holographic memory_store.db importer │ └── system_prompt_block.txt # Injected into the Hermes system prompt ├── test/ -│ └── test_standalone.py # End-to-end MemoryStore + provider tests +│ ├── test_standalone.py # End-to-end MemoryStore + provider tests +│ └── clean-install/ # Disposable Docker install + activation test ├── Tools/ # Development helpers (desktop scripts) │ ├── lib/common.sh # Shared bash helpers: RETINUE_PY resolution │ ├── Run tests # Standalone smoke test runner +│ ├── Test clean installation # Official Hermes + Retinue in clean Docker │ ├── Show Retinue memory contents # Debug CLI: dump/search all memories │ ├── Import Holographic memories # Migrate facts from Holographic memory_store.db │ ├── Open with IntelliJ IDEA @@ -115,39 +118,39 @@ while replacing the plugin source code. What it does, in order: -1. Detects the active Hermes home from the =HERMES_HOME= environment variable, - or falls back to =$HOME/.hermes=. +1. Detects the active Hermes home from =HERMES_HOME=, or falls back to + =$HOME/.hermes=. 2. Creates =$HERMES_HOME/plugins/retinue/= and its =.hf-cache/= subdirectory if they do not exist. 3. Removes every previously deployed item in the plugin directory except the =PRESERVE= list (=.venv=, =.hf-cache=), so stale code cannot linger. 4. Copies the whitelist =(plugin.yaml __init__.py src)= from the workspace into the plugin directory with =cp=. -5. Resolves the Python interpreter that Hermes actually runs under (via the - =PYTHON= environment variable, common venv layouts, or the =hermes= - launcher shebang) and installs any missing =sqlite-vec= / =model2vec= - dependencies into it, trying in order: =pip=, =ensurepip=-bootstrapped - =pip=, then =uv pip install --python= (uv is probed on =PATH= and at - =$HERMES_HOME/bin/uv= — Hermes bundles it there). The chain exists - because the Hermes runtime venv is created by =uv venv= and ships - without pip. -6. Pre-downloads the embedding model into =.hf-cache/= by calling - =retinue.memory.get_model()= with the Hermes Python — the same code path - the plugin uses at runtime. The snapshot download skips the repo's - unused ~512 MB ONNX export (=ignore_patterns=["onnx/*"]=) and loads from - the returned local path, so the cache is ~540 MB and the first Hermes - session does not stall. Failure here is non-fatal: a warning is printed - and the model simply downloads on first use instead. -7. Sets =memory.provider: retinue= with =hermes config set= if the CLI is - available. -8. Enables the =memory= toolset for the CLI platform with =hermes tools - enable --platform cli memory=. Clean "Blank Slate" Hermes installs list - =memory= in =agent.disabled_toolsets= and exclude it from - =platform_toolsets.cli=, which makes Hermes withhold the - =retinue_memory_*= tools and the system-prompt block from the model even - with the provider selected; this step lifts both gates. Other platforms - need their own =--platform = run. -9. Runs a lightweight import check to confirm the plugin loads. +5. Sets =memory.provider: retinue= with =hermes config set=. This happens + before dependency preparation because PM discovers the active provider + from the configuration. +6. Prepares Python dependencies. Current Hermes versions use Hermes PM + (=hermes pm install=), which builds the plugin's declared requirements into + the managed dependency generation. The activation interface + (=hermes --run-module pm.environments --format sh=) then selects the same + Python and site-packages a fresh Hermes process uses. The =sqlite-vec= + requirement excludes Android because that platform has no published wheel, + preventing an impossible branch in uv's universal lock. Older Hermes + versions retain the pip → =ensurepip=-bootstrapped pip → =uv= fallback. +7. Pre-downloads the embedding model into =.hf-cache/= by calling + =retinue.memory.get_model()= with the Hermes Python. The snapshot skips the + unused ONNX export. Failure is non-fatal: the model can download on first + use instead. +8. Calls =retinue.bootstrap= to add the Retinue explanation and quoted + installation-source memories when their exact content is absent. Existing + user memories are untouched; identical seeds are not duplicated. If the + model is unavailable, seeding waits for the next successful install. +9. Enables the =memory= toolset for the CLI platform with =hermes tools + enable --platform cli memory=. Other platforms need their own + =--platform = run. +10. Runs a lightweight import check to confirm the plugin loads and exposes + =retinue_memory_add=, =retinue_memory_search=, =retinue_memory_delete=, + and =retinue_memory_stats=. Every fallible step captures its combined output into a temp directory (cleaned by an EXIT trap) and prints an excerpt on failure — stderr is diff --git a/Documentation/index.org b/Documentation/index.org index 3e94327..30563b4 100644 --- a/Documentation/index.org +++ b/Documentation/index.org @@ -55,14 +55,14 @@ For technical details, read: Prerequisites: +- Linux required. Windows and Android/Termux are not supported. - Hermes Agent must be installed. The =hermes= CLI does not need to be on =PATH= — the installer also probes the standard locations (=~/.local/bin/hermes=, =~/bin/hermes=, =/usr/local/bin=, =/usr/bin=), which matters when installing over a non-login SSH shell where =~/.profile= has not run. -- Nothing else: the installer uses the Python interpreter that Hermes - itself runs (=~/.hermes/hermes-agent/venv=), so it works even on a bare - Debian/Ubuntu server without the =python3-venv= package. +- Nothing else: the installer uses Hermes' own Python and dependency + manager. Run the bundled installer from the Retinue source tree: @@ -73,27 +73,22 @@ bash "Install Retinue" The installer performs five steps: -1. Detects the active Hermes home from the =HERMES_HOME= environment variable - or falls back to =$HOME/.hermes=. -2. Resolves the Python interpreter that Hermes uses and installs =sqlite-vec= - and =model2vec= there if they are missing — via pip when present, via - =ensurepip=-bootstrapped pip otherwise, and finally via =uv= (Hermes - bundles one at =~/.hermes/bin/uv=), which also covers uv-created Hermes - venvs that ship without pip. -3. Copies the Retinue source into =$HERMES_HOME/plugins/retinue/=, preserving - any existing runtime data. -4. Pre-downloads the embedding model - (=minishlab/potion-multilingual-128M=, ~540 MB — the repo's unused - ONNX export is skipped) into - =$HERMES_HOME/plugins/retinue/.hf-cache/= so the first Hermes session - does not stall on the download. The cache is preserved across - re-installs, so this step is a no-op after the first successful run. -5. Sets =memory.provider: retinue= and enables the =memory= toolset for the - CLI platform (=hermes tools enable --platform cli memory=). A clean - "Blank Slate" Hermes install disables the memory toolset, which would - withhold the =retinue_memory_*= tools from the agent even with the - provider selected. Other platforms (telegram, discord, ...) each need - their own =hermes tools enable --platform memory=. +1. Detects the active Hermes home from =HERMES_HOME= (default: + =$HOME/.hermes=) and copies the Retinue plugin into its =plugins/retinue/= + directory, preserving the model cache and existing memory database. +2. Sets =memory.provider: retinue= and prepares =sqlite-vec= plus =model2vec=. + Current Hermes versions use Hermes PM; older versions use pip, + =ensurepip=-bootstrapped pip, or =uv=. +3. Pre-downloads =minishlab/potion-multilingual-128M= (~540 MB, without the + unused ONNX export) into =$HERMES_HOME/plugins/retinue/.hf-cache/= so the + first Hermes session does not stall. The cache survives reinstalls. +4. Adds two memories when their exact content is absent: an explanation of + Retinue's database, embedding model, sqlite-vec index and automatic + prefetch; and the quoted source directory with the command for reinstalling + from that checkout. Reinstalling does not duplicate either memory. +5. Enables the =memory= toolset for the CLI platform and validates the plugin + and all four =retinue_memory_*= tools. Other platforms (telegram, discord, + ...) each need their own =hermes tools enable --platform memory=. Verify the plugin is active: @@ -115,6 +110,37 @@ stop it once (=kill =) and =Restart=always= brings the new instance up. For technical details, read: [[file:Development/index.org::#what-the-installer-does][What the installer does.]] +** Testing a clean installation +:PROPERTIES: +:CUSTOM_ID: testing-a-clean-installation +:ID: f28f8be8-63df-44a5-977e-40d6bbe61ec2 +:END: + +Run the Docker-based clean-install test from the Retinue source tree: + +#+begin_src sh +cd /path/to/retinue +bash "Tools/Test clean installation" +#+end_src + +The test creates a fresh Debian 13 environment, installs the official stable +Hermes release as a non-root user, and installs Retinue from this source tree. +It uses no host Hermes configuration, credentials, plugins, Python environment, +or model cache, and needs no LLM credentials. + +Both the first install and a second idempotency check must pass. The test +verifies automatic provider activation, the CLI memory toolset, all four +Retinue tools, exactly two seed memories without duplicates, and semantic +search with Hugging Face access disabled. + +The first run downloads Hermes, its dependencies, and the embedding model; +allow about 5–15 minutes. Later runs reuse the clean Hermes installation layer +and are faster. To repeat that installation from scratch too: + +#+begin_src sh +bash "Tools/Test clean installation" --no-cache +#+end_src + ** Migrating from Holographic memory :PROPERTIES: :CUSTOM_ID: migrating-from-holographic diff --git a/Install Retinue b/Install Retinue index 069661d..96fcd86 100755 --- a/Install Retinue +++ b/Install Retinue @@ -31,6 +31,26 @@ SOURCE_DIR="$SCRIPT_DIR" HERMES_HOME="${HERMES_HOME:-$HOME/.hermes}" PLUGIN_DIR="$HERMES_HOME/plugins/retinue" +# Find the CLI before resolving Python. Current Hermes launchers are shell +# wrappers around PM-managed Python, so their shebang is NOT an interpreter. +# Probe standard locations explicitly for non-login SSH shells. +find_hermes_cli() { + local found candidate + found="$(command -v hermes 2>/dev/null || true)" + if [[ -z "$found" ]]; then + for candidate in "$HOME/.local/bin/hermes" "$HOME/bin/hermes" \ + /usr/local/bin/hermes /usr/bin/hermes; do + if [[ -x "$candidate" ]]; then + found="$candidate" + break + fi + done + fi + echo "$found" +} + +HERMES_CLI="$(find_hermes_cli)" + # Resolve the Python interpreter that actually runs Hermes. Plugin deps must # live in that environment, not the user's system Python. resolve_hermes_python() { @@ -39,6 +59,13 @@ resolve_hermes_python() { command -v "$PYTHON" >/dev/null 2>&1 && { echo "$PYTHON"; return; } fi + # Set by activate_pm_environment(): PATH now selects the same managed + # Python and PYTHONPATH the next Hermes process will use. + if (( PM_ENV_ACTIVE )); then + command -v python3 + return + fi + # Common Hermes git install layout. local venv_python="$HERMES_HOME/hermes-agent/venv/bin/python3" if [[ -x "$venv_python" ]]; then @@ -68,7 +95,7 @@ resolve_hermes_python() { if [[ "$shebang" == \#\!* ]]; then shebang="${shebang#\#!}" shebang="$(echo -n "$shebang" | sed 's/^[[:space:]]*//')" - if [[ -x "$shebang" ]]; then + if [[ "$shebang" == *python* && -x "$shebang" ]]; then echo "$shebang" return fi @@ -79,7 +106,23 @@ resolve_hermes_python() { echo "python3" } -PYTHON="$(resolve_hermes_python)" +PM_ENV_ACTIVE=0 +PYTHON="${PYTHON:-}" + +# Hermes PM owns current dependency generations. Its activation output is the +# public shell interface for selecting the exact Python and site-packages that +# a fresh Hermes process will use; never pip-install into a guessed generation. +activate_pm_environment() { + local exports + if ! exports="$("$HERMES_CLI" --run-module pm.environments --format sh \ + 2>"$DIAG_DIR/pm-environment.log")"; then + echo "ERROR: could not resolve Hermes' PM-managed Python environment." + show_log_tail "$DIAG_DIR/pm-environment.log" 20 + return 1 + fi + eval "$exports" + PM_ENV_ACTIVE=1 +} while [[ $# -gt 0 ]]; do case "$1" in @@ -229,7 +272,55 @@ ensure_python_deps() { return 1 } -ensure_python_deps +# Select the provider before dependency preparation: Hermes PM discovers the +# active memory provider from config.yaml and includes its plugin.yaml +# python_dependencies in the managed environment. +if [[ -n "$HERMES_CLI" ]]; then + echo "Setting memory.provider to retinue (via $HERMES_CLI)..." + if "$HERMES_CLI" config set memory.provider retinue >"$DIAG_DIR/config-set.log" 2>&1; then + echo " memory.provider set to retinue." + else + echo "ERROR: could not activate Retinue automatically." + echo " --- hermes config set output:" + show_log_tail "$DIAG_DIR/config-set.log" + exit 1 + fi +else + echo "Warning: hermes CLI not found. Set memory.provider: retinue manually in:" + echo " $HERMES_HOME/config.yaml" +fi + +# Current Hermes uses PM-managed Python generations. An explicit `pm install` +# is the supported way to add the newly selected provider's declared deps. +# Older Hermes versions have no PM; those retain the pip/ensurepip/uv chain. +USING_PM=0 +if [[ -z "${PYTHON:-}" && -n "$HERMES_CLI" ]] && \ + "$HERMES_CLI" pm install --help >"$DIAG_DIR/pm-install-help.log" 2>&1; then + USING_PM=1 + echo "Preparing Retinue dependencies through Hermes PM..." + if "$HERMES_CLI" pm install >"$DIAG_DIR/pm-install.log" 2>&1; then + show_log_tail "$DIAG_DIR/pm-install.log" 5 + echo " Hermes PM dependency environment ready." + else + echo "ERROR: Hermes PM could not prepare Retinue's dependencies." + echo " --- hermes pm install output:" + show_log_tail "$DIAG_DIR/pm-install.log" 30 + exit 1 + fi + activate_pm_environment +fi + +PYTHON="$(resolve_hermes_python)" +if (( USING_PM )); then + if ! "$PYTHON" -c "import sqlite_vec, model2vec" \ + >"$DIAG_DIR/pm-import-check.log" 2>&1; then + echo "ERROR: Hermes PM finished, but Retinue's dependencies are not importable." + show_log_tail "$DIAG_DIR/pm-import-check.log" 20 + exit 1 + fi +else + ensure_python_deps +fi # Pre-download the embedding model into the plugin-local cache so the first # Hermes session does not hang on a ~500 MB download. @@ -254,40 +345,31 @@ print(' Embedding model ready.') show_log_tail "$log" 20 echo " It will download on first use inside Hermes." echo " (Check network / Hugging Face connectivity.)" + return 1 fi } -preload_embedding_model - -# Set Hermes memory provider if the CLI is available. -# Same non-login-shell problem as above: hermes usually lives in ~/.local/bin, -# so probe the standard locations instead of relying on PATH alone. -HERMES_CLI="$(command -v hermes 2>/dev/null || true)" -if [[ -z "$HERMES_CLI" ]]; then - for candidate in "$HOME/.local/bin/hermes" "$HOME/bin/hermes" \ - /usr/local/bin/hermes /usr/bin/hermes; do - if [[ -x "$candidate" ]]; then - HERMES_CLI="$candidate" - break - fi - done -fi - -if [[ -n "$HERMES_CLI" ]]; then - echo "Setting memory.provider to retinue (via $HERMES_CLI)..." - if "$HERMES_CLI" config set memory.provider retinue >"$DIAG_DIR/config-set.log" 2>&1; then - echo " memory.provider set to retinue." +seed_example_memories() { + echo "Seeding the Retinue explanation and installation-source memories..." + export HF_HOME="$PLUGIN_DIR/.hf-cache" + local log="$DIAG_DIR/seed-examples.log" + if HERMES_HOME="$HERMES_HOME" \ + RETINUE_SOURCE_DIR="$SOURCE_DIR" \ + PYTHONPATH="$PLUGIN_DIR/src${PYTHONPATH:+:$PYTHONPATH}" \ + "$PYTHON" -m retinue.bootstrap >"$log" 2>&1; then + show_log_tail "$log" 3 else - echo " Warning: could not set memory.provider automatically." - echo " --- hermes config set output:" - show_log_tail "$DIAG_DIR/config-set.log" - echo " Set it manually in $HERMES_HOME/config.yaml:" - echo " memory:" - echo " provider: retinue" + echo "ERROR: could not seed the example memories." + show_log_tail "$log" 20 + return 1 fi +} + +if preload_embedding_model; then + seed_example_memories else - echo "Warning: hermes CLI not found. Set memory.provider: retinue manually in:" - echo " $HERMES_HOME/config.yaml" + echo " Warning: example memories were not seeded because the model is unavailable." + echo " Re-run Install Retinue with network access to add them." fi # Enable the memory toolset for the CLI platform. Clean "Blank Slate" Hermes diff --git a/Tools/Test clean installation b/Tools/Test clean installation new file mode 100755 index 0000000..b49bb7e --- /dev/null +++ b/Tools/Test clean installation @@ -0,0 +1,29 @@ +#!/usr/bin/env bash +# Test clean installation - build a disposable Debian 13 image, install the +# official Hermes release, then install Retinue twice and verify real activation. + +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +PROJECT_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)" +IMAGE="${RETINUE_TEST_IMAGE:-retinue-clean-install-test:latest}" + +if ! command -v docker >/dev/null 2>&1; then + echo "ERROR: Docker is required for the clean-install test." >&2 + exit 1 +fi +if ! docker info >/dev/null 2>&1; then + echo "ERROR: Docker is installed but its daemon is not reachable by $USER." >&2 + echo " Check 'docker info' and your Docker group membership." >&2 + exit 1 +fi + +# Extra arguments are forwarded to docker build. Use --no-cache to repeat even +# the official Hermes installation from scratch; source edits already invalidate +# every Retinue install and verification layer without that slower option. +docker build \ + --progress=plain \ + --tag "$IMAGE" \ + --file "$PROJECT_ROOT/test/clean-install/Dockerfile" \ + "$@" \ + "$PROJECT_ROOT" diff --git a/plugin.yaml b/plugin.yaml index 0c72105..1b0e22d 100644 --- a/plugin.yaml +++ b/plugin.yaml @@ -1,5 +1,5 @@ name: retinue -version: 1.2.0 +version: 1.3.0 description: "Semantic memory provider for Hermes Agent using model2vec and sqlite-vec." hooks: - sync_turn @@ -9,5 +9,9 @@ hooks: # so Retinue's is_available() keeps passing after updates. Keep upper bounds # per the Hermes dependency policy. python_dependencies: - - "sqlite-vec>=0.1.9,<0.2" + # sqlite-vec publishes Linux, macOS and Windows wheels, but no Android build. + # The marker keeps Hermes PM's universal lock from solving an impossible + # Android branch; Retinue remains unavailable there rather than breaking + # dependency installation on every supported host. + - "sqlite-vec>=0.1.9,<0.2; sys_platform != 'android'" - "model2vec>=0.9,<0.10" diff --git a/src/retinue/bootstrap.py b/src/retinue/bootstrap.py new file mode 100644 index 0000000..a569ac6 --- /dev/null +++ b/src/retinue/bootstrap.py @@ -0,0 +1,79 @@ +"""One-time, idempotent memories created by the Retinue installer.""" + +from __future__ import annotations + +import json +import os +import socket +from pathlib import Path + +from retinue.memory import MemoryStore + + +def _quoted(value: str) -> str: + """Quote a path or hostname without breaking on embedded quotes.""" + return json.dumps(value, ensure_ascii=False) + + +def seed_example_memories( + base_dir: str | Path, + source_dir: str | Path, + *, + hostname: str | None = None, +) -> dict[str, int]: + """Add the two installer examples if their exact content is absent. + + Existing user memories are never edited or removed. Repeating an install + from the same source into the same Hermes home adds nothing. + """ + store = MemoryStore(Path(base_dir).resolve()) + source = Path(source_dir).resolve() + host = hostname or socket.gethostname() + contents = [ + ( + "Retinue is the local semantic memory provider for Hermes Agent. " + f"For this installation, Retinue stores memories in {_quoted(str(store.db_path))}, " + "embeds text with minishlab/potion-multilingual-128M, indexes " + "embeddings with sqlite-vec, and uses prefetch to inject relevant " + "memories automatically before each turn." + ), + ( + f"Retinue installation source on host {_quoted(host)} is " + f"{_quoted(str(source))}. To reinstall or update Retinue from this " + f"source checkout, run: bash {_quoted(str(source / 'Install Retinue'))}." + ), + ] + + added = 0 + existing = 0 + try: + for content in contents: + if store.find_exact(content): + existing += 1 + else: + store.add(content) + added += 1 + total = len(store.list()) + finally: + store.close() + return {"added": added, "existing": existing, "total": total} + + +def main() -> int: + """Seed from installer-supplied paths; no command-line arguments needed.""" + hermes_home = os.environ.get("HERMES_HOME") + source_dir = os.environ.get("RETINUE_SOURCE_DIR") + if not hermes_home: + raise SystemExit("HERMES_HOME is required to seed Retinue memories") + if not source_dir: + raise SystemExit("RETINUE_SOURCE_DIR is required to seed Retinue memories") + result = seed_example_memories(hermes_home, source_dir) + print( + "Example memories ready: " + f"{result['added']} added, {result['existing']} already present." + ) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/test/clean-install/Dockerfile b/test/clean-install/Dockerfile new file mode 100644 index 0000000..77420a7 --- /dev/null +++ b/test/clean-install/Dockerfile @@ -0,0 +1,47 @@ +FROM debian:trixie-slim + +ENV DEBIAN_FRONTEND=noninteractive +RUN apt-get update \ + && apt-get install -y --no-install-recommends \ + bash \ + ca-certificates \ + curl \ + git \ + libatomic1 \ + python3 \ + sudo \ + tar \ + && rm -rf /var/lib/apt/lists/* + +# Exercise the normal non-root installation path. Passwordless sudo is needed +# only if Hermes has to install a missing operating-system library. +RUN useradd --create-home --shell /bin/bash tester \ + && echo 'tester ALL=(ALL) NOPASSWD:ALL' > /etc/sudoers.d/tester + +USER tester +ENV HOME=/home/tester \ + PATH=/home/tester/.local/bin:/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin \ + CI=1 +WORKDIR /home/tester + +# This is the only Hermes installation in the image: no host checkout, config, +# credentials, plugin, model cache, or Python environment is mounted in. +RUN curl -fsSL https://hermes-agent.nousresearch.com/install.sh \ + | bash -s -- --non-interactive --skip-browser --skip-computer-use + +WORKDIR /home/tester/retinue +COPY --chown=tester:tester . /home/tester/retinue/ + +# Verify the first install, then reinstall and verify again. The second pass +# proves idempotency, including that installer-seeded memories do not multiply. +RUN bash "Install Retinue" \ + && eval "$(hermes --run-module pm.environments --format sh)" \ + && export HF_HOME="$HOME/.hermes/plugins/retinue/.hf-cache" HF_HUB_OFFLINE=1 \ + && python3 test/clean-install/verify.py + +RUN bash "Install Retinue" \ + && eval "$(hermes --run-module pm.environments --format sh)" \ + && export HF_HOME="$HOME/.hermes/plugins/retinue/.hf-cache" HF_HUB_OFFLINE=1 \ + && python3 test/clean-install/verify.py + +CMD ["/bin/bash"] diff --git a/test/clean-install/verify.py b/test/clean-install/verify.py new file mode 100644 index 0000000..c3fa17e --- /dev/null +++ b/test/clean-install/verify.py @@ -0,0 +1,128 @@ +#!/usr/bin/env python3 +"""Verify a clean Retinue install through Hermes' real plugin loader. + +Run with the Python and environment produced by: + eval "$(hermes --run-module pm.environments --format sh)" +No model provider or API credential is needed. +""" + +from __future__ import annotations + +import json +import os +import subprocess +from pathlib import Path +from typing import Any, cast + +EXPECTED_TOOLS = { + "retinue_memory_add", + "retinue_memory_search", + "retinue_memory_delete", + "retinue_memory_stats", +} +SOURCE_DIR = Path(__file__).resolve().parents[2] +HERMES_HOME = Path( + os.environ.get("HERMES_HOME", str(Path.home() / ".hermes")) +).resolve() + + +def run(command: list[str]) -> str: + result = subprocess.run( + command, + check=False, + text=True, + stdout=subprocess.PIPE, + stderr=subprocess.STDOUT, + ) + output = result.stdout + if result.returncode != 0: + raise AssertionError( + f"command exited {result.returncode}: {' '.join(command)}\n{output}" + ) + return output + + +def require(condition: bool, message: str) -> None: + if not condition: + raise AssertionError(message) + + +def main() -> None: + status = run(["hermes", "memory", "status"]) + require("Provider:" in status and "retinue" in status, + f"Retinue is not the selected provider:\n{status}") + require("available" in status and "✓" in status, + f"Retinue is not available through Hermes:\n{status}") + + selected = run(["hermes", "config", "get", "memory.provider"]) + require("retinue" in selected, + f"memory.provider was not activated automatically:\n{selected}") + + tools = run(["hermes", "tools", "list", "--platform", "cli"]) + memory_lines = [line for line in tools.splitlines() + if " memory " in line and "Memory" in line] + require(len(memory_lines) == 1 and "✓ enabled" in memory_lines[0], + f"the CLI memory toolset is not enabled:\n{tools}") + + # This is Hermes' own discovery and construction path, not a direct import + # that could hide a broken plugin registration. + from plugins.memory import load_memory_provider + + provider = cast(Any, load_memory_provider("retinue")) + require(provider is not None, "Hermes could not load the Retinue provider") + require(provider.is_available(), "Retinue reports unavailable after install") + require({schema["name"] for schema in provider.get_tool_schemas()} == EXPECTED_TOOLS, + "Retinue's four tools were not registered correctly") + + provider.initialize("clean-install-verification", hermes_home=str(HERMES_HOME)) + try: + stats = json.loads(provider.handle_tool_call("retinue_memory_stats", {})) + require(stats.get("success") is True, f"stats tool failed: {stats}") + result = stats["result"] + require(result["total"] == 2, + f"fresh install must contain exactly two seed memories; got {result}") + require(Path(result["db_path"]).resolve() + == HERMES_HOME / "retinue_memory.db", + f"Retinue opened the wrong database: {result}") + + rows = provider._store.list() # noqa: SLF001 - inspect real persisted rows + contents = [row[1] for row in rows] + require(any( + "Retinue" in text + and "minishlab/potion-multilingual-128M" in text + and "sqlite-vec" in text + and "prefetch" in text + for text in contents + ), + f"the Retinue explanation seed is missing or incomplete: {contents}") + require(any( + "Retinue installation source" in text + and f'"{SOURCE_DIR}"' in text + and f'"{SOURCE_DIR}/Install Retinue"' in text + for text in contents + ), + f"the quoted installation-source seed is missing: {contents}") + + found = json.loads(provider.handle_tool_call( + "retinue_memory_search", + { + "query": "How does Retinue store and automatically recall memories?", + "limit": 5, + "include_seen": True, + }, + )) + require(found.get("success") is True and found["result"], + f"semantic search over the seed memories failed: {found}") + require(any("sqlite-vec" in row["content"] for row in found["result"]), + f"the Retinue explanation was not semantically retrievable: {found}") + finally: + provider.shutdown() + + print("PASS: Hermes loads Retinue and selects it automatically") + print("PASS: the CLI memory toolset and all four Retinue tools are available") + print("PASS: exactly two useful seed memories persist and remain idempotent") + print("PASS: semantic search works with the preloaded model in offline mode") + + +if __name__ == "__main__": + main() diff --git a/test/test_standalone.py b/test/test_standalone.py index 1a40c11..018e40a 100644 --- a/test/test_standalone.py +++ b/test/test_standalone.py @@ -306,6 +306,50 @@ def test_rolling_topic_query() -> None: shutil.rmtree(base_dir, ignore_errors=True) +def test_bootstrap_seeds() -> None: + """Installer seeds are useful, quoted, and idempotent.""" + from retinue.bootstrap import seed_example_memories + + base_dir = Path(tempfile.mkdtemp(prefix="retinue-bootstrap-test-")) + try: + source_dir = base_dir / "Retinue source checkout" + first = seed_example_memories( + base_dir, source_dir, hostname="test-host" + ) + assert first == {"added": 2, "existing": 0, "total": 2}, first + + store = MemoryStore(base_dir) + rows = store.list() + contents = [row[1] for row in rows] + assert any( + "Retinue" in text + and str(base_dir / "retinue_memory.db") in text + and "minishlab/potion-multilingual-128M" in text + and "sqlite-vec" in text + and "prefetch" in text + for text in contents + ), contents + assert any( + 'host "test-host"' in text + and f'"{source_dir}"' in text + and f'"{source_dir}/Install Retinue"' in text + for text in contents + ), contents + first_ids = sorted(row[0] for row in rows) + store.close() + + second = seed_example_memories( + base_dir, source_dir, hostname="test-host" + ) + assert second == {"added": 0, "existing": 2, "total": 2}, second + store = MemoryStore(base_dir) + assert sorted(row[0] for row in store.list()) == first_ids + store.close() + print("\nInstaller bootstrap seed tests passed.") + finally: + shutil.rmtree(base_dir, ignore_errors=True) + + def test_cli() -> None: """Exercise the "Show Retinue memory contents" CLI (src/retinue/cli.py).""" base_dir = Path(tempfile.mkdtemp(prefix="retinue-cli-test-")) @@ -347,5 +391,6 @@ if __name__ == "__main__": test_on_memory_write() test_prefetch_session_filter() test_rolling_topic_query() + test_bootstrap_seeds() test_cli() print("\nAll standalone tests passed.")