From 8189927962a782c5a7083846783ff479e5c5b4b5 Mon Sep 17 00:00:00 2001 From: Svjatoslav Agejenko Date: Sat, 11 Jul 2026 00:52:28 +0300 Subject: [PATCH 1/1] initial commit --- .gitignore | 6 + Tools/Open with IntelliJ IDEA | 54 ++++ __init__.py | 20 ++ doc/index.org | 531 ++++++++++++++++++++++++++++++++++ install.sh | 202 +++++++++++++ plugin.yaml | 6 + run-tests.sh | 22 ++ src/retinue/__init__.py | 12 + src/retinue/memory.py | 184 ++++++++++++ src/retinue/provider.py | 367 +++++++++++++++++++++++ test/test_standalone.py | 175 +++++++++++ 11 files changed, 1579 insertions(+) create mode 100644 .gitignore create mode 100755 Tools/Open with IntelliJ IDEA create mode 100644 __init__.py create mode 100644 doc/index.org create mode 100755 install.sh create mode 100644 plugin.yaml create mode 100755 run-tests.sh create mode 100644 src/retinue/__init__.py create mode 100644 src/retinue/memory.py create mode 100644 src/retinue/provider.py create mode 100644 test/test_standalone.py diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..06c3af6 --- /dev/null +++ b/.gitignore @@ -0,0 +1,6 @@ +/.venv/ +/.hf-cache/ +__pycache__/ +*.pyc +/.idea +*.html \ No newline at end of file diff --git a/Tools/Open with IntelliJ IDEA b/Tools/Open with IntelliJ IDEA new file mode 100755 index 0000000..304bf94 --- /dev/null +++ b/Tools/Open with IntelliJ IDEA @@ -0,0 +1,54 @@ +#!/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 diff --git a/__init__.py b/__init__.py new file mode 100644 index 0000000..fb5c013 --- /dev/null +++ b/__init__.py @@ -0,0 +1,20 @@ +"""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()) diff --git a/doc/index.org b/doc/index.org new file mode 100644 index 0000000..93b74ac --- /dev/null +++ b/doc/index.org @@ -0,0 +1,531 @@ +: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 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 ==. + +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 diff --git a/install.sh b/install.sh new file mode 100755 index 0000000..2a58d5f --- /dev/null +++ b/install.sh @@ -0,0 +1,202 @@ +#!/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 diff --git a/plugin.yaml b/plugin.yaml new file mode 100644 index 0000000..c81f1b4 --- /dev/null +++ b/plugin.yaml @@ -0,0 +1,6 @@ +name: retinue +version: 1.0.0 +description: "Semantic memory provider for Hermes Agent using model2vec and sqlite-vec." +hooks: + - sync_turn + - shutdown diff --git a/run-tests.sh b/run-tests.sh new file mode 100755 index 0000000..8e0a7a5 --- /dev/null +++ b/run-tests.sh @@ -0,0 +1,22 @@ +#!/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" diff --git a/src/retinue/__init__.py b/src/retinue/__init__.py new file mode 100644 index 0000000..8209387 --- /dev/null +++ b/src/retinue/__init__.py @@ -0,0 +1,12 @@ +"""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", +] diff --git a/src/retinue/memory.py b/src/retinue/memory.py new file mode 100644 index 0000000..0b548db --- /dev/null +++ b/src/retinue/memory.py @@ -0,0 +1,184 @@ +"""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() diff --git a/src/retinue/provider.py b/src/retinue/provider.py new file mode 100644 index 0000000..2554409 --- /dev/null +++ b/src/retinue/provider.py @@ -0,0 +1,367 @@ +"""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 [] diff --git a/test/test_standalone.py b/test/test_standalone.py new file mode 100644 index 0000000..1f1bd2c --- /dev/null +++ b/test/test_standalone.py @@ -0,0 +1,175 @@ +#!/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.") -- 2.20.1