Six weeks after v0.4.2 made the plugin pip-installable, hermes-memory-pgvector v0.5.0 is out. It is the first release in the 0.x line with a genuinely breaking change, and the reason is a name I should not have taken in the first place.
The collision
The import package was called pgvector. That name is owned by pgvector-python. Install both into the same virtualenv and whichever landed last wins the top-level name — at which point this plugin's discovery shim could import the wrong module entirely.
The failure mode is what makes it worth a major-ish bump: the loader treats a bad import as "plugin absent" and falls back to built-in memory on a single log line. Nothing crashes. The fleet just quietly stops sharing memory, and you find out weeks later when recall comes back empty.
So the import package is now hermes_pgvector. Three things that did not change, because they are separate namespaces and only one of them moved:
- the distribution — still
hermes-memory-pgvector - the CLI — still
hermes-pgvector - the hermes provider — still
pgvector(memory.provider: pgvector)
Upgrading
v0.5.0 declares the hermes_agent.memory_providers entry point, so on a host new enough to resolve plugins that way, a plain pip install is now sufficient and the shim is no longer needed. A shim directory still takes precedence over the entry point, which is exactly why a stale one has to go:
pip install -U hermes-memory-pgvector
# preferred: drop the shim, let the entry point resolve it
hermes-pgvector install --remove
# older host that only scans plugin directories:
hermes-pgvector install --force
hermes memory status # expect: Provider: pgvector; Status: available
Check your scheduled jobs before you walk away. Any python -m pgvector ... invocation is now hermes-pgvector ..., and it fails quietly — the nightly backfill simply stops, and rows written during an embed outage stay unsearchable:
sudo grep -rl 'python -m pgvector' /etc/systemd/system/ /etc/cron.d/
The part I did not expect
A full-codebase review of all 24 tracked files turned up 28 confirmed findings. The ones that stung were not edge cases — they were config contracts that contradicted themselves entirely inside this repo, each one silently inverting a control while looking correctly configured:
allowed_themeswas declared a string and consumed as a list. A string allow-list got iterated character by character, so every theme failed the membership test and the whole fleet was routed todefault. Identity governance looked configured while doing the precise opposite.- Boolean toggles ignored
false.embed_on_write,sync_turns,hybrid_searchandbulk_sync_on_initare declared by the schema as the strings"true"/"false", then read with plain truthiness.bool("false")isTrue. - Embed timeouts were never plumbed from config. Every caller took a hardcoded 10s. Against an endpoint answering in 6–17s, a large share of writes timed out and landed with NULL embeddings — and retries could not help, because each attempt was capped below the latency it needed. Now split by path:
embed_timeout(10s, agent thread) andembed_write_timeout(30s, background writer). replace()updated zero rows. A bulk UPDATE collided withUNIQUE(agent_identity, target, content)and raisedUniqueViolation, so a replace matching two or more entries did nothing while the built-in tool's state moved on.- Every conversation turn was written twice.
sync_turnandon_session_endboth captured the same turns, andconversationshas no unique constraint. - A dead database was silent. Worker failures logged at
debugand_healthywas never re-probed, so a Postgres restart discarded every durable write for the session with no signal at all.
That is the honest story of this release: the rename is the headline, and the config bugs are the reason the rename was worth doing now rather than at 1.0.
Identity bucketing is enforced on both sides now
whatsapp-dm, the new external-group, and _bench were write-side sinks only. Identity normalization stripped PII from the identity, but message bodies still live in content and nothing filtered them on read — so any theme could pull DM content into its context via scope='all'. Now scope='all' excludes those sinks and naming one explicitly is rejected. An agent that is a bucket keeps full access to its own rows, and ordinary cross-theme recall is unchanged.
One caveat worth reading carefully: the gate excludes by bucket name, and bucketing happens at write time. Rows are never retroactively rewritten — that is deliberate, since historical rows would otherwise become unrecallable. Rows written before their key was bucketed keep the raw identity. hermes-pgvector remap --old <raw> --new whatsapp-dm previews the move; add --execute to actually perform it.
Validation
100 tests in skip-mode, 145 against live Postgres 16 with pgvector 0.8.6 and all four migrations applied. No schema changes and no new migrations in this release.
Update — v0.5.1
v0.5.1 landed an hour later, and it is the one release in this line you should not sit on: a single memory remove deleted every mirrored entry for a theme, not one.
The built-in tool's remove op carries its target in old_text and leaves content empty, and the host forwards it through metadata rather than content. _worker was passing old_text=item.content, which was therefore always "" — so store.remove built content LIKE '%%', and that matches every row. One remove wiped the entire mirror for that (agent_identity, target) pair. The built-in store was never affected; only the pgvector mirror. I checked the reference deployment for damage and found none — every theme's history is continuous, which mostly tells you how rare removes are in practice.
It is fixed at two layers, because one is not enough for a silent full-scope delete: _worker now reads extra["old_text"] and refuses a remove with no usable target, and store.remove() rejects an empty pattern outright, so the destructive delete is unreachable by omission from any caller. remove() also now deletes at most one row, matching the built-in.
The same release fixed the empty-content problem sitting behind it. Nothing rejected an empty add/replace, so a row could exist that embed() can never process — EmbeddingError("empty input") is unconditional for blank text. The nightly backfill then retried it on every run, pinning failed above zero and making remaining == 0 unreachable, which destroys the one signal an operator actually watches: a permanently stuck row becomes indistinguishable from a new genuine failure. Empty writes are now skipped (remove exempt), and un-embeddable rows are excluded from the sweep and reported separately as unembeddable.
Two smaller things worth naming, because both were invisible. Postgres trim() strips spaces only, so a row holding just a newline or tab still slipped through and still failed forever — every site now uses content ~ '\S'. And those predicates were plain f-strings, where \S is an invalid escape: four DeprecationWarnings today, SyntaxWarning on 3.12+, and under -W error the module fails to import outright — which would make hermes-agent's loader quietly fall back to built-in memory.
Validation: 118 tests in skip-mode, 164 against live Postgres 16 with pgvector 0.8.6, all four migrations applied. Two review passes — the first found the data-loss bug, the second found the broken escape sequence in that fix.
Get it
pip install hermes-memory-pgvector
Source, upgrade notes, and full config reference on GitHub:
