hermes-memory-pgvector 0.6.0__tar.gz → 1.0.0__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 (53) hide show
  1. {hermes_memory_pgvector-0.6.0 → hermes_memory_pgvector-1.0.0}/LICENSE +1 -1
  2. {hermes_memory_pgvector-0.6.0 → hermes_memory_pgvector-1.0.0}/PKG-INFO +26 -7
  3. {hermes_memory_pgvector-0.6.0 → hermes_memory_pgvector-1.0.0}/README.md +24 -5
  4. {hermes_memory_pgvector-0.6.0 → hermes_memory_pgvector-1.0.0}/hermes_memory_pgvector.egg-info/PKG-INFO +26 -7
  5. {hermes_memory_pgvector-0.6.0 → hermes_memory_pgvector-1.0.0}/hermes_memory_pgvector.egg-info/SOURCES.txt +6 -1
  6. {hermes_memory_pgvector-0.6.0 → hermes_memory_pgvector-1.0.0}/hermes_pgvector/__init__.py +157 -40
  7. {hermes_memory_pgvector-0.6.0 → hermes_memory_pgvector-1.0.0}/hermes_pgvector/__main__.py +9 -5
  8. {hermes_memory_pgvector-0.6.0 → hermes_memory_pgvector-1.0.0}/hermes_pgvector/embed.py +18 -0
  9. {hermes_memory_pgvector-0.6.0 → hermes_memory_pgvector-1.0.0}/hermes_pgvector/identity.py +17 -3
  10. {hermes_memory_pgvector-0.6.0 → hermes_memory_pgvector-1.0.0}/hermes_pgvector/plugin.yaml +1 -1
  11. {hermes_memory_pgvector-0.6.0 → hermes_memory_pgvector-1.0.0}/hermes_pgvector/store.py +134 -27
  12. {hermes_memory_pgvector-0.6.0 → hermes_memory_pgvector-1.0.0}/pyproject.toml +2 -2
  13. {hermes_memory_pgvector-0.6.0 → hermes_memory_pgvector-1.0.0}/tests/test_config_coercion.py +8 -0
  14. {hermes_memory_pgvector-0.6.0 → hermes_memory_pgvector-1.0.0}/tests/test_embed_timeouts.py +25 -1
  15. {hermes_memory_pgvector-0.6.0 → hermes_memory_pgvector-1.0.0}/tests/test_empty_content.py +70 -5
  16. {hermes_memory_pgvector-0.6.0 → hermes_memory_pgvector-1.0.0}/tests/test_identity.py +49 -0
  17. {hermes_memory_pgvector-0.6.0 → hermes_memory_pgvector-1.0.0}/tests/test_install_shim.py +4 -1
  18. {hermes_memory_pgvector-0.6.0 → hermes_memory_pgvector-1.0.0}/tests/test_read_side_gate.py +39 -0
  19. {hermes_memory_pgvector-0.6.0 → hermes_memory_pgvector-1.0.0}/tests/test_save_config_merge.py +15 -18
  20. {hermes_memory_pgvector-0.6.0 → hermes_memory_pgvector-1.0.0}/tests/test_store_v04.py +15 -12
  21. {hermes_memory_pgvector-0.6.0 → hermes_memory_pgvector-1.0.0}/tests/test_turn_dedup.py +5 -4
  22. {hermes_memory_pgvector-0.6.0 → hermes_memory_pgvector-1.0.0}/tests/test_v060_migrations_cli.py +50 -0
  23. {hermes_memory_pgvector-0.6.0 → hermes_memory_pgvector-1.0.0}/tests/test_v060_provider_writes.py +48 -6
  24. {hermes_memory_pgvector-0.6.0 → hermes_memory_pgvector-1.0.0}/tests/test_v060_store.py +10 -8
  25. hermes_memory_pgvector-1.0.0/tests/test_v100_pass2_provider.py +213 -0
  26. hermes_memory_pgvector-1.0.0/tests/test_v100_pass2_store.py +277 -0
  27. hermes_memory_pgvector-1.0.0/tests/test_v100_pass3.py +99 -0
  28. hermes_memory_pgvector-1.0.0/tests/test_v100_provider_fixes.py +343 -0
  29. hermes_memory_pgvector-1.0.0/tests/test_v100_review_fixes.py +239 -0
  30. {hermes_memory_pgvector-0.6.0 → hermes_memory_pgvector-1.0.0}/hermes_memory_pgvector.egg-info/dependency_links.txt +0 -0
  31. {hermes_memory_pgvector-0.6.0 → hermes_memory_pgvector-1.0.0}/hermes_memory_pgvector.egg-info/entry_points.txt +0 -0
  32. {hermes_memory_pgvector-0.6.0 → hermes_memory_pgvector-1.0.0}/hermes_memory_pgvector.egg-info/requires.txt +0 -0
  33. {hermes_memory_pgvector-0.6.0 → hermes_memory_pgvector-1.0.0}/hermes_memory_pgvector.egg-info/top_level.txt +0 -0
  34. {hermes_memory_pgvector-0.6.0 → hermes_memory_pgvector-1.0.0}/hermes_pgvector/migrations/001_schema.sql +0 -0
  35. {hermes_memory_pgvector-0.6.0 → hermes_memory_pgvector-1.0.0}/hermes_pgvector/migrations/002_agent_attribution.sql +0 -0
  36. {hermes_memory_pgvector-0.6.0 → hermes_memory_pgvector-1.0.0}/hermes_pgvector/migrations/003_hybrid_search_fts.sql +0 -0
  37. {hermes_memory_pgvector-0.6.0 → hermes_memory_pgvector-1.0.0}/hermes_pgvector/migrations/004_runtime_grants.sql +0 -0
  38. {hermes_memory_pgvector-0.6.0 → hermes_memory_pgvector-1.0.0}/hermes_pgvector/migrations/005_content_md5_unique.sql +0 -0
  39. {hermes_memory_pgvector-0.6.0 → hermes_memory_pgvector-1.0.0}/hermes_pgvector/writer.py +0 -0
  40. {hermes_memory_pgvector-0.6.0 → hermes_memory_pgvector-1.0.0}/setup.cfg +0 -0
  41. {hermes_memory_pgvector-0.6.0 → hermes_memory_pgvector-1.0.0}/tests/test_async_writer.py +0 -0
  42. {hermes_memory_pgvector-0.6.0 → hermes_memory_pgvector-1.0.0}/tests/test_embed_config.py +0 -0
  43. {hermes_memory_pgvector-0.6.0 → hermes_memory_pgvector-1.0.0}/tests/test_exclude_identities_live.py +0 -0
  44. {hermes_memory_pgvector-0.6.0 → hermes_memory_pgvector-1.0.0}/tests/test_fake_embed_server.py +0 -0
  45. {hermes_memory_pgvector-0.6.0 → hermes_memory_pgvector-1.0.0}/tests/test_hybrid_search.py +0 -0
  46. {hermes_memory_pgvector-0.6.0 → hermes_memory_pgvector-1.0.0}/tests/test_loader_embed_clobber.py +0 -0
  47. {hermes_memory_pgvector-0.6.0 → hermes_memory_pgvector-1.0.0}/tests/test_session_switch_contract.py +0 -0
  48. {hermes_memory_pgvector-0.6.0 → hermes_memory_pgvector-1.0.0}/tests/test_smoke.py +0 -0
  49. {hermes_memory_pgvector-0.6.0 → hermes_memory_pgvector-1.0.0}/tests/test_system_prompt_block.py +0 -0
  50. {hermes_memory_pgvector-0.6.0 → hermes_memory_pgvector-1.0.0}/tests/test_tool_args_hardening.py +0 -0
  51. {hermes_memory_pgvector-0.6.0 → hermes_memory_pgvector-1.0.0}/tests/test_v060_hook_hardening.py +0 -0
  52. {hermes_memory_pgvector-0.6.0 → hermes_memory_pgvector-1.0.0}/tests/test_v060_identity_writer.py +0 -0
  53. {hermes_memory_pgvector-0.6.0 → hermes_memory_pgvector-1.0.0}/tests/test_v060_provider_reads.py +0 -0
