hermes-memory-pgvector 0.4.1__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.1 → hermes_memory_pgvector-0.4.2}/PKG-INFO +37 -12
  2. {hermes_memory_pgvector-0.4.1 → hermes_memory_pgvector-0.4.2}/README.md +36 -11
  3. {hermes_memory_pgvector-0.4.1 → hermes_memory_pgvector-0.4.2}/hermes_memory_pgvector.egg-info/PKG-INFO +37 -12
  4. {hermes_memory_pgvector-0.4.1 → hermes_memory_pgvector-0.4.2}/hermes_memory_pgvector.egg-info/SOURCES.txt +2 -0
  5. {hermes_memory_pgvector-0.4.1 → hermes_memory_pgvector-0.4.2}/pgvector/__init__.py +59 -28
  6. {hermes_memory_pgvector-0.4.1 → hermes_memory_pgvector-0.4.2}/pgvector/__main__.py +149 -1
  7. {hermes_memory_pgvector-0.4.1 → hermes_memory_pgvector-0.4.2}/pgvector/embed.py +16 -1
  8. {hermes_memory_pgvector-0.4.1 → hermes_memory_pgvector-0.4.2}/pgvector/identity.py +11 -2
  9. hermes_memory_pgvector-0.4.2/pgvector/migrations/004_runtime_grants.sql +36 -0
  10. {hermes_memory_pgvector-0.4.1 → hermes_memory_pgvector-0.4.2}/pgvector/plugin.yaml +1 -1
  11. {hermes_memory_pgvector-0.4.1 → hermes_memory_pgvector-0.4.2}/pgvector/store.py +78 -21
  12. {hermes_memory_pgvector-0.4.1 → hermes_memory_pgvector-0.4.2}/pgvector/writer.py +27 -5
  13. {hermes_memory_pgvector-0.4.1 → hermes_memory_pgvector-0.4.2}/pyproject.toml +1 -1
  14. {hermes_memory_pgvector-0.4.1 → hermes_memory_pgvector-0.4.2}/tests/test_identity.py +20 -3
  15. hermes_memory_pgvector-0.4.2/tests/test_install_shim.py +74 -0
  16. {hermes_memory_pgvector-0.4.1 → hermes_memory_pgvector-0.4.2}/tests/test_smoke.py +17 -7
  17. {hermes_memory_pgvector-0.4.1 → hermes_memory_pgvector-0.4.2}/tests/test_store_v04.py +25 -0
  18. {hermes_memory_pgvector-0.4.1 → hermes_memory_pgvector-0.4.2}/LICENSE +0 -0
  19. {hermes_memory_pgvector-0.4.1 → hermes_memory_pgvector-0.4.2}/hermes_memory_pgvector.egg-info/dependency_links.txt +0 -0
  20. {hermes_memory_pgvector-0.4.1 → hermes_memory_pgvector-0.4.2}/hermes_memory_pgvector.egg-info/entry_points.txt +0 -0
  21. {hermes_memory_pgvector-0.4.1 → hermes_memory_pgvector-0.4.2}/hermes_memory_pgvector.egg-info/requires.txt +0 -0
  22. {hermes_memory_pgvector-0.4.1 → hermes_memory_pgvector-0.4.2}/hermes_memory_pgvector.egg-info/top_level.txt +0 -0
  23. {hermes_memory_pgvector-0.4.1 → hermes_memory_pgvector-0.4.2}/pgvector/migrations/001_schema.sql +0 -0
  24. {hermes_memory_pgvector-0.4.1 → hermes_memory_pgvector-0.4.2}/pgvector/migrations/002_agent_attribution.sql +0 -0
  25. {hermes_memory_pgvector-0.4.1 → hermes_memory_pgvector-0.4.2}/pgvector/migrations/003_hybrid_search_fts.sql +0 -0
  26. {hermes_memory_pgvector-0.4.1 → hermes_memory_pgvector-0.4.2}/setup.cfg +0 -0
  27. {hermes_memory_pgvector-0.4.1 → hermes_memory_pgvector-0.4.2}/tests/test_hybrid_search.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: hermes-memory-pgvector
3
- Version: 0.4.1
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
@@ -115,6 +115,12 @@ Maintenance CLI (`python -m pgvector`, installed as `hermes-pgvector`): `migrate
115
115
 
116
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
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
+
118
124
  ## Multi-agent / per-minion themes
119
125
 
120
126
  Each systemd-run minion sets one header on its OpenAI client; everything else flows automatically:
@@ -139,7 +145,28 @@ Set `plugins.pgvector.allowed_themes` to that product/worker list to enforce it
139
145
 
140
146
  ## Install
141
147
 
142
- ### 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)
143
170
 
144
171
  ```bash
145
172
  git clone https://github.com/andreab67/hermes-memory-pgvector.git
@@ -153,7 +180,7 @@ That:
153
180
  2. Copies `pgvector/` into `$HERMES_HOME/plugins/pgvector/` (defaults to `~/.hermes/plugins/pgvector/`).
154
181
  3. Prints the admin migration + activation commands you run next.
155
182
 
156
- ### Option 2: manual
183
+ ### Option 3: manual
157
184
 
158
185
  ```bash
159
186
  # Python deps
@@ -182,15 +209,13 @@ sudo -u postgres psql -d <your-memory-db> \
182
209
  # leg. No new tables/columns/GRANTs; needs no OWNER step.
183
210
  sudo -u postgres psql -d <your-memory-db> \
184
211
  -f ~/.hermes/plugins/pgvector/migrations/003_hybrid_search_fts.sql
185
- # (or apply every migration in order: hermes-pgvector migrate --admin-dsn "user=postgres host=/var/run/postgresql dbname=<your-memory-db>")
186
212
 
187
- # Hand ownership of the v0.1 tables to the hermes runtime role
188
- sudo -u postgres psql -d <your-memory-db> -c "
189
- ALTER TABLE memory_entries OWNER TO hermes;
190
- ALTER SEQUENCE memory_entries_id_seq OWNER TO hermes;
191
- ALTER TABLE conversations OWNER TO hermes;
192
- ALTER SEQUENCE conversations_id_seq OWNER TO hermes;
193
- "
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>")
194
219
 
195
220
  # Activate
196
221
  hermes config set memory.provider pgvector
@@ -314,4 +339,4 @@ Bug reports + PRs welcome. Open an issue describing the failure mode + your envi
314
339
 
315
340
  ## License
316
341
 
317
- [BSD 3-Clause](LICENSE) © 2026 Andrea Borghi.
342
+ [BSD 3-Clause](LICENSE) © 2026 Green Yoga Inc
@@ -82,6 +82,12 @@ Maintenance CLI (`python -m pgvector`, installed as `hermes-pgvector`): `migrate
82
82
 
83
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
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
+
85
91
  ## Multi-agent / per-minion themes
86
92
 
87
93
  Each systemd-run minion sets one header on its OpenAI client; everything else flows automatically:
@@ -106,7 +112,28 @@ Set `plugins.pgvector.allowed_themes` to that product/worker list to enforce it
106
112
 
