hermes-memory-pgvector 0.5.0__tar.gz → 0.5.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 (38) hide show
  1. {hermes_memory_pgvector-0.5.0 → hermes_memory_pgvector-0.5.2}/PKG-INFO +23 -1
  2. {hermes_memory_pgvector-0.5.0 → hermes_memory_pgvector-0.5.2}/README.md +22 -0
  3. {hermes_memory_pgvector-0.5.0 → hermes_memory_pgvector-0.5.2}/hermes_memory_pgvector.egg-info/PKG-INFO +23 -1
  4. {hermes_memory_pgvector-0.5.0 → hermes_memory_pgvector-0.5.2}/hermes_memory_pgvector.egg-info/SOURCES.txt +1 -0
  5. {hermes_memory_pgvector-0.5.0 → hermes_memory_pgvector-0.5.2}/hermes_pgvector/__init__.py +42 -5
  6. {hermes_memory_pgvector-0.5.0 → hermes_memory_pgvector-0.5.2}/hermes_pgvector/__main__.py +9 -2
  7. {hermes_memory_pgvector-0.5.0 → hermes_memory_pgvector-0.5.2}/hermes_pgvector/plugin.yaml +1 -1
  8. {hermes_memory_pgvector-0.5.0 → hermes_memory_pgvector-0.5.2}/hermes_pgvector/store.py +74 -14
  9. {hermes_memory_pgvector-0.5.0 → hermes_memory_pgvector-0.5.2}/pyproject.toml +1 -1
  10. hermes_memory_pgvector-0.5.2/tests/test_empty_content.py +332 -0
  11. {hermes_memory_pgvector-0.5.0 → hermes_memory_pgvector-0.5.2}/LICENSE +0 -0
  12. {hermes_memory_pgvector-0.5.0 → hermes_memory_pgvector-0.5.2}/hermes_memory_pgvector.egg-info/dependency_links.txt +0 -0
  13. {hermes_memory_pgvector-0.5.0 → hermes_memory_pgvector-0.5.2}/hermes_memory_pgvector.egg-info/entry_points.txt +0 -0
  14. {hermes_memory_pgvector-0.5.0 → hermes_memory_pgvector-0.5.2}/hermes_memory_pgvector.egg-info/requires.txt +0 -0
  15. {hermes_memory_pgvector-0.5.0 → hermes_memory_pgvector-0.5.2}/hermes_memory_pgvector.egg-info/top_level.txt +0 -0
  16. {hermes_memory_pgvector-0.5.0 → hermes_memory_pgvector-0.5.2}/hermes_pgvector/embed.py +0 -0
  17. {hermes_memory_pgvector-0.5.0 → hermes_memory_pgvector-0.5.2}/hermes_pgvector/identity.py +0 -0
  18. {hermes_memory_pgvector-0.5.0 → hermes_memory_pgvector-0.5.2}/hermes_pgvector/migrations/001_schema.sql +0 -0
  19. {hermes_memory_pgvector-0.5.0 → hermes_memory_pgvector-0.5.2}/hermes_pgvector/migrations/002_agent_attribution.sql +0 -0
  20. {hermes_memory_pgvector-0.5.0 → hermes_memory_pgvector-0.5.2}/hermes_pgvector/migrations/003_hybrid_search_fts.sql +0 -0
  21. {hermes_memory_pgvector-0.5.0 → hermes_memory_pgvector-0.5.2}/hermes_pgvector/migrations/004_runtime_grants.sql +0 -0
  22. {hermes_memory_pgvector-0.5.0 → hermes_memory_pgvector-0.5.2}/hermes_pgvector/writer.py +0 -0
  23. {hermes_memory_pgvector-0.5.0 → hermes_memory_pgvector-0.5.2}/setup.cfg +0 -0
  24. {hermes_memory_pgvector-0.5.0 → hermes_memory_pgvector-0.5.2}/tests/test_async_writer.py +0 -0
  25. {hermes_memory_pgvector-0.5.0 → hermes_memory_pgvector-0.5.2}/tests/test_config_coercion.py +0 -0
  26. {hermes_memory_pgvector-0.5.0 → hermes_memory_pgvector-0.5.2}/tests/test_embed_timeouts.py +0 -0
  27. {hermes_memory_pgvector-0.5.0 → hermes_memory_pgvector-0.5.2}/tests/test_exclude_identities_live.py +0 -0
  28. {hermes_memory_pgvector-0.5.0 → hermes_memory_pgvector-0.5.2}/tests/test_hybrid_search.py +0 -0
  29. {hermes_memory_pgvector-0.5.0 → hermes_memory_pgvector-0.5.2}/tests/test_identity.py +0 -0
  30. {hermes_memory_pgvector-0.5.0 → hermes_memory_pgvector-0.5.2}/tests/test_install_shim.py +0 -0
  31. {hermes_memory_pgvector-0.5.0 → hermes_memory_pgvector-0.5.2}/tests/test_read_side_gate.py +0 -0
  32. {hermes_memory_pgvector-0.5.0 → hermes_memory_pgvector-0.5.2}/tests/test_save_config_merge.py +0 -0
  33. {hermes_memory_pgvector-0.5.0 → hermes_memory_pgvector-0.5.2}/tests/test_session_switch_contract.py +0 -0
  34. {hermes_memory_pgvector-0.5.0 → hermes_memory_pgvector-0.5.2}/tests/test_smoke.py +0 -0
  35. {hermes_memory_pgvector-0.5.0 → hermes_memory_pgvector-0.5.2}/tests/test_store_v04.py +0 -0
  36. {hermes_memory_pgvector-0.5.0 → hermes_memory_pgvector-0.5.2}/tests/test_system_prompt_block.py +0 -0
  37. {hermes_memory_pgvector-0.5.0 → hermes_memory_pgvector-0.5.2}/tests/test_tool_args_hardening.py +0 -0
  38. {hermes_memory_pgvector-0.5.0 → hermes_memory_pgvector-0.5.2}/tests/test_turn_dedup.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: hermes-memory-pgvector
