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.
- {hermes_memory_pgvector-0.4.1 → hermes_memory_pgvector-0.4.2}/PKG-INFO +37 -12
- {hermes_memory_pgvector-0.4.1 → hermes_memory_pgvector-0.4.2}/README.md +36 -11
- {hermes_memory_pgvector-0.4.1 → hermes_memory_pgvector-0.4.2}/hermes_memory_pgvector.egg-info/PKG-INFO +37 -12
- {hermes_memory_pgvector-0.4.1 → hermes_memory_pgvector-0.4.2}/hermes_memory_pgvector.egg-info/SOURCES.txt +2 -0
- {hermes_memory_pgvector-0.4.1 → hermes_memory_pgvector-0.4.2}/pgvector/__init__.py +59 -28
- {hermes_memory_pgvector-0.4.1 → hermes_memory_pgvector-0.4.2}/pgvector/__main__.py +149 -1
- {hermes_memory_pgvector-0.4.1 → hermes_memory_pgvector-0.4.2}/pgvector/embed.py +16 -1
- {hermes_memory_pgvector-0.4.1 → hermes_memory_pgvector-0.4.2}/pgvector/identity.py +11 -2
- hermes_memory_pgvector-0.4.2/pgvector/migrations/004_runtime_grants.sql +36 -0
- {hermes_memory_pgvector-0.4.1 → hermes_memory_pgvector-0.4.2}/pgvector/plugin.yaml +1 -1
- {hermes_memory_pgvector-0.4.1 → hermes_memory_pgvector-0.4.2}/pgvector/store.py +78 -21
- {hermes_memory_pgvector-0.4.1 → hermes_memory_pgvector-0.4.2}/pgvector/writer.py +27 -5
- {hermes_memory_pgvector-0.4.1 → hermes_memory_pgvector-0.4.2}/pyproject.toml +1 -1
- {hermes_memory_pgvector-0.4.1 → hermes_memory_pgvector-0.4.2}/tests/test_identity.py +20 -3
- hermes_memory_pgvector-0.4.2/tests/test_install_shim.py +74 -0
- {hermes_memory_pgvector-0.4.1 → hermes_memory_pgvector-0.4.2}/tests/test_smoke.py +17 -7
- {hermes_memory_pgvector-0.4.1 → hermes_memory_pgvector-0.4.2}/tests/test_store_v04.py +25 -0
- {hermes_memory_pgvector-0.4.1 → hermes_memory_pgvector-0.4.2}/LICENSE +0 -0
- {hermes_memory_pgvector-0.4.1 → hermes_memory_pgvector-0.4.2}/hermes_memory_pgvector.egg-info/dependency_links.txt +0 -0
- {hermes_memory_pgvector-0.4.1 → hermes_memory_pgvector-0.4.2}/hermes_memory_pgvector.egg-info/entry_points.txt +0 -0
- {hermes_memory_pgvector-0.4.1 → hermes_memory_pgvector-0.4.2}/hermes_memory_pgvector.egg-info/requires.txt +0 -0
- {hermes_memory_pgvector-0.4.1 → hermes_memory_pgvector-0.4.2}/hermes_memory_pgvector.egg-info/top_level.txt +0 -0
- {hermes_memory_pgvector-0.4.1 → hermes_memory_pgvector-0.4.2}/pgvector/migrations/001_schema.sql +0 -0
- {hermes_memory_pgvector-0.4.1 → hermes_memory_pgvector-0.4.2}/pgvector/migrations/002_agent_attribution.sql +0 -0
- {hermes_memory_pgvector-0.4.1 → hermes_memory_pgvector-0.4.2}/pgvector/migrations/003_hybrid_search_fts.sql +0 -0
- {hermes_memory_pgvector-0.4.1 → hermes_memory_pgvector-0.4.2}/setup.cfg +0 -0
- {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.
|
|
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:
|
|
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
|
|
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
|
-
#
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
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
|
|
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:
|
|
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
|
|
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
|
-
#
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
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
|
|
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.
|
|
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:
|
|
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
|
|
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
|
-
#
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
900
|
-
|
|
901
|
-
|
|
902
|
-
|
|
903
|
-
|
|
904
|
-
|
|
905
|
-
|
|
906
|
-
|
|
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
|
-
|
|
983
|
-
|
|
984
|
-
|
|
985
|
-
|
|
986
|
-
|
|
987
|
-
|
|
988
|
-
|
|
989
|
-
|
|
990
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
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.
|
|
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
|
|
140
|
-
"
|
|
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,
|
|
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
|
-
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
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
|
-
"""
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
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 (
|
|
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
|
|
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
|
|
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
|
-
#
|
|
24
|
-
#
|
|
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):
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{hermes_memory_pgvector-0.4.1 → hermes_memory_pgvector-0.4.2}/pgvector/migrations/001_schema.sql
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|