feat(installer): support clean installs and seed initial memories master
authorSvjatoslav Agejenko <svjatoslav@svjatoslav.eu>
Sat, 24 Oct 2026 23:14:08 +0000 (02:14 +0300)
committerSvjatoslav Agejenko <svjatoslav@svjatoslav.eu>
Sat, 24 Oct 2026 23:14:08 +0000 (02:14 +0300)
- 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

.dockerignore [new file with mode: 0644]
AGENTS.org
Documentation/Development/index.org
Documentation/index.org
Install Retinue
Tools/Test clean installation [new file with mode: 0755]
plugin.yaml
src/retinue/bootstrap.py [new file with mode: 0644]
test/clean-install/Dockerfile [new file with mode: 0644]
test/clean-install/verify.py [new file with mode: 0644]
test/test_standalone.py

diff --git a/.dockerignore b/.dockerignore
new file mode 100644 (file)
index 0000000..96ba3cb
--- /dev/null
@@ -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
index 1f19e46..0536e4e 100644 (file)
@@ -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
index c2e5620..1834fdb 100644 (file)
@@ -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 <name>= 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 <name>= 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
index 3e94327..30563b4 100644 (file)
@@ -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 <name> 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 <name> memory=.
 
 Verify the plugin is active:
 
@@ -115,6 +110,37 @@ stop it once (=kill <pid>=) 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
index 069661d..96fcd86 100755 (executable)
@@ -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 (executable)
index 0000000..b49bb7e
--- /dev/null
@@ -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"
index 0c72105..1b0e22d 100644 (file)
@@ -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 (file)
index 0000000..a569ac6
--- /dev/null
@@ -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 (file)
index 0000000..77420a7
--- /dev/null
@@ -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 (file)
index 0000000..c3fa17e
--- /dev/null
@@ -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()
index 1a40c11..018e40a 100644 (file)
@@ -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.")