initial commit
authorSvjatoslav Agejenko <svjatoslav@svjatoslav.eu>
Fri, 10 Jul 2026 21:52:28 +0000 (00:52 +0300)
committerSvjatoslav Agejenko <svjatoslav@svjatoslav.eu>
Fri, 10 Jul 2026 21:52:28 +0000 (00:52 +0300)
.gitignore [new file with mode: 0644]
Tools/Open with IntelliJ IDEA [new file with mode: 0755]
__init__.py [new file with mode: 0644]
doc/index.org [new file with mode: 0644]
install.sh [new file with mode: 0755]
plugin.yaml [new file with mode: 0644]
run-tests.sh [new file with mode: 0755]
src/retinue/__init__.py [new file with mode: 0644]
src/retinue/memory.py [new file with mode: 0644]
src/retinue/provider.py [new file with mode: 0644]
test/test_standalone.py [new file with mode: 0644]

diff --git a/.gitignore b/.gitignore
new file mode 100644 (file)
index 0000000..06c3af6
--- /dev/null
@@ -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 (executable)
index 0000000..304bf94
--- /dev/null
@@ -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 (file)
index 0000000..fb5c013
--- /dev/null
@@ -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 (file)
index 0000000..93b74ac
--- /dev/null
@@ -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 <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
diff --git a/install.sh b/install.sh
new file mode 100755 (executable)
index 0000000..2a58d5f
--- /dev/null
@@ -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 (file)
index 0000000..c81f1b4
--- /dev/null
@@ -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 (executable)
index 0000000..8e0a7a5
--- /dev/null
@@ -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 (file)
index 0000000..8209387
--- /dev/null
@@ -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 (file)
index 0000000..0b548db
--- /dev/null
@@ -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 (file)
index 0000000..2554409
--- /dev/null
@@ -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 (file)
index 0000000..1f1bd2c
--- /dev/null
@@ -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.")