3
- Version: 0.5.0
3
+ Version: 0.5.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
@@ -189,6 +189,28 @@ Correctness release from a full-codebase review. **No schema changes and no new
189
189
  - **`save_config` stops deleting your settings.** It replaced the whole `plugins.pgvector` block with schema-declared keys, silently dropping hand-edited ones that are read at runtime (`identity_aliases`, `embed_write_backoff`). It merges now.
190
190
  - **Fail-soft hardening** (invariant #4): `sync_turn` is wrapped, config casts are guarded, and the recall tools coerce non-string `query`/`scope`/`target` instead of raising `AttributeError` out of the hook. `hermes-pgvector install --remove` now fails closed and requires `--force` on a directory that isn't a generated shim, instead of deleting it outright.
191
191
 
192
+ ## New in v0.5.1 — `memory remove` no longer wipes a whole theme
193
+
194
+ Patch release, but **upgrade promptly**: it fixes a data-loss bug. No schema changes, no migrations, no API changes.
195
+
196
+ - **`remove` deleted every mirrored entry for a theme, not one.** `_worker` passed `old_text=item.content` — but the built-in tool's remove op carries its target in `old_text` and leaves `content` empty, and the host forwards `old_text` via *metadata*. So `item.content` was always `""`, `store.remove` built `content LIKE '%%'`, and that matches every row: a single `memory remove` deleted the entire mirror for that `(agent_identity, target)`. Verified against Postgres — `DELETE … WHERE c LIKE '%%'` removes all rows. `_worker` now reads `extra["old_text"]`, and `store.remove()` **refuses an empty pattern outright**, so no caller can reach that delete by omission. `remove()` also now deletes **at most one row** (lowest id), matching both the built-in tool — which requires a unique match and errors on ambiguity — and this class's own `replace()`. (The built-in store was never affected; only the pgvector mirror. No loss occurred on the reference deployment: every theme's history is continuous.)
197
+
198
+ Found in production: one `memory_entries` row sat with a NULL embedding and **zero-length content**, arrived through the built-in tool's `replace` path. Two defects met there.
199
+
200
+ - **Nothing rejected empty content on write.** `on_memory_write` filtered on `target` and `action` but never on content, so an `add`/`replace` carrying nothing created a row that can never be embedded — `embed()` raises `EmbeddingError("empty input")` unconditionally for empty or whitespace text. Such writes are now ignored (`remove` is exempt: it legitimately arrives with empty content and targets the row via `old_text`).
201
+ - **`backfill_null_embeddings` retried it forever.** The sweep selected `WHERE embedding IS NULL` with no content filter, so every nightly run re-fetched the row, called `embed()`, failed, and moved on — permanently pinning `failed` above zero and making `remaining == 0` unreachable. That is the damaging half: it destroys the one signal an operator watches, because you can no longer distinguish a permanently-stuck row from a new genuine failure. Un-embeddable rows are now **skipped and reported separately** as `unembeddable` (skipping them silently would be equally misleading), so `remaining` can actually reach zero again.
202
+
203
+ ## New in v0.5.2 — documentation only
204
+
205
+ **No code changes.** `git diff v0.5.1..v0.5.2` touches only `README.md` and one test file; nothing under `hermes_pgvector/` differs, so the installed behaviour is byte-for-byte identical to v0.5.1. There is no reason to redeploy for this release.
206
+
207
+ It exists because PyPI renders a project's README **frozen at upload time**: two fixes that landed after v0.5.1 shipped were visible on GitHub but not on the package page.
208
+
209
+ - **The v0.5.1 release notes were out of order.** The section sat before v0.5.0 instead of after it, so the newest release was buried mid-list. These sections run oldest-to-newest.
210
+ - A test asserted a `failed` count against a dry-run baseline that is hardcoded to `0`, making the comparison a no-op. Asserted directly now, with the reasoning recorded rather than the misleading framing.
211
+
212
+ If you are on v0.5.1 you already have every fix in this release. If you are on **v0.5.0 or earlier, upgrade** — v0.5.1 fixed a data-loss bug where a single `memory remove` deleted a whole theme's mirrored memory.
213
+
192
214
  ## Multi-agent / per-minion themes
193
215
 
194
216
  Each systemd-run minion sets one header on its OpenAI client; everything else flows automatically:
@@ -156,6 +156,28 @@ Correctness release from a full-codebase review. **No schema changes and no new
156
156
  - **`save_config` stops deleting your settings.** It replaced the whole `plugins.pgvector` block with schema-declared keys, silently dropping hand-edited ones that are read at runtime (`identity_aliases`, `embed_write_backoff`). It merges now.
157
157
  - **Fail-soft hardening** (invariant #4): `sync_turn` is wrapped, config casts are guarded, and the recall tools coerce non-string `query`/`scope`/`target` instead of raising `AttributeError` out of the hook. `hermes-pgvector install --remove` now fails closed and requires `--force` on a directory that isn't a generated shim, instead of deleting it outright.
158
158
 
159
+ ## New in v0.5.1 — `memory remove` no longer wipes a whole theme
160
+
161
+ Patch release, but **upgrade promptly**: it fixes a data-loss bug. No schema changes, no migrations, no API changes.
162
+
163
+ - **`remove` deleted every mirrored entry for a theme, not one.** `_worker` passed `old_text=item.content` — but the built-in tool's remove op carries its target in `old_text` and leaves `content` empty, and the host forwards `old_text` via *metadata*. So `item.content` was always `""`, `store.remove` built `content LIKE '%%'`, and that matches every row: a single `memory remove` deleted the entire mirror for that `(agent_identity, target)`. Verified against Postgres — `DELETE … WHERE c LIKE '%%'` removes all rows. `_worker` now reads `extra["old_text"]`, and `store.remove()` **refuses an empty pattern outright**, so no caller can reach that delete by omission. `remove()` also now deletes **at most one row** (lowest id), matching both the built-in tool — which requires a unique match and errors on ambiguity — and this class's own `replace()`. (The built-in store was never affected; only the pgvector mirror. No loss occurred on the reference deployment: every theme's history is continuous.)
164
+
165
+ Found in production: one `memory_entries` row sat with a NULL embedding and **zero-length content**, arrived through the built-in tool's `replace` path. Two defects met there.
166
+
167
+ - **Nothing rejected empty content on write.** `on_memory_write` filtered on `target` and `action` but never on content, so an `add`/`replace` carrying nothing created a row that can never be embedded — `embed()` raises `EmbeddingError("empty input")` unconditionally for empty or whitespace text. Such writes are now ignored (`remove` is exempt: it legitimately arrives with empty content and targets the row via `old_text`).
168
+ - **`backfill_null_embeddings` retried it forever.** The sweep selected `WHERE embedding IS NULL` with no content filter, so every nightly run re-fetched the row, called `embed()`, failed, and moved on — permanently pinning `failed` above zero and making `remaining == 0` unreachable. That is the damaging half: it destroys the one signal an operator watches, because you can no longer distinguish a permanently-stuck row from a new genuine failure. Un-embeddable rows are now **skipped and reported separately** as `unembeddable` (skipping them silently would be equally misleading), so `remaining` can actually reach zero again.
169
+
170
+ ## New in v0.5.2 — documentation only
171
+
172
+ **No code changes.** `git diff v0.5.1..v0.5.2` touches only `README.md` and one test file; nothing under `hermes_pgvector/` differs, so the installed behaviour is byte-for-byte identical to v0.5.1. There is no reason to redeploy for this release.
173
+
174
+ It exists because PyPI renders a project's README **frozen at upload time**: two fixes that landed after v0.5.1 shipped were visible on GitHub but not on the package page.
175
+
176
+ - **The v0.5.1 release notes were out of order.** The section sat before v0.5.0 instead of after it, so the newest release was buried mid-list. These sections run oldest-to-newest.
177
+ - A test asserted a `failed` count against a dry-run baseline that is hardcoded to `0`, making the comparison a no-op. Asserted directly now, with the reasoning recorded rather than the misleading framing.
178
+
179
+ If you are on v0.5.1 you already have every fix in this release. If you are on **v0.5.0 or earlier, upgrade** — v0.5.1 fixed a data-loss bug where a single `memory remove` deleted a whole theme's mirrored memory.
180
+
159
181
  ## Multi-agent / per-minion themes
160
182
 
161
183
  Each systemd-run minion sets one header on its OpenAI client; everything else flows automatically:
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: hermes-memory-pgvector
3
- Version: 0.5.0
3
+ Version: 0.5.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
@@ -189,6 +189,28 @@ Correctness release from a full-codebase review. **No schema changes and no new
189
189
  - **`save_config` stops deleting your settings.** It replaced the whole `plugins.pgvector` block with schema-declared keys, silently dropping hand-edited ones that are read at runtime (`identity_aliases`, `embed_write_backoff`). It merges now.
190
190
  - **Fail-soft hardening** (invariant #4): `sync_turn` is wrapped, config casts are guarded, and the recall tools coerce non-string `query`/`scope`/`target` instead of raising `AttributeError` out of the hook. `hermes-pgvector install --remove` now fails closed and requires `--force` on a directory that isn't a generated shim, instead of deleting it outright.
191
191
 
192
+ ## New in v0.5.1 — `memory remove` no longer wipes a whole theme
193
+
194
+ Patch release, but **upgrade promptly**: it fixes a data-loss bug. No schema changes, no migrations, no API changes.
195
+
196
+ - **`remove` deleted every mirrored entry for a theme, not one.** `_worker` passed `old_text=item.content` — but the built-in tool's remove op carries its target in `old_text` and leaves `content` empty, and the host forwards `old_text` via *metadata*. So `item.content` was always `""`, `store.remove` built `content LIKE '%%'`, and that matches every row: a single `memory remove` deleted the entire mirror for that `(agent_identity, target)`. Verified against Postgres — `DELETE … WHERE c LIKE '%%'` removes all rows. `_worker` now reads `extra["old_text"]`, and `store.remove()` **refuses an empty pattern outright**, so no caller can reach that delete by omission. `remove()` also now deletes **at most one row** (lowest id), matching both the built-in tool — which requires a unique match and errors on ambiguity — and this class's own `replace()`. (The built-in store was never affected; only the pgvector mirror. No loss occurred on the reference deployment: every theme's history is continuous.)
197
+
198
+ Found in production: one `memory_entries` row sat with a NULL embedding and **zero-length content**, arrived through the built-in tool's `replace` path. Two defects met there.
199
+
200
+ - **Nothing rejected empty content on write.** `on_memory_write` filtered on `target` and `action` but never on content, so an `add`/`replace` carrying nothing created a row that can never be embedded — `embed()` raises `EmbeddingError("empty input")` unconditionally for empty or whitespace text. Such writes are now ignored (`remove` is exempt: it legitimately arrives with empty content and targets the row via `old_text`).
201
+ - **`backfill_null_embeddings` retried it forever.** The sweep selected `WHERE embedding IS NULL` with no content filter, so every nightly run re-fetched the row, called `embed()`, failed, and moved on — permanently pinning `failed` above zero and making `remaining == 0` unreachable. That is the damaging half: it destroys the one signal an operator watches, because you can no longer distinguish a permanently-stuck row from a new genuine failure. Un-embeddable rows are now **skipped and reported separately** as `unembeddable` (skipping them silently would be equally misleading), so `remaining` can actually reach zero again.
202
+
203
+ ## New in v0.5.2 — documentation only
204
+
205
+ **No code changes.** `git diff v0.5.1..v0.5.2` touches only `README.md` and one test file; nothing under `hermes_pgvector/` differs, so the installed behaviour is byte-for-byte identical to v0.5.1. There is no reason to redeploy for this release.
206
+
207
+ It exists because PyPI renders a project's README **frozen at upload time**: two fixes that landed after v0.5.1 shipped were visible on GitHub but not on the package page.
208
+
209
+ - **The v0.5.1 release notes were out of order.** The section sat before v0.5.0 instead of after it, so the newest release was buried mid-list. These sections run oldest-to-newest.
210
+ - A test asserted a `failed` count against a dry-run baseline that is hardcoded to `0`, making the comparison a no-op. Asserted directly now, with the reasoning recorded rather than the misleading framing.
211
+
212
+ If you are on v0.5.1 you already have every fix in this release. If you are on **v0.5.0 or earlier, upgrade** — v0.5.1 fixed a data-loss bug where a single `memory remove` deleted a whole theme's mirrored memory.
213
+
192
214
  ## Multi-agent / per-minion themes
193
215
 
194
216
  Each systemd-run minion sets one header on its OpenAI client; everything else flows automatically:
@@ -21,6 +21,7 @@ hermes_pgvector/migrations/004_runtime_grants.sql
21
21
  tests/test_async_writer.py
22
22
  tests/test_config_coercion.py
23
23
  tests/test_embed_timeouts.py
24
+ tests/test_empty_content.py
24
25
  tests/test_exclude_identities_live.py
25
26
  tests/test_hybrid_search.py
26
27
  tests/test_identity.py
@@ -888,6 +888,27 @@ class PgvectorMemoryProvider(MemoryProvider):
888
888
  if action not in ("add", "replace", "remove"):
889
889
  logger.debug("pgvector ignoring unknown action: %r", action)
890
890
  return
891
+ # An add/replace with no content produces a row that can NEVER be
892
+ # embedded: embed() raises EmbeddingError("empty input") on empty or
893
+ # whitespace text. Such a row is retried by every nightly backfill
894
+ # forever, always fails, and permanently prevents the NULL-embedding
895
+ # count from reaching zero -- destroying the one signal an operator
896
+ # watches. It also carries no information worth mirroring. `remove`
897
+ # is exempt: it legitimately arrives with empty content and targets
898
+ # the row via old_text.
899
+ if action in ("add", "replace") and not (content or "").strip():
900
+ # Not routine. on_memory_write only fires for writes the built-in
901
+ # tool COMMITTED, and it rejects empty add/replace content -- so
902
+ # arriving here means real content landed on disk and reached us
903
+ # as ''. Skipping keeps an un-embeddable row out of the table, but
904
+ # the mirror is now missing an entry the agent believes it saved,
905
+ # which is worth more than a debug line.
906
+ logger.warning(
907
+ "pgvector: %r for target=%r arrived with empty content; "
908
+ "skipping (the built-in store has it, the mirror will not)",
909
+ action, target,
910
+ )
911
+ return
891
912
 
892
913
  meta = dict(metadata or {})
893
914
  meta.setdefault("session_id", self._session_id)
@@ -957,11 +978,27 @@ class PgvectorMemoryProvider(MemoryProvider):
957
978
  metadata=item.metadata,
958
979
  )
959
980
  elif item.action == "remove":
960
- self._store.remove(
961
- agent_identity=item.agent_identity,
962
- target=item.target,
963
- old_text=item.content,
964
- )
981
+ # The removal target lives in extra["old_text"], NOT in content.
982
+ # The built-in tool's remove op takes old_text and leaves content
983
+ # empty (tools/memory_tool.py: remove -> store.remove(target,
984
+ # old_text)), and the host forwards old_text via METADATA
985
+ # (memory_manager.notify_memory_tool_write). Reading item.content
986
+ # here meant remove() was called with "", which becomes
987
+ # `content LIKE '%%'` -- matching every row and deleting the
988
+ # entire mirror for that (agent_identity, target).
989
+ old_text = item.extra.get("old_text") or item.content
990
+ if not (old_text or "").strip():
991
+ logger.warning(
992
+ "pgvector refusing remove with no old_text for %s/%s "
993
+ "(would match every row)",
994
+ item.agent_identity, item.target,
995
+ )
996
+ else:
997
+ self._store.remove(
998
+ agent_identity=item.agent_identity,
999
+ target=item.target,
1000
+ old_text=old_text,
1001
+ )
965
1002
  elif item.action == "turn":
966
1003
  role = item.extra.get("role") or "user"
967
1004
  sid = item.extra.get("session_id") or "default"
@@ -277,10 +277,17 @@ def cmd_stats(args) -> int:
277
277
  conv = store.count_turns()
278
278
  print(f"memory_entries: {mem} rows")
279
279
  print(f"conversations: {conv} rows")
280
- # null-embedding counts (dry-run backfill returns remaining-null per table)
280
+ # null-embedding counts (dry-run backfill returns remaining-null per table).
281
+ # `remaining` counts only rows that CAN be embedded; empty/whitespace rows
282
+ # are reported separately, because they never shrink and would otherwise
283
+ # make this number look permanently stuck for no actionable reason.
281
284
  nulls = store.backfill_null_embeddings(embed_fn=lambda t: [0.0] * 768, dry_run=True)
282
285
  for table, info in nulls.items():
283
- print(f" {table}: {info['remaining']} null-embedding rows")
286
+ line = f" {table}: {info['remaining']} null-embedding rows (backfillable)"
287
+ stuck = info.get("unembeddable")
288
+ if stuck:
289
+ line += f", {stuck} un-embeddable (empty content — will never backfill)"
290
+ print(line)
284
291
  if store.ensure_migration_002_applied():
285
292
  print("migration 002: applied — per-agent attribution:")
286
293
  for row in store.agent_attribution():
@@ -1,5 +1,5 @@
1
1
  name: pgvector
2
- version: 0.5.0
2
+ version: 0.5.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.5,<4"
@@ -296,18 +296,41 @@ class MemoryStore:
296
296
  target: str,
297
297
  old_text: str,
298
298
  ) -> int:
299
- """Delete entries in (agent_identity, target) matching old_text substring.
300
-
301
- Returns the number of rows deleted.
299
+ """Delete THE entry in (agent_identity, target) matching old_text.
300
+
301
+ Deletes at most ONE row (lowest id), matching both the built-in tool
302
+ and this class's own replace(). The built-in requires a UNIQUE match
303
+ and errors on ambiguity (memory_tool_store._edit -> _find_unique_match),
304
+ so one built-in remove is one entry; a mirror that deleted every
305
+ substring match would remove strictly more -- and the mirror is the
306
+ side that accumulates stale rows MEMORY.md no longer has, so "every
307
+ match" is wider here than it would be there.
308
+
309
+ Returns the number of rows deleted (0 or 1).
310
+
311
+ REFUSES an empty or whitespace-only old_text. `%{""}%` is `LIKE '%%'`,
312
+ which matches every row -- so a caller that lost the removal target
313
+ would silently delete the entire mirror for that (agent_identity,
314
+ target) instead of one entry. A delete this destructive must never be
315
+ reachable by omission; the caller has to say what it means to remove.
302
316
  """
317
+ if not (old_text or "").strip():
318
+ raise ValueError(
319
+ "remove() requires a non-empty old_text: an empty pattern is "
320
+ "LIKE '%%', which would delete every entry in this scope"
321
+ )
303
322
  with self._get_pool().connection() as conn:
304
323
  with conn.cursor() as cur:
305
324
  cur.execute(
306
325
  """
307
326
  DELETE FROM memory_entries
308
- WHERE agent_identity = %s
309
- AND target = %s
310
- AND content LIKE %s
327
+ WHERE id = (
328
+ SELECT id FROM memory_entries
329
+ WHERE agent_identity = %s
330
+ AND target = %s
331
+ AND content LIKE %s
332
+ ORDER BY id LIMIT 1
333
+ )
311
334
  """,
312
335
  (agent_identity, target, f"%{_escape_like(old_text)}%"),
313
336
  )
@@ -922,7 +945,15 @@ class MemoryStore:
922
945
  fail fast, never write a wrong-dim vector). If the embed endpoint is
923
946
  unreachable, the run aborts cleanly (nothing to backfill right now).
924
947
 
925
- Returns {table: {processed, succeeded, failed, remaining}}.
948
+ Rows whose content is empty or whitespace are EXCLUDED, not failed.
949
+ embed() raises EmbeddingError("empty input") on such text, so selecting
950
+ them means retrying a guaranteed failure on every nightly run forever,
951
+ permanently pinning `failed` above zero and making "remaining == 0"
952
+ unreachable -- which destroys the one signal an operator watches. They
953
+ are reported separately as `unembeddable` so they are skipped, not
954
+ hidden.
955
+
956
+ Returns {table: {processed, succeeded, failed, remaining, unembeddable}}.
926
957
  """
927
958
  tables = self._assert_whitelisted(tables)
928
959
  result: Dict[str, Dict[str, Any]] = {}
@@ -933,7 +964,8 @@ class MemoryStore:
933
964
  except Exception as exc: # noqa: BLE001
934
965
  logger.warning("backfill aborted — embed endpoint unavailable: %s", str(exc)[:200])
935
966
  return {t: {"processed": 0, "succeeded": 0, "failed": 0,
936
- "remaining": None, "note": "embed-unavailable"} for t in tables}
967
+ "remaining": None, "unembeddable": None,
968
+ "note": "embed-unavailable"} for t in tables}
937
969
  if not isinstance(probe, list) or len(probe) != 768:
938
970
  got = len(probe) if isinstance(probe, list) else type(probe).__name__
939
971
  raise ValueError(
@@ -944,10 +976,31 @@ class MemoryStore:
944
976
  for t in tables:
945
977
  with self._get_pool().connection() as conn:
946
978
  with conn.cursor() as cur:
947
- cur.execute(f"SELECT count(*) FROM {t} WHERE embedding IS NULL")
948
- remaining = int(cur.fetchone()[0])
979
+ cur.execute(
980
+ # content ~ '\S' means "has at least one non-whitespace
981
+ # character", which matches Python's str.strip()
982
+ # exactly. Postgres trim() defaults to SPACES ONLY, so a
983
+ # row holding just a newline or a tab would still be
984
+ # selected here, still raise EmbeddingError("empty
985
+ # input"), and still fail on every run -- the very bug
986
+ # this filter exists to stop.
987
+ rf"SELECT count(*) FILTER (WHERE content ~ '\S'), "
988
+ rf" count(*) FILTER (WHERE content !~ '\S' OR content IS NULL) "
989
+ f"FROM {t} WHERE embedding IS NULL"
990
+ )
991
+ row = cur.fetchone()
992
+ remaining, unembeddable = int(row[0]), int(row[1])
993
+ if unembeddable:
994
+ # Surfaced, not swallowed: these are permanently un-embeddable
995
+ # and would otherwise be invisible now that they are skipped.
996
+ logger.info(
997
+ "backfill %s: skipping %d row(s) with empty content "
998
+ "(cannot be embedded; delete them or leave them text-only)",
999
+ t, unembeddable,
1000
+ )
949
1001
  if dry_run:
950
- result[t] = {"processed": 0, "succeeded": 0, "failed": 0, "remaining": remaining}
1002
+ result[t] = {"processed": 0, "succeeded": 0, "failed": 0,
1003
+ "remaining": remaining, "unembeddable": unembeddable}
951
1004
  continue
952
1005
 
953
1006
  processed = succeeded = failed = 0
@@ -955,7 +1008,10 @@ class MemoryStore:
955
1008
  with self._get_pool().connection() as conn:
956
1009
  with conn.cursor(row_factory=dict_row) as cur:
957
1010
  cur.execute(
958
- f"SELECT id, content FROM {t} WHERE embedding IS NULL ORDER BY id LIMIT %s",
1011
+ f"SELECT id, content FROM {t} "
1012
+ f"WHERE embedding IS NULL "
1013
+ rf" AND content ~ '\S' "
1014
+ f"ORDER BY id LIMIT %s",
959
1015
  (batch_size,),
960
1016
  )
961
1017
  rows = list(cur.fetchall())
@@ -991,10 +1047,14 @@ class MemoryStore:
991
1047
 
992
1048
  with self._get_pool().connection() as conn:
993
1049
  with conn.cursor() as cur:
994
- cur.execute(f"SELECT count(*) FROM {t} WHERE embedding IS NULL")
1050
+ cur.execute(
1051
+ f"SELECT count(*) FROM {t} "
1052
+ rf"WHERE embedding IS NULL AND content ~ '\S'"
1053
+ )
995
1054
  remaining = int(cur.fetchone()[0])
996
1055
  result[t] = {"processed": processed, "succeeded": succeeded,
997
- "failed": failed, "remaining": remaining}
1056
+ "failed": failed, "remaining": remaining,
1057
+ "unembeddable": unembeddable}
998
1058
  return result
999
1059
 
1000
1060
  def prune_conversations(self, *, older_than_days: int, dry_run: bool = False) -> int:
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "hermes-memory-pgvector"
7
- version = "0.5.0"
7
+ version = "0.5.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"
@@ -0,0 +1,332 @@
1
+ """Empty-content rows must never be created, and never re-tried forever.
2
+
3
+ No DB required for the write-path half; the backfill half is live-mode.
4
+
5
+ IMPORTANT: point PG_TEST_DSN at a THROWAWAY database, never production. The
6
+ backfill cases call backfill_null_embeddings(), which sweeps the WHOLE table --
7
+ so on a shared database it would stamp every pre-existing NULL-embedding row
8
+ with this test's constant [0.1]*768 vector. Those rows are then no longer NULL
9
+ and a real backfill can never repair them. The `failed` assertion is table-wide
10
+ for the same reason.
11
+
12
+ Found in production. One row in `memory_entries` sat with `embedding IS NULL`
13
+ and `length(content) = 0` -- its metadata (`old_text`, `tool_name`) showing it
14
+ arrived through the built-in tool's `replace` path with empty new content.
15
+
16
+ Two separate defects met there:
17
+
18
+ * nothing on the write path rejected empty content, so the row was created;
19
+ * `backfill_null_embeddings` selected `WHERE embedding IS NULL` with no
20
+ content filter, so every nightly sweep re-fetched it, called embed(), got
21
+ EmbeddingError("empty input") -- which is unconditional for empty text --
22
+ marked it `failed`, and moved on. Forever.
23
+
24
+ The second is the damaging one. It pins `failed` above zero and makes
25
+ `remaining == 0` unreachable, so "are there un-embedded rows?" stops being
26
+ answerable: you cannot tell one permanently-stuck row from one new genuine
27
+ failure. Skipping them silently would be just as bad, so they are reported
28
+ separately as `unembeddable`.
29
+ """
30
+
31
+ from __future__ import annotations
32
+
33
+ import os
34
+ import sys
35
+ from pathlib import Path
36
+
37
+ import pytest
38
+
39
+ sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
40
+
41
+ from hermes_pgvector import PgvectorMemoryProvider # noqa: E402
42
+ from hermes_pgvector.store import MemoryStore # noqa: E402
43
+
44
+
45
+ class _RecordingWriter:
46
+ def __init__(self):
47
+ self.items = []
48
+
49
+ def enqueue(self, **kwargs) -> bool:
50
+ self.items.append(kwargs)
51
+ return True
52
+
53
+
54
+ def _provider():
55
+ p = PgvectorMemoryProvider()
56
+ p._healthy = True
57
+ p._writer = _RecordingWriter()
58
+ p._agent_identity = "marketing"
59
+ p._raw_identity = "marketing"
60
+ p._session_id = "sess-empty"
61
+ return p
62
+
63
+
64
+ # --- write path -------------------------------------------------------------
65
+
66
+ @pytest.mark.parametrize("content", ["", " ", "\n", "\t \n "])
67
+ @pytest.mark.parametrize("action", ["add", "replace"])
68
+ def test_empty_add_or_replace_is_not_mirrored(action, content):
69
+ p = _provider()
70
+ p.on_memory_write(action=action, target="memory", content=content)
71
+ assert p._writer.items == [], (
72
+ f"{action!r} with {content!r} must not be mirrored -- it creates a row "
73
+ "that can never be embedded and that backfill retries forever"
74
+ )
75
+
76
+
77
+ def test_remove_with_empty_content_is_still_mirrored():
78
+ """`remove` legitimately arrives with empty content -- the removal target
79
+ travels in metadata as old_text, not in content -- so the empty-content
80
+ guard must not swallow it.
81
+
82
+ NOTE: an earlier version of this docstring said the row is targeted "via
83
+ old_text" and left it there, which quietly asserted a path that was in fact
84
+ broken downstream: _worker read item.content, not extra["old_text"], so the
85
+ forwarded remove deleted EVERYTHING. See the _worker tests below -- this
86
+ test only pins that the enqueue happens, never that it is well-formed."""
87
+ p = _provider()
88
+ p.on_memory_write(
89
+ action="remove", target="memory", content="",
90
+ metadata={"old_text": "the entry being removed"},
91
+ )
92
+ assert len(p._writer.items) == 1
93
+ assert p._writer.items[0]["action"] == "remove"
94
+
95
+
96
+ def test_normal_add_is_unaffected():
97
+ p = _provider()
98
+ p.on_memory_write(action="add", target="memory", content="a real durable note")
99
+ assert len(p._writer.items) == 1
100
+ assert p._writer.items[0]["content"] == "a real durable note"
101
+
102
+
103
+ # --- backfill ---------------------------------------------------------------
104
+
105
+ @pytest.fixture
106
+ def store():
107
+ dsn = os.environ.get("PG_TEST_DSN")
108
+ if not dsn:
109
+ pytest.skip("PG_TEST_DSN not set")
110
+ s = MemoryStore(dsn)
111
+ s.ensure_schema()
112
+ agent = "pytest-empty-" + os.urandom(4).hex()
113
+ yield s, agent
114
+ import psycopg
115
+ with psycopg.connect(dsn) as conn:
116
+ with conn.cursor() as cur:
117
+ cur.execute("DELETE FROM memory_entries WHERE agent_identity LIKE %s", (agent + "%",))
118
+ conn.commit()
119
+
120
+
121
+ def _insert_raw(s, agent, content):
122
+ """Bypass add() to plant the shape production actually had: NULL embedding
123
+ plus empty content."""
124
+ with s._get_pool().connection() as conn:
125
+ with conn.cursor() as cur:
126
+ cur.execute(
127
+ "INSERT INTO memory_entries (agent_identity, target, content, embedding, metadata)"
128
+ " VALUES (%s, 'memory', %s, NULL, '{}'::jsonb) RETURNING id",
129
+ (agent, content),
130
+ )
131
+ rid = cur.fetchone()[0]
132
+ conn.commit()
133
+ return rid
134
+
135
+
136
+ def test_backfill_skips_empty_content_and_reports_it(store):
137
+ s, agent = store
138
+ empty_id = _insert_raw(s, agent, "")
139
+ real_id = _insert_raw(s, agent, "a genuinely embeddable durable note")
140
+
141
+ seen = []
142
+
143
+ def _embed_fn(text):
144
+ seen.append(text)
145
+ return [0.1] * 768
146
+
147
+ report = s.backfill_null_embeddings(embed_fn=_embed_fn, tables=["memory_entries"])
148
+
149
+ assert "" not in seen, "the empty row must never reach embed()"
150
+ assert any("genuinely embeddable" in x for x in seen), "the real row must be embedded"
151
+ assert report["memory_entries"]["unembeddable"] >= 1, "skipped rows must be reported, not hidden"
152
+
153
+ with s._get_pool().connection() as conn:
154
+ with conn.cursor() as cur:
155
+ cur.execute("SELECT embedding IS NULL FROM memory_entries WHERE id = %s", (empty_id,))
156
+ assert cur.fetchone()[0] is True, "empty row is left alone, not failed"
157
+ cur.execute("SELECT embedding IS NULL FROM memory_entries WHERE id = %s", (real_id,))
158
+ assert cur.fetchone()[0] is False, "real row got embedded"
159
+
160
+
161
+ def _scoped_backlog(s, agent):
162
+ """`remaining`, computed for THIS test's rows only.
163
+
164
+ backfill_null_embeddings reports table-wide numbers, but the fixture only
165
+ cleans its own agent_identity prefix -- so asserting on the table-wide
166
+ figure makes the test depend on every other row in the database and fail
167
+ permanently after one interrupted run. Assert the same property, scoped."""
168
+ with s._get_pool().connection() as conn:
169
+ with conn.cursor() as cur:
170
+ cur.execute(
171
+ r"SELECT count(*) FROM memory_entries "
172
+ r"WHERE agent_identity = %s AND embedding IS NULL AND content ~ '\S'",
173
+ (agent,),
174
+ )
175
+ return int(cur.fetchone()[0])
176
+
177
+
178
+ def test_backfill_remaining_can_reach_zero_despite_an_empty_row(store):
179
+ """The point of the fix: an un-embeddable row must not pin the backlog
180
+ above zero forever, or 'is the backlog clear?' becomes unanswerable."""
181
+ s, agent = store
182
+ _insert_raw(s, agent, "")
183
+ _insert_raw(s, agent, "another embeddable note for the sweep")
184
+ assert _scoped_backlog(s, agent) == 1, "one embeddable row to start"
185
+
186
+ report = s.backfill_null_embeddings(
187
+ embed_fn=lambda t: [0.1] * 768, tables=["memory_entries"]
188
+ )
189
+
190
+ assert _scoped_backlog(s, agent) == 0, (
191
+ "the backlog must exclude un-embeddable rows so it can actually reach 0"
192
+ )
193
+ # Asserted directly, not as a delta against a dry-run baseline: dry_run
194
+ # hardcodes "failed": 0 (store.py), so such a baseline is always 0 and the
195
+ # comparison would be theatre. `failed` is table-wide, which is sound here
196
+ # only because this fixture requires a THROWAWAY database (stated in the
197
+ # module docstring above) -- on a shared one, a pre-existing failing row
198
+ # would surface here, and that is worth knowing rather than hiding.
199
+ assert report["memory_entries"]["failed"] == 0, (
200
+ "an un-embeddable row must be skipped, not counted as a failure"
201
+ )
202
+ assert report["memory_entries"]["unembeddable"] >= 1
203
+
204
+
205
+ def test_backfill_skips_whitespace_only_content(store):
206
+ """Not just the empty string. Postgres trim() strips SPACES ONLY, so a row
207
+ holding a newline or tab used to pass the filter, reach embed(), and fail
208
+ forever -- the same bug for a different shape. The predicate now matches
209
+ Python's str.strip()."""
210
+ s, agent = store
211
+ for blank in ("", " ", chr(10), chr(9) + chr(10) + " "):
212
+ _insert_raw(s, agent, blank)
213
+ real_id = _insert_raw(s, agent, "a real note alongside the blank ones")
214
+
215
+ seen = []
216
+
217
+ def _embed_fn(text):
218
+ seen.append(text)
219
+ return [0.1] * 768
220
+
221
+ report = s.backfill_null_embeddings(embed_fn=_embed_fn, tables=["memory_entries"])
222
+
223
+ assert all(x.strip() for x in seen), f"a blank row reached embed(): {seen!r}"
224
+ assert report["memory_entries"]["unembeddable"] >= 4
225
+ assert _scoped_backlog(s, agent) == 0
226
+
227
+ with s._get_pool().connection() as conn:
228
+ with conn.cursor() as cur:
229
+ cur.execute("SELECT embedding IS NULL FROM memory_entries WHERE id = %s", (real_id,))
230
+ assert cur.fetchone()[0] is False
231
+
232
+
233
+ # ---------------------------------------------------------------------------
234
+ # CRITICAL: the remove path must never be able to wipe a whole scope.
235
+ #
236
+ # store.remove() matches with `content LIKE %<old_text>%`. With an empty
237
+ # old_text that is LIKE '%%', which matches EVERY row -- so a remove that lost
238
+ # its target deletes the entire mirror for that (agent_identity, target)
239
+ # instead of one entry. Verified against Postgres: DELETE ... WHERE c LIKE '%%'
240
+ # removed all rows.
241
+ #
242
+ # _worker used to pass old_text=item.content. The built-in tool's remove op
243
+ # takes old_text and leaves content empty (tools/memory_tool.py:87), and the
244
+ # host forwards old_text through METADATA (memory_manager.notify_memory_tool_write),
245
+ # so item.content was ALWAYS '' for a remove -- making every mirrored
246
+ # `memory remove` a full wipe of that theme.
247
+ # ---------------------------------------------------------------------------
248
+
249
+ class _FakeStore:
250
+ def __init__(self):
251
+ self.removes = []
252
+
253
+ def remove(self, *, agent_identity, target, old_text):
254
+ self.removes.append(old_text)
255
+ return 1
256
+
257
+
258
+ def _worker_provider():
259
+ p = PgvectorMemoryProvider()
260
+ p._healthy = True
261
+ p._store = _FakeStore()
262
+ return p
263
+
264
+
265
+ class _Item:
266
+ def __init__(self, content="", extra=None):
267
+ self.action = "remove"
268
+ self.agent_identity = "marketing"
269
+ self.target = "memory"
270
+ self.content = content
271
+ self.extra = extra or {}
272
+ self.metadata = {}
273
+
274
+
275
+ def test_worker_remove_uses_old_text_not_content():
276
+ """The regression: content is empty for every real remove, so reading it
277
+ produced LIKE '%%' and deleted the whole scope."""
278
+ p = _worker_provider()
279
+ p._worker(_Item(content="", extra={"old_text": "the entry to delete"}))
280
+ assert p._store.removes == ["the entry to delete"]
281
+
282
+
283
+ def test_worker_remove_refuses_when_no_target_is_available():
284
+ """Belt and braces: with neither content nor old_text there is nothing to
285
+ match, and the store must not be called at all."""
286
+ for item in (_Item(content="", extra={}), _Item(content=" ", extra={"old_text": " "})):
287
+ p = _worker_provider()
288
+ p._worker(item)
289
+ assert p._store.removes == [], "a target-less remove must never reach the store"
290
+
291
+
292
+ def test_worker_remove_falls_back_to_content_when_old_text_absent():
293
+ """Older/other callers that put the target in content still work."""
294
+ p = _worker_provider()
295
+ p._worker(_Item(content="target in content", extra={}))
296
+ assert p._store.removes == ["target in content"]
297
+
298
+
299
+ def test_store_remove_rejects_empty_old_text():
300
+ """Store-level guard, independent of any caller: an empty pattern is
301
+ LIKE '%%' and must be impossible to reach by omission."""
302
+ s = MemoryStore.__new__(MemoryStore) # no connection needed; guard is first
303
+ for bad in ("", " ", chr(10), chr(9) + " "):
304
+ with pytest.raises(ValueError, match="non-empty old_text"):
305
+ s.remove(agent_identity="a", target="memory", old_text=bad)
306
+
307
+
308
+ def test_store_remove_guard_runs_before_any_db_work(store):
309
+ """Live: the guard must fire before touching the pool, and delete nothing."""
310
+ s, agent = store
311
+ _insert_raw(s, agent, "row one that must survive")
312
+ _insert_raw(s, agent, "row two that must survive")
313
+
314
+ with pytest.raises(ValueError):
315
+ s.remove(agent_identity=agent, target="memory", old_text="")
316
+
317
+ with s._get_pool().connection() as conn:
318
+ with conn.cursor() as cur:
319
+ cur.execute("SELECT count(*) FROM memory_entries WHERE agent_identity = %s", (agent,))
320
+ assert cur.fetchone()[0] == 2, "an empty remove must delete nothing"
321
+
322
+
323
+ def test_store_remove_still_deletes_a_real_match(store):
324
+ s, agent = store
325
+ _insert_raw(s, agent, "delete me please")
326
+ _insert_raw(s, agent, "keep me around")
327
+ n = s.remove(agent_identity=agent, target="memory", old_text="delete me")
328
+ assert n == 1
329
+ with s._get_pool().connection() as conn:
330
+ with conn.cursor() as cur:
331
+ cur.execute("SELECT content FROM memory_entries WHERE agent_identity = %s", (agent,))
332
+ assert [r[0] for r in cur.fetchall()] == ["keep me around"]