From 1528b7dd210904b085b54d56de4f963d6061fb52 Mon Sep 17 00:00:00 2001 From: Svjatoslav Agejenko Date: Sat, 18 Jul 2026 14:12:38 +0300 Subject: [PATCH 1/1] initial commit --- .gitignore | 6 + AGENTS.org | 250 +++++++++++++++++ COPYING | 121 +++++++++ Documentation/Development/index.org | 406 ++++++++++++++++++++++++++++ Documentation/index.org | 194 +++++++++++++ Documentation/logo.png | Bin 0 -> 83808 bytes Install Retinue | 233 ++++++++++++++++ Tools/Import Holographic memories | 37 +++ Tools/Open with IntelliJ IDEA | 54 ++++ Tools/Run tests | 23 ++ Tools/Show Retinue memory contents | 35 +++ Tools/Update web site | 80 ++++++ __init__.py | 20 ++ plugin.yaml | 6 + src/retinue/__init__.py | 12 + src/retinue/cli.py | 145 ++++++++++ src/retinue/import_holographic.py | 222 +++++++++++++++ src/retinue/memory.py | 205 ++++++++++++++ src/retinue/provider.py | 308 +++++++++++++++++++++ src/retinue/system_prompt_block.txt | 99 +++++++ test/test_standalone.py | 209 ++++++++++++++ 21 files changed, 2665 insertions(+) create mode 100644 .gitignore create mode 100644 AGENTS.org create mode 100644 COPYING create mode 100644 Documentation/Development/index.org create mode 100644 Documentation/index.org create mode 100644 Documentation/logo.png create mode 100755 Install Retinue create mode 100755 Tools/Import Holographic memories create mode 100755 Tools/Open with IntelliJ IDEA create mode 100755 Tools/Run tests create mode 100755 Tools/Show Retinue memory contents create mode 100755 Tools/Update web site create mode 100644 __init__.py create mode 100644 plugin.yaml create mode 100644 src/retinue/__init__.py create mode 100644 src/retinue/cli.py create mode 100644 src/retinue/import_holographic.py create mode 100644 src/retinue/memory.py create mode 100644 src/retinue/provider.py create mode 100644 src/retinue/system_prompt_block.txt 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/AGENTS.org b/AGENTS.org new file mode 100644 index 0000000..b508afb --- /dev/null +++ b/AGENTS.org @@ -0,0 +1,250 @@ +#+TITLE: AGENTS — Operating guide for Retinue + +* At a glance +:PROPERTIES: +:ID: 94584d56-4303-4aef-ab1c-8757bd860ff1 +:END: + +| Fact | Value | +|------------------+----------------------------------------------------------------| +| Project | Retinue — semantic memory provider plugin for Hermes Agent | +| License | CC0 (see COPYING) | +| Language | Python 3 (PEP 668 host — always use a venv, never system pip) | +| Runtime deps | =model2vec=, =sqlite-vec= | +| Embedding model | =minishlab/potion-multilingual-128M= (~540 MB, cached offline; the repo's unused ONNX export is skipped) | +| Plugin metadata | plugin.yaml (version 1.1.0) | +| Public docs site | https://www.svjatoslav.eu/projects/ (served from Documentation/) | + +* Purpose +:PROPERTIES: +:ID: 67822f95-d295-455b-ac84-fde0aed8b008 +:END: + +Retinue adds persistent, semantic memory to every Hermes Agent +session. It is a Hermes =MemoryProvider= plugin: the LLM stores and +recalls facts through four =retinue_memory_*= tools backed by +model2vec embeddings and sqlite-vec vector search, fully local, +CPU-only, single SQLite file. + +Retinue is developed in this workspace and *deployed* into the active +Hermes home (~/.hermes/plugins/retinue/) by the =Install Retinue= script. +The deployed copy is disposable — this tree is the source of truth. Never +edit =~/.hermes/plugins/retinue/= directly; edit here and re-run the +installer. + +* Layout +:PROPERTIES: +:ID: a8c8c555-d1c1-4f96-b236-be8106e03a9a +:END: + +#+begin_example +retinue/ +├── plugin.yaml # Hermes plugin metadata (name, version, hooks) +├── __init__.py # Plugin entry point: register(ctx) +├── src/retinue/ +│ ├── __init__.py +│ ├── memory.py # MemoryStore: model2vec embed + sqlite-vec CRUD +│ ├── provider.py # RetinueMemoryProvider: MemoryProvider ABC impl +│ └── system_prompt_block.txt # Injected into the Hermes system prompt +├── Install Retinue # Deploy into $HERMES_HOME/plugins/retinue/ +├── test/test_standalone.py # End-to-end MemoryStore + provider tests +├── Documentation/ +│ ├── index.org # Public user documentation (exports to HTML) +│ └── Development/index.org # Architecture & integration documentation +├── Tools/ # Development helpers (filenames contain spaces) +│ ├── Run tests # Standalone smoke test runner (no Hermes needed) +│ ├── Show Retinue memory contents # Debug CLI: dump/search all memories +│ ├── Import Holographic memories # Migrate facts from Holographic memory_store.db +│ ├── Open with IntelliJ IDEA +│ └── Update web site # Export Documentation/*.org to HTML, rsync to www3 +#+end_example + +Created at runtime, never committed (=.gitignore=): +- =.venv/= — project-local venv used by "Tools/Run tests" +- =.hf-cache/= — Hugging Face cache for the embedding model +- =__pycache__/=, =*.pyc=, =.idea/=, =*.html= (HTML exports are regenerated) + +The deployed database lives in the active Hermes home, not here: +=$HERMES_HOME/retinue_memory.db= (single file: SQLite + sqlite-vec +virtual table =memories=). + +* Conventions +:PROPERTIES: +:ID: 237ae2c1-1c42-4572-ab0a-d118b90e52cb +:END: + +** Org-mode documentation + +- Every .org page starts with a file-level =:PROPERTIES:= drawer holding + a unique =:ID:= UUID, then =#+SETUPFILE: ~/.emacs.d/org-styles/html/darksun.theme=, + =#+TITLE:=, =#+LANGUAGE: en=, and the three standard =#+LATEX_HEADER:= + lines (geometry, parskip, hyphenat). Copy the header of + =Documentation/index.org= verbatim for new pages. +- Every heading gets its own =:PROPERTIES:= drawer with a fresh =:ID:= + UUID (and a kebab-case =:CUSTOM_ID:= matching the heading text for + in-page anchors). Generate UUIDs with + =python3 -c "import uuid; print(uuid.uuid4())"=. +- Cross-page links use =[[file:...]]=, e.g. =[[file:Development/][Development]]= + and =[[file:../index.org][Back to main documentation]]=. +- Org tables need a =|----+----|= separator row between header and data, + and a trailing =|= on every row. +- Options line is exactly: + =#+OPTIONS: H:20 num:20= plus =#+OPTIONS: author:nil=. + +** Code + +- ~...~ for inline code spans in org (=~/.hermes=, ="Tools/Run tests"=); + =...= for verbatim tokens (function names, keywords, model IDs). +- Python: stdlib + =model2vec= + =sqlite-vec= only. No new runtime + dependencies without a strong reason — the design goal is "fully + local, lightweight, no server, no GPU". +- =provider.py= must remain importable outside Hermes: the + =agent.memory_provider= import has a local stub fallback used by the + standalone tests. Keep that pattern intact when editing. +- The embedding model name and dimension live in =src/retinue/memory.py= + as the =MODEL= constant. Changing the model changes the vector + dimension, which changes the =vec0= virtual table schema — existing + databases are not migrated automatically. + +* How to: common tasks +:PROPERTIES: +:ID: e5e31989-cfd9-4152-961c-217b5838115f +:END: + +** Run the standalone tests + +#+begin_src sh +cd /path/to/retinue +"Tools/Run tests" +#+end_src + +Uses =.venv/= and =.hf-cache/= inside the project. If the venv is +missing: + +#+begin_src sh +python3 -m venv .venv +.venv/bin/python -m pip install sqlite-vec model2vec +#+end_src + +** Deploy a change into Hermes + +#+begin_src sh +cd /path/to/retinue +bash "Install Retinue" +hermes memory status # verify +#+end_src + +The installer is idempotent: it wipes everything under +=$HERMES_HOME/plugins/retinue/= except =.venv= / =.hf-cache=, copies the +whitelist =(plugin.yaml __init__.py src)=, installs missing Python deps +into the interpreter Hermes actually runs, pre-downloads the embedding +model into =.hf-cache/= (no first-use stall), sets =memory.provider: +retinue=, and validates the plugin imports. + +To target a non-default Hermes home: + +#+begin_src sh +HERMES_HOME="$HOME/.hermes/profiles/coder" bash "Install Retinue" +#+end_src + +*Restart requirement:* Hermes loads plugins at startup. After every +installer run, any running Hermes session keeps using the previous +copy until restarted. + +** Verify end-to-end inside Hermes + +#+begin_src sh +hermes chat -q "Remember that my default shell is fish." +hermes chat -q "What shell do I use?" +#+end_src + +** Inspect the memory store from the command line + +#+begin_src sh +cd /path/to/retinue +"Tools/Show Retinue memory contents" # dump all memories +"Tools/Show Retinue memory contents" --search "deployment" # semantic search +"Tools/Show Retinue memory contents" --home ~/.hermes/profiles/coder # other Hermes home +"Tools/Show Retinue memory contents" --json | jq . # machine-readable output +#+end_src + +=Show Retinue memory contents= is a debugging helper implemented in +=src/retinue/cli.py=. +It targets =$HERMES_HOME/retinue_memory.db= (auto-detecting a single +profile database under =~/.hermes/profiles/=), with =--db= / =--home= +overrides. Plain listing never loads the embedding model, so it starts +instantly and works offline; only =--search= loads it. It is a +development tool and is intentionally not deployed by the installer. + +** Update the public documentation site + +#+begin_src sh +cd /path/to/retinue +bash "Tools/Update web site" +#+end_src + +This batch-exports every =Documentation/**/*.org= to HTML with +=emacs --batch -l ~/.emacs --visit=... --funcall=org-html-export-to-html= +and rsyncs =Documentation/= to +=n0@www3.svjatoslav.eu:/mnt/big/projects/retinue/= over SSH port 10006. +The script re-execs itself inside a gnome-terminal unless invoked with +a =T= argument; run it with =bash= from a terminal to skip that wrapper. +Never commit the generated =*.html= — they are gitignored. + +* Tooling notes +:PROPERTIES: +:ID: 6f0702f2-2957-4083-a602-caa709ed046e +:END: + +- *Python on Debian 13 is PEP 668.* System pip is blocked. The installer + deliberately installs deps into the Python that runs Hermes + (=PYTHON= env override, =$HERMES_HOME/hermes-agent/venv/bin/python3=, + or the =hermes= launcher shebang), and falls back to + =--break-system-packages= only inside that interpreter. The standalone + tests use the project =.venv=. Do not pip-install into the system + Python from this project. +- *Filenames with spaces.* Everything under =Tools/= contains spaces — + quote paths in shell commands. +- *The =Tools/Open with IntelliJ IDEA= helper* opens this tree as an + IDEA project (=.idea/= is gitignored). +- *Git remote.* Upstream is the user's own git server: + =https://www3.svjatoslav.eu/git/retinue.git= (browse at + =https://www2.svjatoslav.eu/gitweb/?p=retinue.git;a=summary=). + +* Pitfalls +:PROPERTIES: +:ID: ba35d4cc-fb67-4af0-8b73-02c2b6e13cef +:END: + +- *Pitfall: editing the deployed plugin copy.* Changes made in + =~/.hermes/plugins/retinue/= are silently destroyed by the next + installer run. Always edit this workspace tree. +- *Pitfall: forgetting the restart.* The installer finishes green but + Hermes still behaves like the old version — the plugin is loaded at + process start. Restart the Hermes session before concluding a change + did not work. +- *Pitfall: first embedding call is slow (only if the model cache is + missing).* The =minishlab/potion-multilingual-128M= snapshot downloads + ~540 MB on first use into the Hugging Face cache (the repo's unused + ~512 MB ONNX export is skipped — =memory.py= resolves the snapshot + with =snapshot_download(..., ignore_patterns=["onnx/*"])= and loads + from the returned local path, which also prevents re-fetching the + skipped files on every load). The installer pre-downloads it into + =$HERMES_HOME/plugins/retinue/.hf-cache/=, so a normal install never + hits this. If you see a hang on first use, the cache was deleted or + the installer's pre-download step failed (offline install) — re-run + =Install Retinue= with network access. The standalone tests use the + project =.hf-cache/= instead. +- *Pitfall: running tests without the venv.* "Tools/Run tests" refuses + with instructions; do not work around it with system Python (see PEP + 668 note above). +- *Pitfall: model dimension change.* Switching =MODEL= in + =src/retinue/memory.py= changes =get_dim()=, and the existing + =retinue_memory.db= virtual table keeps the old dimension — searches + will fail or return garbage. Delete or rebuild the database when + changing the model. +- *Pitfall: Emacs batch truncation.* When validating .org exports with + Emacs batch mode, never let the script save the buffer — batch + find-file + save has truncated org files on this host. Write with + write_file, validate with a separate read-only batch export, delete + the generated .html afterwards. diff --git a/COPYING b/COPYING new file mode 100644 index 0000000..0e259d4 --- /dev/null +++ b/COPYING @@ -0,0 +1,121 @@ +Creative Commons Legal Code + +CC0 1.0 Universal + + CREATIVE COMMONS CORPORATION IS NOT A LAW FIRM AND DOES NOT PROVIDE + LEGAL SERVICES. DISTRIBUTION OF THIS DOCUMENT DOES NOT CREATE AN + ATTORNEY-CLIENT RELATIONSHIP. CREATIVE COMMONS PROVIDES THIS + INFORMATION ON AN "AS-IS" BASIS. CREATIVE COMMONS MAKES NO WARRANTIES + REGARDING THE USE OF THIS DOCUMENT OR THE INFORMATION OR WORKS + PROVIDED HEREUNDER, AND DISCLAIMS LIABILITY FOR DAMAGES RESULTING FROM + THE USE OF THIS DOCUMENT OR THE INFORMATION OR WORKS PROVIDED + HEREUNDER. + +Statement of Purpose + +The laws of most jurisdictions throughout the world automatically confer +exclusive Copyright and Related Rights (defined below) upon the creator +and subsequent owner(s) (each and all, an "owner") of an original work of +authorship and/or a database (each, a "Work"). + +Certain owners wish to permanently relinquish those rights to a Work for +the purpose of contributing to a commons of creative, cultural and +scientific works ("Commons") that the public can reliably and without fear +of later claims of infringement build upon, modify, incorporate in other +works, reuse and redistribute as freely as possible in any form whatsoever +and for any purposes, including without limitation commercial purposes. +These owners may contribute to the Commons to promote the ideal of a free +culture and the further production of creative, cultural and scientific +works, or to gain reputation or greater distribution for their Work in +part through the use and efforts of others. + +For these and/or other purposes and motivations, and without any +expectation of additional consideration or compensation, the person +associating CC0 with a Work (the "Affirmer"), to the extent that he or she +is an owner of Copyright and Related Rights in the Work, voluntarily +elects to apply CC0 to the Work and publicly distribute the Work under its +terms, with knowledge of his or her Copyright and Related Rights in the +Work and the meaning and intended legal effect of CC0 on those rights. + +1. Copyright and Related Rights. A Work made available under CC0 may be +protected by copyright and related or neighboring rights ("Copyright and +Related Rights"). Copyright and Related Rights include, but are not +limited to, the following: + + i. the right to reproduce, adapt, distribute, perform, display, + communicate, and translate a Work; + ii. moral rights retained by the original author(s) and/or performer(s); +iii. publicity and privacy rights pertaining to a person's image or + likeness depicted in a Work; + iv. rights protecting against unfair competition in regards to a Work, + subject to the limitations in paragraph 4(a), below; + v. rights protecting the extraction, dissemination, use and reuse of data + in a Work; + vi. database rights (such as those arising under Directive 96/9/EC of the + European Parliament and of the Council of 11 March 1996 on the legal + protection of databases, and under any national implementation + thereof, including any amended or successor version of such + directive); and +vii. other similar, equivalent or corresponding rights throughout the + world based on applicable law or treaty, and any national + implementations thereof. + +2. Waiver. To the greatest extent permitted by, but not in contravention +of, applicable law, Affirmer hereby overtly, fully, permanently, +irrevocably and unconditionally waives, abandons, and surrenders all of +Affirmer's Copyright and Related Rights and associated claims and causes +of action, whether now known or unknown (including existing as well as +future claims and causes of action), in the Work (i) in all territories +worldwide, (ii) for the maximum duration provided by applicable law or +treaty (including future time extensions), (iii) in any current or future +medium and for any number of copies, and (iv) for any purpose whatsoever, +including without limitation commercial, advertising or promotional +purposes (the "Waiver"). Affirmer makes the Waiver for the benefit of each +member of the public at large and to the detriment of Affirmer's heirs and +successors, fully intending that such Waiver shall not be subject to +revocation, rescission, cancellation, termination, or any other legal or +equitable action to disrupt the quiet enjoyment of the Work by the public +as contemplated by Affirmer's express Statement of Purpose. + +3. Public License Fallback. Should any part of the Waiver for any reason +be judged legally invalid or ineffective under applicable law, then the +Waiver shall be preserved to the maximum extent permitted taking into +account Affirmer's express Statement of Purpose. In addition, to the +extent the Waiver is so judged Affirmer hereby grants to each affected +person a royalty-free, non transferable, non sublicensable, non exclusive, +irrevocable and unconditional license to exercise Affirmer's Copyright and +Related Rights in the Work (i) in all territories worldwide, (ii) for the +maximum duration provided by applicable law or treaty (including future +time extensions), (iii) in any current or future medium and for any number +of copies, and (iv) for any purpose whatsoever, including without +limitation commercial, advertising or promotional purposes (the +"License"). The License shall be deemed effective as of the date CC0 was +applied by Affirmer to the Work. Should any part of the License for any +reason be judged legally invalid or ineffective under applicable law, such +partial invalidity or ineffectiveness shall not invalidate the remainder +of the License, and in such case Affirmer hereby affirms that he or she +will not (i) exercise any of his or her remaining Copyright and Related +Rights in the Work or (ii) assert any associated claims and causes of +action with respect to the Work, in either case contrary to Affirmer's +express Statement of Purpose. + +4. Limitations and Disclaimers. + + a. No trademark or patent rights held by Affirmer are waived, abandoned, + surrendered, licensed or otherwise affected by this document. + b. Affirmer offers the Work as-is and makes no representations or + warranties of any kind concerning the Work, express, implied, + statutory or otherwise, including without limitation warranties of + title, merchantability, fitness for a particular purpose, non + infringement, or the absence of latent or other defects, accuracy, or + the present or absence of errors, whether or not discoverable, all to + the greatest extent permissible under applicable law. + c. Affirmer disclaims responsibility for clearing rights of other persons + that may apply to the Work or any use thereof, including without + limitation any person's Copyright and Related Rights in the Work. + Further, Affirmer disclaims responsibility for obtaining any necessary + consents, permissions or other rights required for any use of the + Work. + d. Affirmer understands and acknowledges that Creative Commons is not a + party to this document and has no duty or obligation with respect to + this CC0 or use of the Work. diff --git a/Documentation/Development/index.org b/Documentation/Development/index.org new file mode 100644 index 0000000..09863e3 --- /dev/null +++ b/Documentation/Development/index.org @@ -0,0 +1,406 @@ +#+SETUPFILE: ~/.emacs.d/org-styles/html/darksun.theme +#+TITLE: Development - Retinue +#+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 + +[[file:../index.org][Back to main documentation]] + +* Architecture +:PROPERTIES: +:CUSTOM_ID: architecture +:ID: ad252f6e-8152-4dd4-95f6-7b52f773e740 +:END: + +Retinue uses: + +- =model2vec= :: Static embedding model (=minishlab/potion-multilingual-128M=, + ~540 MB download on first use, then offline; the repo's unused ~512 MB + ONNX export is skipped). 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. + +** 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 deletion. | +| =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. + +* Project structure +:PROPERTIES: +:CUSTOM_ID: project-structure +:ID: 6d9ea612-7ecf-4eef-96e0-ab2e9b744a2e +:END: + +#+begin_src +Retinue/ +├── Documentation/ +│ └── index.org # This file +├── Install Retinue # 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 # Debug CLI behind "Show Retinue memory contents" +├── Tools/ # Development helpers (desktop scripts) +│ ├── Run tests # Standalone smoke test runner +│ ├── Show Retinue memory contents # Debug CLI: dump/search all memories +│ ├── Import Holographic memories # Migrate facts from Holographic memory_store.db +│ └── Update web site # Export Documentation/*.org to HTML + publish +└── $HERMES_HOME/retinue_memory.db # Created at runtime: SQLite + sqlite-vec +#+end_src + +* What the installer does +:PROPERTIES: +:CUSTOM_ID: what-the-installer-does +:ID: 9f6b3951-e8f0-4643-bd54-b35bdf528d6e +:END: + +=Install Retinue= 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/= and its =.hf-cache/= subdirectory + if they do not exist. +3. Removes every previously deployed item in the plugin directory except the + =PRESERVE= list (=.venv=, =.hf-cache=), so stale code cannot linger. +4. Copies the whitelist =(plugin.yaml __init__.py src)= from the workspace + into the plugin directory with =cp=. +5. Resolves the Python interpreter that Hermes actually runs under (via the + =PYTHON= environment variable, common venv layouts, or the =hermes= + launcher shebang) and installs any missing =sqlite-vec= / =model2vec= + dependencies into it with =pip= (falling back to + =--break-system-packages= inside that interpreter on PEP 668 systems). +6. Pre-downloads the embedding model into =.hf-cache/= by calling + =retinue.memory.get_model()= with the Hermes Python — the same code path + the plugin uses at runtime. The snapshot download skips the repo's + unused ~512 MB ONNX export (=ignore_patterns=["onnx/*"]=) and loads from + the returned local path, so the cache is ~540 MB and the first Hermes + session does not stall. Failure here is non-fatal: a warning is printed + and the model simply downloads on first use instead. +7. Sets =memory.provider: retinue= with =hermes config set= if the CLI is + available. +8. 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 Retinue" +#+end_src + +* 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 + +Read more: [[#memory-save][How memory save works]]. + +** =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 + +Read more: [[#memory-retrieval][How memory retrieval works]]. + +** =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: + +Returns facts about the store itself — not the memories. Takes no +parameters; the LLM calls it with an empty object: + +: retinue_memory_stats({}) + +and gets back a single object: =total= (number of stored memories), +=latest_id= (the rowid assigned to the most recent memory — the next +memory gets =latest_id + 1=, and ids up to it are valid delete +candidates even after deletions), =db_path= (which database file is in +use), =model= (embedding model name), and =dim= (embedding vector +dimension): + +: {"success": true, "result": {"total": 2418, "latest_id": 2675, +: "db_path": "/home/john/.hermes/retinue_memory.db", +: "model": "minishlab/potion-multilingual-128M", "dim": 256}} + +* How memory save works +:PROPERTIES: +:CUSTOM_ID: memory-save +:ID: 6325a3ac-8629-461e-aa5e-62408ba73bde +:END: + +1) LLM decides something is worth remembering it issues the call: + + : retinue_memory_add({"content": "ProjectX is hosted on our primary server"}) + + Alternative trigger: LLM instead calls Hermes' built-in =memory= tool. + Hermes then calls Retinue's =on_memory_write= hook, which feeds the + same text into the same chain at step 2. Both triggers produce one + identical memory in the same single store — one memory system, two + ways to trigger a save. + +2) Hermes routes it to =handle_tool_call("retinue_memory_add", args)=. + +3) Text is embedded with model2vec into a float32 vector packed as bytes. + On the first call in a process, =memory.py= resolves the model snapshot + with =snapshot_download(MODEL, ignore_patterns=["onnx/*"])= (skipping the + repo's unused ~512 MB ONNX export) and loads =StaticModel= from the + returned local path — loading from a local path is what prevents + re-fetching the skipped files on every load; + +4) Resulting vector with original text is written into database + : INSERT INTO memories (embedding, content, created) + +5) JSON string is then returned back to the LLM: + : {"success": true, "result": {"id": 1, "content": "ProjectX is hosted on our primary server"}} + +* How memory retrieval works +:PROPERTIES: +:CUSTOM_ID: memory-retrieval +:ID: e30a9286-6083-48ed-a861-61c72a5737b4 +:END: + +Retrieval happens through exactly one concrete path: the +=retinue_memory_search= tool. The =prefetch= hook is a no-op, so nothing +is injected automatically — every retrieval is an explicit tool call +visible in the conversation transcript. + +The step-by-step mechanics: + +1. *The LLM is notified the tool exists.* At session start Hermes calls + =get_tool_schemas()= and places all four =retinue_memory_*= schemas + into the LLM's tool list next to the built-in tools. The + =retinue_memory_search= schema declares two parameters: =query= + (required string) and =limit= (optional integer, default 50). + Separately, the text of =src/retinue/system_prompt_block.txt= is + injected into the system prompt, instructing the LLM to run + =retinue_memory_search= before acting on a task. + +2. *The LLM invokes it with a natural-language query.* Example call: + =retinue_memory_search({"query": "coworker", "limit": 2})=. + +3. *Retinue embeds the query and runs a vector query.* The query text is + embedded with the same model2vec model used at write time, then + =MemoryStore.search()= executes a =sqlite-vec= KNN query: + =WHERE embedding MATCH ? AND k = = against the =memories= + virtual table. + +4. *Result count is limited by count only.* The =k= parameter is a + hard top-N cap: sqlite-vec returns at most =limit= nearest rows, + *with no similarity threshold*. Even a nonsense query (="zzzzqqq + nonexistent"=) returns exactly =limit= rows. Filtering weak matches + is left to the LLM reading the scores. + +5. *Results are sorted best-first.* sqlite-vec orders KNN results by + ascending distance, so the most similar memory is always + =results[0]=. Ordering is deterministic; ties can occur (two + different memories scored 0.458 on the same query). + +6. *The tool returns a JSON string* — the result of + =json.dumps({"success": True, "result": [...]})=. Each element has + four fields: =id= (the rowid, for =retinue_memory_delete=), =content= + (full stored text), =created= (ISO 8601 UTC timestamp), and + =similarity=. The =similarity= is computed in =provider.py= from the + raw sqlite-vec distance as =1 / (1 + distance)= — higher is better, + 1.0 means identical vectors. (Raw distance in the database is + unbounded L2 on the model's non-normalized embeddings, so typical + real-world matches land in the 0.4–0.6 range; treat the score as a + relative ranking signal, not an absolute confidence.) + + #+begin_src + {"success": true, "result": [ + {"id": 2, "content": "Coworker John reads Spanish", + "created": "2026-07-17T20:33:43.081952+00:00", "similarity": 0.4626}, + {"id": 1, "content": "ProjectX is hosted on our serverY", + "created": "2026-07-17T20:33:40.987271+00:00", "similarity": 0.4357} + ]} + #+end_src + + (Captured verbatim from =handle_tool_call("retinue_memory_search", + {"query": "coworker", "limit": 2})= against a store holding exactly + those two memories; similarity values shortened for print.) + + +7. *The LLM reads the JSON and decides.* Weak-looking results (all + scores near the noise floor) mean the store has nothing relevant; + the LLM proceeds without memory context. + +* 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 Retinue=. 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 Retinue" + #+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 +"Tools/Run tests" +#+end_src + +This uses a project-local =.venv= and caches downloaded models in +=.hf-cache=, both of which are excluded from deployment by the installer. + +** Inspecting the memory store +:PROPERTIES: +:CUSTOM_ID: inspecting-the-memory-store +:ID: 3b7f2c8e-5a1d-4e9f-b6c0-9d2e4f7a1b3c +:END: + +For debugging, the =Show Retinue memory contents= script dumps every stored memory to +the terminal without going through Hermes: + +#+begin_src sh +cd /path/to/retinue +"Tools/Show Retinue memory contents" # dump all memories +"Tools/Show Retinue memory contents" --search "deployment" # semantic search +"Tools/Show Retinue memory contents" --home ~/.hermes/profiles/coder # other Hermes home +"Tools/Show Retinue memory contents" --json # machine-readable output +#+end_src + +It resolves the database from =--db=, =--home=, =$HERMES_HOME=, or by +auto-detecting a single profile database under =~/.hermes/profiles/=. +Plain listing never loads the embedding model, so it works offline and +starts instantly; only =--search= loads it (~540 MB into =.hf-cache= on +first use). The implementation lives in =src/retinue/cli.py= and is not +deployed by the installer — it is a development-only tool. diff --git a/Documentation/index.org b/Documentation/index.org new file mode 100644 index 0000000..8c84af2 --- /dev/null +++ b/Documentation/index.org @@ -0,0 +1,194 @@ +:PROPERTIES: +:ID: ee70e32d-0ead-4236-bbc2-9dc321dc6a92 +:END: +#+SETUPFILE: ~/.emacs.d/org-styles/html/darksun.theme +#+TITLE: Retinue — 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: + +[[file:logo.png]] + +*Retinue* is a [[https://hermes-agent.nousresearch.com/][Hermes Agent]] memory provider plugin that adds +persistent, semantic memory to every Hermes session. Once installed +and configured, it becomes Hermes' external memory backend. + +Note: While Hermes already has multiple built-in memory systems, I did +not find a solution that: +- Runs fully locally, *and* +- Offers semantic memory retrieval (find memories by meaning, not by the + exact keyword used), *and* +- Is lightweight (runs fast using CPU-only compute, with no need for a + GPU) + +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. + + +For technical details, read: +- [[file:Development/index.org::#memory-save][How memory save works]]. +- [[file:Development/index.org::#memory-retrieval][How memory retrieval works]]. + +* 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 +bash "Install Retinue" +#+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. Pre-downloads the embedding model + (=minishlab/potion-multilingual-128M=, ~540 MB — the repo's unused + ONNX export is skipped) into + =$HERMES_HOME/plugins/retinue/.hf-cache/= so the first Hermes session + does not stall on the download. The cache is preserved across + re-installs, so this step is a no-op after the first successful run. +5. Sets =memory.provider: retinue=. + +Verify the plugin is active: + +#+begin_src sh +hermes memory status +#+end_src + +*Important:* If Hermes is already running, restart it. Hermes loads plugins +at startup, so an active session will not see the newly installed Retinue plugin +until it is restarted. + +For technical details, read: [[file:Development/index.org::#what-the-installer-does][What the installer does.]] + +** Migrating from Holographic memory +:PROPERTIES: +:CUSTOM_ID: migrating-from-holographic +:ID: a1b2c3d4-e5f6-7890-abcd-ef1234567890 +:END: + +If you previously used the Holographic memory provider plugin (the +=fact_store= tool with =memory_store.db=), you can import your old facts +into Retinue with the bundled migration tool. + +Run the importer from the Retinue source tree: + +#+begin_src sh +cd /path/to/retinue +"Tools/Import Holographic memories" --source /path/to/memory_store.db +#+end_src + +The importer performs three steps: + +1. Reads every fact from the Holographic =memory_store.db=. +2. Embeds each fact's content with the Retinue embedding model. +3. Writes new memories into the active Retinue database, skipping exact + duplicates. + +To preview what would be imported without writing anything: + +#+begin_src sh +"Tools/Import Holographic memories" --source /path/to/memory_store.db --dry-run +#+end_src + +* Updating +:PROPERTIES: +:CUSTOM_ID: updating +:ID: 3c4e5f6a-7b8c-9d0e-1a2b-3c4d5e6f7a8b +:END: + +To install an update to Retinue, run the same installer again from the +latest source tree: + +#+begin_src sh +cd /path/to/retinue +bash "Install Retinue" +#+end_src + +The installer is idempotent: it will replace the deployed plugin code +with the current source, install any missing or updated Python +dependencies, and leave the existing memory database untouched. + +To verify the update after re-running the installer: + +#+begin_src sh +hermes memory status +#+end_src + +*Important:* Restart any running Hermes sessions. Hermes loads plugins at +startup, so a running session will continue to use the previous version of +Retinue until it is restarted. + +For technical details, read: [[file:Development/index.org::#what-the-installer-does][What the installer does.]] + +* 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. + +* Source code +:PROPERTIES: +:CUSTOM_ID: development +:END: + +*This program is free software: released under Creative Commons Zero +(CC0) license* + +*Program author:* +- Svjatoslav Agejenko +- Homepage: https://svjatoslav.eu +- Email: mailto://svjatoslav@svjatoslav.eu +- See also: [[https://www.svjatoslav.eu/projects/][Other software projects hosted at svjatoslav.eu]] + +*Getting the source code:* +- [[https://www2.svjatoslav.eu/gitweb/?p=retinue.git;a=snapshot;h=HEAD;sf=tgz][Download latest source code snapshot in TAR GZ format]] +- [[https://www2.svjatoslav.eu/gitweb/?p=retinue.git;a=summary][Browse Git repository online]] +- Clone Git repository using command: + : git clone https://www3.svjatoslav.eu/git/retinue.git + + +For detailed architecture, integration with Hermes Agent, and the +project development workflow, see the dedicated [[file:Development/][Development]] page. diff --git a/Documentation/logo.png b/Documentation/logo.png new file mode 100644 index 0000000000000000000000000000000000000000..7bc3f1380224a91713aaf9da1f248b5bec84303a GIT binary patch literal 83808 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 T(Y>Jp00000NkvXXu0mjfhK}/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 Retinue\"" + 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 Retinue\" --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 Retinue\" 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, this installer, IDE metadata, etc.) is left behind. +WHITELIST=(plugin.yaml __init__.py src) +PRESERVE=(.venv .hf-cache) + +# Create the Hugging Face cache directory so the model pre-download step +# has somewhere to write even on a completely fresh install. +mkdir -p "$PLUGIN_DIR/.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 + +# Pre-download the embedding model into the plugin-local cache so the first +# Hermes session does not hang on a ~500 MB download. +# HF_HOME is preserved across installer runs (PRESERVE list above), so this +# is a no-op on re-installs after the first successful download. +preload_embedding_model() { + echo "Pre-downloading embedding model (minishlab/potion-multilingual-128M)..." + export HF_HOME="$PLUGIN_DIR/.hf-cache" + if "$PYTHON" -c " +import sys +sys.path.insert(0, '$PLUGIN_DIR/src') +from retinue.memory import get_model +get_model() +print(' Embedding model ready.') +" 2>/dev/null; then + echo " Model cached at $PLUGIN_DIR/.hf-cache" + else + echo " Warning: could not pre-download the embedding model." + echo " It will download on first use inside Hermes." + echo " (Check network / Hugging Face connectivity.)" + fi +} + +preload_embedding_model + +# 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 "The embedding model is pre-downloaded and ready — no first-use delay." +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/Tools/Import Holographic memories b/Tools/Import Holographic memories new file mode 100755 index 0000000..eba9e61 --- /dev/null +++ b/Tools/Import Holographic memories @@ -0,0 +1,37 @@ +#!/usr/bin/env bash +# Import Holographic memories - migrate facts from the old Holographic +# memory provider (memory_store.db) into Retinue's semantic memory store. +# +# Uses the project-local venv (same as "Run tests"), so dependencies and +# the Hugging Face cache live inside the project tree. +# +# Usage: +# "Tools/Import Holographic memories" [--source PATH] [--db PATH | --home PATH] +# [--dry-run] [--json] [--verbose] +# +# Examples: +# "Tools/Import Holographic memories" --source ~/.hermes/memory_store.db +# "Tools/Import Holographic memories" --source "/mnt/backup/.hermes" # directory works too +# "Tools/Import Holographic memories" --dry-run --verbose +# "Tools/Import Holographic memories" --json +# +# The embedding model (~500 MB) downloads on first use into .hf-cache. + +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +PROJECT_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)" +VENV="$PROJECT_ROOT/.venv" + +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:-$PROJECT_ROOT/.hf-cache}" +export PYTHONPATH="$PROJECT_ROOT/src${PYTHONPATH:+:$PYTHONPATH}" + +exec "$VENV/bin/python" -m retinue.import_holographic "$@" 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/Tools/Run tests b/Tools/Run tests new file mode 100755 index 0000000..deae42c --- /dev/null +++ b/Tools/Run tests @@ -0,0 +1,23 @@ +#!/usr/bin/env bash +# Run tests - 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)" +PROJECT_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)" +VENV="$PROJECT_ROOT/.venv" +TEST="$PROJECT_ROOT/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:-$PROJECT_ROOT/.hf-cache}" + +exec "$VENV/bin/python" "$TEST" diff --git a/Tools/Show Retinue memory contents b/Tools/Show Retinue memory contents new file mode 100755 index 0000000..9a1a1bc --- /dev/null +++ b/Tools/Show Retinue memory contents @@ -0,0 +1,35 @@ +#!/usr/bin/env bash +# Show Retinue memory contents - dump all Retinue semantic memories to the +# terminal. Debugging helper. +# Uses the project-local venv (same as "Run tests"), so dependencies and +# the Hugging Face cache live inside the project tree. +# +# Usage: +# "Tools/Show Retinue memory contents" [--db PATH | --home PATH] [--search QUERY] [--limit N] [--json] +# +# Examples: +# "Tools/Show Retinue memory contents" # dump everything +# "Tools/Show Retinue memory contents" --search "deployment" # semantic search +# "Tools/Show Retinue memory contents" --home ~/.hermes/profiles/coder # other Hermes home +# +# Listing works offline and never downloads the embedding model; only +# --search loads it (~500 MB into .hf-cache on first use). + +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +PROJECT_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)" +VENV="$PROJECT_ROOT/.venv" + +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:-$PROJECT_ROOT/.hf-cache}" +export PYTHONPATH="$PROJECT_ROOT/src${PYTHONPATH:+:$PYTHONPATH}" + +exec "$VENV/bin/python" -m retinue.cli "$@" diff --git a/Tools/Update web site b/Tools/Update web site new file mode 100755 index 0000000..76f22ae --- /dev/null +++ b/Tools/Update web site @@ -0,0 +1,80 @@ +#!/bin/bash +cd "${0%/*}"; if [ "$1" != "T" ]; then gnome-terminal -e "'$0' T"; exit; fi; + +cd .. + +# Function to export org to html using emacs in batch mode +export_org_to_html() { + local org_file=$1 + local dir=$(dirname "$org_file") + local base=$(basename "$org_file" .org) + ( + cd "$dir" || return 1 + local html_file="${base}.html" + if [ -f "$html_file" ]; then + rm -f "$html_file" + fi + echo "Exporting: $org_file → $dir/$html_file" + emacs --batch -l ~/.emacs --visit="${base}.org" --funcall=org-html-export-to-html --kill + if [ $? -eq 0 ]; then + echo "✓ Successfully exported $org_file" + else + echo "✗ Failed to export $org_file" + return 1 + fi + ) +} + +export_org_files_to_html() { + echo "🔍 Searching for .org files in Documentation/ ..." + echo "=======================================" + + mapfile -t ORG_FILES < <(find Documentation -type f -name "*.org" | sort) + + if [ ${#ORG_FILES[@]} -eq 0 ]; then + echo "❌ No .org files found!" + return 1 + fi + + echo "Found ${#ORG_FILES[@]} .org file(s):" + printf '%s\n' "${ORG_FILES[@]}" + echo "=======================================" + + SUCCESS_COUNT=0 + FAILED_COUNT=0 + + for org_file in "${ORG_FILES[@]}"; do + export_org_to_html "$org_file" + if [ $? -eq 0 ]; then + ((SUCCESS_COUNT++)) + else + ((FAILED_COUNT++)) + fi + done + + echo "=======================================" + echo "📊 SUMMARY:" + echo " ✓ Successful: $SUCCESS_COUNT" + echo " ✗ Failed: $FAILED_COUNT" + echo " Total: $((SUCCESS_COUNT + FAILED_COUNT))" + echo "" +} + +# Publish Emacs org-mode files into HTML format +export_org_files_to_html + +# Upload assembled documentation to server +SERVER_DIR="n0@www3.svjatoslav.eu:/mnt/big/projects/retinue/" + +echo "📤 Uploading to $SERVER_DIR ..." +rsync -avz --delete -e 'ssh -p 10006' Documentation/ "$SERVER_DIR" + +if [ $? -eq 0 ]; then + echo "✓ Upload completed successfully!" +else + echo "✗ Upload failed!" +fi + +echo "" +echo "Press ENTER to close this window." +read 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/plugin.yaml b/plugin.yaml new file mode 100644 index 0000000..5c63698 --- /dev/null +++ b/plugin.yaml @@ -0,0 +1,6 @@ +name: retinue +version: 1.1.0 +description: "Semantic memory provider for Hermes Agent using model2vec and sqlite-vec." +hooks: + - sync_turn + - shutdown 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/cli.py b/src/retinue/cli.py new file mode 100644 index 0000000..8a89355 --- /dev/null +++ b/src/retinue/cli.py @@ -0,0 +1,145 @@ +#!/usr/bin/env python3 +"""Dump all Retinue semantic memories to the terminal. + +Debugging helper. Lists every memory stored in the Retinue database in +human-readable form, optionally filtering by semantic similarity. + +Usage: + python3 -m retinue.cli [--db PATH | --home PATH] [--search QUERY] + [--limit N] [--json] + +The database is located in this order: + 1. --db PATH explicit path to retinue_memory.db + 2. --home PATH explicit Hermes home (looks for retinue_memory.db) + 3. $HERMES_HOME environment variable + 4. Auto-detect: ~/.hermes/profiles/*/retinue_memory.db (if exactly + one exists), else ~/.hermes/retinue_memory.db + +Listing and stats do NOT load the embedding model, so they work offline +and start instantly. Only --search needs the model +(minishlab/potion-multilingual-128M, ~500 MB download on first use). +""" + +from __future__ import annotations + +import argparse +import json +import os +import sys +from pathlib import Path + +from retinue.memory import MemoryStore + +MODEL_HINT = ( + "The embedding model (minishlab/potion-multilingual-128M, ~500 MB) " + "will be downloaded on first use." +) + + +def _detect_db(args: argparse.Namespace) -> Path: + """Resolve which database file to open, per the documented order.""" + if args.db: + return Path(args.db).expanduser() + if args.home: + return Path(args.home).expanduser() / "retinue_memory.db" + env_home = os.environ.get("HERMES_HOME") + if env_home: + return Path(env_home).expanduser() / "retinue_memory.db" + default_home = Path.home() / ".hermes" + profiles = sorted( + (default_home / "profiles").glob("*/retinue_memory.db") + ) if (default_home / "profiles").is_dir() else [] + if len(profiles) == 1: + return profiles[0] + return default_home / "retinue_memory.db" + + +def _print_row(memory_id: int, content: str, created: str, + similarity: float | None = None) -> None: + header = f"[{memory_id}] {created}" + if similarity is not None: + header += f" (similarity {similarity:.3f})" + print(header) + for line in content.splitlines() or [""]: + print(f" {line}") + print() + + +def main(argv: list[str] | None = None) -> int: + parser = argparse.ArgumentParser( + prog="show-retinue-memory-contents", + description="Dump all Retinue memories to the terminal (debugging tool).", + ) + parser.add_argument("--db", metavar="PATH", + help="path to retinue_memory.db") + parser.add_argument("--home", metavar="PATH", + help="Hermes home directory (default: $HERMES_HOME or ~/.hermes)") + parser.add_argument("--search", metavar="QUERY", + help="semantic search instead of a full dump " + "(loads the embedding model)") + parser.add_argument("--limit", metavar="N", type=int, default=50, + help="max results for --search (default: 50)") + parser.add_argument("--json", action="store_true", + help="emit JSON instead of human-readable text") + args = parser.parse_args(argv) + + db_path = _detect_db(args) + if not db_path.is_file(): + print(f"error: Retinue database not found: {db_path}", file=sys.stderr) + print("Use --db PATH or --home PATH to point at the right location.", + file=sys.stderr) + return 1 + + store = MemoryStore(base_dir=db_path.parent) + try: + if args.search: + try: + rows = store.search(args.search, limit=args.limit) + except RuntimeError as e: + print(f"error: {e}", file=sys.stderr) + print(MODEL_HINT, file=sys.stderr) + return 1 + results = [ + { + "id": row[0], + "content": row[1], + "created": row[2], + "similarity": max(0.0, 1.0 / (1.0 + row[3])), + } + for row in rows + ] + if args.json: + print(json.dumps({ + "db_path": str(db_path), + "query": args.search, + "results": results, + }, indent=2, ensure_ascii=False)) + else: + print(f"Database: {db_path}") + print(f"Query: {args.search!r} — {len(results)} match(es)\n") + for r in results: + _print_row(r["id"], r["content"], r["created"], + r["similarity"]) + else: + rows = store.list() + if args.json: + print(json.dumps({ + "db_path": str(db_path), + "total": len(rows), + "memories": [ + {"id": row[0], "content": row[1], "created": row[2]} + for row in rows + ], + }, indent=2, ensure_ascii=False)) + else: + print(f"Database: {db_path}") + print(f"Memories: {len(rows)}\n") + for row in rows: + _print_row(row[0], row[1], row[2]) + return 0 + finally: + store.close() + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/src/retinue/import_holographic.py b/src/retinue/import_holographic.py new file mode 100644 index 0000000..744712f --- /dev/null +++ b/src/retinue/import_holographic.py @@ -0,0 +1,222 @@ +#!/usr/bin/env python3 +"""Import memories from a Holographic memory store (memory_store.db) into Retinue. + +Reads facts from the old Holographic MemoryProvider SQLite database and writes +them into Retinue's semantic memory store. This facilitates migration for +users who previously used the Holographic memory plugin and want to switch +to Retinue. + +Usage: + python3 -m retinue.import_holographic [--source PATH] [--db PATH | --home PATH] + [--dry-run] [--json] [--verbose] + +The source database is located in this order: + 1. --source PATH memory_store.db file, or a directory containing it + 2. $HERMES_HOME environment variable (looks for memory_store.db) + 3. ~/.hermes/memory_store.db + +The target Retinue database is located in this order: + 1. --db PATH explicit path to retinue_memory.db + 2. --home PATH explicit Hermes home (looks for retinue_memory.db) + 3. $HERMES_HOME environment variable + 4. Auto-detect: ~/.hermes/profiles/*/retinue_memory.db (if exactly + one exists), else ~/.hermes/retinue_memory.db + +Notes: + - Only the fact content is imported; trust scores, retrieval counts, and + HRR vectors are discarded (Retinue uses semantic embeddings instead). + - Duplicate facts (same content) are skipped. + - The embedding model (minishlab/potion-multilingual-128M, ~500 MB) is + downloaded on first use into .hf-cache. +""" + +from __future__ import annotations + +import argparse +import json +import os +import sqlite3 +import sys +from pathlib import Path + +from retinue.memory import MemoryStore + +MODEL_HINT = ( + "The embedding model (minishlab/potion-multilingual-128M, ~500 MB) " + "will be downloaded on first use." +) + + +def _detect_source(args: argparse.Namespace) -> Path: + """Resolve the Holographic memory_store.db path. + + --source accepts either the database file itself or a directory that + contains it (e.g. a backed-up Hermes home). + """ + candidate: Path + if args.source: + candidate = Path(args.source).expanduser() + else: + env_home = os.environ.get("HERMES_HOME") + candidate = (Path(env_home).expanduser() if env_home + else Path.home() / ".hermes") / "memory_store.db" + if candidate.is_dir() or (not candidate.exists() and candidate.suffix != ".db"): + candidate = candidate / "memory_store.db" + return candidate + + +def _detect_target(args: argparse.Namespace) -> Path: + """Resolve the Retinue retinue_memory.db path.""" + if args.db: + return Path(args.db).expanduser() + if args.home: + return Path(args.home).expanduser() / "retinue_memory.db" + env_home = os.environ.get("HERMES_HOME") + if env_home: + return Path(env_home).expanduser() / "retinue_memory.db" + default_home = Path.home() / ".hermes" + profiles = sorted( + (default_home / "profiles").glob("*/retinue_memory.db") + ) if (default_home / "profiles").is_dir() else [] + if len(profiles) == 1: + return profiles[0] + return default_home / "retinue_memory.db" + + +def _read_holographic_facts(db_path: Path) -> list[dict]: + """Read all facts from the Holographic memory_store.db.""" + conn = sqlite3.connect(str(db_path)) + conn.row_factory = sqlite3.Row + try: + rows = conn.execute( + """ + SELECT fact_id, content, category, tags, trust_score, + retrieval_count, helpful_count, created_at, updated_at + FROM facts + ORDER BY fact_id + """ + ).fetchall() + return [dict(r) for r in rows] + finally: + conn.close() + + +def main(argv: list[str] | None = None) -> int: + parser = argparse.ArgumentParser( + prog="import-holographic-memories", + description="Import facts from a Holographic memory_store.db into Retinue.", + ) + parser.add_argument( + "--source", metavar="PATH", + help="Holographic memory_store.db file, or a directory containing it " + "(default: $HERMES_HOME/memory_store.db, else ~/.hermes/memory_store.db)", + ) + parser.add_argument( + "--db", metavar="PATH", + help="path to Retinue retinue_memory.db", + ) + parser.add_argument( + "--home", metavar="PATH", + help="Hermes home directory (default: $HERMES_HOME or ~/.hermes)", + ) + parser.add_argument( + "--dry-run", action="store_true", + help="show what would be imported without writing anything", + ) + parser.add_argument( + "--json", action="store_true", + help="emit JSON instead of human-readable text", + ) + parser.add_argument( + "--verbose", "-v", action="store_true", + help="print each fact as it is imported", + ) + args = parser.parse_args(argv) + + source_path = _detect_source(args) + target_path = _detect_target(args) + + if not source_path.is_file(): + print(f"error: no Holographic database file at: {source_path}", file=sys.stderr) + print("--source expects the memory_store.db FILE, or a directory containing it.", + file=sys.stderr) + print("Examples:", file=sys.stderr) + print(' "Tools/Import Holographic memories" --source "/mnt/backup/.hermes/memory_store.db"', + file=sys.stderr) + print(' "Tools/Import Holographic memories" --source "/mnt/backup/.hermes"', + file=sys.stderr) + return 1 + + facts = _read_holographic_facts(source_path) + if not facts: + print("No facts found in the Holographic database.", file=sys.stderr) + return 0 + + if args.dry_run: + if args.json: + print(json.dumps({ + "source": str(source_path), + "target": str(target_path), + "dry_run": True, + "would_import": len(facts), + "facts": [ + {"fact_id": f["fact_id"], "content": f["content"][:120]} + for f in facts + ], + }, indent=2, ensure_ascii=False)) + else: + print(f"Source: {source_path}") + print(f"Target: {target_path}") + print(f"Facts: {len(facts)}") + print() + for f in facts: + preview = f["content"][:120].replace("\n", " ") + print(f" [{f['fact_id']}] {preview}") + return 0 + + # Real import: open the Retinue store. + store = MemoryStore(base_dir=target_path.parent) + try: + # Load existing contents to deduplicate. + existing = {row[1] for row in store.list()} + imported = 0 + skipped = 0 + for fact in facts: + content = fact["content"].strip() + if not content: + skipped += 1 + continue + if content in existing: + skipped += 1 + continue + try: + mid = store.add(content) + imported += 1 + if args.verbose: + print(f" imported [{fact['fact_id']}] -> retinue id {mid}") + except Exception as e: + print(f" error importing fact {fact['fact_id']}: {e}", file=sys.stderr) + skipped += 1 + + if args.json: + print(json.dumps({ + "source": str(source_path), + "target": str(target_path), + "dry_run": False, + "imported": imported, + "skipped": skipped, + "total_in_source": len(facts), + }, indent=2, ensure_ascii=False)) + else: + print(f"Source: {source_path}") + print(f"Target: {target_path}") + print(f"Imported: {imported} new memories") + print(f"Skipped: {skipped} duplicates/empty") + print(f"Total: {len(facts)} facts in source") + return 0 + finally: + store.close() + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/src/retinue/memory.py b/src/retinue/memory.py new file mode 100644 index 0000000..b6e2676 --- /dev/null +++ b/src/retinue/memory.py @@ -0,0 +1,205 @@ +"""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" + +# The repo also ships onnx/model.onnx (~512 MB), an ONNX export of the same +# weights that model2vec never reads. Skipping it halves download and disk. +_IGNORE_PATTERNS = ["onnx/*"] + +# --------------------------------------------------------------------------- +# Lazy-loaded embedding model +# --------------------------------------------------------------------------- +_model: Any | None = None +_model_dim: int | None = None + + +def _resolve_model_path() -> str: + """Download the model snapshot (without the unused ONNX export) and return + the local directory path. Loading from the local path is what prevents + StaticModel.from_pretrained from re-fetching the skipped onnx/ files on + every load.""" + try: + from huggingface_hub import snapshot_download + except ImportError as e: + raise RuntimeError( + "huggingface_hub is not installed. Run: pip install huggingface-hub" + ) from e + logger.info("Downloading embedding model snapshot: %s", MODEL) + return snapshot_download(MODEL, ignore_patterns=_IGNORE_PATTERNS) + + +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(_resolve_model_path()) + 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] + latest_id = db.execute(f"SELECT MAX(rowid) FROM {TABLE}").fetchone()[0] + return { + "total": total, + "latest_id": latest_id or 0, + "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..5a4d0a9 --- /dev/null +++ b/src/retinue/provider.py @@ -0,0 +1,308 @@ +"""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 Path(__file__).with_name("system_prompt_block.txt").read_text() + + # ------------------------------------------------------------------ + # 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_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: total memories stored, latest assigned memory ID, database path, embedding model and dimension.", + "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_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"], + "latest_id": stats["latest_id"], + "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/src/retinue/system_prompt_block.txt b/src/retinue/system_prompt_block.txt new file mode 100644 index 0000000..f3400f1 --- /dev/null +++ b/src/retinue/system_prompt_block.txt @@ -0,0 +1,99 @@ +Memory discipline + +Retinue semantic memory is available, cheap, and persistent across sessions. Use it eagerly. + +Workflow: SEARCH → ACT → SAVE → CLEAN + +1. SEARCH before you act. + Before answering, coding, debugging, planning, or searching the web, always run + retinue_memory_search first. Retinue is semantic: "Vehicle" matches "Volvo", + "deployment" matches "install". Search even for questions that seem trivial + or unrelated to memory — the user may have already told you the answer. + + Treat the memory store as a graph you traverse, not a one-shot lookup. + Every returned memory mentions entities — projects, paths, people, tools, + hosts. Run follow-up searches on those entities to uncover connected + memories that the first query did not surface. Chain searches until you + have assembled the full picture needed to answer: one query finds a + project name, the next finds that project's deployment host, the third + finds the host's SSH port. Iterate like graph traversal: node → edges → + node → edges, until the in-memory representation is complete. + +2. ACT with what you found. + If memory returns partial info, use it and only fill the gaps with external + sources. If it returns nothing, proceed normally. + +3. SAVE after every task. + After every task, before your final reply, run retinue_memory_add. Do not + skip this because the fact seems "obvious" or the task was small. Save when + ANY of the following are true (and they usually are): + - search returned nothing useful AND an external source (docs, files, + URLs, CLI output) gave the answer. + - you discovered a stable identifier the next session will need: URL, + path, hostname, port, env var, version, command flag. + - you learned a person's role / handle, or a system's alias / nickname. + - a task succeeded via a non-obvious path, or you had to back out of a + wrong first attempt. + - the user corrected a fact, preference, or process. + - the user asked about their own project, files, or environment. + + If in doubt, save. The cost of a redundant memory is far lower than the + cost of re-asking or re-discovering it later. + +4. CLEAN proactively. + Whenever you list or search memories, inspect the results for quality + problems and fix them immediately — do not ask permission for obvious cleanup: + - Delete exact or near-duplicate memories (keep the newest / most complete one). + - Delete stale or obsolete memories (paths that moved, versions that changed, + preferences that were corrected, bugs that were fixed). + - Merge several overlapping memories into one self-contained entry by deleting + the fragments and adding a consolidated memory. + - Update a memory when the underlying fact changes. + + Only ask the user when the correct action is genuinely ambiguous. + +Pre-save quality gate + +Before saving any memory, run this checklist. If any check fails, rewrite the +memory before saving. +1. Search first. Use retinue_memory_search for related concepts. If an + existing memory already covers the fact, merge or replace it instead of + adding a fragment. +2. Fresh-session test. Read the candidate memory verbatim and ask: "If I had + never seen this conversation, could I correctly understand and act on this + fact?" If not, rewrite it. +3. Quote paths with spaces. Every file path that contains spaces or + shell-special characters must be enclosed in double quotes, e.g., + "/home/n0/data/projects/Internet hosting/apache2 setup.org". Paths with + no spaces may be quoted too. +4. Name the referents. Include project/repository full name, file or path, + tool/library/version, and environment/host where relevant. +5. No bare pronouns. Remove "it", "this", "here", "the file", "the project", + "the user" without naming the referent in the same sentence. +6. One fact per memory. Do not bundle unrelated facts. If a rule depends on a + file, include the file path. + +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. If not, rewrite it. + +Always make explicit in the content: +- project / repository (full name, e.g., sixth-3d, not "the project") +- file or path (absolute or repo-relative, e.g., src/retinue/provider.py, + not "that script") +- tool / library / version (e.g., sqlite-vec 0.1.x, not "the vec extension") +- environment / host (e.g., Linux host "jupiter", not "this machine") + +Never use bare pronouns ("it", "this", "here", "the file", "the project", +"the user") without naming the referent in the same memory. + +Bad (context-dependent, misleading later): +- "the build fails on Java 21 -- use 17 instead" +- "user prefers tabs" + +Good (self-contained, safe to retrieve in any future session): +- "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." +- "User John prefers plain text output, not markdown tables." diff --git a/test/test_standalone.py b/test/test_standalone.py new file mode 100644 index 0000000..8c2cd7b --- /dev/null +++ b/test/test_standalone.py @@ -0,0 +1,209 @@ +#!/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 +from retinue.cli import main as cli_main + + +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) == 4 + + # 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) + + +def test_cli() -> None: + """Exercise the "Show Retinue memory contents" CLI (src/retinue/cli.py).""" + base_dir = Path(tempfile.mkdtemp(prefix="retinue-cli-test-")) + try: + store = MemoryStore(base_dir=base_dir) + store.add("CLI smoke test memory: default browser is Firefox") + store.close() + + db_path = base_dir / "retinue_memory.db" + assert db_path.is_file(), db_path + + # Plain dump (must not require the embedding model). + rc = cli_main(["--db", str(db_path)]) + assert rc == 0, rc + + # JSON dump. + rc = cli_main(["--db", str(db_path), "--json"]) + assert rc == 0, rc + + # Semantic search through the CLI. + rc = cli_main(["--db", str(db_path), "--search", "web browser", "--limit", "5"]) + assert rc == 0, rc + + # Missing database must exit 1. + rc = cli_main(["--db", str(base_dir / "nonexistent.db")]) + assert rc == 1, rc + + print("\nCLI 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() + test_cli() + print("\nAll standalone tests passed.") -- 2.20.1