--- /dev/null
+/.venv/
+/.hf-cache/
+__pycache__/
+*.pyc
+/.idea
+*.html
\ No newline at end of file
--- /dev/null
+#!/bin/bash
+
+# This script launches IntelliJ IDEA with the current project
+# directory. The script is designed to be run by double-clicking it in
+# the GNOME Nautilus file manager.
+
+# First, we change the current working directory to the directory of
+# the script.
+
+# "${0%/*}" gives us the path of the script itself, without the
+# script's filename.
+
+# This command basically tells the system "change the current
+# directory to the directory containing this script".
+
+cd "${0%/*}"
+
+# Then, we move up one directory level.
+# The ".." tells the system to go to the parent directory of the current directory.
+# This is done because we assume that the project directory is one level up from the script.
+cd ..
+
+# Now, we use the 'setsid' command to start a new session and run
+# IntelliJ IDEA in the background. 'setsid' is a UNIX command that
+# runs a program in a new session.
+
+# The command 'idea .' opens IntelliJ IDEA with the current directory
+# as the project directory. The '&' at the end is a UNIX command that
+# runs the process in the background. The '> /dev/null' part tells
+# the system to redirect all output (both stdout and stderr, denoted
+# by '&') that would normally go to the terminal to go to /dev/null
+# instead, which is a special file that discards all data written to
+# it.
+
+setsid idea . &>/dev/null &
+
+# The 'disown' command is a shell built-in that removes a shell job
+# from the shell's active list. Therefore, the shell will not send a
+# SIGHUP to this particular job when the shell session is terminated.
+
+# '-h' option specifies that if the shell receives a SIGHUP, it also
+# doesn't send a SIGHUP to the job.
+
+# '$!' is a shell special parameter that expands to the process ID of
+# the most recent background job.
+disown -h $!
+
+
+sleep 2
+
+# Finally, we use the 'exit' command to terminate the shell script.
+# This command tells the system to close the terminal window after
+# IntelliJ IDEA has been opened.
+exit
--- /dev/null
+"""Retinue Hermes memory provider plugin entry point.
+
+When Hermes loads the plugin from plugins/memory/retinue/, it imports this
+file and calls register(ctx).
+"""
+
+import sys
+from pathlib import Path
+
+# Make src/retinue importable from the plugin directory.
+_heresrc = Path(__file__).parent / "src"
+if str(_heresrc) not in sys.path:
+ sys.path.insert(0, str(_heresrc))
+
+from retinue.provider import RetinueMemoryProvider
+
+
+def register(ctx) -> None:
+ """Called by the Hermes memory plugin discovery system."""
+ ctx.register_memory_provider(RetinueMemoryProvider())
--- /dev/null
+:PROPERTIES:
+:ID: ee70e32d-0ead-4236-bbc2-9dc321dc6a92
+:END:
+#+SETUPFILE: ~/.emacs.d/org-styles/html/darksun.theme
+#+TITLE: Retinue — Native memory provider plugin for Hermes Agent
+#+LANGUAGE: en
+#+LATEX_HEADER: \usepackage[margin=1.0in]{geometry}
+#+LATEX_HEADER: \usepackage{parskip}
+#+LATEX_HEADER: \usepackage[none]{hyphenat}
+
+#+OPTIONS: H:20 num:20
+#+OPTIONS: author:nil
+
+* Overview
+:PROPERTIES:
+:CUSTOM_ID: overview
+:ID: a22b19b1-bc50-4368-a0c0-ae349f732df1
+:END:
+
+Retinue is a native *Hermes Agent memory provider plugin* that adds
+persistent, semantic memory to every Hermes session. Once installed
+and configured, it becomes Hermes' external memory backend.
+
+It is built on =model2vec= static embeddings and =sqlite-vec=, so it
+runs offline, requires no server, and stores everything in a single
+SQLite database.
+
+* Installation
+:PROPERTIES:
+:CUSTOM_ID: installation
+:ID: bc83f42b-b126-4b48-b365-762d27618651
+:END:
+
+Prerequisites: Hermes Agent must be installed and the =hermes= CLI must be on
+your =PATH=.
+
+Run the bundled installer from the Retinue source tree:
+
+#+begin_src sh
+cd /path/to/retinue
+./install.sh
+#+end_src
+
+The installer performs four 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.
+3. Copies the Retinue source into =$HERMES_HOME/plugins/retinue/=, preserving
+ any existing runtime data.
+4. Sets =memory.provider: retinue=.
+
+Verify the plugin is active:
+
+#+begin_src sh
+hermes memory status
+#+end_src
+
+* Quick start
+:PROPERTIES:
+:CUSTOM_ID: quick-start
+:ID: e1341a37-be8c-4c3b-810e-4e34a222406d
+:END:
+
+After installing the plugin, try storing and recalling a memory through a
+Hermes session:
+
+1. Store a fact:
+
+ #+begin_src text
+ Remember that ProjectX is hosted on our primary server.
+ #+end_src
+
+ Hermes will call =retinue_memory_add= with the content.
+
+2. Later, ask a related question:
+
+ #+begin_src text
+ Where is ProjectX hosted?
+ #+end_src
+
+ Hermes will call =retinue_memory_search= and retrieve the relevant memory.
+
+* Development
+:PROPERTIES:
+:CUSTOM_ID: development
+:ID: d77ed683-4da0-4cf1-84ee-73544b93f28b
+:END:
+
+** Architecture
+:PROPERTIES:
+:CUSTOM_ID: architecture
+:ID: ad252f6e-8152-4dd4-95f6-7b52f773e740
+:END:
+
+Retinue uses:
+
+- =model2vec= :: Static embedding model (=minishlab/potion-multilingual-128M=,
+ ~500 MB download on first use, then offline). Understands meaning across
+ languages.
+- =sqlite-vec= :: SQLite extension for vector similarity search. No
+ server, no daemon, no port.
+- Unified store :: All memories share one table. Each memory has a
+ content string, a timestamp, and an auto-generated embedding.
+
+** What install.sh does
+:PROPERTIES:
+:CUSTOM_ID: what-install-sh-does
+:ID: 9f6b3951-e8f0-4643-bd54-b35bdf528d6e
+:END:
+
+=install.sh= is the deployment bridge between the Retinue workspace and the
+active Hermes home. It is intended for iterative development: edit the
+source tree, run the script, and test inside Hermes immediately.
+
+The script is idempotent, so you can run it after every code change. It
+preserves runtime artifacts (the database, virtual environments, and caches)
+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=.
+2. Creates =$HERMES_HOME/plugins/retinue/= if it does not exist.
+3. Resolves the Python interpreter that Hermes actually runs under (via the
+ =PYTHON= environment variable, common venv layouts, or the =hermes=
+ launcher shebang) and checks for =sqlite-vec= and =model2vec=.
+4. Installs any missing dependencies in that interpreter with =pip=.
+5. Copies the latest source tree into the plugin directory using =rsync=,
+ excluding =data/=, =.venv/=, =.hf-cache/=, =__pycache__/=, and =.git/= so
+ the runtime store and virtual environment are preserved.
+6. Sets =memory.provider: retinue= with =hermes config set= if the CLI is
+ available.
+7. Runs a lightweight import check to confirm the plugin loads.
+
+To target a specific Hermes profile or home directory:
+
+#+begin_src sh
+HERMES_HOME="$HOME/.hermes/profiles/coder" bash install.sh
+#+end_src
+
+** Project structure
+:PROPERTIES:
+:CUSTOM_ID: project-structure
+:ID: 6d9ea612-7ecf-4eef-96e0-ab2e9b744a2e
+:END:
+
+#+begin_src
+Retinue/
+├── doc/
+│ └── index.org # This file
+├── install.sh # Deploy into the active Hermes plugin directory
+├── plugin.yaml # Hermes plugin metadata
+├── __init__.py # Plugin entry point: register(ctx)
+├── src/ # Plugin source package
+│ └── retinue/
+│ ├── __init__.py # Package init
+│ ├── memory.py # Memory engine: embeddings + sqlite-vec
+│ └── provider.py # MemoryProvider subclass
+├── cli.py # Optional: hermes retinue <subcommand> CLI
+└── data/ # Created at runtime under the active Hermes home
+ └── memory.db # SQLite + sqlite-vec database
+#+end_src
+
+** Database layout
+:PROPERTIES:
+:CUSTOM_ID: database-layout
+:ID: 2e9bc319-c314-4a48-8fdf-0c6bac11f344
+:END:
+
+Retinue stores all data in a single SQLite virtual table under the active
+Hermes home:
+
+#+begin_src text
+$HERMES_HOME/retinue_memory.db
+#+end_src
+
+*"memories" virtual table:*
+
+| Column | Type | Meaning |
+|-------------+-------------------+------------------------------------------------------------------|
+| =rowid= | INTEGER | Auto-increment primary key. Stable ID used for delete. |
+| =embedding= | float[MODEL_DIM] | The model2vec embedding vector for =content=. |
+| =content= | TEXT | The full text of the memory/fact. |
+| =created= | TEXT | ISO 8601 UTC timestamp when the row was inserted. |
+
+=MODEL_DIM= depends on the model (hardcoded to
+=minishlab/potion-multilingual-128M=). The table is created with
+=sqlite-vec= and supports cosine-similarity search.
+
+Why a /virtual/ table? In SQLite, a virtual table is created with
+=CREATE VIRTUAL TABLE ... USING vec0(...)=. The =vec0= module from
+=sqlite-vec= provides the storage and indexing logic for the
+=embedding= column, which enables the =embedding MATCH ?= vector
+similarity query. The table is still persisted in the same SQLite
+=.db= file; “virtual” only means SQLite delegates the table engine to
+the =sqlite-vec= extension.
+
+** Integration with Hermes Agent
+:PROPERTIES:
+:CUSTOM_ID: integration-with-hermes-agent
+:ID: ca6a072f-b686-4dec-b50a-e4b83980be61
+:END:
+
+Retinue implements the Hermes =MemoryProvider= abstract base class from
+=agent/memory_provider.py=.
+
+*** Required methods
+:PROPERTIES:
+:CUSTOM_ID: required-methods
+:ID: 845b8e5d-7e94-4b93-b147-d065f864eeb3
+:END:
+
+| Method | Purpose | Notes |
+|------------------------------------------+---------------------------------------+-------------------------------------------------------------------------------------------|
+| =name= (property) | Provider identifier, e.g. ="retinue"= | Must be unique |
+| =is_available()= | Can this provider activate? | No network calls; check deps/config/env |
+| =initialize(session_id, **kwargs)= | One-time startup | Receives =hermes_home=, =platform=, =agent_context=, etc. |
+| =get_tool_schemas()= | Tool schemas to expose to the model | OpenAI function-calling format; return =[]= if context-only |
+| =handle_tool_call(name, args, **kwargs)= | Execute a tool call | Must return a JSON string |
+| =get_config_schema()= | Fields for =hermes memory setup= | List of field descriptors (key, description, secret, required, default, choices, env_var) |
+| =save_config(values, hermes_home)= | Persist non-secret config | Secrets go to =.env=; env-var-only providers can no-op |
+
+*** Optional hooks
+:PROPERTIES:
+:CUSTOM_ID: optional-hooks
+:ID: f0792483-e9ea-4ad0-be64-7a4e9544a489
+:END:
+
+| Method | Purpose |
+|---------------------------------------------------------------+---------------------------------------------------------------|
+| =system_prompt_block()= | Static text injected into the system prompt |
+| =prefetch(query, *, session_id="")= | Recall relevant context before each turn (should be fast) |
+| =queue_prefetch(query)= | Pre-warm recall for the next turn |
+| =sync_turn(user, assistant, *, session_id="", messages=None)= | Persist a completed turn (must be non-blocking) |
+| =on_session_end(messages)= | Flush/extract at conversation end |
+| =on_pre_compress(messages) -> str= | Capture insights before context compression |
+| =on_memory_write(action, target, content, metadata=None)= | Mirror built-in =memory= writes to the provider |
+| =on_delegation(task, result, **kwargs)= | Observe subagent work from the parent |
+| =shutdown()= | Clean up connections/threads |
+| =backup_paths() -> list[str]= | Extra on-disk paths outside =HERMES_HOME= for =hermes backup= |
+
+*** Memory save
+:PROPERTIES:
+:CUSTOM_ID: memory-save
+:ID: 6325a3ac-8629-461e-aa5e-62408ba73bde
+:END:
+
+Hermes does not automatically persist every turn. A memory provider
+can receive data through three separate paths, and the provider
+decides what to store and how.
+
+**** Explicit memory tools (provider tools)
+:PROPERTIES:
+:CUSTOM_ID: explicit-memory-tools-provider-tools
+:ID: a0b54263-fd4c-4997-a867-834ccce8a8b6
+:END:
+
+The tools returned by =get_tool_schemas()= are offered to the LLM
+alongside Hermes' built-in tools. When the LLM decides a fact is worth
+remembering, it calls one of them. The provider receives the call in
+=handle_tool_call(name, args, **kwargs)= and must return a JSON
+string.
+
+Retinue exposes these tools:
+
+- =retinue_memory_add= — store a fact.
+- =retinue_memory_search= — search stored facts by semantic similarity.
+- =retinue_memory_list= — list facts.
+- =retinue_memory_delete= — delete a fact by ID.
+- =retinue_memory_stats= — show store statistics.
+
+This is the primary path in Retinue. The LLM decides when to save, and
+the user can explicitly ask "remember that ...".
+
+**** Built-in =memory= tool mirror (=on_memory_write=)
+:PROPERTIES:
+:CUSTOM_ID: built-in-memory-tool-mirror-on-memory-write
+:ID: 25f1b5ee-0b77-412f-98f1-74d69b884f19
+:END:
+
+Hermes has a built-in =memory= tool that the LLM can call to write durable
+facts. When that tool successfully commits a write, Hermes notifies the active
+external memory provider through =on_memory_write(action, target, content,
+metadata=None)=.
+
+Fields:
+
+- =action= — ="add"=, ="replace"=, or ="remove"=.
+- =target= — ="memory"= or ="user"=.
+- =content= — the text being written.
+- =metadata= — provenance dictionary, commonly containing:
+ - =write_origin= — e.g. ="assistant_tool"=
+ - =execution_context= — e.g. ="foreground"= or ="background"=
+ - =session_id= / =parent_session_id=
+ - =platform= — e.g. ="cli"=, ="telegram"=
+ - =tool_name= — always ="memory"= for this hook
+ - =task_id= / =tool_call_id= — when the write happened inside a task
+ - =old_text= — present for =replace= actions
+
+Retinue implements this hook so that successful =memory= writes are mirrored
+into the Retinue semantic store. They can then be searched via
+=retinue_memory_search=.
+
+**** Turn-level extraction (=sync_turn=)
+:PROPERTIES:
+:CUSTOM_ID: turn-level-extraction-sync-turn
+:ID: 52f3d0c2-5e75-4a49-90b1-50fca051ae1e
+:END:
+
+After every completed turn, Hermes calls =sync_turn(user_content,
+assistant_content, *, session_id="", messages=None)= on a background
+thread.
+
+Fields:
+
+- =user_content= — the cleaned user message for this turn (skill
+ scaffolding stripped).
+- =assistant_content= — the assistant's final response for this turn.
+- =session_id= — the active Hermes session ID.
+- =messages= — the full OpenAI-style conversation message list /as of
+ the completed turn/, including any assistant tool calls and tool
+ results. This is *not* just the latest pair; it is the entire
+ transcript so far.
+
+The provider can override this method to extract and store facts from
+the raw conversation. It should be non-blocking — queue work to a
+background thread if needed, because Hermes calls it synchronously and
+a slow provider will stall the agent.
+
+Retinue's current implementation leaves =sync_turn= as a no-op. Future
+versions may use it to summarize each turn and store extracted facts
+automatically.
+
+**** Summary of who decides what to save
+:PROPERTIES:
+:CUSTOM_ID: summary-of-who-decides-what-to-save
+:ID: f9bf3bb2-f15e-4f37-82d1-52254e12bfd0
+:END:
+
+| Path | Trigger | What the provider receives |
+|----------------------+---------------------------------------------+------------------------------------------------------|
+| Provider tools | LLM calls one of =get_tool_schemas()= tools | Tool name + structured arguments |
+| Built-in memory tool | LLM calls =memory= | =on_memory_write(action, target, content, metadata)= |
+| Turn sync | Hermes after every completed turn | =sync_turn(user, assistant, session_id, messages)= |
+
+*** Memory retrieval
+:PROPERTIES:
+:CUSTOM_ID: memory-retrieval
+:ID: e30a9286-6083-48ed-a861-61c72a5737b4
+:END:
+
+Retrieval is also provider-driven. Hermes calls =prefetch(query,
+session_id="")= before each turn; the provider can return relevant
+context as a formatted string. This text is injected into the system
+prompt block and fenced as =<memory-context>=.
+
+In addition, the LLM can explicitly call provider tools (e.g.
+=retinue_memory_search=) or the built-in =memory= tool to look up facts on
+demand.
+
+Retinue currently uses the provider-tool path for retrieval; =prefetch= is a
+no-op.
+
+*** Relationship to built-in =memory=
+:PROPERTIES:
+:CUSTOM_ID: relationship-to-built-in-memory
+:ID: 4aba035e-ab65-48c6-925c-49b68a3e65be
+:END:
+
+Hermes always exposes the built-in =memory= tool. It stores compact,
+high-signal notes in two text files (=MEMORY.md= and =USER.md=) that are
+injected into the system prompt as a frozen snapshot. It is best for short,
+durable facts: preferences, corrections, environment details, and reusable
+lessons.
+
+When Retinue is active, the LLM sees two memory surfaces:
+
+- Built-in =memory= — compact, always-on, text-file storage.
+- =retinue_memory_*= — local semantic memory with embeddings and similarity
+ search.
+
+The LLM chooses based on the tool descriptions. There is no hard-coded rule.
+In practice:
+
+- Use =memory= for short, high-signal facts that should appear in every system
+ prompt (e.g. "User prefers concise responses", "Project uses pytest").
+- Use =retinue_memory_add= for richer knowledge that may be recalled later by
+ meaning (e.g. "ProjectX is hosted on our primary server").
+ =retinue_memory_search= can find it even when the query uses different words.
+
+To avoid losing facts when the LLM picks the built-in =memory= tool, Retinue
+implements =on_memory_write= so successful =memory= writes (add, replace, remove)
+are mirrored into the Retinue semantic store. They can then be searched via
+=retinue_memory_search=.
+
+*** Tools exposed to Hermes
+:PROPERTIES:
+:CUSTOM_ID: tools-exposed-to-hermes
+:ID: 04cf7ccf-e71a-4f91-a023-3dbf87d5dbf5
+:END:
+
+When Retinue is the active memory provider, Hermes can call these tools
+natively. The exact names and schemas are defined in
+=get_tool_schemas()= and dispatched through =handle_tool_call()=.
+
+**** =retinue_memory_add=
+:PROPERTIES:
+:CUSTOM_ID: retinue-memory-add
+:ID: 69dbc374-8c72-4c8c-a46e-7617bc0c2b2d
+:END:
+
+Store a memory with content.
+
+#+begin_src json
+{
+ "name": "retinue_memory_add",
+ "description": "Store a semantic memory in Retinue.",
+ "parameters": {
+ "type": "object",
+ "properties": {
+ "content": {"type": "string", "description": "The memory content"}
+ },
+ "required": ["content"]
+ }
+}
+#+end_src
+
+**** =retinue_memory_search=
+:PROPERTIES:
+:CUSTOM_ID: retinue-memory-search
+:ID: 8c479c9b-141e-4a12-9ee0-8f15e398c0c1
+:END:
+
+Semantic search across all memories.
+
+#+begin_src json
+{
+ "name": "retinue_memory_search",
+ "description": "Search Retinue memories by semantic similarity.",
+ "parameters": {
+ "type": "object",
+ "properties": {
+ "query": {"type": "string", "description": "Natural-language search query"},
+ "limit": {"type": "integer", "default": 10}
+ },
+ "required": ["query"]
+ }
+}
+#+end_src
+
+**** =retinue_memory_list=
+:PROPERTIES:
+:CUSTOM_ID: retinue-memory-list
+:ID: 42c019eb-69d9-438f-b7c9-cdc1685a5526
+:END:
+
+List all stored memories.
+
+**** =retinue_memory_delete=
+:PROPERTIES:
+:CUSTOM_ID: retinue-memory-delete
+:ID: 4d3fb3e3-6dc3-4184-9c23-dfd0714130db
+:END:
+
+Delete a memory by ID.
+
+**** =retinue_memory_stats=
+:PROPERTIES:
+:CUSTOM_ID: retinue-memory-stats
+:ID: 7d56b6c5-c889-43a3-980e-6b8ed8277ec1
+:END:
+
+Show memory store statistics.
+** Development and testing workflow
+:PROPERTIES:
+:CUSTOM_ID: development-and-testing-workflow
+:ID: 246ce71b-6841-49fb-a330-2bb83fc275d3
+:END:
+
+Retinue is developed in its own source tree and deployed into the active
+Hermes home with =install.sh=. This makes iteration fast and safe: the
+runtime =data/= directory lives in Hermes, while the source code lives in
+the workspace.
+
+*** Iterative development loop
+:PROPERTIES:
+:CUSTOM_ID: iterative-development-loop
+:ID: b5b488be-6961-4b2a-ae81-f11d37b73c0b
+:END:
+
+1. Edit source files in the Retinue workspace.
+2. Run the installer to copy the latest code into Hermes:
+
+ #+begin_src sh
+ cd /path/to/retinue
+ bash install.sh
+ #+end_src
+
+3. Verify the plugin loads:
+
+ #+begin_src sh
+ hermes memory status
+ #+end_src
+
+4. Test through a Hermes session:
+
+ #+begin_src sh
+ hermes chat -q "Remember that my default shell is fish."
+ hermes chat -q "What shell do I use?"
+ #+end_src
+
+5. Repeat from step 1.
+
+*** Standalone testing
+:PROPERTIES:
+:CUSTOM_ID: standalone-testing
+:ID: 24aa4d55-e730-451c-aa27-9b9f4e5959c7
+:END:
+
+You can also test Retinue without installing it into Hermes. The project
+includes a standalone test runner:
+
+#+begin_src sh
+cd /path/to/retinue
+./run-tests.sh
+#+end_src
+
+This uses a project-local =.venv= and caches downloaded models in
+=.hf-cache=, both of which are excluded from deployment by =install.sh=.
\ No newline at end of file
--- /dev/null
+#!/usr/bin/env bash
+# install.sh - Deploy Retinue into the active Hermes home as a memory provider plugin.
+#
+# Usage:
+# bash install.sh
+# HERMES_HOME="$HOME/.hermes/profiles/coder" bash install.sh
+#
+# This script is idempotent: run it again after every code change to
+# re-deploy the latest version into Hermes. It never overwrites the
+# runtime data/ directory.
+
+set -euo pipefail
+
+SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
+SOURCE_DIR="$SCRIPT_DIR"
+
+HERMES_HOME="${HERMES_HOME:-$HOME/.hermes}"
+PLUGIN_DIR="$HERMES_HOME/plugins/retinue"
+
+# Resolve the Python interpreter that actually runs Hermes. Plugin deps must
+# live in that environment, not the user's system Python.
+resolve_hermes_python() {
+ # Allow explicit override.
+ if [[ -n "${PYTHON:-}" ]]; then
+ command -v "$PYTHON" >/dev/null 2>&1 && { echo "$PYTHON"; return; }
+ fi
+
+ # Common Hermes git install layout.
+ local venv_python="$HERMES_HOME/hermes-agent/venv/bin/python3"
+ if [[ -x "$venv_python" ]]; then
+ echo "$venv_python"
+ return
+ fi
+
+ # Derive from the hermes launcher shebang.
+ local hermes_bin
+ hermes_bin="$(command -v hermes 2>/dev/null || true)"
+ if [[ -n "$hermes_bin" && -r "$hermes_bin" ]]; then
+ local shebang
+ shebang="$(head -n1 "$hermes_bin" 2>/dev/null || true)"
+ if [[ "$shebang" == \#\!* ]]; then
+ shebang="${shebang#\#!}"
+ shebang="$(echo -n "$shebang" | sed 's/^[[:space:]]*//')"
+ if [[ -x "$shebang" ]]; then
+ echo "$shebang"
+ return
+ fi
+ fi
+ fi
+
+ # Fallback.
+ echo "python3"
+}
+
+PYTHON="$(resolve_hermes_python)"
+
+while [[ $# -gt 0 ]]; do
+ case "$1" in
+ -h|--help)
+ echo "Usage: bash install.sh"
+ echo ""
+ echo "Environment:"
+ echo " HERMES_HOME Target Hermes home (default: $HOME/.hermes)"
+ echo " PYTHON Python interpreter to use (default: python3)"
+ exit 0
+ ;;
+ *)
+ echo "Unknown argument: $1"
+ echo "Run 'bash install.sh --help' for usage."
+ exit 1
+ ;;
+ esac
+done
+
+echo "=== Retinue installer ==="
+echo "Source: $SOURCE_DIR"
+echo "Target: $PLUGIN_DIR"
+
+if [[ "$SOURCE_DIR" == "$PLUGIN_DIR" ]]; then
+ echo "ERROR: source and target directories are the same."
+ echo " Run install.sh from the Retinue source tree, not the plugin directory."
+ exit 1
+fi
+
+mkdir -p "$PLUGIN_DIR"
+
+# Deploy only the files/directories required by the plugin at runtime.
+# Everything else (tests, README, install.sh, IDE metadata, etc.) is left behind.
+WHITELIST=(plugin.yaml __init__.py src)
+PRESERVE=(.venv .hf-cache)
+
+echo "Deploying plugin files..."
+
+# Remove old deployed items, but preserve runtime directories.
+find "$PLUGIN_DIR" -mindepth 1 -maxdepth 1 -print0 | while IFS= read -r -d '' entry; do
+ name="$(basename "$entry")"
+ for keep in "${PRESERVE[@]}"; do
+ if [[ "$name" == "$keep" ]]; then
+ echo " Preserving: $entry"
+ continue 2
+ fi
+ done
+ echo " Removing: $entry"
+ rm -rf "$entry"
+done
+
+# Copy the whitelisted items from the source tree.
+for item in "${WHITELIST[@]}"; do
+ if [[ -e "$SOURCE_DIR/$item" ]]; then
+ cp -r "$SOURCE_DIR/$item" "$PLUGIN_DIR/"
+ fi
+done
+
+# Ensure runtime data directory exists
+# (Retinue stores runtime data under HERMES_HOME/retinue, not in the plugin directory.)
+# mkdir -p "$PLUGIN_DIR/data"
+
+# Ensure Python dependencies are available in the target interpreter.
+# Hermes itself runs with this Python, so the deps must be importable there.
+ensure_python_deps() {
+ local missing=""
+ if ! "$PYTHON" -c "import sqlite_vec" 2>/dev/null; then
+ missing="$missing sqlite-vec"
+ fi
+ if ! "$PYTHON" -c "import model2vec" 2>/dev/null; then
+ missing="$missing model2vec"
+ fi
+ if [[ -z "$missing" ]]; then
+ return 0
+ fi
+
+ echo "Missing Python dependencies:$missing"
+ echo "Installing with $PYTHON -m pip ..."
+
+ if "$PYTHON" -m pip install $missing 2>/dev/null; then
+ echo "Dependencies installed successfully."
+ return 0
+ fi
+
+ if "$PYTHON" -m pip install --break-system-packages $missing 2>/dev/null; then
+ echo "Dependencies installed successfully (with --break-system-packages)."
+ return 0
+ fi
+
+ echo "ERROR: Failed to install dependencies."
+ echo " Install them manually:"
+ echo " $PYTHON -m pip install --break-system-packages sqlite-vec model2vec"
+ return 1
+}
+
+ensure_python_deps
+
+# Set Hermes memory provider if the CLI is available.
+# This may fail in read-only sandbox configs; the user can set it manually.
+if command -v hermes &>/dev/null; then
+ echo "Setting memory.provider to retinue..."
+ if hermes config set memory.provider retinue 2>/dev/null; then
+ echo " memory.provider set to retinue."
+ else
+ echo " Warning: could not set memory.provider automatically."
+ echo " Set it manually in $HERMES_HOME/config.yaml:"
+ echo " memory:"
+ echo " provider: retinue"
+ fi
+else
+ echo "Warning: hermes CLI not found. Set memory.provider: retinue manually in:"
+ echo " $HERMES_HOME/config.yaml"
+fi
+
+# Validate that the plugin imports.
+echo "Validating plugin import with: $PYTHON"
+if "$PYTHON" -c "
+import sys
+sys.path.insert(0, '$PLUGIN_DIR/src')
+from retinue.provider import RetinueMemoryProvider
+p = RetinueMemoryProvider()
+print('name:', p.name)
+print('available:', p.is_available())
+print('schemas:', [s['name'] for s in p.get_tool_schemas()])
+" 2>/dev/null; then
+ echo "Plugin validation passed."
+else
+ echo "Plugin validation failed. Dependencies may be missing."
+ echo "Install them with:"
+ echo " $PYTHON -m pip install sqlite-vec model2vec"
+ echo "The embedding model (~500 MB) will download on first use."
+ exit 1
+fi
+
+echo ""
+echo "=== Installation complete ==="
+echo ""
+echo "Next steps:"
+if command -v hermes &>/dev/null; then
+ echo " hermes memory setup"
+ echo " hermes memory status"
+ echo " hermes chat -q 'Remember something...'"
+else
+ echo " Install hermes CLI, then:"
+ echo " hermes memory setup"
+ echo " hermes memory status"
+fi
--- /dev/null
+name: retinue
+version: 1.0.0
+description: "Semantic memory provider for Hermes Agent using model2vec and sqlite-vec."
+hooks:
+ - sync_turn
+ - shutdown
--- /dev/null
+#!/usr/bin/env bash
+# Standalone test runner for Retinue, no Hermes installation required.
+# This uses the project-local venv and caches models under the project tree
+# so everything survives across agent instances.
+
+set -euo pipefail
+
+SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
+VENV="$SCRIPT_DIR/.venv"
+TEST="$SCRIPT_DIR/test/test_standalone.py"
+
+if [[ ! -d "$VENV" ]]; then
+ echo "Virtual environment not found at $VENV"
+ echo "Create it with:"
+ echo " python3 -m venv $VENV"
+ echo " $VENV/bin/python -m pip install sqlite-vec model2vec"
+ exit 1
+fi
+
+export HF_HOME="${HF_HOME:-$SCRIPT_DIR/.hf-cache}"
+
+exec "$VENV/bin/python" "$TEST"
--- /dev/null
+"""Retinue package."""
+
+from retinue.memory import MemoryStore, embed_text, get_dim, get_model
+from retinue.provider import RetinueMemoryProvider
+
+__all__ = [
+ "MemoryStore",
+ "RetinueMemoryProvider",
+ "embed_text",
+ "get_dim",
+ "get_model",
+]
--- /dev/null
+"""Retinue memory engine.
+
+Semantic memory using model2vec embeddings + sqlite-vec vector search.
+"""
+
+from __future__ import annotations
+
+import logging
+import sqlite3
+import struct
+from datetime import datetime, timezone
+from pathlib import Path
+from typing import Any
+
+logger = logging.getLogger(__name__)
+
+MODEL = "minishlab/potion-multilingual-128M"
+TABLE = "memories"
+
+# ---------------------------------------------------------------------------
+# Lazy-loaded embedding model
+# ---------------------------------------------------------------------------
+_model: Any | None = None
+_model_dim: int | None = None
+
+
+def get_model() -> Any:
+ """Load the model2vec model on first call, then cache it."""
+ global _model
+ if _model is not None:
+ return _model
+
+ try:
+ from model2vec import StaticModel
+ except ImportError as e:
+ raise RuntimeError(
+ "model2vec is not installed. Run: pip install model2vec"
+ ) from e
+
+ logger.info("Loading embedding model: %s", MODEL)
+ _model = StaticModel.from_pretrained(MODEL, force_download=False)
+ return _model
+
+
+def embed_text(text: str) -> bytes:
+ """Embed text into a byte vector for sqlite-vec storage."""
+ model = get_model()
+ vec = model.encode([text])[0]
+ return struct.pack(f"{len(vec)}f", *vec.astype("float32"))
+
+
+def get_dim() -> int:
+ """Get embedding dimension (detected from model on first call)."""
+ global _model_dim
+ if _model_dim is None:
+ dim = get_model().dim
+ _model_dim = dim
+ return _model_dim
+
+
+# ---------------------------------------------------------------------------
+# SQLite + sqlite-vec store
+# ---------------------------------------------------------------------------
+class MemoryStore:
+ """Persistent semantic memory store backed by sqlite-vec."""
+
+ def __init__(self, base_dir: str | Path):
+ self.base_dir = Path(base_dir)
+ self.db_path = self.base_dir / "retinue_memory.db"
+ self.model_name = MODEL
+ self._db: sqlite3.Connection | None = None
+
+ def _get_db(self) -> sqlite3.Connection:
+ if self._db is not None:
+ return self._db
+
+ self.db_path.parent.mkdir(parents=True, exist_ok=True)
+ db = sqlite3.connect(str(self.db_path), check_same_thread=False)
+ db.enable_load_extension(True)
+
+ try:
+ import sqlite_vec
+ sqlite_vec.load(db)
+ except ImportError as e:
+ raise RuntimeError(
+ "sqlite-vec is not installed. Run: pip install sqlite-vec"
+ ) from e
+
+ dim = get_dim()
+
+ db.execute(
+ f"""
+ CREATE VIRTUAL TABLE IF NOT EXISTS {TABLE}
+ USING vec0(embedding float[{dim}], content TEXT, created TEXT)
+ """
+ )
+ db.commit()
+ self._db = db
+ return db
+
+ # ------------------------------------------------------------------
+ # Public operations
+ # ------------------------------------------------------------------
+ def add(self, content: str) -> int:
+ """Store a memory and return its ID."""
+ created = datetime.now(timezone.utc).isoformat()
+ embedding = embed_text(content)
+
+ db = self._get_db()
+ cursor = db.execute(
+ f"INSERT INTO {TABLE} (embedding, content, created) VALUES (?, ?, ?)",
+ (embedding, content, created),
+ )
+ memory_id = cursor.lastrowid
+ if memory_id is None:
+ raise RuntimeError("Failed to insert memory: no rowid returned")
+ db.commit()
+ return memory_id
+
+ def search(self, query: str, limit: int = 50) -> list[tuple]:
+ """Semantic search across all memories."""
+ db = self._get_db()
+ query_vec = embed_text(query)
+ results = db.execute(
+ f"""
+ SELECT rowid, content, created, distance
+ FROM {TABLE}
+ WHERE embedding MATCH ?
+ AND k = ?
+ """,
+ (query_vec, limit),
+ ).fetchall()
+ return results
+
+ def list(self) -> list[tuple]:
+ """List all memories, newest first."""
+ db = self._get_db()
+ rows = db.execute(
+ f"SELECT rowid, content, created FROM {TABLE} ORDER BY rowid DESC"
+ ).fetchall()
+ return rows
+
+ def delete(self, memory_id: int) -> tuple | None:
+ """Delete a memory by ID. Returns the old row or None."""
+ db = self._get_db()
+ row = db.execute(
+ f"SELECT rowid, content FROM {TABLE} WHERE rowid = ?", (memory_id,)
+ ).fetchone()
+ if not row:
+ return None
+ db.execute(f"DELETE FROM {TABLE} WHERE rowid = ?", (memory_id,))
+ db.commit()
+ return row
+
+ def find_by_content(self, text: str) -> list[tuple]:
+ """Find memories whose content contains the given substring."""
+ db = self._get_db()
+ rows = db.execute(
+ f"SELECT rowid, content, created FROM {TABLE} WHERE content LIKE ? ORDER BY rowid",
+ (f"%{text}%",),
+ ).fetchall()
+ return rows
+
+ def stats(self) -> dict[str, Any]:
+ """Return memory store statistics."""
+ db = self._get_db()
+ total = db.execute(f"SELECT COUNT(*) FROM {TABLE}").fetchone()[0]
+ return {
+ "total": total,
+ "db_path": str(self.db_path),
+ "model": self.model_name,
+ "dim": get_dim(),
+ }
+
+ def close(self) -> None:
+ if self._db is not None:
+ self._db.close()
+ self._db = None
+
+ def __enter__(self) -> MemoryStore:
+ return self
+
+ def __exit__(self, exc_type, exc_val, exc_tb) -> None:
+ self.close()
--- /dev/null
+"""Retinue Hermes memory provider plugin.
+
+Implements the MemoryProvider abstract base class from Hermes Agent.
+"""
+
+from __future__ import annotations
+
+import json
+import logging
+import threading
+from pathlib import Path
+from typing import Any, Dict, List, Optional, TYPE_CHECKING
+
+from retinue.memory import MemoryStore
+
+logger = logging.getLogger(__name__)
+
+# Try to inherit from Hermes' MemoryProvider ABC; fall back to a local stub
+# when developing or testing outside of a Hermes process.
+if TYPE_CHECKING:
+ from agent.memory_provider import MemoryProvider # type: ignore[import-not-found]
+else:
+ try:
+ from agent.memory_provider import MemoryProvider
+ except ImportError: # pragma: no cover - Hermes not available in plain Python env
+ from abc import ABC, abstractmethod
+
+ class MemoryProvider(ABC): # type: ignore[no-redef]
+ """Minimal local stub for standalone development."""
+
+ @property
+ @abstractmethod
+ def name(self) -> str:
+ ...
+
+ @abstractmethod
+ def is_available(self) -> bool:
+ ...
+
+ @abstractmethod
+ def initialize(self, session_id: str, **kwargs) -> None:
+ ...
+
+ @abstractmethod
+ def get_tool_schemas(self) -> List[Dict[str, Any]]:
+ ...
+
+ def handle_tool_call(self, tool_name: str, args: Dict[str, Any], **kwargs) -> str:
+ raise NotImplementedError(tool_name)
+
+ @abstractmethod
+ def get_config_schema(self) -> List[Dict[str, Any]]:
+ ...
+
+ def save_config(self, values: Dict[str, Any], hermes_home: str) -> None:
+ pass
+
+ def sync_turn(
+ self,
+ user_content: str,
+ assistant_content: str,
+ *,
+ session_id: str = "",
+ messages: Optional[List[Dict[str, Any]]] = None,
+ ) -> None:
+ pass
+
+ def shutdown(self) -> None:
+ pass
+
+
+class RetinueMemoryProvider(MemoryProvider):
+ """Semantic memory provider for Hermes Agent."""
+
+ def __init__(self) -> None:
+ self._store: MemoryStore | None = None
+ self._hermes_home: str | None = None
+ self._session_id: str = ""
+ self._sync_thread: threading.Thread | None = None
+
+ @property
+ def name(self) -> str:
+ return "retinue"
+
+ # ------------------------------------------------------------------
+ # Lifecycle
+ # ------------------------------------------------------------------
+ def is_available(self) -> bool:
+ """Return True if the required Python dependencies are importable."""
+ try:
+ import sqlite_vec # noqa: F401
+ from model2vec import StaticModel # noqa: F401
+ return True
+ except ImportError:
+ return False
+
+ def initialize(self, session_id: str, **kwargs) -> None:
+ """Called once when Hermes starts a session."""
+ self._hermes_home = kwargs.get("hermes_home")
+ self._session_id = session_id
+
+ if self._hermes_home:
+ base_dir = Path(self._hermes_home)
+ else:
+ base_dir = Path.home() / ".hermes"
+ logger.warning(
+ "hermes_home not provided to Retinue provider; using %s", base_dir
+ )
+
+ self._store = MemoryStore(base_dir=base_dir)
+ logger.info("Retinue memory provider initialized at %s", self._store.db_path)
+
+ # ------------------------------------------------------------------
+ # System prompt guidance
+ # ------------------------------------------------------------------
+ def system_prompt_block(self) -> str:
+ return (
+ "Memory discipline\n\n"
+ "Retinue semantic memory is available and cheap. Use it eagerly.\n\n"
+ "Before doing anything substantive -- answering questions, writing code, debugging, planning -- "
+ "search memory first with retinue_memory_search. Retinue is semantic, so \"Vehicle\" matches \"Volvo\" "
+ "and \"deployment\" matches \"install\". If memory has partial info, use it; if not, fill the gap "
+ "after you find the answer.\n\n"
+ "After every task, before your final reply, run retinue_memory_add if any of these are true:\n"
+ "- search returned nothing useful AND an external source (docs, files, URLs, CLI output) gave the answer.\n"
+ "- you discovered a stable identifier the next session will need: URL, path, hostname, port, env var, version, command flag.\n"
+ "- you learned a person's role / handle, or a system's alias / nickname.\n"
+ "- a task succeeded via a non-obvious path, or you had to back out of a wrong first attempt.\n"
+ "- the user corrected a fact, preference, or process.\n"
+ "Memories MUST be self-contained. You will work on many projects across many sessions, and a memory "
+ "written today may be retrieved months later with no surrounding context. If a memory relies on context "
+ "that is only obvious right now, it becomes a landmine. Before saving, re-read the memory as if you had "
+ "never seen this conversation and ask: \"Could a fresh session correctly understand and apply this fact?\" "
+ "If not, rewrite it.\n\n"
+ "Memory hygiene -- clean proactively, do not ask for permission.\n"
+ "Whenever you list or search memories, inspect the results for quality problems and fix them immediately:\n"
+ "- Delete exact or near-duplicate memories (keep the newest / most complete one).\n"
+ "- Delete stale or obsolete memories (paths that moved, versions that changed, preferences that were corrected, bugs that were fixed).\n"
+ "- Merge several overlapping memories into one self-contained entry by deleting the fragments and adding a consolidated memory.\n"
+ "- Update a memory when the underlying fact changes.\n"
+ "If a cleanup is obvious, do it silently and briefly report what changed. Only ask the user when the correct action is genuinely ambiguous.\n\n"
+ "Always make explicit in the content:\n"
+ "- project / repository (full name, e.g. sixth-3d, not \"the project\")\n"
+ "- file or path (absolute or repo-relative, e.g. src/retinue/provider.py, not \"that script\")\n"
+ "- tool / library / version (e.g. sqlite-vec 0.1.x, not \"the vec extension\")\n"
+ "- environment / host (e.g. Linux host \"jupiter\", not \"this machine\")\n\n"
+ "Never use bare pronouns (\"it\", \"this\", \"here\", \"the file\", \"the project\", \"the user\") without "
+ "naming the referent in the same memory.\n\n"
+ "Bad (context-dependent, misleading later):\n"
+ "- \"the build fails on Java 21 -- use 17 instead\"\n"
+ "- \"user prefers tabs\"\n\n"
+ "Good (self-contained, safe to retrieve in any future session):\n"
+ "- \"sixth-3d (pom.xml): toolchain pinned to Java Language Version of 21 "
+ "'unsupported class file version' usually means the daemon is running an older JDK -- run with JAVA_HOME "
+ "pointing to JDK 21.\"\n"
+ "- \"User John prefers plain text output, not markdown tables.\""
+ )
+
+ # ------------------------------------------------------------------
+ # Tool interface
+ # ------------------------------------------------------------------
+ def get_tool_schemas(self) -> List[Dict[str, Any]]:
+ return [
+ {
+ "name": "retinue_memory_add",
+ "description": "Store a durable semantic memory in Retinue. Use for factual knowledge you want to recall later by meaning rather than exact keywords.",
+ "parameters": {
+ "type": "object",
+ "properties": {
+ "content": {
+ "type": "string",
+ "description": "The factual memory to store.",
+ },
+ },
+ "required": ["content"],
+ },
+ },
+ {
+ "name": "retinue_memory_search",
+ "description": "Search Retinue memories by semantic similarity. Use when you need to recall relevant context even if the user uses different wording than the stored memory.",
+ "parameters": {
+ "type": "object",
+ "properties": {
+ "query": {
+ "type": "string",
+ "description": "Natural-language search query.",
+ },
+ "limit": {
+ "type": "integer",
+ "description": "Maximum number of results to return.",
+ "default": 50,
+ },
+ },
+ "required": ["query"],
+ },
+ },
+ {
+ "name": "retinue_memory_list",
+ "description": "List stored memories.",
+ "parameters": {
+ "type": "object",
+ "properties": {},
+ },
+ },
+ {
+ "name": "retinue_memory_delete",
+ "description": "Delete a memory from Retinue by its ID.",
+ "parameters": {
+ "type": "object",
+ "properties": {
+ "id": {
+ "type": "integer",
+ "description": "Memory ID to delete.",
+ }
+ },
+ "required": ["id"],
+ },
+ },
+ {
+ "name": "retinue_memory_stats",
+ "description": "Show Retinue memory store statistics.",
+ "parameters": {
+ "type": "object",
+ "properties": {},
+ },
+ },
+ ]
+
+ def handle_tool_call(self, tool_name: str, args: Dict[str, Any], **kwargs) -> str:
+ """Dispatch a tool call and return a JSON string."""
+ if self._store is None:
+ return json.dumps({"error": "Retinue memory provider not initialized"})
+
+ handler = getattr(self, f"_tool_{tool_name}", None)
+ if handler is None:
+ return json.dumps({"error": f"Unknown Retinue tool: {tool_name}"})
+
+ try:
+ result = handler(args)
+ return json.dumps({"success": True, "result": result})
+ except Exception as e:
+ logger.exception("Retinue tool %s failed", tool_name)
+ return json.dumps({"success": False, "error": str(e)})
+
+ # ------------------------------------------------------------------
+ # Tool implementations
+ # ------------------------------------------------------------------
+ def _tool_retinue_memory_add(self, args: Dict[str, Any]) -> Dict[str, Any]:
+ assert self._store is not None
+ mid = self._store.add(content=args["content"])
+ return {"id": mid, "content": args["content"]}
+
+ def _tool_retinue_memory_search(self, args: Dict[str, Any]) -> List[Dict[str, Any]]:
+ assert self._store is not None
+ rows = self._store.search(args["query"], limit=args.get("limit", 50))
+ return [
+ {
+ "id": row[0],
+ "content": row[1],
+ "created": row[2],
+ "similarity": max(0.0, 1.0 / (1.0 + row[3])),
+ }
+ for row in rows
+ ]
+
+ def _tool_retinue_memory_list(self, args: Dict[str, Any]) -> List[Dict[str, Any]]:
+ assert self._store is not None
+ rows = self._store.list()
+ return [
+ {
+ "id": row[0],
+ "content": row[1],
+ "created": row[2],
+ }
+ for row in rows
+ ]
+
+ def _tool_retinue_memory_delete(self, args: Dict[str, Any]) -> Dict[str, Any]:
+ assert self._store is not None
+ row = self._store.delete(int(args["id"]))
+ if row is None:
+ return {"deleted": False, "message": f"Memory {args['id']} not found"}
+ return {"deleted": True, "id": row[0], "content": row[1]}
+
+ def _tool_retinue_memory_stats(self, args: Dict[str, Any]) -> Dict[str, Any]:
+ assert self._store is not None
+ stats = self._store.stats()
+ return {
+ "total": stats["total"],
+ "db_path": stats["db_path"],
+ "model": stats["model"],
+ "dim": stats["dim"],
+ }
+
+ # ------------------------------------------------------------------
+ # Config
+ # ------------------------------------------------------------------
+ def get_config_schema(self) -> List[Dict[str, Any]]:
+ """Retinue does not expose configurable settings."""
+ return []
+
+ def save_config(self, values: Dict[str, Any], hermes_home: str) -> None:
+ """Persist non-secret config. Retinue currently does not persist config."""
+ pass
+
+ # ------------------------------------------------------------------
+ # Optional hooks
+ # ------------------------------------------------------------------
+ def sync_turn(
+ self,
+ user_content: str,
+ assistant_content: str,
+ *,
+ session_id: str = "",
+ messages: Optional[List[Dict[str, Any]]] = None,
+ ) -> None:
+ """Persist a completed turn. Currently a no-op; future versions will
+ summarize the turn and store it."""
+ def _sync() -> None:
+ pass
+
+ if self._sync_thread and self._sync_thread.is_alive():
+ self._sync_thread.join(timeout=1.0)
+ self._sync_thread = threading.Thread(target=_sync, daemon=True)
+ self._sync_thread.start()
+
+ def shutdown(self) -> None:
+ if self._store is not None:
+ self._store.close()
+ self._store = None
+ if self._sync_thread and self._sync_thread.is_alive():
+ self._sync_thread.join(timeout=5.0)
+
+ def on_memory_write(
+ self,
+ action: str,
+ target: str,
+ content: str,
+ metadata: Optional[Dict[str, Any]] = None,
+ ) -> None:
+ """Mirror successful built-in memory writes into Retinue.
+
+ This lets Retinue act as the semantic search layer over the built-in
+ memory tool: even if the LLM chooses `memory(action='add')`, the fact
+ can still be recalled later via `retinue_memory_search`.
+ """
+ if self._store is None or not content:
+ return
+
+ try:
+ if action == "add":
+ self._store.add(content=content)
+ elif action == "remove":
+ for row in self._store.find_by_content(content):
+ self._store.delete(row[0])
+ elif action == "replace":
+ old_text = (metadata or {}).get("old_text", "")
+ if old_text:
+ for row in self._store.find_by_content(old_text):
+ self._store.delete(row[0])
+ self._store.add(content=content)
+ except Exception as e:
+ logger.debug("Retinue on_memory_write failed: %s", e)
+
+ def backup_paths(self) -> List[str]:
+ """Retinue stores everything under HERMES_HOME, so no external paths."""
+ return []
--- /dev/null
+#!/usr/bin/env python3
+"""Standalone smoke test for Retinue without installing into Hermes Agent."""
+
+from __future__ import annotations
+
+import json
+import os
+import shutil
+import sys
+import tempfile
+from pathlib import Path
+
+# Add source package to path so we import the workspace code directly.
+PROJECT_ROOT = Path(__file__).resolve().parent.parent
+sys.path.insert(0, str(PROJECT_ROOT / "src"))
+
+from retinue.memory import MemoryStore, embed_text, get_dim, get_model
+from retinue.provider import RetinueMemoryProvider
+
+
+def test_memory_store() -> None:
+ """Exercise the core MemoryStore class end-to-end."""
+ base_dir = Path(tempfile.mkdtemp(prefix="retinue-test-"))
+ try:
+ store = MemoryStore(base_dir=base_dir)
+
+ print("MemoryStore created at:", store.db_path)
+ print("Model:", store.model_name)
+ print("Embedding dimension:", get_dim())
+
+ # Add a few memories
+ id1 = store.add("ProjectX is hosted on our primary server")
+ id2 = store.add("ProjectX CI pipeline uses GitHub Actions")
+ id3 = store.add("User's default shell is fish")
+ print(f"Added memories: {id1}, {id2}, {id3}")
+
+ # Search
+ print("\nSearch 'Where is ProjectX deployed?':")
+ for row in store.search("Where is ProjectX deployed?", limit=3):
+ print(" ", row)
+
+ # List
+ print("\nList all memories:")
+ for row in store.list():
+ print(" ", row)
+
+ # Stats
+ print("\nStats:", store.stats())
+
+ # Delete
+ deleted = store.delete(id3)
+ print("\nDeleted:", deleted)
+ print("Stats after delete:", store.stats())
+
+ store.close()
+ print("\nMemoryStore tests passed.")
+ finally:
+ shutil.rmtree(base_dir, ignore_errors=True)
+
+
+def test_provider() -> None:
+ """Exercise the RetinueMemoryProvider without Hermes."""
+ base_dir = Path(tempfile.mkdtemp(prefix="retinue-provider-test-"))
+ try:
+ provider = RetinueMemoryProvider()
+ assert provider.name == "retinue"
+ assert provider.is_available() is True
+ print("Provider name:", provider.name)
+ print("Provider available:", provider.is_available())
+
+ provider.initialize("test-session", hermes_home=str(base_dir))
+ schemas = provider.get_tool_schemas()
+ print("Tool schemas:", [s["name"] for s in schemas])
+ assert len(schemas) == 5
+
+ # Add via tool
+ result = provider.handle_tool_call(
+ "retinue_memory_add",
+ {"content": "Offline semantic memory provider"},
+ )
+ data = json.loads(result)
+ assert data["success"] is True, data
+ memory_id = data["result"]["id"]
+ print("Tool add result:", data)
+
+ # Search via tool
+ result = provider.handle_tool_call(
+ "retinue_memory_search",
+ {"query": "semantic memory", "limit": 5},
+ )
+ data = json.loads(result)
+ assert data["success"] is True, data
+ print("Tool search result:", data)
+
+ # Stats
+ result = provider.handle_tool_call("retinue_memory_stats", {})
+ data = json.loads(result)
+ assert data["success"] is True, data
+ print("Tool stats result:", data)
+
+ # Delete
+ result = provider.handle_tool_call("retinue_memory_delete", {"id": memory_id})
+ data = json.loads(result)
+ assert data["success"] is True, data
+ print("Tool delete result:", data)
+
+ provider.shutdown()
+ print("\nProvider tests passed.")
+ finally:
+ shutil.rmtree(base_dir, ignore_errors=True)
+
+
+def test_on_memory_write() -> None:
+ """Exercise the built-in memory mirror hook."""
+ base_dir = Path(tempfile.mkdtemp(prefix="retinue-mirror-test-"))
+ try:
+ provider = RetinueMemoryProvider()
+ provider.initialize("mirror-session", hermes_home=str(base_dir))
+
+ # Simulate a built-in memory write being mirrored.
+ provider.on_memory_write(
+ action="add",
+ target="user",
+ content="Prefers concise responses",
+ metadata={"tool_name": "memory"},
+ )
+ result = provider.handle_tool_call(
+ "retinue_memory_search",
+ {"query": "concise responses", "limit": 5},
+ )
+ data = json.loads(result)
+ assert data["success"] is True, data
+ assert len(data["result"]) == 1, data
+ print("Mirror add result:", data)
+
+ # Replace the mirrored memory.
+ provider.on_memory_write(
+ action="replace",
+ target="user",
+ content="Default browser is Firefox",
+ metadata={"tool_name": "memory", "old_text": "Prefers concise responses"},
+ )
+ # Verify the old content is gone by exact substring.
+ assert provider._store is not None
+ old_rows = provider._store.find_by_content("Prefers concise responses")
+ assert len(old_rows) == 0, old_rows
+ new_rows = provider._store.find_by_content("Default browser is Firefox")
+ assert len(new_rows) == 1, new_rows
+ print("Mirror replace cleared old content and added new content.")
+
+ # Remove.
+ provider.on_memory_write(
+ action="remove",
+ target="user",
+ content="Default browser is Firefox",
+ metadata={"tool_name": "memory"},
+ )
+ remaining = provider._store.find_by_content("Default browser is Firefox")
+ assert len(remaining) == 0, remaining
+ print("Mirror remove removed the memory.")
+
+ provider.shutdown()
+ print("\nMirror tests passed.")
+ finally:
+ shutil.rmtree(base_dir, ignore_errors=True)
+
+
+if __name__ == "__main__":
+ # Put the Hugging Face cache inside the project so it survives across agent instances.
+ project_root = Path(__file__).resolve().parent.parent
+ os.environ.setdefault("HF_HOME", str(project_root / ".hf-cache"))
+ test_memory_store()
+ test_provider()
+ test_on_memory_write()
+ print("\nAll standalone tests passed.")