hermes-memory-pgvector 0.4.0__tar.gz → 0.4.2__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (27) hide show
  1. {hermes_memory_pgvector-0.4.0 → hermes_memory_pgvector-0.4.2}/PKG-INFO +52 -12
  2. {hermes_memory_pgvector-0.4.0 → hermes_memory_pgvector-0.4.2}/README.md +51 -11
  3. {hermes_memory_pgvector-0.4.0 → hermes_memory_pgvector-0.4.2}/hermes_memory_pgvector.egg-info/PKG-INFO +52 -12
  4. {hermes_memory_pgvector-0.4.0 → hermes_memory_pgvector-0.4.2}/hermes_memory_pgvector.egg-info/SOURCES.txt +4 -0
  5. {hermes_memory_pgvector-0.4.0 → hermes_memory_pgvector-0.4.2}/pgvector/__init__.py +166 -39
  6. {hermes_memory_pgvector-0.4.0 → hermes_memory_pgvector-0.4.2}/pgvector/__main__.py +149 -1
  7. {hermes_memory_pgvector-0.4.0 → hermes_memory_pgvector-0.4.2}/pgvector/embed.py +16 -1
  8. {hermes_memory_pgvector-0.4.0 → hermes_memory_pgvector-0.4.2}/pgvector/identity.py +11 -2
  9. hermes_memory_pgvector-0.4.2/pgvector/migrations/003_hybrid_search_fts.sql +51 -0
  10. hermes_memory_pgvector-0.4.2/pgvector/migrations/004_runtime_grants.sql +36 -0
  11. {hermes_memory_pgvector-0.4.0 → hermes_memory_pgvector-0.4.2}/pgvector/plugin.yaml +2 -2
  12. {hermes_memory_pgvector-0.4.0 → hermes_memory_pgvector-0.4.2}/pgvector/store.py +262 -21
  13. {hermes_memory_pgvector-0.4.0 → hermes_memory_pgvector-0.4.2}/pgvector/writer.py +27 -5
  14. {hermes_memory_pgvector-0.4.0 → hermes_memory_pgvector-0.4.2}/pyproject.toml +1 -1
  15. hermes_memory_pgvector-0.4.2/tests/test_hybrid_search.py +123 -0
  16. {hermes_memory_pgvector-0.4.0 → hermes_memory_pgvector-0.4.2}/tests/test_identity.py +20 -3
  17. hermes_memory_pgvector-0.4.2/tests/test_install_shim.py +74 -0
  18. {hermes_memory_pgvector-0.4.0 → hermes_memory_pgvector-0.4.2}/tests/test_smoke.py +17 -7
  19. {hermes_memory_pgvector-0.4.0 → hermes_memory_pgvector-0.4.2}/tests/test_store_v04.py +27 -2
  20. {hermes_memory_pgvector-0.4.0 → hermes_memory_pgvector-0.4.2}/LICENSE +0 -0
  21. {hermes_memory_pgvector-0.4.0 → hermes_memory_pgvector-0.4.2}/hermes_memory_pgvector.egg-info/dependency_links.txt +0 -0
  22. {hermes_memory_pgvector-0.4.0 → hermes_memory_pgvector-0.4.2}/hermes_memory_pgvector.egg-info/entry_points.txt +0 -0
  23. {hermes_memory_pgvector-0.4.0 → hermes_memory_pgvector-0.4.2}/hermes_memory_pgvector.egg-info/requires.txt +0 -0
  24. {hermes_memory_pgvector-0.4.0 → hermes_memory_pgvector-0.4.2}/hermes_memory_pgvector.egg-info/top_level.txt +0 -0
  25. {hermes_memory_pgvector-0.4.0 → hermes_memory_pgvector-0.4.2}/pgvector/migrations/001_schema.sql +0 -0
  26. {hermes_memory_pgvector-0.4.0 → hermes_memory_pgvector-0.4.2}/pgvector/migrations/002_agent_attribution.sql +0 -0
  27. {hermes_memory_pgvector-0.4.0 → hermes_memory_pgvector-0.4.2}/setup.cfg +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: hermes-memory-pgvector
3
- Version: 0.4.0
3
+ Version: 0.4.2
4
4
  Summary: Postgres + pgvector memory provider plugin for hermes-agent. Multi-agent storage layer with per-minion themes, identity governance, agent attribution + delegation provenance, async writer, no LLM in the memory hot path.
5
5
  Author: Andrea Borghi
6
6
  License: BSD-3-Clause
@@ -106,6 +106,21 @@ Four capabilities, all storage-layer (still no LLM in the hot path):
106
106
 
107
107
  Maintenance CLI (`python -m pgvector`, installed as `hermes-pgvector`): `migrate · stats · backfill · prune · cleanup · remap` — destructive commands default to dry-run. v0.4.0 is a clean upgrade from v0.3.x: apply migration `002` to light up attribution/delegation; without it the new hooks no-op and everything else runs unchanged.
108
108
 