@@ -1,6 +1,6 @@
1
1
  BSD 3-Clause License
2
2
 
3
- Copyright (c) 2026, Andrea Borghi
3
+ Copyright (c) 2026, Green Yoga Inc
4
4
 
5
5
  Redistribution and use in source and binary forms, with or without
6
6
  modification, are permitted provided that the following conditions are met:
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: hermes-memory-pgvector
3
- Version: 0.6.0
3
+ Version: 1.0.0
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
@@ -9,7 +9,7 @@ Project-URL: Source, https://github.com/andreab67/hermes-memory-pgvector
9
9
  Project-URL: Issues, https://github.com/andreab67/hermes-memory-pgvector/issues
10
10
  Project-URL: Roadmap, https://github.com/andreab67/hermes-memory-pgvector/blob/main/ROADMAP.md
11
11
  Keywords: hermes-agent,memory-provider,pgvector,postgres,multi-agent,llm-memory,semantic-search
12
- Classifier: Development Status :: 4 - Beta
12
+ Classifier: Development Status :: 5 - Production/Stable
13
13
  Classifier: Intended Audience :: Developers
14
14
  Classifier: Intended Audience :: System Administrators
15
15
  Classifier: License :: OSI Approved :: BSD License
@@ -73,7 +73,7 @@ What was missing: a **storage layer** that gives the built-in `memory` model dur
73
73
  | `initialize()` | Verifies schema, opens a `psycopg_pool.ConnectionPool`, bulk-imports existing `MEMORY.md` + `USER.md` content. |
74
74
  | `on_memory_write(action, target, content, meta)` | Mirrors built-in `memory` writes into `memory_entries` (add / replace / remove). |
75
75
  | `sync_turn(user, assistant, session_id)` / `on_session_end(messages)` | Captures every substantive chat turn into `conversations`; `on_session_end` is a dedup'd backstop. |
76
- | `on_delegation(task, result, …)` | Records parent→child delegation provenance (`memory_agent_edges`) and stores the exchange as a recallable turn. |
76
+ | `on_delegation(task, result, …)` | Records parent→child delegation provenance (`memory_agent_edges`) and stores the exchange as a recallable turn. `memory_agent_edges.child_identity` is only filled when the host passes a child identity; current upstream hermes-agent passes only the task, result and `child_session_id`, so it is NULL there (`child_session_id` is populated). |
77
77
  | `prefetch(query)` / `queue_prefetch(query, session_id=...)` | Ambient recall: top-K similar `memory_entries` for the current theme, injected into the system prompt. `queue_prefetch` pre-computes it off the agent thread. |
78
78
  | `recall_memory(query, scope, target, limit)` tool | Explicit cross-theme search of durable memory entries. |
79
79
  | `recall_conversation(query, scope, limit)` tool | Explicit search over past chat turns. `scope ∈ {current, session, all, <theme>}`. |
@@ -121,7 +121,7 @@ mkdir -p ~/.hermes/plugins
121
121
  cp -r hermes_pgvector ~/.hermes/plugins/pgvector