107
113
  ## Install
108
114
 
109
- ### 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)
110
137
 
111
138
  ```bash
112
139
  git clone https://github.com/andreab67/hermes-memory-pgvector.git
@@ -120,7 +147,7 @@ That:
120
147
  2. Copies `pgvector/` into `$HERMES_HOME/plugins/pgvector/` (defaults to `~/.hermes/plugins/pgvector/`).
121
148
  3. Prints the admin migration + activation commands you run next.
122
149
 
123
- ### Option 2: manual
150
+ ### Option 3: manual
124
151
 
125
152
  ```bash
126
153
  # Python deps
@@ -149,15 +176,13 @@ sudo -u postgres psql -d <your-memory-db> \
149
176
  # leg. No new tables/columns/GRANTs; needs no OWNER step.
150
177
  sudo -u postgres psql -d <your-memory-db> \
151
178
  -f ~/.hermes/plugins/pgvector/migrations/003_hybrid_search_fts.sql
152
- # (or apply every migration in order: hermes-pgvector migrate --admin-dsn "user=postgres host=/var/run/postgresql dbname=<your-memory-db>")
153
179
 
154
- # Hand ownership of the v0.1 tables to the hermes runtime role
155
- sudo -u postgres psql -d <your-memory-db> -c "
156
- ALTER TABLE memory_entries OWNER TO hermes;
157
- ALTER SEQUENCE memory_entries_id_seq OWNER TO hermes;
158
- ALTER TABLE conversations OWNER TO hermes;
159
- ALTER SEQUENCE conversations_id_seq OWNER TO hermes;
160
- "
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>")
161
186
 
162
187
  # Activate
163
188
  hermes config set memory.provider pgvector
@@ -281,4 +306,4 @@ Bug reports + PRs welcome. Open an issue describing the failure mode + your envi
281
306
 
282
307
  ## License
283
308
 