109
+ ## New in v0.4.1 — hybrid recall (vector + full-text)
110
+
111
+ `recall_memory` and `recall_conversation` now fuse the HNSW **vector** ranking with a Postgres **full-text** ranking using **Reciprocal Rank Fusion** (RRF, `k=60`). A row surfaces if *either* ranker likes it, which fixes the two blind spots of pure cosine similarity:
112
+
113
+ - **Exact-lexical hits** the embedding smooths away — a specific error code, hostname, flag name, or rare identifier the agent quotes verbatim.
114
+ - **Text-only rows with a `NULL` embedding** (written while the embed endpoint was down) — invisible to the vector index, but the full-text leg finds them. So hybrid recall doubles as best-effort recovery until the next `backfill`.
115
+
116
+ Still a storage-layer feature: **no LLM, no entity graph, no new tables or columns** — just a GIN index over the existing `content` column (migration `003`) and a fused query. It stays inside invariant #1 (a second index over the same text is not a parallel ontology). Fail-soft as ever: a hybrid hiccup degrades to the proven pure-vector path, and a query that *itself* fails to embed degrades to full-text-only instead of erroring. Toggle with `plugins.pgvector.hybrid_search` (default `true`); the ambient `prefetch()` path stays pure-vector. Works without migration `003` — the GIN index only makes the full-text leg faster.
117
+
118
+ ## New in v0.4.2 — pip-native install + hardening
119
+
120
+ - **`hermes-pgvector install`** — makes a plain `pip install hermes-memory-pgvector` deployable on ANY hermes-agent install: generates the `$HERMES_HOME/plugins/pgvector/` discovery shim (see *Install · Option 1*). No more vendored copies or editable checkouts.
121
+ - **Migration `004`** — `hermes-pgvector migrate` now grants the runtime role DML on `memory_entries`/`conversations` itself; the manual OWNER-transfer step is gone (fresh installs previously hit `permission denied` if it was skipped).
122
+ - **Correctness fixes** from a full-codebase review: `replace`/`remove` now match `old_text` as a *literal* substring (LIKE `%`/`_`/`\` metacharacters no longer over- or under-match — parity with the built-in tool's `in` semantics); the async writer drains its queue on shutdown instead of silently abandoning up to 255 accepted writes when full; a wrong-dimension embed model now surfaces as `expected 768 dims, got N` instead of a masking 404; DM-key bucketing no longer sweeps ordinary `:signal:`-containing theme names into `whatsapp-dm`; bulk MEMORY.md import circuit-breaks after 3 consecutive embed failures (a hanging endpoint can no longer block session start for minutes); `remap` re-checks its duplicate-drop guard under the advisory lock; tool errors redact credential-looking fragments and preserve `score: null` for full-text-only hybrid hits (with `rrf_score` now included); `recall_memory(scope='session')` returns a helpful error instead of silently matching nothing.
123
+
109
124
  ## Multi-agent / per-minion themes
110
125
 
111
126
  Each systemd-run minion sets one header on its OpenAI client; everything else flows automatically:
@@ -130,7 +145,28 @@ Set `plugins.pgvector.allowed_themes` to that product/worker list to enforce it
130
145
 
131
146
  ## Install
132
147
 
133
- ### Option 1: clone + run the installer script (recommended)
148
+ ### Option 1: pip + discovery shim (recommended, v0.4.2+)
149
+
150
+ ```bash
151
+ # 1. Install the package into the SAME environment hermes-agent runs in
152
+ pip install hermes-memory-pgvector
153
+
154
+ # 2. Create the discovery shim
155
+ hermes-pgvector install # writes $HERMES_HOME/plugins/pgvector/
156
+
157
+ # 3. Apply ALL migrations (schema + attribution + FTS + runtime grants)
158
+ hermes-pgvector migrate --admin-dsn \
159
+ "dbname=<your-memory-db> user=postgres host=/var/run/postgresql"
160
+
161
+ # 4. Activate + verify
162
+ hermes config set memory.provider pgvector
163
+ sudo systemctl restart hermes.service
164
+ hermes memory status # expect: Provider: pgvector; Status: available
165
+ ```
166
+
167
+ **Why the shim?** hermes-agent discovers memory providers by scanning plugin *directories* — `plugins/memory/<name>/` (bundled) and `$HERMES_HOME/plugins/<name>/` (user) — and never looks at installed packages. `pip install` alone is therefore invisible to it. `hermes-pgvector install` writes a two-line shim whose absolute import resolves to the pip-installed package, so upgrades are just `pip install -U hermes-memory-pgvector` + restart, and rollback is `pip install hermes-memory-pgvector==<prev>` + restart — the shim never changes. `--remove` deletes it; if the package is uninstalled the shim import fails cleanly and hermes falls back to built-in memory.
168
+
169
+ ### Option 2: clone + run the installer script (from source)
134
170
 
135
171
  ```bash
136
172
  git clone https://github.com/andreab67/hermes-memory-pgvector.git
@@ -144,7 +180,7 @@ That:
144
180
  2. Copies `pgvector/` into `$HERMES_HOME/plugins/pgvector/` (defaults to `~/.hermes/plugins/pgvector/`).
145
181
  3. Prints the admin migration + activation commands you run next.
146
182
 
147
- ### Option 2: manual
183
+ ### Option 3: manual
148
184
 
149
185
  ```bash
150
186
  # Python deps
@@ -167,15 +203,19 @@ sudo -u postgres psql -d <your-memory-db> \
167
203
  # runtime role needs — it self-grants, so no extra OWNER step for these).
168
204
  sudo -u postgres psql -d <your-memory-db> \
169
205
  -f ~/.hermes/plugins/pgvector/migrations/002_agent_attribution.sql
170
- # (or apply every migration in order: hermes-pgvector migrate --admin-dsn "user=postgres host=/var/run/postgresql dbname=<your-memory-db>")
171
206
 
172
- # Hand ownership of the v0.1 tables to the hermes runtime role
173
- sudo -u postgres psql -d <your-memory-db> -c "
174
- ALTER TABLE memory_entries OWNER TO hermes;
175
- ALTER SEQUENCE memory_entries_id_seq OWNER TO hermes;
176
- ALTER TABLE conversations OWNER TO hermes;
177
- ALTER SEQUENCE conversations_id_seq OWNER TO hermes;
178
- "
207
+ # v0.4.1: apply the hybrid-search full-text indexes (GIN over content on both
208
+ # tables). Optional — hybrid recall works without it, just seq-scans the FTS
209
+ # leg. No new tables/columns/GRANTs; needs no OWNER step.
210
+ sudo -u postgres psql -d <your-memory-db> \
211
+ -f ~/.hermes/plugins/pgvector/migrations/003_hybrid_search_fts.sql
212
+
213
+ # v0.4.2: grant the runtime role DML on the core tables (replaces the old
214
+ # manual "ALTER TABLE ... OWNER TO hermes" step; skips with a NOTICE if your
215
+ # runtime role isn't named 'hermes' — grant manually in that case).
216
+ sudo -u postgres psql -d <your-memory-db> \
217
+ -f ~/.hermes/plugins/pgvector/migrations/004_runtime_grants.sql
218
+ # (or apply every migration in order: hermes-pgvector migrate --admin-dsn "user=postgres host=/var/run/postgresql dbname=<your-memory-db>")
179
219
 
180
220
  # Activate
181
221
  hermes config set memory.provider pgvector
@@ -299,4 +339,4 @@ Bug reports + PRs welcome. Open an issue describing the failure mode + your envi
299
339
 
300
340
  ## License
301
341
 
302
- [BSD 3-Clause](LICENSE) © 2026 Andrea Borghi.
342
+ [BSD 3-Clause](LICENSE) © 2026 Green Yoga Inc
@@ -73,6 +73,21 @@ Four capabilities, all storage-layer (still no LLM in the hot path):
73
73
 
74
74
  Maintenance CLI (`python -m pgvector`, installed as `hermes-pgvector`): `migrate · stats · backfill · prune · cleanup · remap` — destructive commands default to dry-run. v0.4.0 is a clean upgrade from v0.3.x: apply migration `002` to light up attribution/delegation; without it the new hooks no-op and everything else runs unchanged.
75
75
 
76
+ ## New in v0.4.1 — hybrid recall (vector + full-text)
77
+
78
+ `recall_memory` and `recall_conversation` now fuse the HNSW **vector** ranking with a Postgres **full-text** ranking using **Reciprocal Rank Fusion** (RRF, `k=60`). A row surfaces if *either* ranker likes it, which fixes the two blind spots of pure cosine similarity:
79
+
80
+ - **Exact-lexical hits** the embedding smooths away — a specific error code, hostname, flag name, or rare identifier the agent quotes verbatim.
81
+ - **Text-only rows with a `NULL` embedding** (written while the embed endpoint was down) — invisible to the vector index, but the full-text leg finds them. So hybrid recall doubles as best-effort recovery until the next `backfill`.
82
+
83
+ Still a storage-layer feature: **no LLM, no entity graph, no new tables or columns** — just a GIN index over the existing `content` column (migration `003`) and a fused query. It stays inside invariant #1 (a second index over the same text is not a parallel ontology). Fail-soft as ever: a hybrid hiccup degrades to the proven pure-vector path, and a query that *itself* fails to embed degrades to full-text-only instead of erroring. Toggle with `plugins.pgvector.hybrid_search` (default `true`); the ambient `prefetch()` path stays pure-vector. Works without migration `003` — the GIN index only makes the full-text leg faster.
84
+
85
+ ## New in v0.4.2 — pip-native install + hardening
86
+
87
+ - **`hermes-pgvector install`** — makes a plain `pip install hermes-memory-pgvector` deployable on ANY hermes-agent install: generates the `$HERMES_HOME/plugins/pgvector/` discovery shim (see *Install · Option 1*). No more vendored copies or editable checkouts.
88
+ - **Migration `004`** — `hermes-pgvector migrate` now grants the runtime role DML on `memory_entries`/`conversations` itself; the manual OWNER-transfer step is gone (fresh installs previously hit `permission denied` if it was skipped).
89
+ - **Correctness fixes** from a full-codebase review: `replace`/`remove` now match `old_text` as a *literal* substring (LIKE `%`/`_`/`\` metacharacters no longer over- or under-match — parity with the built-in tool's `in` semantics); the async writer drains its queue on shutdown instead of silently abandoning up to 255 accepted writes when full; a wrong-dimension embed model now surfaces as `expected 768 dims, got N` instead of a masking 404; DM-key bucketing no longer sweeps ordinary `:signal:`-containing theme names into `whatsapp-dm`; bulk MEMORY.md import circuit-breaks after 3 consecutive embed failures (a hanging endpoint can no longer block session start for minutes); `remap` re-checks its duplicate-drop guard under the advisory lock; tool errors redact credential-looking fragments and preserve `score: null` for full-text-only hybrid hits (with `rrf_score` now included); `recall_memory(scope='session')` returns a helpful error instead of silently matching nothing.
90
+
76
91
  ## Multi-agent / per-minion themes
77
92
 
78
93
  Each systemd-run minion sets one header on its OpenAI client; everything else flows automatically:
@@ -97,7 +112,28 @@ Set `plugins.pgvector.allowed_themes` to that product/worker list to enforce it
97
112
 
98
113
  ## Install
99
114
 
100
- ### Option 1: clone + run the installer script (recommended)
115
+ ### Option 1: pip + discovery shim (recommended, v0.4.2+)
116
+
117
+ ```bash
118
+ # 1. Install the package into the SAME environment hermes-agent runs in
119
+ pip install hermes-memory-pgvector
120
+
121
+ # 2. Create the discovery shim
122
+ hermes-pgvector install # writes $HERMES_HOME/plugins/pgvector/
123
+
124
+ # 3. Apply ALL migrations (schema + attribution + FTS + runtime grants)
125
+ hermes-pgvector migrate --admin-dsn \
126
+ "dbname=<your-memory-db> user=postgres host=/var/run/postgresql"
127
+
128
+ # 4. Activate + verify
129
+ hermes config set memory.provider pgvector
130
+ sudo systemctl restart hermes.service
131
+ hermes memory status # expect: Provider: pgvector; Status: available
132
+ ```
133
+
134
+ **Why the shim?** hermes-agent discovers memory providers by scanning plugin *directories* — `plugins/memory/<name>/` (bundled) and `$HERMES_HOME/plugins/<name>/` (user) — and never looks at installed packages. `pip install` alone is therefore invisible to it. `hermes-pgvector install` writes a two-line shim whose absolute import resolves to the pip-installed package, so upgrades are just `pip install -U hermes-memory-pgvector` + restart, and rollback is `pip install hermes-memory-pgvector==<prev>` + restart — the shim never changes. `--remove` deletes it; if the package is uninstalled the shim import fails cleanly and hermes falls back to built-in memory.
135
+
136
+ ### Option 2: clone + run the installer script (from source)
101
137
 
102
138
  ```bash
103
139
  git clone https://github.com/andreab67/hermes-memory-pgvector.git
@@ -111,7 +147,7 @@ That:
111
147
  2. Copies `pgvector/` into `$HERMES_HOME/plugins/pgvector/` (defaults to `~/.hermes/plugins/pgvector/`).
112
148
  3. Prints the admin migration + activation commands you run next.
113
149
 
114
- ### Option 2: manual
150
+ ### Option 3: manual
115
151
 
116
152
  ```bash
117
153
  # Python deps
@@ -134,15 +170,19 @@ sudo -u postgres psql -d <your-memory-db> \
134
170
  # runtime role needs — it self-grants, so no extra OWNER step for these).
135
171
  sudo -u postgres psql -d <your-memory-db> \
136
172
  -f ~/.hermes/plugins/pgvector/migrations/002_agent_attribution.sql
137
- # (or apply every migration in order: hermes-pgvector migrate --admin-dsn "user=postgres host=/var/run/postgresql dbname=<your-memory-db>")
138
173
 
139
- # Hand ownership of the v0.1 tables to the hermes runtime role
140
- sudo -u postgres psql -d <your-memory-db> -c "
141
- ALTER TABLE memory_entries OWNER TO hermes;
142
- ALTER SEQUENCE memory_entries_id_seq OWNER TO hermes;
143
- ALTER TABLE conversations OWNER TO hermes;
144
- ALTER SEQUENCE conversations_id_seq OWNER TO hermes;
145
- "
174
+ # v0.4.1: apply the hybrid-search full-text indexes (GIN over content on both
175
+ # tables). Optional — hybrid recall works without it, just seq-scans the FTS
176
+ # leg. No new tables/columns/GRANTs; needs no OWNER step.
177
+ sudo -u postgres psql -d <your-memory-db> \
178
+ -f ~/.hermes/plugins/pgvector/migrations/003_hybrid_search_fts.sql
179
+
180
+ # v0.4.2: grant the runtime role DML on the core tables (replaces the old
181
+ # manual "ALTER TABLE ... OWNER TO hermes" step; skips with a NOTICE if your
182
+ # runtime role isn't named 'hermes' — grant manually in that case).
183
+ sudo -u postgres psql -d <your-memory-db> \
184
+ -f ~/.hermes/plugins/pgvector/migrations/004_runtime_grants.sql
185
+ # (or apply every migration in order: hermes-pgvector migrate --admin-dsn "user=postgres host=/var/run/postgresql dbname=<your-memory-db>")
146
186
 
147
187
  # Activate
148
188
  hermes config set memory.provider pgvector
@@ -266,4 +306,4 @@ Bug reports + PRs welcome. Open an issue describing the failure mode + your envi
266
306
 
267
307
  ## License
268
308
 
269
- [BSD 3-Clause](LICENSE) © 2026 Andrea Borghi.
309
+ [BSD 3-Clause](LICENSE) © 2026 Green Yoga Inc
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: hermes-memory-pgvector
3
- Version: 0.4.0
3
+ Version: 0.4.2
4
4
  Summary: Postgres + pgvector memory provider plugin for hermes-agent. Multi-agent storage layer with per-minion themes, identity governance, agent attribution + delegation provenance, async writer, no LLM in the memory hot path.
5
5
  Author: Andrea Borghi
6
6
  License: BSD-3-Clause
@@ -106,6 +106,21 @@ Four capabilities, all storage-layer (still no LLM in the hot path):
106
106
 
107
107
  Maintenance CLI (`python -m pgvector`, installed as `hermes-pgvector`): `migrate · stats · backfill · prune · cleanup · remap` — destructive commands default to dry-run. v0.4.0 is a clean upgrade from v0.3.x: apply migration `002` to light up attribution/delegation; without it the new hooks no-op and everything else runs unchanged.
108
108
 
109
+ ## New in v0.4.1 — hybrid recall (vector + full-text)
110
+
111
+ `recall_memory` and `recall_conversation` now fuse the HNSW **vector** ranking with a Postgres **full-text** ranking using **Reciprocal Rank Fusion** (RRF, `k=60`). A row surfaces if *either* ranker likes it, which fixes the two blind spots of pure cosine similarity:
112
+
113
+ - **Exact-lexical hits** the embedding smooths away — a specific error code, hostname, flag name, or rare identifier the agent quotes verbatim.
114
+ - **Text-only rows with a `NULL` embedding** (written while the embed endpoint was down) — invisible to the vector index, but the full-text leg finds them. So hybrid recall doubles as best-effort recovery until the next `backfill`.
115
+
116
+ Still a storage-layer feature: **no LLM, no entity graph, no new tables or columns** — just a GIN index over the existing `content` column (migration `003`) and a fused query. It stays inside invariant #1 (a second index over the same text is not a parallel ontology). Fail-soft as ever: a hybrid hiccup degrades to the proven pure-vector path, and a query that *itself* fails to embed degrades to full-text-only instead of erroring. Toggle with `plugins.pgvector.hybrid_search` (default `true`); the ambient `prefetch()` path stays pure-vector. Works without migration `003` — the GIN index only makes the full-text leg faster.
117
+
118
+ ## New in v0.4.2 — pip-native install + hardening
119
+
120
+ - **`hermes-pgvector install`** — makes a plain `pip install hermes-memory-pgvector` deployable on ANY hermes-agent install: generates the `$HERMES_HOME/plugins/pgvector/` discovery shim (see *Install · Option 1*). No more vendored copies or editable checkouts.
121
+ - **Migration `004`** — `hermes-pgvector migrate` now grants the runtime role DML on `memory_entries`/`conversations` itself; the manual OWNER-transfer step is gone (fresh installs previously hit `permission denied` if it was skipped).
122
+ - **Correctness fixes** from a full-codebase review: `replace`/`remove` now match `old_text` as a *literal* substring (LIKE `%`/`_`/`\` metacharacters no longer over- or under-match — parity with the built-in tool's `in` semantics); the async writer drains its queue on shutdown instead of silently abandoning up to 255 accepted writes when full; a wrong-dimension embed model now surfaces as `expected 768 dims, got N` instead of a masking 404; DM-key bucketing no longer sweeps ordinary `:signal:`-containing theme names into `whatsapp-dm`; bulk MEMORY.md import circuit-breaks after 3 consecutive embed failures (a hanging endpoint can no longer block session start for minutes); `remap` re-checks its duplicate-drop guard under the advisory lock; tool errors redact credential-looking fragments and preserve `score: null` for full-text-only hybrid hits (with `rrf_score` now included); `recall_memory(scope='session')` returns a helpful error instead of silently matching nothing.
123
+
109
124
  ## Multi-agent / per-minion themes
110
125
 
111
126
  Each systemd-run minion sets one header on its OpenAI client; everything else flows automatically:
@@ -130,7 +145,28 @@ Set `plugins.pgvector.allowed_themes` to that product/worker list to enforce it
130
145
 
131
146
  ## Install
132
147
 
133
- ### Option 1: clone + run the installer script (recommended)
148
+ ### Option 1: pip + discovery shim (recommended, v0.4.2+)
149
+
150
+ ```bash
151
+ # 1. Install the package into the SAME environment hermes-agent runs in
152
+ pip install hermes-memory-pgvector
153
+
154
+ # 2. Create the discovery shim
155
+ hermes-pgvector install # writes $HERMES_HOME/plugins/pgvector/
156
+
157
+ # 3. Apply ALL migrations (schema + attribution + FTS + runtime grants)
158
+ hermes-pgvector migrate --admin-dsn \
159
+ "dbname=<your-memory-db> user=postgres host=/var/run/postgresql"
160
+
161
+ # 4. Activate + verify
162
+ hermes config set memory.provider pgvector
163
+ sudo systemctl restart hermes.service
164
+ hermes memory status # expect: Provider: pgvector; Status: available
165
+ ```
166
+
167
+ **Why the shim?** hermes-agent discovers memory providers by scanning plugin *directories* — `plugins/memory/<name>/` (bundled) and `$HERMES_HOME/plugins/<name>/` (user) — and never looks at installed packages. `pip install` alone is therefore invisible to it. `hermes-pgvector install` writes a two-line shim whose absolute import resolves to the pip-installed package, so upgrades are just `pip install -U hermes-memory-pgvector` + restart, and rollback is `pip install hermes-memory-pgvector==<prev>` + restart — the shim never changes. `--remove` deletes it; if the package is uninstalled the shim import fails cleanly and hermes falls back to built-in memory.
168
+
169
+ ### Option 2: clone + run the installer script (from source)
134
170
 
135
171
  ```bash
136
172
  git clone https://github.com/andreab67/hermes-memory-pgvector.git
@@ -144,7 +180,7 @@ That:
144
180
  2. Copies `pgvector/` into `$HERMES_HOME/plugins/pgvector/` (defaults to `~/.hermes/plugins/pgvector/`).
145
181
  3. Prints the admin migration + activation commands you run next.
146
182
 
147
- ### Option 2: manual
183
+ ### Option 3: manual
148
184
 
149
185
  ```bash
150
186
  # Python deps
@@ -167,15 +203,19 @@ sudo -u postgres psql -d <your-memory-db> \
167
203
  # runtime role needs — it self-grants, so no extra OWNER step for these).
168
204
  sudo -u postgres psql -d <your-memory-db> \
169
205
  -f ~/.hermes/plugins/pgvector/migrations/002_agent_attribution.sql
170
- # (or apply every migration in order: hermes-pgvector migrate --admin-dsn "user=postgres host=/var/run/postgresql dbname=<your-memory-db>")
171
206
 
172
- # Hand ownership of the v0.1 tables to the hermes runtime role
173
- sudo -u postgres psql -d <your-memory-db> -c "
174
- ALTER TABLE memory_entries OWNER TO hermes;
175
- ALTER SEQUENCE memory_entries_id_seq OWNER TO hermes;
176
- ALTER TABLE conversations OWNER TO hermes;
177
- ALTER SEQUENCE conversations_id_seq OWNER TO hermes;
178
- "
207
+ # v0.4.1: apply the hybrid-search full-text indexes (GIN over content on both
208
+ # tables). Optional — hybrid recall works without it, just seq-scans the FTS
209
+ # leg. No new tables/columns/GRANTs; needs no OWNER step.
210
+ sudo -u postgres psql -d <your-memory-db> \
211
+ -f ~/.hermes/plugins/pgvector/migrations/003_hybrid_search_fts.sql
212
+
213
+ # v0.4.2: grant the runtime role DML on the core tables (replaces the old
214
+ # manual "ALTER TABLE ... OWNER TO hermes" step; skips with a NOTICE if your
215
+ # runtime role isn't named 'hermes' — grant manually in that case).
216
+ sudo -u postgres psql -d <your-memory-db> \
217
+ -f ~/.hermes/plugins/pgvector/migrations/004_runtime_grants.sql
218
+ # (or apply every migration in order: hermes-pgvector migrate --admin-dsn "user=postgres host=/var/run/postgresql dbname=<your-memory-db>")
179
219
 
180
220
  # Activate
181
221
  hermes config set memory.provider pgvector
@@ -299,4 +339,4 @@ Bug reports + PRs welcome. Open an issue describing the failure mode + your envi
299
339
 
300
340
  ## License
301
341
 
302
- [BSD 3-Clause](LICENSE) © 2026 Andrea Borghi.
342
+ [BSD 3-Clause](LICENSE) © 2026 Green Yoga Inc
@@ -16,6 +16,10 @@ pgvector/store.py
16
16
  pgvector/writer.py
17
17
  pgvector/migrations/001_schema.sql
18
18
  pgvector/migrations/002_agent_attribution.sql
19
+ pgvector/migrations/003_hybrid_search_fts.sql
20
+ pgvector/migrations/004_runtime_grants.sql
21
+ tests/test_hybrid_search.py
19
22
  tests/test_identity.py
23
+ tests/test_install_shim.py
20
24
  tests/test_smoke.py
21
25
  tests/test_store_v04.py
@@ -23,6 +23,7 @@ Config in $HERMES_HOME/config.yaml under plugins.pgvector:
23
23
  min_similarity: 0.30
24
24
  embed_on_write: true
25
25
  scope_default: "current" # 'current' | 'all'
26
+ hybrid_search: true # fuse vector + full-text (RRF) in recall tools
26
27
 
27
28
  Tools exposed: `recall_memory` (one explicit search tool). All built-in
28
29
  memory writes (add/replace/remove) are mirrored automatically via the
@@ -83,6 +84,17 @@ _NOISE_RE = re.compile(
83
84
  logger = logging.getLogger(__name__)
84
85
 
85
86
 
87
+ # Credential-looking fragments that must never round-trip into a tool response
88
+ # (psycopg/libpq errors can echo conninfo verbatim; tool errors flow back to
89
+ # the model and can be persisted durably by conversation capture).
90
+ _CRED_RE = re.compile(r"(password|passfile|sslkey|sslpassword)=\S+", re.IGNORECASE)
91
+
92
+
93
+ def _safe_err(exc: BaseException) -> str:
94
+ """Exception text safe to return to the model: truncated + creds redacted."""
95
+ return _CRED_RE.sub(r"\1=[redacted]", str(exc)[:300])
96
+
97
+
86
98
  # ---------------------------------------------------------------------------
87
99
  # Tool schema — one explicit search over memory_entries
88
100
  # ---------------------------------------------------------------------------
@@ -182,12 +194,19 @@ DEFAULTS = {
182
194
  "min_similarity": 0.30,
183
195
  "embed_on_write": True,
184
196
  "scope_default": "current",
197
+ # v0.4.1 — hybrid recall: fuse the HNSW vector ranking with a Postgres
198
+ # full-text ranking via RRF in the recall_memory / recall_conversation
199
+ # tools. Recovers exact-lexical hits cosine smooths away + text-only rows
200
+ # with NULL embeddings. Degrades to pure vector on any error, and to
201
+ # full-text-only when the query fails to embed. Needs migration 003 for the
202
+ # GIN index (works without it, just slower — seq-scan FTS on a small table).
203
+ "hybrid_search": True,
185
204
  "write_queue_maxsize": 256,
186
205
  # v0.1.1 — bulk sync MEMORY.md / USER.md on init
187
206
  "bulk_sync_on_init": True,
188
207
  # v0.2 — conversation turn capture
189
208
  "sync_turns": True,
190
- "turn_min_chars": 40, # turns shorter than this are noise unless > 200 chars or contain tool refs
209
+ "turn_min_chars": 40, # turns shorter than this (after strip) are boilerplate noise
191
210
  # v0.4 — identity governance
192
211
  "allowed_themes": None, # None/empty = governance off; set a list to enforce an allow-list
193
212
  "identity_aliases": {}, # {raw: canonical} remaps applied before normalization
@@ -249,6 +268,11 @@ class PgvectorMemoryProvider(MemoryProvider):
249
268
 
250
269
  def initialize(self, session_id: str, **kwargs) -> None:
251
270
  self._session_id = session_id
271
+ # Per-session log-signal reset (v0.4.2): the provider instance is
272
+ # reused across sessions, so without this a single embed failure in
273
+ # any past session silences the one-time warning for the process
274
+ # lifetime — a later session with a broken endpoint gets zero signal.
275
+ self._embed_warned = False
252
276
  # Per-agent theme scoping — priority order:
253
277
  # 1. gateway_session_key — from the `X-Hermes-Session-Key` header on
254
278
  # API requests. This is the EXPLICIT minion-scope signal sent by
@@ -568,7 +592,7 @@ class PgvectorMemoryProvider(MemoryProvider):
568
592
  logger.debug("pgvector on_delegation failed (ignored): %s", exc)
569
593
 
570
594
  def on_session_end(self, messages: List[Dict[str, Any]]) -> None:
571
- """Best-effort: bump this agent's last_seen in the registry. Fail-soft,
595
+ """Best-effort: capture session turns + bump last_seen. Fail-soft,
572
596
  non-blocking. No LLM summary (invariant #2). No-op until 002 applied."""
573
597
  if not self._healthy or not self._writer or not self._delegation_enabled:
574
598
  return
@@ -581,6 +605,36 @@ class PgvectorMemoryProvider(MemoryProvider):
581
605
  extra={"kind": classify_kind(self._agent_identity)},
582
606
  metadata={},
583
607
  )
608
+ # Capture substantive user/assistant turns for conversation recall.
609
+ if messages:
610
+ policy = self._config.get("conversation_embed_policy", "all")
611
+ for msg in messages:
612
+ role = (msg.get("role") or "").lower()
613
+ if role not in ("user", "assistant"):
614
+ continue
615
+ raw = msg.get("content") or ""
616
+ content = (
617
+ raw if isinstance(raw, str)
618
+ else " ".join(
619
+ p.get("text", "") for p in raw
620
+ if isinstance(p, dict) and p.get("type") == "text"
621
+ ) if isinstance(raw, list) else str(raw)
622
+ )
623
+ if self._is_noise(content, min_chars=40):
624
+ continue
625
+ self._writer.enqueue(
626
+ action="turn",
627
+ agent_identity=self._agent_identity,
628
+ target="conversations",
629
+ content=content[:8000],
630
+ extra={
631
+ "role": role,
632
+ "session_id": self._session_id or "default",
633
+ "embed": self._should_embed_turn(role, content, policy),
634
+ "parent_session_id": self._parent_session_id,
635
+ },
636
+ metadata={},
637
+ )
584
638
  except Exception as exc: # noqa: BLE001
585
639
  logger.debug("pgvector on_session_end failed (ignored): %s", exc)
586
640
 
@@ -799,6 +853,14 @@ class PgvectorMemoryProvider(MemoryProvider):
799
853
  agent_filter: Optional[str] = self._agent_identity
800
854
  elif scope == "all":
801
855
  agent_filter = None
856
+ elif scope == "session":
857
+ # Only recall_conversation supports 'session'. Falling through
858
+ # would silently filter on a literal 'session' theme and return
859
+ # zero rows — indistinguishable from "nothing found" (v0.4.2).
860
+ return tool_error(
861
+ "scope='session' is only valid for recall_conversation; "
862
+ "use 'current', 'all', or a theme name here."
863
+ )
802
864
  else:
803
865
  agent_filter = scope
804
866
 
@@ -808,6 +870,7 @@ class PgvectorMemoryProvider(MemoryProvider):
808
870
  if target_filter not in (None, "memory", "user"):
809
871
  return tool_error(f"Invalid target: {target_arg!r}")
810
872
 
873
+ hybrid = bool(self._config.get("hybrid_search", True))
811
874
  try:
812
875
  vec = embed(
813
876
  query,
@@ -815,31 +878,63 @@ class PgvectorMemoryProvider(MemoryProvider):
815
878
  model=self._config["embed_model"],
816
879
  )
817
880
  except EmbeddingError as exc:
818
- return json.dumps({"results": [], "count": 0, "error": f"embed: {exc}"})
881
+ if not hybrid:
882
+ return json.dumps({"results": [], "count": 0, "error": f"embed: {_safe_err(exc)}"})
883
+ # Hybrid on: the query text still drives a full-text search without a
884
+ # vector, so degrade to lexical recall instead of erroring.
885
+ logger.debug("recall_memory embed failed; full-text-only: %s", exc)
886
+ vec = None
819
887
 
820
888
  try:
821
- rows = self._store.search(
822
- query_embedding=vec,
823
- agent_identity=agent_filter,
824
- target=target_filter,
825
- limit=limit,
826
- )
889
+ if hybrid:
890
+ rows = self._store.hybrid_search(
891
+ query_text=query,
892
+ query_embedding=vec,
893
+ agent_identity=agent_filter,
894
+ target=target_filter,
895
+ limit=limit,
896
+ )
897
+ else:
898
+ rows = self._store.search(
899
+ query_embedding=vec,
900
+ agent_identity=agent_filter,
901
+ target=target_filter,
902
+ limit=limit,
903
+ )
827
904
  except Exception as exc: # noqa: BLE001
828
- return json.dumps({"results": [], "count": 0, "error": f"db: {exc}"})
905
+ # Fail-soft: a hybrid hiccup with a usable vector falls back to the
906
+ # proven pure-vector path rather than returning nothing.
907
+ if hybrid and vec is not None:
908
+ try:
909
+ rows = self._store.search(
910
+ query_embedding=vec,
911
+ agent_identity=agent_filter,
912
+ target=target_filter,
913
+ limit=limit,
914
+ )
915
+ except Exception as exc2: # noqa: BLE001
916
+ return json.dumps({"results": [], "count": 0, "error": f"db: {_safe_err(exc2)}"})
917
+ else:
918
+ return json.dumps({"results": [], "count": 0, "error": f"db: {_safe_err(exc)}"})
829
919
 
830
920
  results = []
831
921
  for r in rows:
832
922
  ts = r.get("updated_at") or r.get("created_at")
833
- results.append(
834
- {
835
- "id": r.get("id"),
836
- "agent_identity": r.get("agent_identity"),
837
- "target": r.get("target"),
838
- "ts": ts.isoformat() if ts else None,
839
- "score": round(float(r.get("score") or 0.0), 4),
840
- "content": (r.get("content") or "")[:2000],
841
- }
842
- )
923
+ score = r.get("score")
924
+ entry = {
925
+ "id": r.get("id"),
926
+ "agent_identity": r.get("agent_identity"),
927
+ "target": r.get("target"),
928
+ "ts": ts.isoformat() if ts else None,
929
+ # None stays None (v0.4.2): a full-text-only hybrid hit has no
930
+ # cosine score; flattening it to 0.0 made a strong lexical
931
+ # match look identical to an orthogonal vector match.
932
+ "score": round(float(score), 4) if score is not None else None,
933
+ "content": (r.get("content") or "")[:2000],
934
+ }
935
+ if r.get("rrf_score") is not None:
936
+ entry["rrf_score"] = round(float(r["rrf_score"]), 6)
937
+ results.append(entry)
843
938
  return json.dumps({"results": results, "count": len(results)})
844
939
 
845
940
  def _handle_recall_conversation(self, args: Dict[str, Any]) -> str:
@@ -867,6 +962,7 @@ class PgvectorMemoryProvider(MemoryProvider):
867
962
  else:
868
963
  agent_filter = scope # treat as a specific theme name
869
964
 
965
+ hybrid = bool(self._config.get("hybrid_search", True))
870
966
  try:
871
967
  vec = embed(
872
968
  query,
@@ -874,32 +970,57 @@ class PgvectorMemoryProvider(MemoryProvider):
874
970
  model=self._config["embed_model"],
875
971
  )
876
972
  except EmbeddingError as exc:
877
- return json.dumps({"results": [], "count": 0, "error": f"embed: {exc}"})
973
+ if not hybrid:
974
+ return json.dumps({"results": [], "count": 0, "error": f"embed: {_safe_err(exc)}"})
975
+ logger.debug("recall_conversation embed failed; full-text-only: %s", exc)
976
+ vec = None
878
977
 
879
978
  try:
880
- rows = self._store.search_turns(
881
- query_embedding=vec,
882
- agent_identity=agent_filter,
883
- session_id=session_filter,
884
- limit=limit,
885
- )
979
+ if hybrid:
980
+ rows = self._store.hybrid_search_turns(
981
+ query_text=query,
982
+ query_embedding=vec,
983
+ agent_identity=agent_filter,
984
+ session_id=session_filter,
985
+ limit=limit,
986
+ )
987
+ else:
988
+ rows = self._store.search_turns(
989
+ query_embedding=vec,
990
+ agent_identity=agent_filter,
991
+ session_id=session_filter,
992
+ limit=limit,
993
+ )
886
994
  except Exception as exc: # noqa: BLE001
887
- return json.dumps({"results": [], "count": 0, "error": f"db: {exc}"})
995
+ if hybrid and vec is not None:
996
+ try:
997
+ rows = self._store.search_turns(
998
+ query_embedding=vec,
999
+ agent_identity=agent_filter,
1000
+ session_id=session_filter,
1001
+ limit=limit,
1002
+ )
1003
+ except Exception as exc2: # noqa: BLE001
1004
+ return json.dumps({"results": [], "count": 0, "error": f"db: {_safe_err(exc2)}"})
1005
+ else:
1006
+ return json.dumps({"results": [], "count": 0, "error": f"db: {_safe_err(exc)}"})
888
1007
 
889
1008
  results = []
890
1009
  for r in rows:
891
1010
  ts = r.get("ts")
892
- results.append(
893
- {
894
- "id": r.get("id"),
895
- "agent_identity": r.get("agent_identity"),
896
- "session_id": r.get("session_id"),
897
- "role": r.get("role"),
898
- "ts": ts.isoformat() if ts else None,
899
- "score": round(float(r.get("score") or 0.0), 4),
900
- "content": (r.get("content") or "")[:2000],
901
- }
902
- )
1011
+ score = r.get("score")
1012
+ entry = {
1013
+ "id": r.get("id"),
1014
+ "agent_identity": r.get("agent_identity"),
1015
+ "session_id": r.get("session_id"),
1016
+ "role": r.get("role"),
1017
+ "ts": ts.isoformat() if ts else None,
1018
+ "score": round(float(score), 4) if score is not None else None,
1019
+ "content": (r.get("content") or "")[:2000],
1020
+ }
1021
+ if r.get("rrf_score") is not None:
1022
+ entry["rrf_score"] = round(float(r["rrf_score"]), 6)
1023
+ results.append(entry)
903
1024
  return json.dumps({"results": results, "count": len(results)})
904
1025
 
905
1026
  # -- Setup hooks ---------------------------------------------------------
@@ -945,6 +1066,12 @@ class PgvectorMemoryProvider(MemoryProvider):
945
1066
  "default": DEFAULTS["scope_default"],
946
1067
  "choices": ["current", "all"],
947
1068
  },
1069
+ {
1070
+ "key": "hybrid_search",
1071
+ "description": "v0.4.1: fuse the HNSW vector ranking with a Postgres full-text ranking (Reciprocal Rank Fusion) in recall_memory / recall_conversation. Recovers exact-lexical hits cosine smooths away and text-only rows with NULL embeddings; degrades to pure vector on error and to full-text-only when the query fails to embed. Apply migration 003 for the GIN index (works without it, just slower).",
1072
+ "default": "true",
1073
+ "choices": ["true", "false"],
1074
+ },
948
1075
  {
949
1076
  "key": "write_queue_maxsize",
950
1077
  "description": "Bounded async-writer queue size; full = oldest writes drop with a warning",