122
122
  ```
123
123
 
124
- Then apply migrations and activate as in Option 1 above (`hermes-pgvector migrate --admin-dsn ...`, or `psql -f` each `migrations/*.sql` in order — see [docs/operations.md](docs/operations.md) for the full admin walkthrough and [docs/upgrading.md](docs/upgrading.md) if you're coming from an older version).
124
+ Then apply migrations and activate as in Option 1 above (`hermes-pgvector migrate --admin-dsn ...`, or `psql -f` each `hermes_pgvector/migrations/*.sql` in order — see [docs/operations.md](docs/operations.md) for the full admin walkthrough and [docs/upgrading.md](docs/upgrading.md) if you're coming from an older version).
125
125
 
126
126
  ## Configuration
127
127
 
@@ -152,11 +152,14 @@ client = AsyncOpenAI(
152
152
 
153
153
  The gateway plumbs `X-Hermes-Session-Key` through as `gateway_session_key=…` in `MemoryProvider.initialize` kwargs, taking priority over the profile default so unprofiled API traffic doesn't collapse every minion into one shared `default` scope.
154
154
 
155
- Convention: lowercase, dash-separated, stable (identities are case-folded automatically — `Marketing` and `marketing` are the same theme). Governed sinks that always exist regardless of your themes: `whatsapp-dm` (collapsed DM/session keys), `external-group` (collapsed group/channel/thread keys), `_bench` (benchmark traffic), `default` (last resort). Set `plugins.pgvector.allowed_themes` to your product/worker list to enforce an allow-list — an unknown or typo'd header then falls back to `default` (one-time warning) instead of silently minting a new theme.
155
+ Identity resolution order in `initialize`: `gateway_session_key` > `agent_identity` (unless it is `default`) > `agent_workspace` > `default`. Upstream hermes-agent always passes `agent_workspace="hermes"` (and `agent_identity=<profile name>`), so a default-profile CLI or cron session with no gateway session key lands in the theme `hermes`, not `default`; named profiles land in their profile name. Set the `X-Hermes-Session-Key` header (or a non-default profile) if you want a scope other than `hermes`. When `allowed_themes` is set, `hermes` and your profile names are not exempt: include them in the list if you want those sessions to keep their own theme, otherwise they fall back to `default`.
156
+
157
+ Convention: lowercase, dash-separated, stable (identities are case-folded automatically — `Marketing` and `marketing` are the same theme). Governed sinks that always exist regardless of your themes: `whatsapp-dm` (collapsed DM/session keys), `external-group` (collapsed group/channel/thread keys), `_bench` (benchmark traffic), `default` (only when the host passes no identity kwargs at all). Set `plugins.pgvector.allowed_themes` to your product/worker list to enforce an allow-list — an unknown or typo'd header then falls back to `default` (one-time warning) instead of silently minting a new theme.
156
158
 
157
159
  ## Compatibility policy
158
160
 
159
- This plugin follows semantic versioning on its **public surface**:
161
+ This plugin follows semantic versioning on its **public surface**, stable as
162
+ of `1.0.0`:
160
163
 
161
164
  - `plugins.pgvector.*` config keys (see [docs/configuration.md](docs/configuration.md))
162
165
  - Tool names and parameters (`recall_memory`, `recall_conversation`)
@@ -164,7 +167,11 @@ This plugin follows semantic versioning on its **public surface**:
164
167
  - Database table/column names
165
168
  - The `pgvector` provider name and the package's pip entry point
166
169
 
167
- `MemoryStore` and every other Python class/module are **internal** — not covered by semver, safe to change between minor releases. A migration file, once released, is never edited in place; schema changes land as a new numbered migration.
170
+ Within the `1.x` series this surface is guaranteed stable: it may grow (new
171
+ config keys, new optional params), but nothing already listed is renamed or
172
+ removed, and no default changes behaviour, without a `2.0` release.
173
+
174
+ `MemoryStore` and every other Python class/module are **internal** — not covered by semver, safe to change between minor releases, and excluded from the `1.x` guarantee above. A migration file, once released, is never edited in place; schema changes land as a new numbered migration.
168
175
 
169
176
  ## Support matrix
170
177
 
@@ -198,6 +205,18 @@ pytest -q # no DB/embed endpoint: everything
198
205
  # Live mode (against a throwaway Postgres + your embed endpoint)
199
206
  export PG_TEST_DSN='dbname=hermes_test user=postgres host=/var/run/postgresql'
200
207
  export PG_TEST_EMBED_URL='http://your-embed-endpoint:11434'
208
+ # Superuser DSN for the same database: the migration, CLI and empty-content
209
+ # tests skip without it, and CI fails on any skip.
210
+ export PG_TEST_ADMIN_DSN='dbname=hermes_test user=postgres host=/var/run/postgresql'
211
+ pytest -q
212
+ ```
213
+
214
+ The simplest path is the bundled harness (Postgres + pgvector in Docker and a
215
+ fake embed server), which prints all the variables for you:
216
+
217
+ ```bash
218
+ scripts/test-env.sh up pg17
219
+ eval "$(scripts/test-env.sh db hermes_test)"
201
220
  pytest -q
202
221
  ```
203
222
 
@@ -36,7 +36,7 @@ What was missing: a **storage layer** that gives the built-in `memory` model dur
36
36
  | `initialize()` | Verifies schema, opens a `psycopg_pool.ConnectionPool`, bulk-imports existing `MEMORY.md` + `USER.md` content. |
37
37
  | `on_memory_write(action, target, content, meta)` | Mirrors built-in `memory` writes into `memory_entries` (add / replace / remove). |
38
38
  | `sync_turn(user, assistant, session_id)` / `on_session_end(messages)` | Captures every substantive chat turn into `conversations`; `on_session_end` is a dedup'd backstop. |
39
- | `on_delegation(task, result, …)` | Records parent→child delegation provenance (`memory_agent_edges`) and stores the exchange as a recallable turn. |
39
+ | `on_delegation(task, result, …)` | Records parent→child delegation provenance (`memory_agent_edges`) and stores the exchange as a recallable turn. `memory_agent_edges.child_identity` is only filled when the host passes a child identity; current upstream hermes-agent passes only the task, result and `child_session_id`, so it is NULL there (`child_session_id` is populated). |
40
40
  | `prefetch(query)` / `queue_prefetch(query, session_id=...)` | Ambient recall: top-K similar `memory_entries` for the current theme, injected into the system prompt. `queue_prefetch` pre-computes it off the agent thread. |
41
41
  | `recall_memory(query, scope, target, limit)` tool | Explicit cross-theme search of durable memory entries. |
42
42
  | `recall_conversation(query, scope, limit)` tool | Explicit search over past chat turns. `scope ∈ {current, session, all, <theme>}`. |
@@ -84,7 +84,7 @@ mkdir -p ~/.hermes/plugins
84
84
  cp -r hermes_pgvector ~/.hermes/plugins/pgvector
85
85
  ```
86
86
 
87
- Then apply migrations and activate as in Option 1 above (`hermes-pgvector migrate --admin-dsn ...`, or `psql -f` each `migrations/*.sql` in order — see [docs/operations.md](docs/operations.md) for the full admin walkthrough and [docs/upgrading.md](docs/upgrading.md) if you're coming from an older version).
87
+ Then apply migrations and activate as in Option 1 above (`hermes-pgvector migrate --admin-dsn ...`, or `psql -f` each `hermes_pgvector/migrations/*.sql` in order — see [docs/operations.md](docs/operations.md) for the full admin walkthrough and [docs/upgrading.md](docs/upgrading.md) if you're coming from an older version).
88
88
 
89
89
  ## Configuration
90
90
 
@@ -115,11 +115,14 @@ client = AsyncOpenAI(
115
115
 
116
116
  The gateway plumbs `X-Hermes-Session-Key` through as `gateway_session_key=…` in `MemoryProvider.initialize` kwargs, taking priority over the profile default so unprofiled API traffic doesn't collapse every minion into one shared `default` scope.
117
117
 
118
- Convention: lowercase, dash-separated, stable (identities are case-folded automatically — `Marketing` and `marketing` are the same theme). Governed sinks that always exist regardless of your themes: `whatsapp-dm` (collapsed DM/session keys), `external-group` (collapsed group/channel/thread keys), `_bench` (benchmark traffic), `default` (last resort). Set `plugins.pgvector.allowed_themes` to your product/worker list to enforce an allow-list — an unknown or typo'd header then falls back to `default` (one-time warning) instead of silently minting a new theme.
118
+ Identity resolution order in `initialize`: `gateway_session_key` > `agent_identity` (unless it is `default`) > `agent_workspace` > `default`. Upstream hermes-agent always passes `agent_workspace="hermes"` (and `agent_identity=<profile name>`), so a default-profile CLI or cron session with no gateway session key lands in the theme `hermes`, not `default`; named profiles land in their profile name. Set the `X-Hermes-Session-Key` header (or a non-default profile) if you want a scope other than `hermes`. When `allowed_themes` is set, `hermes` and your profile names are not exempt: include them in the list if you want those sessions to keep their own theme, otherwise they fall back to `default`.
119
+
120
+ Convention: lowercase, dash-separated, stable (identities are case-folded automatically — `Marketing` and `marketing` are the same theme). Governed sinks that always exist regardless of your themes: `whatsapp-dm` (collapsed DM/session keys), `external-group` (collapsed group/channel/thread keys), `_bench` (benchmark traffic), `default` (only when the host passes no identity kwargs at all). Set `plugins.pgvector.allowed_themes` to your product/worker list to enforce an allow-list — an unknown or typo'd header then falls back to `default` (one-time warning) instead of silently minting a new theme.
119
121
 
120
122
  ## Compatibility policy
121
123
 
122
- This plugin follows semantic versioning on its **public surface**:
124
+ This plugin follows semantic versioning on its **public surface**, stable as
125
+ of `1.0.0`:
123
126
 
124
127
  - `plugins.pgvector.*` config keys (see [docs/configuration.md](docs/configuration.md))
125
128
  - Tool names and parameters (`recall_memory`, `recall_conversation`)
@@ -127,7 +130,11 @@ This plugin follows semantic versioning on its **public surface**:
127
130
  - Database table/column names
128
131
  - The `pgvector` provider name and the package's pip entry point
129
132
 
130
- `MemoryStore` and every other Python class/module are **internal** — not covered by semver, safe to change between minor releases. A migration file, once released, is never edited in place; schema changes land as a new numbered migration.
133
+ Within the `1.x` series this surface is guaranteed stable: it may grow (new
134
+ config keys, new optional params), but nothing already listed is renamed or
135
+ removed, and no default changes behaviour, without a `2.0` release.
136
+
137
+ `MemoryStore` and every other Python class/module are **internal** — not covered by semver, safe to change between minor releases, and excluded from the `1.x` guarantee above. A migration file, once released, is never edited in place; schema changes land as a new numbered migration.
131
138
 
132
139
  ## Support matrix
133
140
 
@@ -161,6 +168,18 @@ pytest -q # no DB/embed endpoint: everything
161
168
  # Live mode (against a throwaway Postgres + your embed endpoint)
162
169
  export PG_TEST_DSN='dbname=hermes_test user=postgres host=/var/run/postgresql'
163
170
  export PG_TEST_EMBED_URL='http://your-embed-endpoint:11434'
171
+ # Superuser DSN for the same database: the migration, CLI and empty-content
172
+ # tests skip without it, and CI fails on any skip.
173
+ export PG_TEST_ADMIN_DSN='dbname=hermes_test user=postgres host=/var/run/postgresql'
174
+ pytest -q
175
+ ```
176
+
177
+ The simplest path is the bundled harness (Postgres + pgvector in Docker and a
178
+ fake embed server), which prints all the variables for you:
179
+
180
+ ```bash
181
+ scripts/test-env.sh up pg17
182
+ eval "$(scripts/test-env.sh db hermes_test)"
164
183
  pytest -q
165
184
  ```
166
185
 
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: hermes-memory-pgvector
3
- Version: 0.6.0
3
+ Version: 1.0.0
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
@@ -9,7 +9,7 @@ Project-URL: Source, https://github.com/andreab67/hermes-memory-pgvector
9
9
  Project-URL: Issues, https://github.com/andreab67/hermes-memory-pgvector/issues
10
10
  Project-URL: Roadmap, https://github.com/andreab67/hermes-memory-pgvector/blob/main/ROADMAP.md
11
11
  Keywords: hermes-agent,memory-provider,pgvector,postgres,multi-agent,llm-memory,semantic-search
12
- Classifier: Development Status :: 4 - Beta
12
+ Classifier: Development Status :: 5 - Production/Stable
13
13
  Classifier: Intended Audience :: Developers
14
14
  Classifier: Intended Audience :: System Administrators
15
15
  Classifier: License :: OSI Approved :: BSD License
@@ -73,7 +73,7 @@ What was missing: a **storage layer** that gives the built-in `memory` model dur
73
73
  | `initialize()` | Verifies schema, opens a `psycopg_pool.ConnectionPool`, bulk-imports existing `MEMORY.md` + `USER.md` content. |
74
74
  | `on_memory_write(action, target, content, meta)` | Mirrors built-in `memory` writes into `memory_entries` (add / replace / remove). |
75
75
  | `sync_turn(user, assistant, session_id)` / `on_session_end(messages)` | Captures every substantive chat turn into `conversations`; `on_session_end` is a dedup'd backstop. |
76
- | `on_delegation(task, result, …)` | Records parent→child delegation provenance (`memory_agent_edges`) and stores the exchange as a recallable turn. |
76
+ | `on_delegation(task, result, …)` | Records parent→child delegation provenance (`memory_agent_edges`) and stores the exchange as a recallable turn. `memory_agent_edges.child_identity` is only filled when the host passes a child identity; current upstream hermes-agent passes only the task, result and `child_session_id`, so it is NULL there (`child_session_id` is populated). |
77
77
  | `prefetch(query)` / `queue_prefetch(query, session_id=...)` | Ambient recall: top-K similar `memory_entries` for the current theme, injected into the system prompt. `queue_prefetch` pre-computes it off the agent thread. |
78
78
  | `recall_memory(query, scope, target, limit)` tool | Explicit cross-theme search of durable memory entries. |
79
79
  | `recall_conversation(query, scope, limit)` tool | Explicit search over past chat turns. `scope ∈ {current, session, all, <theme>}`. |
@@ -121,7 +121,7 @@ mkdir -p ~/.hermes/plugins
121
121
  cp -r hermes_pgvector ~/.hermes/plugins/pgvector
122
122
  ```
123
123
 
124
- Then apply migrations and activate as in Option 1 above (`hermes-pgvector migrate --admin-dsn ...`, or `psql -f` each `migrations/*.sql` in order — see [docs/operations.md](docs/operations.md) for the full admin walkthrough and [docs/upgrading.md](docs/upgrading.md) if you're coming from an older version).
124
+ Then apply migrations and activate as in Option 1 above (`hermes-pgvector migrate --admin-dsn ...`, or `psql -f` each `hermes_pgvector/migrations/*.sql` in order — see [docs/operations.md](docs/operations.md) for the full admin walkthrough and [docs/upgrading.md](docs/upgrading.md) if you're coming from an older version).
125
125
 
126
126
  ## Configuration
127
127
 
@@ -152,11 +152,14 @@ client = AsyncOpenAI(
152
152
 
153
153
  The gateway plumbs `X-Hermes-Session-Key` through as `gateway_session_key=…` in `MemoryProvider.initialize` kwargs, taking priority over the profile default so unprofiled API traffic doesn't collapse every minion into one shared `default` scope.
154
154
 
155
- Convention: lowercase, dash-separated, stable (identities are case-folded automatically — `Marketing` and `marketing` are the same theme). Governed sinks that always exist regardless of your themes: `whatsapp-dm` (collapsed DM/session keys), `external-group` (collapsed group/channel/thread keys), `_bench` (benchmark traffic), `default` (last resort). Set `plugins.pgvector.allowed_themes` to your product/worker list to enforce an allow-list — an unknown or typo'd header then falls back to `default` (one-time warning) instead of silently minting a new theme.
155
+ Identity resolution order in `initialize`: `gateway_session_key` > `agent_identity` (unless it is `default`) > `agent_workspace` > `default`. Upstream hermes-agent always passes `agent_workspace="hermes"` (and `agent_identity=<profile name>`), so a default-profile CLI or cron session with no gateway session key lands in the theme `hermes`, not `default`; named profiles land in their profile name. Set the `X-Hermes-Session-Key` header (or a non-default profile) if you want a scope other than `hermes`. When `allowed_themes` is set, `hermes` and your profile names are not exempt: include them in the list if you want those sessions to keep their own theme, otherwise they fall back to `default`.
156
+
157
+ Convention: lowercase, dash-separated, stable (identities are case-folded automatically — `Marketing` and `marketing` are the same theme). Governed sinks that always exist regardless of your themes: `whatsapp-dm` (collapsed DM/session keys), `external-group` (collapsed group/channel/thread keys), `_bench` (benchmark traffic), `default` (only when the host passes no identity kwargs at all). Set `plugins.pgvector.allowed_themes` to your product/worker list to enforce an allow-list — an unknown or typo'd header then falls back to `default` (one-time warning) instead of silently minting a new theme.
156
158
 
157
159
  ## Compatibility policy
158
160
 
159
- This plugin follows semantic versioning on its **public surface**:
161
+ This plugin follows semantic versioning on its **public surface**, stable as
162
+ of `1.0.0`:
160
163
 
161
164
  - `plugins.pgvector.*` config keys (see [docs/configuration.md](docs/configuration.md))
162
165
  - Tool names and parameters (`recall_memory`, `recall_conversation`)
@@ -164,7 +167,11 @@ This plugin follows semantic versioning on its **public surface**:
164
167
  - Database table/column names
165
168
  - The `pgvector` provider name and the package's pip entry point
166
169
 
167
- `MemoryStore` and every other Python class/module are **internal** — not covered by semver, safe to change between minor releases. A migration file, once released, is never edited in place; schema changes land as a new numbered migration.
170
+ Within the `1.x` series this surface is guaranteed stable: it may grow (new
171
+ config keys, new optional params), but nothing already listed is renamed or
172
+ removed, and no default changes behaviour, without a `2.0` release.
173
+
174
+ `MemoryStore` and every other Python class/module are **internal** — not covered by semver, safe to change between minor releases, and excluded from the `1.x` guarantee above. A migration file, once released, is never edited in place; schema changes land as a new numbered migration.
168
175
 
169
176
  ## Support matrix
170
177
 
@@ -198,6 +205,18 @@ pytest -q # no DB/embed endpoint: everything
198
205
  # Live mode (against a throwaway Postgres + your embed endpoint)
199
206
  export PG_TEST_DSN='dbname=hermes_test user=postgres host=/var/run/postgresql'
200
207
  export PG_TEST_EMBED_URL='http://your-embed-endpoint:11434'
208
+ # Superuser DSN for the same database: the migration, CLI and empty-content
209
+ # tests skip without it, and CI fails on any skip.
210
+ export PG_TEST_ADMIN_DSN='dbname=hermes_test user=postgres host=/var/run/postgresql'
211
+ pytest -q
212
+ ```
213
+
214
+ The simplest path is the bundled harness (Postgres + pgvector in Docker and a
215
+ fake embed server), which prints all the variables for you:
216
+
217
+ ```bash
218
+ scripts/test-env.sh up pg17
219
+ eval "$(scripts/test-env.sh db hermes_test)"
201
220
  pytest -q
202
221
  ```
203
222
 
@@ -43,4 +43,9 @@ tests/test_v060_identity_writer.py
43
43
  tests/test_v060_migrations_cli.py
44
44
  tests/test_v060_provider_reads.py
45
45
  tests/test_v060_provider_writes.py
46
- tests/test_v060_store.py
46
+ tests/test_v060_store.py
47
+ tests/test_v100_pass2_provider.py
48
+ tests/test_v100_pass2_store.py
49
+ tests/test_v100_pass3.py
50
+ tests/test_v100_provider_fixes.py
51
+ tests/test_v100_review_fixes.py
@@ -225,7 +225,7 @@ DEFAULTS = {
225
225
  # header, OpenAI-compatible path with the Ollama-native fallback.
226
226
  # embed_dim must equal the model's output AND the vector(N) columns;
227
227
  # changing it on an existing database needs a column migration + re-embed
228
- # (see README, "Changing the embedding dimension").
228
+ # (see docs/upgrading.md, "Changing the embedding dimension").
229
229
  "embed_dim": 768,
230
230
  # NAME of an environment variable holding a bearer token (e.g.
231
231
  # "OPENROUTER_API_KEY"), never the token itself. Read at call time.
@@ -306,6 +306,20 @@ DEFAULTS = {
306
306
  }
307
307
 
308
308
 
309
+ # Bounds for prefetch_budget. The maximum sits below the host's 8s hard cap on
310
+ # an external provider's prefetch (upstream _EXTERNAL_PREFETCH_TIMEOUT_S); the
311
+ # config schema and prefetch()'s clamp both read these so they cannot drift.
312
+ _PREFETCH_BUDGET_MIN = 0.1
313
+ _PREFETCH_BUDGET_MAX = 7.5
314
+
315
+ # Bounds for prefetch_limit / min_similarity; get_config_schema advertises
316
+ # them and _compute_prefetch_block clamps to them.
317
+ _PREFETCH_LIMIT_MIN = 1
318
+ _PREFETCH_LIMIT_MAX = 50
319
+ _MIN_SIMILARITY_MIN = 0.0
320
+ _MIN_SIMILARITY_MAX = 1.0
321
+
322
+
309
323
  def _as_bool(value: Any, default: bool) -> bool:
310
324
  """Coerce a config value to a real bool.
311
325
 
@@ -313,13 +327,22 @@ def _as_bool(value: Any, default: bool) -> bool:
313
327
  hand-edited config.yaml (YAML bools), and save_config(), which persists
314
328
  the config schema's declared values -- the STRINGS "true"/"false". A plain
315
329
  truthiness test silently inverts the string form (bool("false") is True),
316
- so every boolean toggle reads through here.
330
+ so every boolean toggle reads through here. Only the explicit true/false
331
+ vocabularies are honoured; None, blank and unrecognized strings return
332
+ `default`.
317
333
  """
318
334
  if value is None:
319
335
  return default
320
336
  if isinstance(value, bool):
321
337
  return value
322
- return str(value).strip().lower() in ("1", "true", "yes", "on")
338
+ text = str(value).strip().lower()
339
+ if text in ("1", "true", "yes", "on"):
340
+ return True
341
+ if text in ("0", "false", "no", "off"):
342
+ return False
343
+ # Blank / unrecognized: fall back to the default, like _as_int/_as_float,
344
+ # so a blank value for a default-true toggle does not silently disable it.
345
+ return default
323
346
 
324
347
 
325
348
  def _as_int(value: Any, default: int) -> int:
@@ -452,18 +475,52 @@ def _embed_with_config(
452
475
  key_env = config.get("embed_api_key_env")
453
476
  return _embed_text(
454
477
  text,
455
- base_url=config.get("embed_url", DEFAULTS["embed_url"]),
456
- model=config.get("embed_model", DEFAULTS["embed_model"]),
478
+ # `or`, not a .get default: a blank `embed_url:` in config.yaml loads
479
+ # as None and would otherwise reach embed() as base_url=None.
480
+ base_url=config.get("embed_url") or DEFAULTS["embed_url"],
481
+ model=config.get("embed_model") or DEFAULTS["embed_model"],
457
482
  timeout=timeout,
458
483
  retries=retries,
459
484
  backoff=backoff,
460
485
  max_total=max_total,
461
486
  dim=_embed_dim(config),
462
487
  api_key_env=str(key_env).strip() if key_env else None,
463
- protocol=config.get("embed_protocol", DEFAULTS["embed_protocol"]),
488
+ protocol=config.get("embed_protocol") or DEFAULTS["embed_protocol"],
464
489
  )
465
490
 
466
491
 
492
+ _TEXT_PART_TYPES = frozenset({"text", "input_text", "output_text"})
493
+ _IMAGE_PART_TYPES = frozenset({"image_url", "input_image"})
494
+
495
+
496
+ def _flatten_user_content(content: Any) -> str:
497
+ """Flatten user message content exactly as the host does before sync_turn().
498
+
499
+ Mirrors upstream agent.codex_responses_adapter._summarize_user_message_for_log
500
+ (called with a newline separator): text parts joined with newlines, images collapsed
501
+ to a "[N image(s)]" prefix, "" for None, str() for other scalars. The
502
+ on_session_end backstop fingerprints this form, so a multimodal turn hashes
503
+ the same as what sync_turn() already recorded.
504
+ """
505
+ if not isinstance(content, list):
506
+ return "" if content is None else str(content)
507
+ texts: List[str] = []
508
+ images = 0
509
+ for part in content:
510
+ if isinstance(part, str):
511
+ if part:
512
+ texts.append(part)
513
+ elif isinstance(part, dict):
514
+ ptype = str(part.get("type") or "").strip().lower()
515
+ text = part.get("text")
516
+ if ptype in _TEXT_PART_TYPES and isinstance(text, str) and text:
517
+ texts.append(text)
518
+ elif ptype in _IMAGE_PART_TYPES:
519
+ images += 1
520
+ note = f"[{images} image{'s' if images != 1 else ''}]" if images else ""
521
+ return " ".join(bit for bit in (note, "\n".join(texts).strip()) if bit)
522
+
523
+
467
524
  def _load_plugin_config() -> dict:
468
525
  try:
469
526
  from hermes_constants import get_hermes_home
@@ -508,6 +565,7 @@ class PgvectorMemoryProvider(MemoryProvider):
508
565
  self._healthy: bool = False
509
566
  self._delegation_enabled: bool = False # set in initialize() iff migration 002 applied
510
567
  self._embed_warned: bool = False
568
+ self._embed_bug_warned: set = set()
511
569
  self._db_warned: bool = False
512
570
  # v0.6.0 (M4) -- agent_context gate. True until the first initialize()
513
571
  # decides otherwise, so a provider used without initialize() (unit
@@ -874,7 +932,13 @@ class PgvectorMemoryProvider(MemoryProvider):
874
932
  # embed() as its timeout, which is now ONE shared deadline across
875
933
  # embed_protocol='auto''s two HTTP attempts, not a fresh timeout for
876
934
  # each (see embed.py).
877
- budget = _as_float(self._config.get("prefetch_budget"), DEFAULTS["prefetch_budget"])
935
+ budget = min(
936
+ max(
937
+ _as_float(self._config.get("prefetch_budget"), DEFAULTS["prefetch_budget"]),
938
+ _PREFETCH_BUDGET_MIN,
939
+ ),
940
+ _PREFETCH_BUDGET_MAX,
941
+ )
878
942
  deadline = time.monotonic() + budget
879
943
  return self._compute_prefetch_block(query, timeout=budget, deadline=deadline)
880
944
 
@@ -962,10 +1026,14 @@ class PgvectorMemoryProvider(MemoryProvider):
962
1026
  rows = store.search(
963
1027
  query_embedding=vec,
964
1028
  agent_identity=self._agent_identity,
965
- limit=_as_int(self._config.get("prefetch_limit"),
966
- DEFAULTS["prefetch_limit"]),
967
- min_similarity=_as_float(self._config.get("min_similarity"),
968
- DEFAULTS["min_similarity"]),
1029
+ limit=max(_PREFETCH_LIMIT_MIN, min(
1030
+ _as_int(self._config.get("prefetch_limit"),
1031
+ DEFAULTS["prefetch_limit"]),
1032
+ _PREFETCH_LIMIT_MAX)),
1033
+ min_similarity=max(_MIN_SIMILARITY_MIN, min(
1034
+ _as_float(self._config.get("min_similarity"),
1035
+ DEFAULTS["min_similarity"]),
1036
+ _MIN_SIMILARITY_MAX)),
969
1037
  )
970
1038
  except Exception as exc: # noqa: BLE001
971
1039
  logger.debug("pgvector prefetch query failed: %s", exc)
@@ -1090,12 +1158,20 @@ class PgvectorMemoryProvider(MemoryProvider):
1090
1158
  Guarded import, matching how this module already reaches for
1091
1159
  `agent.memory_provider` / `hermes_constants`: outside hermes-agent the
1092
1160
  content is simply used as-is.
1161
+
1162
+ When the host helper is present but returns None/empty (a bare
1163
+ `/skill` with no user instruction) the host skips sync_turn()
1164
+ entirely, so this returns "" -- callers must treat that as "nothing
1165
+ to store" rather than falling back to the multi-KB skill body.
1093
1166
  """
1094
1167
  try:
1095
1168
  from agent.skill_commands import (
1096
1169
  extract_user_instruction_from_skill_message as _strip,
1097
1170
  )
1098
- return _strip(content) or content
1171
+ except Exception: # noqa: BLE001 -- host internals are optional
1172
+ return content
1173
+ try:
1174
+ return _strip(content) or ""
1099
1175
  except Exception: # noqa: BLE001 -- host internals are optional
1100
1176
  return content
1101
1177
 
@@ -1178,6 +1254,19 @@ class PgvectorMemoryProvider(MemoryProvider):
1178
1254
  return
1179
1255
  try:
1180
1256
  child_identity = kwargs.get("child_identity") or kwargs.get("agent_identity")
1257
+ if isinstance(child_identity, str) and child_identity:
1258
+ # Same governance initialize() applies to this agent's own
1259
+ # identity; a raw value would bypass the allow-list/aliases/
1260
+ # bench isolation. Upstream does not pass this today
1261
+ # (tools/delegate_tool_results.py), so None stays None.
1262
+ child_identity, _, _ = normalize_identity(
1263
+ child_identity,
1264
+ allowed_themes=_as_theme_list(self._config.get("allowed_themes")),
1265
+ aliases=_as_alias_map(self._config.get("identity_aliases")),
1266
+ bench_mode=self._config.get("bench_mode", "bucket"),
1267
+ )
1268
+ else:
1269
+ child_identity = None
1181
1270
  child_session_id = kwargs.get("child_session_id") or kwargs.get("session_id")
1182
1271
  self._writer.enqueue(
1183
1272
  action="edge",
@@ -1265,23 +1354,20 @@ class PgvectorMemoryProvider(MemoryProvider):
1265
1354
  role = (msg.get("role") or "").lower()
1266
1355
  if role not in ("user", "assistant"):
1267
1356
  continue
1268
- raw = msg.get("content") or ""
1269
- content = (
1270
- raw if isinstance(raw, str)
1271
- else " ".join(
1272
- p.get("text", "") for p in raw
1273
- if isinstance(p, dict) and p.get("type") == "text"
1274
- ) if isinstance(raw, list) else str(raw)
1275
- )
1357
+ # The host flattens every role with the same helper
1358
+ # before sync_turn(), so do the same for a matching hash.
1359
+ content = _flatten_user_content(msg.get("content") or "")
1360
+ # Normalize user turns the way the host does: a /skill
1361
+ # turn reaches sync_turn() as just the user's instruction
1362
+ # (and not at all for a bare /skill). Noise-check, dedup
1363
+ # and STORE that same string, never the skill body.
1364
+ if role == "user":
1365
+ content = self._strip_skill_scaffolding(content)
1366
+ if not content:
1367
+ continue
1276
1368
  if self._is_noise(content, min_chars=min_chars):
1277
1369
  continue
1278
- # Fingerprint the NORMALIZED form for user turns, so a
1279
- # /skill turn matches what sync_turn() was handed.
1280
- fp_content = (
1281
- self._strip_skill_scaffolding(content)
1282
- if role == "user" else content
1283
- )
1284
- fp = self._turn_fingerprint(role, fp_content)
1370
+ fp = self._turn_fingerprint(role, content)
1285
1371
  if fp in self._turn_fingerprints:
1286
1372
  continue # already enqueued by sync_turn this session
1287
1373
  accepted = self._writer.enqueue(
@@ -1866,18 +1952,39 @@ class PgvectorMemoryProvider(MemoryProvider):
1866
1952
  """Governance config that must bust a cached gateway agent when it
1867
1953
  changes. Upstream calls this on an UNINITIALIZED instance on every
1868
1954
  inbound message (agent.memory_provider.MemoryProvider.identity_signature),
1869
- so it reads self._config only -- no I/O, no self._store, cheap and
1870
- read-only. allowed_themes/identity_aliases/bench_mode decide which
1955
+ and the gateway memoises ONE provider per process, so self._config
1956
+ (frozen at register()) would never reflect an edit. Instead the values
1957
+ come from config.yaml, memoised on (path, st_mtime_ns, st_size) -- one
1958
+ stat per call, a re-parse only when the file changed (same pattern as
1959
+ upstream's Honcho provider). self._config is never mutated and is the
1960
+ fallback on any stat/parse failure. No self._store, never raises.
1961
+ allowed_themes/identity_aliases/bench_mode decide which
1871
1962
  theme a raw identity normalizes to (identity.normalize_identity);
1872
1963
  write_contexts decides whether a context writes at all -- a cached
1873
1964
  agent built under the OLD values would keep routing/writing wrong
1874
1965
  until the process restarts, without this.
1875
1966
  """
1967
+ try:
1968
+ from hermes_constants import get_hermes_home
1969
+ path = get_hermes_home() / "config.yaml"
1970
+ stat = path.stat()
1971
+ key = (str(path), stat.st_mtime_ns, stat.st_size)
1972
+ memo = getattr(self, "_sig_memo", None)
1973
+ if memo is not None and memo[0] == key:
1974
+ return dict(memo[1])
1975
+ values = self._signature_values({**DEFAULTS, **_load_plugin_config()})
1976
+ self._sig_memo = (key, values)
1977
+ return dict(values)
1978
+ except Exception: # noqa: BLE001 -- never raise; fall back to the frozen config
1979
+ return self._signature_values(self._config)
1980
+
1981
+ @staticmethod
1982
+ def _signature_values(config: Dict[str, Any]) -> Dict[str, Any]:
1876
1983
  return {
1877
- "pgvector.allowed_themes": _as_theme_list(self._config.get("allowed_themes")),
1878
- "pgvector.identity_aliases": _as_alias_map(self._config.get("identity_aliases")),
1879
- "pgvector.bench_mode": self._config.get("bench_mode", DEFAULTS["bench_mode"]),
1880
- "pgvector.write_contexts": _as_write_contexts(self._config.get("write_contexts")),
1984
+ "pgvector.allowed_themes": _as_theme_list(config.get("allowed_themes")),
1985
+ "pgvector.identity_aliases": _as_alias_map(config.get("identity_aliases")),
1986
+ "pgvector.bench_mode": config.get("bench_mode", DEFAULTS["bench_mode"]),
1987
+ "pgvector.write_contexts": _as_write_contexts(config.get("write_contexts")),
1881
1988
  }
1882
1989
 
1883
1990
  def get_config_schema(self) -> List[Dict[str, Any]]:
@@ -1904,7 +2011,7 @@ class PgvectorMemoryProvider(MemoryProvider):
1904
2011
  },
1905
2012
  {
1906
2013
  "key": "embed_dim",
1907
- "description": "Vector length the embed model returns. Must also match the database's vector(N) columns (768 as created by migration 001). Changing it on an existing database requires migrating those columns and re-embedding every row -- see README, 'Changing the embedding dimension'.",
2014
+ "description": "Vector length the embed model returns. Must also match the database's vector(N) columns (768 as created by migration 001). Changing it on an existing database requires migrating those columns and re-embedding every row -- see docs/upgrading.md, 'Changing the embedding dimension'.",
1908
2015
  "type": "integer",
1909
2016
  "default": DEFAULTS["embed_dim"],
1910
2017
  "minimum": 1,
@@ -1927,16 +2034,16 @@ class PgvectorMemoryProvider(MemoryProvider):
1927
2034
  "description": "Max ambient recall results injected per turn",
1928
2035
  "type": "integer",
1929
2036
  "default": DEFAULTS["prefetch_limit"],
1930
- "minimum": 1,
1931
- "maximum": 50,
2037
+ "minimum": _PREFETCH_LIMIT_MIN,
2038
+ "maximum": _PREFETCH_LIMIT_MAX,
1932
2039
  },
1933
2040
  {
1934
2041
  "key": "min_similarity",
1935
2042
  "description": "Cosine similarity cutoff for ambient prefetch",
1936
2043
  "type": "number",
1937
2044
  "default": DEFAULTS["min_similarity"],
1938
- "minimum": 0.0,
1939
- "maximum": 1.0,
2045
+ "minimum": _MIN_SIMILARITY_MIN,
2046
+ "maximum": _MIN_SIMILARITY_MAX,
1940
2047
  },
1941
2048
  {
1942
2049
  "key": "embed_on_write",
@@ -2062,8 +2169,8 @@ class PgvectorMemoryProvider(MemoryProvider):
2062
2169
  "description": "Hard wall-clock budget (seconds) for prefetch()'s synchronous fallback path (embed + search), used when queue_prefetch() has not already cached a block for the session. Must stay below the host's external-provider prefetch timeout (8s), or the host logs a timeout warning and skips this provider on later turns until the stuck call returns.",
2063
2170
  "type": "number",
2064
2171
  "default": DEFAULTS["prefetch_budget"],
2065
- "minimum": 0.1,
2066
- "maximum": 7.5,
2172
+ "minimum": _PREFETCH_BUDGET_MIN,
2173
+ "maximum": _PREFETCH_BUDGET_MAX,
2067
2174
  },
2068
2175
  ]
2069
2176
 
@@ -2137,6 +2244,16 @@ class PgvectorMemoryProvider(MemoryProvider):
2137
2244
  logger.warning("pgvector embed failed (degrading to text-only): %s", exc)
2138
2245
  self._embed_warned = True
2139
2246
  return None
2247
+ except Exception as exc: # noqa: BLE001 -- an embed-side bug must never drop a durable row
2248
+ # Not an endpoint failure but a bug: never hide it behind the
2249
+ # one-shot EmbeddingError warning (a transient outage earlier
2250
+ # would otherwise silence it); warn once per exception type.
2251
+ kind = type(exc).__name__
2252
+ warned = self.__dict__.setdefault("_embed_bug_warned", set())
2253
+ if kind not in warned:
2254
+ warned.add(kind)
2255
+ logger.warning("pgvector embed raised unexpectedly (degrading to text-only)", exc_info=exc)
2256
+ return None
2140
2257
 
2141
2258
 
2142
2259
  # ---------------------------------------------------------------------------