284
- [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.1
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
@@ -115,6 +115,12 @@ Maintenance CLI (`python -m pgvector`, installed as `hermes-pgvector`): `migrate
115
115
 
116
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
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
+
118
124
  ## Multi-agent / per-minion themes
119
125
 
120
126
  Each systemd-run minion sets one header on its OpenAI client; everything else flows automatically:
@@ -139,7 +145,28 @@ Set `plugins.pgvector.allowed_themes` to that product/worker list to enforce it
139
145
 
140
146
  ## Install
141
147
 
142
- ### 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)
143
170
 
144
171
  ```bash
145
172
  git clone https://github.com/andreab67/hermes-memory-pgvector.git
@@ -153,7 +180,7 @@ That:
153
180
  2. Copies `pgvector/` into `$HERMES_HOME/plugins/pgvector/` (defaults to `~/.hermes/plugins/pgvector/`).
154
181
  3. Prints the admin migration + activation commands you run next.
155
182
 
156
- ### Option 2: manual
183
+ ### Option 3: manual
157
184
 
158
185
  ```bash
159
186
  # Python deps
@@ -182,15 +209,13 @@ sudo -u postgres psql -d <your-memory-db> \
182
209
  # leg. No new tables/columns/GRANTs; needs no OWNER step.
183
210
  sudo -u postgres psql -d <your-memory-db> \
184
211
  -f ~/.hermes/plugins/pgvector/migrations/003_hybrid_search_fts.sql
185
- # (or apply every migration in order: hermes-pgvector migrate --admin-dsn "user=postgres host=/var/run/postgresql dbname=<your-memory-db>")
186
212
 
187
- # Hand ownership of the v0.1 tables to the hermes runtime role
188
- sudo -u postgres psql -d <your-memory-db> -c "
189
- ALTER TABLE memory_entries OWNER TO hermes;
190
- ALTER SEQUENCE memory_entries_id_seq OWNER TO hermes;
191
- ALTER TABLE conversations OWNER TO hermes;
192
- ALTER SEQUENCE conversations_id_seq OWNER TO hermes;
193
- "
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>")
194
219
 
195
220
  # Activate
196
221
  hermes config set memory.provider pgvector
@@ -314,4 +339,4 @@ Bug reports + PRs welcome. Open an issue describing the failure mode + your envi
314
339
 
315
340
  ## License
316
341
 
317
- [BSD 3-Clause](LICENSE) © 2026 Andrea Borghi.
342
+ [BSD 3-Clause](LICENSE) © 2026 Green Yoga Inc
@@ -17,7 +17,9 @@ pgvector/writer.py
17
17
  pgvector/migrations/001_schema.sql
18
18
  pgvector/migrations/002_agent_attribution.sql
19
19
  pgvector/migrations/003_hybrid_search_fts.sql
20
+ pgvector/migrations/004_runtime_grants.sql
20
21
  tests/test_hybrid_search.py
21
22
  tests/test_identity.py
23
+ tests/test_install_shim.py
22
24
  tests/test_smoke.py
23
25
  tests/test_store_v04.py
@@ -84,6 +84,17 @@ _NOISE_RE = re.compile(
84
84
  logger = logging.getLogger(__name__)
85
85
 
86
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
+
87
98
  # ---------------------------------------------------------------------------
88
99
  # Tool schema — one explicit search over memory_entries
89
100
  # ---------------------------------------------------------------------------
@@ -195,7 +206,7 @@ DEFAULTS = {
195
206
  "bulk_sync_on_init": True,
196
207
  # v0.2 — conversation turn capture
197
208
  "sync_turns": True,
198
- "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
199
210
  # v0.4 — identity governance
200
211
  "allowed_themes": None, # None/empty = governance off; set a list to enforce an allow-list
201
212
  "identity_aliases": {}, # {raw: canonical} remaps applied before normalization
@@ -257,6 +268,11 @@ class PgvectorMemoryProvider(MemoryProvider):
257
268
 
258
269
  def initialize(self, session_id: str, **kwargs) -> None:
259
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
260
276
  # Per-agent theme scoping — priority order:
261
277
  # 1. gateway_session_key — from the `X-Hermes-Session-Key` header on
262
278
  # API requests. This is the EXPLICIT minion-scope signal sent by
@@ -837,6 +853,14 @@ class PgvectorMemoryProvider(MemoryProvider):
837
853
  agent_filter: Optional[str] = self._agent_identity
838
854
  elif scope == "all":
839
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
+ )
840
864
  else:
841
865
  agent_filter = scope
842
866
 
@@ -855,7 +879,7 @@ class PgvectorMemoryProvider(MemoryProvider):
855
879
  )
856
880
  except EmbeddingError as exc:
857
881
  if not hybrid:
858
- return json.dumps({"results": [], "count": 0, "error": f"embed: {exc}"})
882
+ return json.dumps({"results": [], "count": 0, "error": f"embed: {_safe_err(exc)}"})
859
883
  # Hybrid on: the query text still drives a full-text search without a
860
884
  # vector, so degrade to lexical recall instead of erroring.
861
885
  logger.debug("recall_memory embed failed; full-text-only: %s", exc)
@@ -889,23 +913,28 @@ class PgvectorMemoryProvider(MemoryProvider):
889
913
  limit=limit,
890
914
  )
891
915
  except Exception as exc2: # noqa: BLE001
892
- return json.dumps({"results": [], "count": 0, "error": f"db: {exc2}"})
916
+ return json.dumps({"results": [], "count": 0, "error": f"db: {_safe_err(exc2)}"})
893
917
  else:
894
- return json.dumps({"results": [], "count": 0, "error": f"db: {exc}"})
918
+ return json.dumps({"results": [], "count": 0, "error": f"db: {_safe_err(exc)}"})
895
919
 
896
920
  results = []
897
921
  for r in rows:
898
922
  ts = r.get("updated_at") or r.get("created_at")
899
- results.append(
900
- {
901
- "id": r.get("id"),
902
- "agent_identity": r.get("agent_identity"),
903
- "target": r.get("target"),
904
- "ts": ts.isoformat() if ts else None,
905
- "score": round(float(r.get("score") or 0.0), 4),
906
- "content": (r.get("content") or "")[:2000],
907
- }
908
- )
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)
909
938
  return json.dumps({"results": results, "count": len(results)})
910
939
 
911
940
  def _handle_recall_conversation(self, args: Dict[str, Any]) -> str:
@@ -942,7 +971,7 @@ class PgvectorMemoryProvider(MemoryProvider):
942
971
  )
943
972
  except EmbeddingError as exc:
944
973
  if not hybrid:
945
- return json.dumps({"results": [], "count": 0, "error": f"embed: {exc}"})
974
+ return json.dumps({"results": [], "count": 0, "error": f"embed: {_safe_err(exc)}"})
946
975
  logger.debug("recall_conversation embed failed; full-text-only: %s", exc)
947
976
  vec = None
948
977
 
@@ -972,24 +1001,26 @@ class PgvectorMemoryProvider(MemoryProvider):
972
1001
  limit=limit,
973
1002
  )
974
1003
  except Exception as exc2: # noqa: BLE001
975
- return json.dumps({"results": [], "count": 0, "error": f"db: {exc2}"})
1004
+ return json.dumps({"results": [], "count": 0, "error": f"db: {_safe_err(exc2)}"})
976
1005
  else:
977
- return json.dumps({"results": [], "count": 0, "error": f"db: {exc}"})
1006
+ return json.dumps({"results": [], "count": 0, "error": f"db: {_safe_err(exc)}"})
978
1007
 
979
1008
  results = []
980
1009
  for r in rows:
981
1010
  ts = r.get("ts")
982
- results.append(
983
- {
984
- "id": r.get("id"),
985
- "agent_identity": r.get("agent_identity"),
986
- "session_id": r.get("session_id"),
987
- "role": r.get("role"),
988
- "ts": ts.isoformat() if ts else None,
989
- "score": round(float(r.get("score") or 0.0), 4),
990
- "content": (r.get("content") or "")[:2000],
991
- }
992
- )
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)
993
1024
  return json.dumps({"results": results, "count": len(results)})
994
1025
 
995
1026
  # -- Setup hooks ---------------------------------------------------------
@@ -2,6 +2,7 @@
2
2
 
3
3
  Runs standalone (no hermes-agent runtime needed), so it is safe in cron:
4
4
 
5
+ python -m pgvector install [--hermes-home ~/.hermes] [--remove] [--force]
5
6
  python -m pgvector migrate --admin-dsn "dbname=hermes_memory user=postgres host=/var/run/postgresql"
6
7
  python -m pgvector stats [--dsn ...]
7
8
  python -m pgvector backfill [--dsn ...] [--embed-url ...] [--batch-size 100] [--dry-run]
@@ -9,6 +10,11 @@ Runs standalone (no hermes-agent runtime needed), so it is safe in cron:
9
10
  python -m pgvector cleanup --identities "agent:main:whatsapp:dm:17192714834,skill-bench,skill-bench-ws" [--execute]
10
11
  python -m pgvector remap --old hermes --new agent-hermes [--execute] [--force]
11
12
 
13
+ `install` (v0.4.2) makes a pip-installed package discoverable by hermes-agent:
14
+ it writes a tiny shim into $HERMES_HOME/plugins/pgvector/ that resolves to
15
+ this package at import time. Required because hermes-agent discovers memory
16
+ providers by scanning plugin DIRECTORIES only — never site-packages.
17
+
12
18
  Destructive commands (prune/cleanup/remap) DEFAULT TO DRY-RUN. Pass --execute
13
19
  to actually mutate. Every destructive run that mutates is recorded in
14
20
  memory_maintenance_log.
@@ -21,7 +27,9 @@ from __future__ import annotations
21
27
 
22
28
  import argparse
23
29
  import json
30
+ import os
24
31
  import sys
32
+ from pathlib import Path
25
33
  from typing import Optional
26
34
 
27
35
  from . import DEFAULTS
@@ -66,10 +74,135 @@ def _make_embed_fn(args, file_cfg: dict):
66
74
  # --- commands -------------------------------------------------------------
67
75
 
68
76
  def cmd_migrate(args) -> int:
69
- store = MemoryStore("dbname=postgres") # dsn unused; admin path opens its own conn
77
+ # The store is constructed on the ADMIN dsn so the post-migration probe
78
+ # below checks the database that was actually migrated. (v0.4.2 — it
79
+ # previously probed a hard-coded 'dbname=postgres', so the "migration 002
80
+ # present" line reported on the wrong database.)
81
+ store = MemoryStore(args.admin_dsn)
70
82
  applied = store.apply_all_migrations(admin_dsn=args.admin_dsn)
71
83
  print(f"applied migrations: {', '.join(applied) if applied else '(none found)'}")
72
84
  print(f"migration 002 present: {store.ensure_migration_002_applied()}")
85
+ store.close()
86
+ return 0
87
+
88
+
89
+ # ---------------------------------------------------------------------------
90
+ # install — discovery shim for pip-installed packages (v0.4.2)
91
+ # ---------------------------------------------------------------------------
92
+
93
+ SHIM_MARKER = "Auto-generated by `hermes-pgvector install`"
94
+
95
+ # The docstring deliberately contains the literal strings "MemoryProvider" and
96
+ # "register_memory_provider": hermes-agent's discovery heuristic
97
+ # (_is_memory_provider_dir) greps the first 8 KiB of __init__.py for them.
98
+ SHIM_INIT = '''"""Discovery shim for the pip-installed hermes-memory-pgvector package.
99
+
100
+ ''' + SHIM_MARKER + ''' — do not edit; re-run the installer to regenerate.
101
+ Delete this directory (or run `hermes-pgvector install --remove`) to
102
+ deactivate the plugin.
103
+
104
+ hermes-agent discovers memory providers by scanning plugin DIRECTORIES —
105
+ plugins/memory/<name>/ (bundled) and $HERMES_HOME/plugins/<name>/ (user) —
106
+ for an __init__.py mentioning MemoryProvider / register_memory_provider.
107
+ It never looks at installed packages, so `pip install hermes-memory-pgvector`
108
+ alone is invisible to it. This shim bridges the gap: the loader imports this
109
+ file, and the absolute import below resolves to the real package installed
110
+ in the SAME environment hermes-agent runs in. Uninstall the package and the
111
+ import fails cleanly — the loader logs it and falls back to built-in memory.
112
+ """
113
+
114
+ from pgvector import PgvectorMemoryProvider, register # noqa: F401
115
+ '''
116
+
117
+
118
+ def _resolve_hermes_home(cli_value: Optional[str]) -> Path:
119
+ """--hermes-home flag > $HERMES_HOME > hermes_constants > ~/.hermes."""
120
+ if cli_value:
121
+ return Path(cli_value).expanduser()
122
+ env = os.environ.get("HERMES_HOME")
123
+ if env:
124
+ return Path(env).expanduser()
125
+ try:
126
+ from hermes_constants import get_hermes_home
127
+ return Path(get_hermes_home())
128
+ except Exception: # noqa: BLE001 — standalone venv without hermes-agent
129
+ return Path.home() / ".hermes"
130
+
131
+
132
+ def cmd_install(args) -> int:
133
+ """Create/refresh (or --remove) the discovery shim in $HERMES_HOME/plugins/pgvector."""
134
+ home = _resolve_hermes_home(args.hermes_home)
135
+ shim_dir = home / "plugins" / "pgvector"
136
+ init_py = shim_dir / "__init__.py"
137
+
138
+ if args.remove:
139
+ if shim_dir.is_symlink():
140
+ shim_dir.unlink()
141
+ print(f"removed symlink {shim_dir}")
142
+ return 0
143
+ if not shim_dir.exists():
144
+ print(f"nothing to remove at {shim_dir}")
145
+ return 0
146
+ if init_py.exists() and SHIM_MARKER not in init_py.read_text(
147
+ encoding="utf-8", errors="replace"
148
+ ):
149
+ print(
150
+ f"refusing to remove {shim_dir}: not a generated shim (no marker)",
151
+ file=sys.stderr,
152
+ )
153
+ return 1
154
+ import shutil
155
+ shutil.rmtree(shim_dir)
156
+ print(f"removed shim {shim_dir}")
157
+ return 0
158
+
159
+ # A pre-existing symlink or hand-installed plugin dir is not ours to
160
+ # clobber silently. --force replaces a symlink outright and moves a real
161
+ # directory aside with a timestamped .bak suffix.
162
+ if shim_dir.is_symlink():
163
+ if not args.force:
164
+ print(
165
+ f"{shim_dir} is a symlink (existing manual install); "
166
+ "re-run with --force to replace it with the shim",
167
+ file=sys.stderr,
168
+ )
169
+ return 1
170
+ shim_dir.unlink()
171
+ elif init_py.exists() and SHIM_MARKER not in init_py.read_text(
172
+ encoding="utf-8", errors="replace"
173
+ ):
174
+ if not args.force:
175
+ print(
176
+ f"{shim_dir} already contains a non-shim plugin install; "
177
+ "move it aside or re-run with --force",
178
+ file=sys.stderr,
179
+ )
180
+ return 1
181
+ from datetime import datetime
182
+ bak = shim_dir.with_name(
183
+ shim_dir.name + ".bak-" + datetime.now().strftime("%Y%m%d-%H%M%S")
184
+ )
185
+ shim_dir.rename(bak)
186
+ print(f"moved existing install aside to {bak}")
187
+
188
+ shim_dir.mkdir(parents=True, exist_ok=True)
189
+ init_py.write_text(SHIM_INIT, encoding="utf-8")
190
+ # plugin.yaml beside the shim lets discovery display the description.
191
+ try:
192
+ src = Path(__file__).parent / "plugin.yaml"
193
+ (shim_dir / "plugin.yaml").write_text(
194
+ src.read_text(encoding="utf-8"), encoding="utf-8"
195
+ )
196
+ except Exception as exc: # noqa: BLE001 — cosmetic only
197
+ print(f"warning: could not copy plugin.yaml: {exc}", file=sys.stderr)
198
+
199
+ print(f"installed discovery shim: {shim_dir}")
200
+ print(" -> resolves to the pip-installed `pgvector` package at import time")
201
+ print("next steps:")
202
+ print(" 1. config.yaml: memory.provider: pgvector (+ plugins.pgvector settings)")
203
+ print(" 2. migrations (admin, once):")
204
+ print(" hermes-pgvector migrate --admin-dsn 'dbname=<db> user=postgres host=/var/run/postgresql'")
205
+ print(" 3. restart hermes; verify with: hermes memory status")
73
206
  return 0
74
207
 
75
208
 
@@ -179,6 +312,21 @@ def build_parser() -> argparse.ArgumentParser:
179
312
  sp.add_argument("--dsn", default=None, help="Postgres DSN (runtime hermes role)")
180
313
  sp.add_argument("--config", default=None, help="config.yaml to read plugins.pgvector from")
181
314
 
315
+ ins = sub.add_parser(
316
+ "install",
317
+ help="create the $HERMES_HOME/plugins/pgvector discovery shim for a pip-installed package",
318
+ )
319
+ ins.add_argument(
320
+ "--hermes-home", default=None,
321
+ help="override HERMES_HOME (default: $HERMES_HOME env, hermes_constants, ~/.hermes)",
322
+ )
323
+ ins.add_argument("--remove", action="store_true", help="remove a previously generated shim")
324
+ ins.add_argument(
325
+ "--force", action="store_true",
326
+ help="replace an existing symlink / move a non-shim install aside (.bak-<ts>)",
327
+ )
328
+ ins.set_defaults(func=cmd_install)
329
+
182
330
  m = sub.add_parser("migrate", help="apply all migrations as DB admin")
183
331
  m.add_argument("--admin-dsn", required=True, help="superuser/owner DSN (CREATE/GRANT)")
184
332
  m.set_defaults(func=cmd_migrate)
@@ -22,6 +22,16 @@ class EmbeddingError(Exception):
22
22
  """Raised when the embedding endpoint fails to return a usable vector."""
23
23
 
24
24
 
25
+ class EmbeddingDimensionError(EmbeddingError):
26
+ """Endpoint reachable but returned wrong-dimension vectors.
27
+
28
+ Distinct from generic EmbeddingError so the protocol-fallback path can
29
+ tell "endpoint down, try the other protocol" apart from "endpoint fine,
30
+ the MODEL is misconfigured" — the latter must surface as-is (invariant
31
+ #7), not be masked by a 404 from retrying the other protocol (v0.4.2).
32
+ """
33
+
34
+
25
35
  def embed(
26
36
  text: str,
27
37
  *,
@@ -74,6 +84,11 @@ def _embed_once(text: str, *, base_url: str, model: str, timeout: float) -> List
74
84
  timeout=timeout,
75
85
  extract=lambda d: d["data"][0]["embedding"],
76
86
  )
87
+ except EmbeddingDimensionError:
88
+ # The endpoint answered — the model is just wrong-dimensional.
89
+ # Falling through to the native protocol would 404 on a pure
90
+ # OpenAI-compatible server and bury the actionable message.
91
+ raise
77
92
  except EmbeddingError as exc:
78
93
  logger.debug("OpenAI-compat embed failed (%s); trying native", exc)
79
94
 
@@ -112,7 +127,7 @@ def _post(url: str, body: dict, *, timeout: float, extract) -> List[float]:
112
127
  if not isinstance(vec, list) or not vec:
113
128
  raise EmbeddingError("response had no embedding array")
114
129
  if len(vec) != 768:
115
- raise EmbeddingError(f"expected 768 dims, got {len(vec)}")
130
+ raise EmbeddingDimensionError(f"expected 768 dims, got {len(vec)}")
116
131
  return vec
117
132
 
118
133
 
@@ -35,8 +35,17 @@ from typing import Iterable, Mapping, Optional, Tuple
35
35
  # segment. Collapse the whole family to ONE bucket so we neither get a theme
36
36
  # per contact (cardinality) nor store the phone number as an agent_identity
37
37
  # (PII). Matches e.g. 'agent:main:whatsapp:dm:17192714834',
38
- # 'agent:x:telegram:dm:55512345', 'signal:dm:+1...'.
39
- _DM_RE = re.compile(r"(?:^|:)dm:|:whatsapp:|:telegram:|:signal:", re.IGNORECASE)
38
+ # 'agent:x:telegram:dm:55512345', 'signal:dm:+1...', 'whatsapp:17195550000'.
39
+ #
40
+ # v0.4.2: a platform token alone no longer triggers — it must be followed by
41
+ # a 'dm:' segment or a phone/chat id. The old pattern's bare ':signal:'
42
+ # alternative swept ordinary colon-namespaced themes (e.g. a trading key like
43
+ # 'desk:signal:main') into the DM bucket, breaking theme isolation on nothing
44
+ # but the word "signal".
45
+ _DM_RE = re.compile(
46
+ r"(?:^|:)dm:|(?:^|:)(?:whatsapp|telegram|signal):(?=dm:|\+?\d)",
47
+ re.IGNORECASE,
48
+ )
40
49
 
41
50
  # Benchmark / test-harness identities that must not pollute durable prod memory.
42
51
  # 'skill-bench', 'skill-bench-ws', '<x>-bench', '<x>-bench-ws', 'bench'.
@@ -0,0 +1,36 @@
1
+ -- 004_runtime_grants.sql — v0.4.2 runtime-role DML grants for the 001 tables.
2
+ --
3
+ -- ADDITIVE ONLY. Never edit 001/002/003. Idempotent (GRANT is); safe to re-run.
4
+ -- Apply once as DB admin, AFTER 001:
5
+ -- sudo -u postgres psql -d hermes_memory -f 004_runtime_grants.sql
6
+ --
7
+ -- ===========================================================================
8
+ -- WHY
9
+ -- ===========================================================================
10
+ -- 001 creates memory_entries + conversations with NO grants: the runtime role
11
+ -- only gained access through a MANUAL "ALTER TABLE ... OWNER TO hermes" step
12
+ -- documented in the README — a step `hermes-pgvector migrate` never ran. An
13
+ -- operator following the CLI path alone got "permission denied for table
14
+ -- memory_entries" on every plugin write (002 grants covered only the v0.4
15
+ -- provenance tables). This migration closes that gap so `migrate` alone
16
+ -- yields a fully-functional install.
17
+ --
18
+ -- GRANTs (not ownership transfer): the runtime role needs DML + sequence
19
+ -- usage, nothing more — ownership stays with the admin role that ran the
20
+ -- migration, consistent with the admin/runtime DDL split (invariant #5).
21
+ --
22
+ -- The role name 'hermes' matches this plugin's default DSN and migration
23
+ -- 002's existing GRANTs. Deployments using a different runtime role get a
24
+ -- NOTICE and must grant manually (same as they already had to for 002).
25
+
26
+ DO $$
27
+ BEGIN
28
+ IF EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'hermes') THEN
29
+ GRANT USAGE ON SCHEMA public TO hermes;
30
+ GRANT SELECT, INSERT, UPDATE, DELETE ON memory_entries, conversations TO hermes;
31
+ GRANT USAGE, SELECT ON SEQUENCE memory_entries_id_seq, conversations_id_seq TO hermes;
32
+ ELSE
33
+ RAISE NOTICE 'role "hermes" not found — grant DML on memory_entries/conversations (and USAGE on their id sequences) to your runtime role manually';
34
+ END IF;
35
+ END
36
+ $$;
@@ -1,5 +1,5 @@
1
1
  name: pgvector
2
- version: 0.4.1
2
+ version: 0.4.2
3
3
  description: "Postgres + pgvector storage layer for hermes-agent. Mirrors built-in MEMORY.md / USER.md + captures every substantive (user, assistant) chat turn into a conversations table. Per-agent themes via X-Hermes-Session-Key with v0.4 identity governance (PII/DM-key bucketing, bench isolation, allow-list). Agent attribution + parent->child delegation provenance (memory_agents / memory_agent_edges). 768-dim semantic recall with hybrid vector+full-text (RRF) ranking, embedding backfill, conversation TTL. Async writer + connection pool. No LLM mediation."
4
4
  pip_dependencies:
5
5
  - "psycopg[binary]>=3.3.4"
@@ -31,6 +31,20 @@ from .embed import to_pgvector_literal
31
31
  logger = logging.getLogger(__name__)
32
32
 
33
33
 
34
+ def _escape_like(text: str) -> str:
35
+ """Escape LIKE metacharacters so `text` matches as a literal substring.
36
+
37
+ The built-in memory tool matches old_text with plain Python `in` — no
38
+ wildcards. Postgres LIKE treats %/_ as wildcards and backslash as the
39
+ default escape character, so an unescaped pattern both over-matches
40
+ ('15% YoY' matches '15<anything>YoY' and can hit unrelated rows) and
41
+ under-matches (backslashes in stored Windows paths are consumed as
42
+ escapes, so the row that literally contains them never matches).
43
+ Escaping all three restores exact-substring semantics (v0.4.2).
44
+ """
45
+ return text.replace("\\", "\\\\").replace("%", "\\%").replace("_", "\\_")
46
+
47
+
34
48
  class MemoryStore:
35
49
  """Postgres-backed mirror of hermes-agent's built-in memory entries."""
36
50
 
@@ -136,8 +150,10 @@ class MemoryStore:
136
150
  cur.execute("SELECT to_regclass('memory_entries')")
137
151
  if cur.fetchone()[0] is None:
138
152
  raise self.SchemaNotApplied(
139
- "memory_entries table missing. Apply the migration as DB admin: "
140
- "psql -d <dbname> -f plugins/memory/pgvector/migrations/001_schema.sql"
153
+ "memory_entries table missing. Apply migrations as DB admin: "
154
+ "hermes-pgvector migrate --admin-dsn 'dbname=<db> user=postgres "
155
+ "host=/var/run/postgresql' (or psql -f the files in the "
156
+ "installed package's pgvector/migrations/ directory, in order)"
141
157
  )
142
158
 
143
159
  def apply_migration_as_admin(self, *, admin_dsn: str, migration: str = "001_schema.sql") -> None:
@@ -261,7 +277,8 @@ class MemoryStore:
261
277
  AND target = %s
262
278
  AND content LIKE %s
263
279
  """,
264
- (new_content, vec_literal, agent_identity, target, f"%{old_text}%"),
280
+ (new_content, vec_literal, agent_identity, target,
281
+ f"%{_escape_like(old_text)}%"),
265
282
  )
266
283
  updated = cur.rowcount
267
284
  conn.commit()
@@ -287,7 +304,7 @@ class MemoryStore:
287
304
  AND target = %s
288
305
  AND content LIKE %s
289
306
  """,
290
- (agent_identity, target, f"%{old_text}%"),
307
+ (agent_identity, target, f"%{_escape_like(old_text)}%"),
291
308
  )
292
309
  deleted = cur.rowcount
293
310
  conn.commit()
@@ -555,15 +572,31 @@ class MemoryStore:
555
572
 
556
573
  inserted = 0
557
574
  skipped = 0
575
+ # Circuit breaker (v0.4.2): this loop runs SYNCHRONOUSLY inside
576
+ # initialize(), and a hanging (not refused) embed endpoint costs up to
577
+ # ~20s per entry (two protocol attempts × 10s timeout). Without a
578
+ # breaker, 50 new entries would block session start for ~17 minutes.
579
+ # After 3 consecutive failures, stop embedding and insert the rest
580
+ # text-only — the nightly backfill re-embeds them later.
581
+ consecutive_embed_failures = 0
558
582
  for entry in entries:
559
583
  if entry in existing:
560
584
  skipped += 1
561
585
  continue
562
586
  vec = None
563
- try:
564
- vec = embed_fn(entry) if embed_fn else None
565
- except Exception: # noqa: BLE001 — fail-soft on bulk embed
566
- vec = None
587
+ if embed_fn and consecutive_embed_failures < 3:
588
+ try:
589
+ vec = embed_fn(entry)
590
+ consecutive_embed_failures = 0
591
+ except Exception: # noqa: BLE001 — fail-soft on bulk embed
592
+ vec = None
593
+ consecutive_embed_failures += 1
594
+ if consecutive_embed_failures == 3:
595
+ logger.warning(
596
+ "bulk_upsert_md: 3 consecutive embed failures — "
597
+ "inserting remaining entries text-only "
598
+ "(the backfill sweep will re-embed them)"
599
+ )
567
600
  row_id = self.add(
568
601
  agent_identity=agent_identity,
569
602
  target=target,
@@ -908,19 +941,24 @@ class MemoryStore:
908
941
  batch_progress = 0
909
942
  for r in rows:
910
943
  processed += 1
944
+ # The whole embed → literal → UPDATE pipeline is fail-soft
945
+ # per row (v0.4.2): a vector that embeds "successfully" but
946
+ # is unstorable (NaN/Inf tokens rejected by vector_in, a
947
+ # transient DB error) must mark THIS row failed and move
948
+ # on, not abort the run for every remaining table.
911
949
  try:
912
950
  vec = embed_fn(r["content"])
951
+ vec_literal = to_pgvector_literal(vec)
952
+ with self._get_pool().connection() as conn:
953
+ with conn.cursor() as cur:
954
+ cur.execute(
955
+ f"UPDATE {t} SET embedding = %s::vector WHERE id = %s",
956
+ (vec_literal, r["id"]),
957
+ )
958
+ conn.commit()
913
959
  except Exception: # noqa: BLE001 — fail-soft, leave NULL
914
960
  failed += 1
915
961
  continue
916
- vec_literal = to_pgvector_literal(vec)
917
- with self._get_pool().connection() as conn:
918
- with conn.cursor() as cur:
919
- cur.execute(
920
- f"UPDATE {t} SET embedding = %s::vector WHERE id = %s",
921
- (vec_literal, r["id"]),
922
- )
923
- conn.commit()
924
962
  succeeded += 1
925
963
  batch_progress += 1
926
964
  # If an entire batch failed to embed (endpoint flapping), stop —
@@ -1053,14 +1091,33 @@ class MemoryStore:
1053
1091
  "memory_entries": {"moved": me_old - dupes, "dropped_duplicates": dupes},
1054
1092
  "conversations": {"moved": conv_old},
1055
1093
  }
1056
- if dupes > 10 and not force:
1057
- raise RuntimeError(
1058
- f"remap {old_identity!r} -> {new_identity!r} would drop {dupes} "
1059
- "duplicate memory_entries rows; re-run with force=True to proceed"
1060
- )
1061
1094
 
1062
1095
  with conn.cursor() as cur:
1063
1096
  cur.execute("SELECT pg_advisory_xact_lock(9999)")
1097
+ # Re-count duplicates INSIDE the advisory lock (v0.4.2): the
1098
+ # pre-count above ran unlocked (it also feeds the dry-run
1099
+ # report), so a concurrent writer inserting colliding rows
1100
+ # between count and mutate could push real duplicates past the
1101
+ # unlocked estimate — the >10 force-guard must be enforced
1102
+ # against the locked truth, mirroring delete_by_identity's
1103
+ # count-under-lock ordering.
1104
+ cur.execute(
1105
+ """
1106
+ SELECT count(*) FROM memory_entries o
1107
+ WHERE o.agent_identity = %s
1108
+ AND EXISTS (SELECT 1 FROM memory_entries n
1109
+ WHERE n.agent_identity = %s
1110
+ AND n.target = o.target
1111
+ AND n.content = o.content)
1112
+ """,
1113
+ (old_identity, new_identity),
1114
+ )
1115
+ dupes = int(cur.fetchone()[0])
1116
+ if dupes > 10 and not force:
1117
+ raise RuntimeError(
1118
+ f"remap {old_identity!r} -> {new_identity!r} would drop {dupes} "
1119
+ "duplicate memory_entries rows; re-run with force=True to proceed"
1120
+ )
1064
1121
  cur.execute(
1065
1122
  """
1066
1123
  INSERT INTO memory_entries
@@ -63,7 +63,8 @@ class AsyncWriter:
63
63
  self._worker_fn = worker_fn
64
64
  self._queue: "queue.Queue[Optional[_PendingWrite]]" = queue.Queue(maxsize=maxsize)
65
65
  self._thread: Optional[threading.Thread] = None
66
- self._stop = threading.Event()
66
+ self._stop = threading.Event() # hard stop: exit ASAP, abandon queue
67
+ self._draining = threading.Event() # graceful stop: exit once queue is empty
67
68
  self._dropped = 0
68
69
  self._dropped_warned = False
69
70
  self._lock = threading.Lock()
@@ -109,17 +110,31 @@ class AsyncWriter:
109
110
  return False
110
111
 
111
112
  def shutdown(self, timeout: float = 5.0) -> None:
112
- """Signal stop + wait for the queue to drain (up to `timeout` seconds)."""
113
+ """Drain the queue (up to `timeout` seconds), then stop the thread.
114
+
115
+ The drain flag is set FIRST, so the thread exits only once the queue
116
+ is empty — a full queue at shutdown time is drained rather than
117
+ silently abandoned, and items racing in behind the wake-up sentinel
118
+ are still processed (v0.4.2; previously a full queue downgraded to a
119
+ hard stop that dropped up to maxsize-1 accepted writes with no log).
120
+ """
113
121
  if not self._thread or not self._thread.is_alive():
114
122
  return
115
- # Sentinel wakes the thread even if the queue is empty.
123
+ self._draining.set()
116
124
  try:
125
+ # Sentinel wakes the thread instantly if it's idle on an empty
126
+ # queue; if the queue is full the 0.5s get-timeout wakes it anyway.
117
127
  self._queue.put_nowait(None)
118
128
  except queue.Full:
119
- self._stop.set()
129
+ pass
120
130
  self._thread.join(timeout=timeout)
121
131
  if self._thread.is_alive():
122
132
  logger.warning("pgvector writer thread did not drain within %.1fs", timeout)
133
+ leftover = self._queue.qsize()
134
+ if leftover:
135
+ logger.warning(
136
+ "pgvector writer shutdown abandoned %d queued write(s)", leftover
137
+ )
123
138
 
124
139
  def stats(self) -> Dict[str, Any]:
125
140
  return {
@@ -136,6 +151,7 @@ class AsyncWriter:
136
151
  if self._thread and self._thread.is_alive():
137
152
  return
138
153
  self._stop.clear()
154
+ self._draining.clear()
139
155
  self._thread = threading.Thread(
140
156
  target=self._run,
141
157
  name="pgvector-writer",
@@ -148,9 +164,15 @@ class AsyncWriter:
148
164
  try:
149
165
  item = self._queue.get(timeout=0.5)
150
166
  except queue.Empty:
167
+ if self._draining.is_set():
168
+ break # queue fully drained — graceful shutdown complete
151
169
  continue
152
- if item is None: # shutdown sentinel
170
+ if item is None: # shutdown wake-up sentinel
153
171
  self._queue.task_done()
172
+ if self._draining.is_set():
173
+ # Keep draining — real items can be queued BEHIND the
174
+ # sentinel (enqueue racing shutdown); exit on Empty above.
175
+ continue
154
176
  break
155
177
  try:
156
178
  self._worker_fn(item)
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "hermes-memory-pgvector"
7
- version = "0.4.1"
7
+ version = "0.4.2"
8
8
  description = "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."
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.11"
@@ -1,7 +1,9 @@
1
1
  """Unit tests for pgvector/identity.py — pure normalization, no DB or embed.
2
2
 
3
3
  These run everywhere (no PG_TEST_DSN needed); identity.py has no I/O.
4
- Import style matches test_smoke.py (sibling import via sys.path insert).
4
+ Import style matches test_smoke.py (repo root on sys.path, package imports —
5
+ v0.4.2: the old pgvector/-dir insert leaked bare module names onto sys.path
6
+ for the whole pytest process, masking broken imports in sibling test files).
5
7
  """
6
8
 
7
9
  from __future__ import annotations
@@ -9,9 +11,9 @@ from __future__ import annotations
9
11
  import sys
10
12
  from pathlib import Path
11
13
 
12
- sys.path.insert(0, str(Path(__file__).resolve().parent.parent / "pgvector"))
14
+ sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
13
15
 
14
- from identity import ( # noqa: E402
16
+ from pgvector.identity import ( # noqa: E402
15
17
  BENCH_BUCKET,
16
18
  DEFAULT_IDENTITY,
17
19
  DM_BUCKET,
@@ -68,6 +70,21 @@ def test_dm_bucket_is_idempotent():
68
70
  assert normalized is False # already canonical, nothing changed
69
71
 
70
72
 
73
+ def test_platform_token_alone_is_not_a_dm_key():
74
+ # v0.4.2: a bare ':signal:'/':whatsapp:' segment no longer sweeps ordinary
75
+ # colon-namespaced themes into the DM bucket on nothing but the word.
76
+ canon, _, reason = normalize_identity("desk:signal:main")
77
+ assert canon == "desk:signal:main"
78
+ assert reason == "unchanged"
79
+
80
+
81
+ def test_platform_followed_by_id_is_a_dm_key():
82
+ for raw in ("whatsapp:17195550000", "agent:x:signal:12345", "telegram:+15551234"):
83
+ canon, _, reason = normalize_identity(raw)
84
+ assert canon == DM_BUCKET
85
+ assert reason == "dm-bucket"
86
+
87
+
71
88
  # --- bench isolation ------------------------------------------------------
72
89
 
73
90
  def test_skill_bench_buckets_by_default():
@@ -0,0 +1,74 @@
1
+ """Unit tests for the `hermes-pgvector install` discovery-shim generator.
2
+
3
+ Pure filesystem — no DB, no embed endpoint, runs everywhere. The shim is the
4
+ bridge that makes a pip-installed package visible to hermes-agent's
5
+ directory-scan provider discovery, so its exact contents are contract:
6
+ the loader greps the first 8 KiB of __init__.py for "MemoryProvider" /
7
+ "register_memory_provider", and the absolute import must resolve the real
8
+ package.
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ import sys
14
+ from pathlib import Path
15
+
16
+ sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
17
+
18
+ from pgvector.__main__ import SHIM_MARKER, main # noqa: E402
19
+
20
+
21
+ def test_install_creates_shim(tmp_path):
22
+ assert main(["install", "--hermes-home", str(tmp_path)]) == 0
23
+ init = tmp_path / "plugins" / "pgvector" / "__init__.py"
24
+ text = init.read_text(encoding="utf-8")
25
+ assert SHIM_MARKER in text
26
+ # Discovery heuristic strings the hermes-agent loader scans for.
27
+ assert "MemoryProvider" in text
28
+ assert "register_memory_provider" in text
29
+ # The actual bridge: absolute import of the installed package.
30
+ assert "from pgvector import PgvectorMemoryProvider, register" in text
31
+ # plugin.yaml copied beside the shim for the discovery description.
32
+ assert (tmp_path / "plugins" / "pgvector" / "plugin.yaml").exists()
33
+
34
+
35
+ def test_install_is_idempotent(tmp_path):
36
+ assert main(["install", "--hermes-home", str(tmp_path)]) == 0
37
+ assert main(["install", "--hermes-home", str(tmp_path)]) == 0
38
+ assert SHIM_MARKER in (
39
+ tmp_path / "plugins" / "pgvector" / "__init__.py"
40
+ ).read_text(encoding="utf-8")
41
+
42
+
43
+ def test_install_refuses_foreign_dir(tmp_path):
44
+ d = tmp_path / "plugins" / "pgvector"
45
+ d.mkdir(parents=True)
46
+ (d / "__init__.py").write_text("# hand-written plugin, not a shim\n", encoding="utf-8")
47
+ assert main(["install", "--hermes-home", str(tmp_path)]) == 1
48
+ # untouched
49
+ assert "hand-written" in (d / "__init__.py").read_text(encoding="utf-8")
50
+
51
+
52
+ def test_install_force_moves_foreign_dir_aside(tmp_path):
53
+ d = tmp_path / "plugins" / "pgvector"
54
+ d.mkdir(parents=True)
55
+ (d / "__init__.py").write_text("# hand-written plugin, not a shim\n", encoding="utf-8")
56
+ assert main(["install", "--hermes-home", str(tmp_path), "--force"]) == 0
57
+ baks = list((tmp_path / "plugins").glob("pgvector.bak-*"))
58
+ assert len(baks) == 1
59
+ assert "hand-written" in (baks[0] / "__init__.py").read_text(encoding="utf-8")
60
+ assert SHIM_MARKER in (d / "__init__.py").read_text(encoding="utf-8")
61
+
62
+
63
+ def test_remove_deletes_generated_shim(tmp_path):
64
+ main(["install", "--hermes-home", str(tmp_path)])
65
+ assert main(["install", "--hermes-home", str(tmp_path), "--remove"]) == 0
66
+ assert not (tmp_path / "plugins" / "pgvector").exists()
67
+
68
+
69
+ def test_remove_refuses_foreign_dir(tmp_path):
70
+ d = tmp_path / "plugins" / "pgvector"
71
+ d.mkdir(parents=True)
72
+ (d / "__init__.py").write_text("# hand-written plugin, not a shim\n", encoding="utf-8")
73
+ assert main(["install", "--hermes-home", str(tmp_path), "--remove"]) == 1
74
+ assert d.exists()
@@ -6,7 +6,7 @@ tools.registry, …) and is only exercised when the plugin runs inside
6
6
  hermes-agent.
7
7
 
8
8
  Run with:
9
- pytest plugins/memory/pgvector/tests/
9
+ pytest tests/
10
10
 
11
11
  DB tests skip when PG_TEST_DSN is unset; embed live tests skip when
12
12
  PG_TEST_EMBED_URL is unset.
@@ -20,8 +20,9 @@ from pathlib import Path
20
20
 
21
21
  import pytest
22
22
 
23
- # Import sibling modules without going through hermes-agent's package
24
- # system (which isn't on the sys.path during workstation testing).
23
+ # Repo root on sys.path so `pgvector.*` imports resolve without installing.
24
+ # (v0.4.2: bare `from embed import ...` only worked when another test file
25
+ # happened to leak pgvector/ onto sys.path first — order-dependent.)
25
26
  sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
26
27
 
27
28
 
@@ -30,7 +31,7 @@ sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
30
31
  # ---------------------------------------------------------------------------
31
32
 
32
33
  def test_pgvector_literal_roundtrip():
33
- from embed import to_pgvector_literal
34
+ from pgvector.embed import to_pgvector_literal
34
35
 
35
36
  lit = to_pgvector_literal([0.1, -0.25, 0.333333])
36
37
  assert lit.startswith("[") and lit.endswith("]")
@@ -38,7 +39,7 @@ def test_pgvector_literal_roundtrip():
38
39
 
39
40
 
40
41
  def test_embed_empty_input_raises():
41
- from embed import embed, EmbeddingError
42
+ from pgvector.embed import embed, EmbeddingError
42
43
 
43
44
  with pytest.raises(EmbeddingError):
44
45
  embed("", base_url="http://localhost:11434")
@@ -46,12 +47,21 @@ def test_embed_empty_input_raises():
46
47
  embed(" ", base_url="http://localhost:11434")
47
48
 
48
49
 
50
+ def test_escape_like_literalizes_metacharacters():
51
+ from pgvector.store import _escape_like
52
+
53
+ assert _escape_like("15% YoY") == "15\\% YoY"
54
+ assert _escape_like("a_b") == "a\\_b"
55
+ assert _escape_like("C:\\U") == "C:\\\\U"
56
+ assert _escape_like("plain text") == "plain text"
57
+
58
+
49
59
  @pytest.mark.skipif(
50
60
  not os.environ.get("PG_TEST_EMBED_URL"),
51
61
  reason="PG_TEST_EMBED_URL not set",
52
62
  )
53
63
  def test_embed_live_returns_768_dims():
54
- from embed import embed
64
+ from pgvector.embed import embed
55
65
 
56
66
  base_url = os.environ["PG_TEST_EMBED_URL"]
57
67
  vec = embed("hello world", base_url=base_url, model="nomic-embed-text")
@@ -74,7 +84,7 @@ def store():
74
84
  if not dsn:
75
85
  pytest.skip("PG_TEST_DSN not set")
76
86
 
77
- from store import MemoryStore
87
+ from pgvector.store import MemoryStore
78
88
 
79
89
  s = MemoryStore(dsn)
80
90
  s.ensure_schema()
@@ -118,6 +118,31 @@ def test_remap_identity_merges_and_dedupes(store):
118
118
  assert s.count(agent_identity=new) == 2 # shared (once) + unique-to-old
119
119
 
120
120
 
121
+ # --- LIKE-literal substring semantics (v0.4.2) -----------------------------
122
+
123
+ def test_replace_treats_percent_as_literal(store):
124
+ s, agent = store
125
+ s.add(agent_identity=agent, target="memory", content="Q3 revenue grew 15 million YoY")
126
+ s.add(agent_identity=agent, target="memory", content="Q3 revenue grew 15% YoY")
127
+ n = s.replace(
128
+ agent_identity=agent, target="memory",
129
+ old_text="15% YoY", new_content="Q3 revenue grew 15pct YoY",
130
+ )
131
+ assert n == 1 # only the literal-'%' row — never the 'million' row via wildcard
132
+ contents = {r["content"] for r in s.list_entries(agent_identity=agent, target="memory", limit=10)}
133
+ assert "Q3 revenue grew 15 million YoY" in contents
134
+
135
+
136
+ def test_remove_underscore_and_backslash_literal(store):
137
+ s, agent = store
138
+ s.add(agent_identity=agent, target="memory", content="snake_case_name noted")
139
+ s.add(agent_identity=agent, target="memory", content="snakeXcaseXname noted") # unescaped '_' would match this too
140
+ s.add(agent_identity=agent, target="memory", content=r"path C:\Users\andreab noted")
141
+ assert s.remove(agent_identity=agent, target="memory", old_text="snake_case_name") == 1
142
+ assert s.remove(agent_identity=agent, target="memory", old_text=r"C:\Users\andreab") == 1
143
+ assert s.count(agent_identity=agent, target="memory") == 1 # only the X-row remains
144
+
145
+
121
146
  # --- prune ----------------------------------------------------------------
122
147
 
123
148
  def test_prune_conversations_deletes_old(store):