memgres 0.3.2__tar.gz → 0.4.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 (71) hide show
  1. {memgres-0.3.2 → memgres-0.4.0}/PKG-INFO +16 -5
  2. {memgres-0.3.2 → memgres-0.4.0}/README.md +15 -4
  3. {memgres-0.3.2 → memgres-0.4.0}/memgres/__init__.py +2 -0
  4. memgres-0.4.0/memgres/_version.py +11 -0
  5. {memgres-0.3.2 → memgres-0.4.0}/memgres/blame.py +2 -1
  6. {memgres-0.3.2 → memgres-0.4.0}/memgres/config.py +64 -14
  7. memgres-0.4.0/memgres/embed_worker.py +134 -0
  8. {memgres-0.3.2 → memgres-0.4.0}/memgres/identity.py +13 -0
  9. memgres-0.4.0/memgres/indexing.py +144 -0
  10. {memgres-0.3.2 → memgres-0.4.0}/memgres/info.py +10 -1
  11. {memgres-0.3.2 → memgres-0.4.0}/memgres/mcp_server.py +95 -46
  12. memgres-0.4.0/memgres/migrations/0003_history_author.sql +25 -0
  13. memgres-0.4.0/memgres/migrations/0004_title.sql +22 -0
  14. memgres-0.4.0/memgres/migrations/0005_chunk_index.sql +41 -0
  15. memgres-0.4.0/memgres/migrations/0006_reader_floor.sql +15 -0
  16. memgres-0.4.0/memgres/migrations/0007_embed_retry.sql +13 -0
  17. memgres-0.4.0/memgres/reembed.py +139 -0
  18. memgres-0.4.0/memgres/schema.py +237 -0
  19. memgres-0.4.0/memgres/search.py +212 -0
  20. {memgres-0.3.2 → memgres-0.4.0}/memgres/server.py +39 -26
  21. {memgres-0.3.2 → memgres-0.4.0}/memgres/store.py +254 -80
  22. memgres-0.4.0/memgres/vector/base.py +209 -0
  23. memgres-0.4.0/memgres/vector/pgvector.py +69 -0
  24. memgres-0.4.0/memgres/vector/qdrant.py +185 -0
  25. memgres-0.4.0/memgres/worker.py +59 -0
  26. {memgres-0.3.2 → memgres-0.4.0}/memgres.egg-info/PKG-INFO +16 -5
  27. {memgres-0.3.2 → memgres-0.4.0}/memgres.egg-info/SOURCES.txt +15 -0
  28. {memgres-0.3.2 → memgres-0.4.0}/memgres.egg-info/entry_points.txt +2 -0
  29. {memgres-0.3.2 → memgres-0.4.0}/pyproject.toml +6 -1
  30. memgres-0.4.0/tests/test_chunk_index.py +234 -0
  31. memgres-0.4.0/tests/test_claim_and_reembed.py +232 -0
  32. memgres-0.4.0/tests/test_diffing.py +176 -0
  33. memgres-0.4.0/tests/test_embed_worker.py +250 -0
  34. {memgres-0.3.2 → memgres-0.4.0}/tests/test_lexical_match.py +1 -1
  35. {memgres-0.3.2 → memgres-0.4.0}/tests/test_list.py +2 -1
  36. memgres-0.4.0/tests/test_mcp_instructions.py +99 -0
  37. {memgres-0.3.2 → memgres-0.4.0}/tests/test_mcp_recall_schema.py +1 -0
  38. memgres-0.4.0/tests/test_migration_upgrade.py +169 -0
  39. {memgres-0.3.2 → memgres-0.4.0}/tests/test_qdrant_integration.py +14 -9
  40. {memgres-0.3.2 → memgres-0.4.0}/tests/test_search_integration.py +3 -3
  41. {memgres-0.3.2 → memgres-0.4.0}/tests/test_security_integration.py +3 -3
  42. {memgres-0.3.2 → memgres-0.4.0}/tests/test_segments_store.py +85 -87
  43. {memgres-0.3.2 → memgres-0.4.0}/tests/test_server_info.py +17 -3
  44. {memgres-0.3.2 → memgres-0.4.0}/tests/test_server_integration.py +27 -5
  45. {memgres-0.3.2 → memgres-0.4.0}/tests/test_snippets.py +59 -26
  46. memgres-0.4.0/tests/test_store_integration.py +438 -0
  47. memgres-0.3.2/memgres/schema.py +0 -156
  48. memgres-0.3.2/memgres/search.py +0 -188
  49. memgres-0.3.2/memgres/vector/base.py +0 -90
  50. memgres-0.3.2/memgres/vector/pgvector.py +0 -83
  51. memgres-0.3.2/memgres/vector/qdrant.py +0 -220
  52. memgres-0.3.2/tests/test_diffing.py +0 -87
  53. memgres-0.3.2/tests/test_store_integration.py +0 -193
  54. {memgres-0.3.2 → memgres-0.4.0}/LICENSE +0 -0
  55. {memgres-0.3.2 → memgres-0.4.0}/memgres/diffing.py +0 -0
  56. {memgres-0.3.2 → memgres-0.4.0}/memgres/embeddings.py +0 -0
  57. {memgres-0.3.2 → memgres-0.4.0}/memgres/migrations/0001_core.sql +0 -0
  58. {memgres-0.3.2 → memgres-0.4.0}/memgres/migrations/0002_identity.sql +0 -0
  59. {memgres-0.3.2 → memgres-0.4.0}/memgres/segments.py +0 -0
  60. {memgres-0.3.2 → memgres-0.4.0}/memgres/vector/__init__.py +0 -0
  61. {memgres-0.3.2 → memgres-0.4.0}/memgres.egg-info/dependency_links.txt +0 -0
  62. {memgres-0.3.2 → memgres-0.4.0}/memgres.egg-info/requires.txt +0 -0
  63. {memgres-0.3.2 → memgres-0.4.0}/memgres.egg-info/top_level.txt +0 -0
  64. {memgres-0.3.2 → memgres-0.4.0}/setup.cfg +0 -0
  65. {memgres-0.3.2 → memgres-0.4.0}/tests/test_blame_integration.py +0 -0
  66. {memgres-0.3.2 → memgres-0.4.0}/tests/test_config.py +0 -0
  67. {memgres-0.3.2 → memgres-0.4.0}/tests/test_embeddings.py +0 -0
  68. {memgres-0.3.2 → memgres-0.4.0}/tests/test_identity_integration.py +0 -0
  69. {memgres-0.3.2 → memgres-0.4.0}/tests/test_limits.py +0 -0
  70. {memgres-0.3.2 → memgres-0.4.0}/tests/test_qdrant_ca.py +0 -0
  71. {memgres-0.3.2 → memgres-0.4.0}/tests/test_segments.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: memgres
3
- Version: 0.3.2
3
+ Version: 0.4.0
4
4
  Summary: Drop-in memory for AI agents: one Postgres, lexical + semantic recall, diff-versioned history, GDPR-erasable.
5
5
  Author: mozgsml
6
6
  License-Expression: MIT
@@ -41,7 +41,7 @@ Dynamic: license-file
41
41
 
42
42
  **Versioned document memory for AI agents — one Postgres, lexical *or* semantic recall, diff-based history, GDPR-erasable.**
43
43
 
44
- > Status: v0.2.0 on [PyPI](https://pypi.org/project/memgres/) (`pip install memgres`) and [GHCR](https://ghcr.io/mozgsml/memgres). Core library, search (pgvector/Qdrant), multi-tenant identity (users / namespaces / scoped tokens), HTTP API and MCP server, all tested in CI against live Postgres + Qdrant.
44
+ > Status: released on [PyPI](https://pypi.org/project/memgres/) (`pip install memgres`) and [GHCR](https://ghcr.io/mozgsml/memgres) — see the [releases](https://github.com/mozgsml/memgres/releases) page for the current version. Core library, search (pgvector/Qdrant), multi-tenant identity (users / namespaces / scoped tokens), HTTP API and MCP server, all tested in CI against live Postgres + Qdrant.
45
45
 
46
46
  memgres is a lightweight, drop-in memory layer — a Python library plus an optional HTTP/MCP service — backed by a single PostgreSQL database. You store **documents** (bodies of text an agent owns and edits), not facts an LLM guessed at. Every change is an authored diff with provenance, kept in a tamper-evident history that you can still delete when the law says you must.
47
47
 
@@ -71,7 +71,7 @@ Reach for memgres when you want **auditable, authored, versioned text memory**.
71
71
  | **Hash-chained, GDPR-deletable history** | Tamper-evident provenance you can *still* erase: `forget()` hard-deletes the row, its vectors, and crypto-shreds the chain — no ["ghost vectors" left reconstructible in the index](https://arxiv.org/pdf/2606.18497). |
72
72
  | **Lexical works with zero embeddings** | Deploy with no model, no API, no GPU — Postgres full-text search out of the box. Turn on semantic recall only when you want it. |
73
73
  | **Lexical *and* semantic (hybrid)** | Exact identifiers/codes go to lexical (where [dense retrieval alone stumbles](https://tianpan.co/blog/2026-04-12-hybrid-search-production-bm25-dense-embeddings)); meaning-based queries go to vectors; hybrid fuses both with RRF. |
74
- | **Snippets, not walls of text** | Recall returns the most relevant slice of each hit plus its line number semantic hits pick their best segment (embedded once, then cached), lexical uses `ts_headline`. Pass `full_body=false` for just the snippet. |
74
+ | **Snippets, not walls of text** | Recall returns one body view per hit — never both a slice and the whole thing. Long hits come back as the most relevant slice (`kind="snippet"`) with its `lines` range; semantic hits pick their best segment (embedded once, then cached), lexical uses a clean `ts_headline` (no markup). A body short enough that a slice would just repeat it comes back whole (`kind="full"`). Pass `full_body=true` to force whole bodies. |
75
75
  | **Embedding-model safety by construction** | The model id + dimension are stamped into the schema; a mismatch **hard-fails** instead of silently returning garbage. |
76
76
  | **Optional TTL, renewed on read** | Off by default — memory is kept forever. Turn on a retention window and active memory persists because it's used, while abandoned memory expires itself: storage self-cleans instead of growing. |
77
77
  | **Optional multi-tenant identity** | Users, namespaces and rotatable scoped tokens when you need isolation; nothing to configure for single-user. |
@@ -184,11 +184,14 @@ Or pull the container image (public, no login):
184
184
  docker pull ghcr.io/mozgsml/memgres:latest
185
185
  ```
186
186
 
187
- ### Three ways to run it
187
+ ### Ways to run it
188
+
189
+ Not sure which fits? Start with the decision guide: [docs/CHOOSING.md](docs/CHOOSING.md). In short — **more than one user → run the Docker server (shared), not a per-machine install.**
188
190
 
189
191
  1. **`docker compose up`** — `pgvector` + service, nothing to configure. For a dedicated vector service instead, `docker compose --profile qdrant up` and set `MEMGRES_VECTOR_BACKEND=qdrant` (Qdrant ranks vectors; Postgres still holds bodies and does tag/subtree/TTL filtering).
190
192
  2. **Your own Postgres** — install the `[server]` extra (above), point `MEMGRES_DATABASE_URL` at it, run `memgres-server` (migrates on startup).
191
193
  3. **Embedded library** — install the core package, use `Store` directly, no HTTP at all.
194
+ 4. **Split service (many clients)** — a stateless API tier that only flags writes plus a scalable `memgres-worker` tier that embeds; see [docs/DEPLOYMENT.md](docs/DEPLOYMENT.md) and `deploy/docker-compose.yml`. Switch the embedding model later with `memgres-reembed`.
192
195
 
193
196
  Semantic recall is optional: the default `MEMGRES_EMBED_PROVIDER=none` gives you lexical FTS with zero models. Turn on `local` (sentence-transformers), a cloud API (`openai`/`jina`), or any OpenAI-compatible server (LM Studio, Ollama, …) when you want meaning-based search — see [docs/EMBEDDINGS.md](docs/EMBEDDINGS.md) for choosing local vs cloud and [docs/BACKENDS.md](docs/BACKENDS.md) for copy-paste setups. The model id + dimension get stamped into the schema and a later mismatch hard-fails instead of silently returning garbage.
194
197
 
@@ -213,12 +216,20 @@ Everything is env, all optional (defaults suit a single-user embed). Full list i
213
216
  | `MEMGRES_HISTORY` | `true` | keep the hash-chained diff history (deleted with the record) |
214
217
  | `MEMGRES_FTS_LANGUAGE` | `simple` | Postgres FTS dictionary (`simple`/`english`/…) |
215
218
  | `MEMGRES_LEXICAL_MATCH` | `any` | lexical query words OR-ed (`any`) or AND-ed (`all`); per-call `match` overrides |
216
- | `MEMGRES_SNIPPET` | `true` | attach a best-match snippet + line to each hit (`MEMGRES_SNIPPET_*` tune size/semantic; `full_body` per call) |
219
+ | `MEMGRES_SNIPPET` | `true` | extract a best-match slice per hit (`MEMGRES_SNIPPET_*` tune size/semantic); `false` returns whole bodies |
220
+ | `MEMGRES_FULL_BODY` | `false` | force the whole body on every hit (off = auto: short whole, long sliced); `full_body` per call overrides |
221
+ | `MEMGRES_FULL_BODY_MAX_CHARS` | `500` | a body this short is returned whole (`kind="full"`) instead of sliced |
217
222
  | `MEMGRES_LIST_PREVIEW_CHARS` | `120` | first-line preview length returned by `memory_list` |
223
+ | `MEMGRES_INSTRUCTION` | — | server-side MCP instructions emitted at `initialize` (a client like Claude Code loads it once at connect); unset = omitted; capped at 2 KB |
218
224
  | `MEMGRES_VECTOR_BACKEND` | `pgvector` | `pgvector` (same DB) or `qdrant` (set `QDRANT_URL`, `QDRANT_API_KEY`, `MEMGRES_QDRANT_COLLECTION`) |
219
225
  | `MEMGRES_EMBED_PROVIDER` | `none` | `none` / `local` / `openai` / `jina` / `openai-compatible` (LM Studio, Ollama, vLLM, TEI…) |
220
226
  | `MEMGRES_EMBED_MODEL` / `_DIM` / `_API_KEY` / `_API_BASE` | — | model id · dimension (HTTP providers require it, `local` infers) · token · server URL |
221
227
  | `MEMGRES_EMBED_MAX_SEQ` | `0` | override the local model's max input length in tokens (`0` = the model's default) |
228
+ | `MEMGRES_CHUNK_CHARS` / `_OVERLAP` | `400` / `80` | chunk size / overlap for the chunk index (legacy names `MEMGRES_SNIPPET_SEG_CHARS` / `_OVERLAP` still work) |
229
+ | `MEMGRES_EMBED_DISPATCH` | `inline` | `inline` = embed within the write (safe default, no worker); `async` = flag `embed_pending` and let a worker embed. A server sets `async` when it starts an in-process worker; set `async` + `MEMGRES_EMBED_WORKER=off` for a split deployment with a separate `memgres-worker` |
230
+ | `MEMGRES_EMBED_WORKER` | `true` | a server process runs an in-process embed worker |
231
+ | `MEMGRES_EMBED_WORKER_INTERVAL` | `1.0` | worker idle poll seconds between drains |
232
+ | `MEMGRES_EMBED_MAX_ATTEMPTS` / `_RETRY_BACKOFF_S` | `5` / `60` | a failing row is retried after the back-off; after this many attempts it's dead-lettered (left flagged, out of rotation, logged) so it can't wedge the queue |
222
233
 
223
234
  ## HTTP API
224
235
 
@@ -5,7 +5,7 @@
5
5
 
6
6
  **Versioned document memory for AI agents — one Postgres, lexical *or* semantic recall, diff-based history, GDPR-erasable.**
7
7
 
8
- > Status: v0.2.0 on [PyPI](https://pypi.org/project/memgres/) (`pip install memgres`) and [GHCR](https://ghcr.io/mozgsml/memgres). Core library, search (pgvector/Qdrant), multi-tenant identity (users / namespaces / scoped tokens), HTTP API and MCP server, all tested in CI against live Postgres + Qdrant.
8
+ > Status: released on [PyPI](https://pypi.org/project/memgres/) (`pip install memgres`) and [GHCR](https://ghcr.io/mozgsml/memgres) — see the [releases](https://github.com/mozgsml/memgres/releases) page for the current version. Core library, search (pgvector/Qdrant), multi-tenant identity (users / namespaces / scoped tokens), HTTP API and MCP server, all tested in CI against live Postgres + Qdrant.
9
9
 
10
10
  memgres is a lightweight, drop-in memory layer — a Python library plus an optional HTTP/MCP service — backed by a single PostgreSQL database. You store **documents** (bodies of text an agent owns and edits), not facts an LLM guessed at. Every change is an authored diff with provenance, kept in a tamper-evident history that you can still delete when the law says you must.
11
11
 
@@ -35,7 +35,7 @@ Reach for memgres when you want **auditable, authored, versioned text memory**.
35
35
  | **Hash-chained, GDPR-deletable history** | Tamper-evident provenance you can *still* erase: `forget()` hard-deletes the row, its vectors, and crypto-shreds the chain — no ["ghost vectors" left reconstructible in the index](https://arxiv.org/pdf/2606.18497). |
36
36
  | **Lexical works with zero embeddings** | Deploy with no model, no API, no GPU — Postgres full-text search out of the box. Turn on semantic recall only when you want it. |
37
37
  | **Lexical *and* semantic (hybrid)** | Exact identifiers/codes go to lexical (where [dense retrieval alone stumbles](https://tianpan.co/blog/2026-04-12-hybrid-search-production-bm25-dense-embeddings)); meaning-based queries go to vectors; hybrid fuses both with RRF. |
38
- | **Snippets, not walls of text** | Recall returns the most relevant slice of each hit plus its line number semantic hits pick their best segment (embedded once, then cached), lexical uses `ts_headline`. Pass `full_body=false` for just the snippet. |
38
+ | **Snippets, not walls of text** | Recall returns one body view per hit — never both a slice and the whole thing. Long hits come back as the most relevant slice (`kind="snippet"`) with its `lines` range; semantic hits pick their best segment (embedded once, then cached), lexical uses a clean `ts_headline` (no markup). A body short enough that a slice would just repeat it comes back whole (`kind="full"`). Pass `full_body=true` to force whole bodies. |
39
39
  | **Embedding-model safety by construction** | The model id + dimension are stamped into the schema; a mismatch **hard-fails** instead of silently returning garbage. |
40
40
  | **Optional TTL, renewed on read** | Off by default — memory is kept forever. Turn on a retention window and active memory persists because it's used, while abandoned memory expires itself: storage self-cleans instead of growing. |
41
41
  | **Optional multi-tenant identity** | Users, namespaces and rotatable scoped tokens when you need isolation; nothing to configure for single-user. |
@@ -148,11 +148,14 @@ Or pull the container image (public, no login):
148
148
  docker pull ghcr.io/mozgsml/memgres:latest
149
149
  ```
150
150
 
151
- ### Three ways to run it
151
+ ### Ways to run it
152
+
153
+ Not sure which fits? Start with the decision guide: [docs/CHOOSING.md](docs/CHOOSING.md). In short — **more than one user → run the Docker server (shared), not a per-machine install.**
152
154
 
153
155
  1. **`docker compose up`** — `pgvector` + service, nothing to configure. For a dedicated vector service instead, `docker compose --profile qdrant up` and set `MEMGRES_VECTOR_BACKEND=qdrant` (Qdrant ranks vectors; Postgres still holds bodies and does tag/subtree/TTL filtering).
154
156
  2. **Your own Postgres** — install the `[server]` extra (above), point `MEMGRES_DATABASE_URL` at it, run `memgres-server` (migrates on startup).
155
157
  3. **Embedded library** — install the core package, use `Store` directly, no HTTP at all.
158
+ 4. **Split service (many clients)** — a stateless API tier that only flags writes plus a scalable `memgres-worker` tier that embeds; see [docs/DEPLOYMENT.md](docs/DEPLOYMENT.md) and `deploy/docker-compose.yml`. Switch the embedding model later with `memgres-reembed`.
156
159
 
157
160
  Semantic recall is optional: the default `MEMGRES_EMBED_PROVIDER=none` gives you lexical FTS with zero models. Turn on `local` (sentence-transformers), a cloud API (`openai`/`jina`), or any OpenAI-compatible server (LM Studio, Ollama, …) when you want meaning-based search — see [docs/EMBEDDINGS.md](docs/EMBEDDINGS.md) for choosing local vs cloud and [docs/BACKENDS.md](docs/BACKENDS.md) for copy-paste setups. The model id + dimension get stamped into the schema and a later mismatch hard-fails instead of silently returning garbage.
158
161
 
@@ -177,12 +180,20 @@ Everything is env, all optional (defaults suit a single-user embed). Full list i
177
180
  | `MEMGRES_HISTORY` | `true` | keep the hash-chained diff history (deleted with the record) |
178
181
  | `MEMGRES_FTS_LANGUAGE` | `simple` | Postgres FTS dictionary (`simple`/`english`/…) |
179
182
  | `MEMGRES_LEXICAL_MATCH` | `any` | lexical query words OR-ed (`any`) or AND-ed (`all`); per-call `match` overrides |
180
- | `MEMGRES_SNIPPET` | `true` | attach a best-match snippet + line to each hit (`MEMGRES_SNIPPET_*` tune size/semantic; `full_body` per call) |
183
+ | `MEMGRES_SNIPPET` | `true` | extract a best-match slice per hit (`MEMGRES_SNIPPET_*` tune size/semantic); `false` returns whole bodies |
184
+ | `MEMGRES_FULL_BODY` | `false` | force the whole body on every hit (off = auto: short whole, long sliced); `full_body` per call overrides |
185
+ | `MEMGRES_FULL_BODY_MAX_CHARS` | `500` | a body this short is returned whole (`kind="full"`) instead of sliced |
181
186
  | `MEMGRES_LIST_PREVIEW_CHARS` | `120` | first-line preview length returned by `memory_list` |
187
+ | `MEMGRES_INSTRUCTION` | — | server-side MCP instructions emitted at `initialize` (a client like Claude Code loads it once at connect); unset = omitted; capped at 2 KB |
182
188
  | `MEMGRES_VECTOR_BACKEND` | `pgvector` | `pgvector` (same DB) or `qdrant` (set `QDRANT_URL`, `QDRANT_API_KEY`, `MEMGRES_QDRANT_COLLECTION`) |
183
189
  | `MEMGRES_EMBED_PROVIDER` | `none` | `none` / `local` / `openai` / `jina` / `openai-compatible` (LM Studio, Ollama, vLLM, TEI…) |
184
190
  | `MEMGRES_EMBED_MODEL` / `_DIM` / `_API_KEY` / `_API_BASE` | — | model id · dimension (HTTP providers require it, `local` infers) · token · server URL |
185
191
  | `MEMGRES_EMBED_MAX_SEQ` | `0` | override the local model's max input length in tokens (`0` = the model's default) |
192
+ | `MEMGRES_CHUNK_CHARS` / `_OVERLAP` | `400` / `80` | chunk size / overlap for the chunk index (legacy names `MEMGRES_SNIPPET_SEG_CHARS` / `_OVERLAP` still work) |
193
+ | `MEMGRES_EMBED_DISPATCH` | `inline` | `inline` = embed within the write (safe default, no worker); `async` = flag `embed_pending` and let a worker embed. A server sets `async` when it starts an in-process worker; set `async` + `MEMGRES_EMBED_WORKER=off` for a split deployment with a separate `memgres-worker` |
194
+ | `MEMGRES_EMBED_WORKER` | `true` | a server process runs an in-process embed worker |
195
+ | `MEMGRES_EMBED_WORKER_INTERVAL` | `1.0` | worker idle poll seconds between drains |
196
+ | `MEMGRES_EMBED_MAX_ATTEMPTS` / `_RETRY_BACKOFF_S` | `5` / `60` | a failing row is retried after the back-off; after this many attempts it's dead-lettered (left flagged, out of rotation, logged) so it can't wedge the queue |
186
197
 
187
198
  ## HTTP API
188
199
 
@@ -15,6 +15,7 @@ Quick start::
15
15
  hits = store.recall(None, "what did I remember?") # lexical or semantic
16
16
  """
17
17
 
18
+ from ._version import __version__
18
19
  from .config import Config, load as load_config
19
20
  from .diffing import apply_diff, content_hash, make_diff, DiffConflict
20
21
  from .embeddings import Embedder, get_embedder
@@ -31,6 +32,7 @@ from .identity import (
31
32
  )
32
33
 
33
34
  __all__ = [
35
+ "__version__",
34
36
  "Config", "load_config",
35
37
  "Store", "Memory", "Conflict", "NotFound", "TooLarge", "NoParent",
36
38
  "make_diff", "apply_diff", "content_hash", "DiffConflict",
@@ -0,0 +1,11 @@
1
+ """Single source of truth for the package version.
2
+
3
+ Read at runtime by ``server_info`` (so an editable/dev checkout reports the code
4
+ it is actually running, not stale install metadata) and at build time by
5
+ ``pyproject.toml`` (``[tool.setuptools.dynamic] version = {attr = ...}``). Bump
6
+ here at release; nowhere else carries the number.
7
+
8
+ PEP 440: a ``.devN`` suffix marks an unreleased build ahead of the last tag.
9
+ """
10
+
11
+ __version__ = "0.4.0"
@@ -20,7 +20,8 @@ from typing import Iterable, List, Optional, Tuple
20
20
 
21
21
  from .diffing import _HUNK
22
22
 
23
- _ATTR_KEYS = ("seq", "op", "source", "reason", "created_at")
23
+ _ATTR_KEYS = ("seq", "op", "source", "reason", "created_at",
24
+ "author_user_id", "author_token_id", "author_name")
24
25
 
25
26
 
26
27
  def _apply_attributed(src: List[Tuple[str, dict]], patch: str,
@@ -35,6 +35,11 @@ def _bool(name: str, default: bool) -> bool:
35
35
  return raw.strip().lower() in ("1", "true", "yes", "on")
36
36
 
37
37
 
38
+ def _float(name: str, default: float) -> float:
39
+ raw = os.environ.get(name)
40
+ return float(raw) if raw not in (None, "") else default
41
+
42
+
38
43
  def _str(name: str, default: str) -> str:
39
44
  raw = os.environ.get(name)
40
45
  return raw if raw not in (None, "") else default
@@ -47,11 +52,12 @@ class Config:
47
52
  max_write_bytes: int # one write/diff payload ceiling (<= max_body)
48
53
  max_source_bytes: int # provenance `source` field ceiling (per write)
49
54
  max_reason_bytes: int # provenance `reason` field ceiling (per write)
55
+ max_title_bytes: int # curated `title` field ceiling (per write)
50
56
  # retention
51
57
  retention_days: int # 0 = forever; >0 = expire N days after last touch
52
58
  renew_on_read: bool # a read pushes the expiry clock forward
53
59
  # multi-tenant isolation
54
- token: str # default token used when a call passes none
60
+ default_token: str # default token used when a call passes none
55
61
  # (set in MCP/env for a single-tenant deployment)
56
62
  # identity / tenancy (see docs/TENANCY.md)
57
63
  key_mode: str # single | open | managed (how tokens/users are minted)
@@ -66,14 +72,37 @@ class Config:
66
72
  fts_language: str # Postgres FTS dict: simple | english | russian | …
67
73
  lexical_match: str # any (OR-any words, default) | all (AND-all words)
68
74
  vector_backend: str # pgvector (default) | qdrant
69
- # snippets (a relevant slice of each recall hit's body + its line number)
70
- snippet: bool # attach a snippet+line to each hit
71
- full_body: bool # also return the whole body (off = snippet only)
75
+ # snippets (a relevant slice of each recall hit's body + its line range)
76
+ snippet: bool # extract a relevant slice; off = return the body
77
+ full_body: bool # force the whole body on every hit (off = auto:
78
+ # short bodies whole, long bodies sliced)
79
+ full_body_max_chars: int # a body this short is returned whole (kind=full)
80
+ # instead of sliced — a slice would just repeat it
72
81
  snippet_semantic: bool # semantic/hybrid hits use the best-matching
73
82
  # segment (needs the model); off = ts_headline,
74
83
  # avoiding per-query model calls on a paid API
75
- snippet_seg_chars: int # segment size for the best-segment snippet cache
76
- snippet_seg_overlap: int # chars shared between consecutive segments
84
+ chunk_chars: int # chunk size for the chunk index (ranking+snippet);
85
+ # MEMGRES_CHUNK_CHARS (legacy MEMGRES_SNIPPET_SEG_CHARS)
86
+ chunk_overlap: int # chars shared between consecutive chunks
87
+ # (MEMGRES_CHUNK_OVERLAP / legacy _SNIPPET_SEG_OVERLAP)
88
+ # embedding pipeline (chunks are the semantic index; see docs/EMBEDDINGS.md)
89
+ embed_dispatch: str # how a write's chunk-embedding happens:
90
+ # inline — embed within the write (safe default;
91
+ # library/embedded use, no worker needed);
92
+ # async — flag embed_pending and return; a worker
93
+ # (in-process or a separate memgres-worker)
94
+ # drains it. A SERVER upgrades inline→async when
95
+ # it starts an in-process worker (see
96
+ # embed_worker.wire_server); set it to async
97
+ # explicitly WITH embed_worker=off for a split
98
+ # deployment where an external worker embeds.
99
+ embed_worker: bool # a server process runs an in-process embed worker
100
+ embed_worker_interval: float # seconds the idle worker sleeps between drains
101
+ embed_max_attempts: int # after this many failed embed attempts a row is a
102
+ # dead letter — left flagged but out of the claim
103
+ # rotation (logged), so one poison body can't wedge
104
+ # the queue behind it. A successful embed resets it.
105
+ embed_retry_backoff_s: float # seconds a failed row is skipped before retry
77
106
  # listing / browse
78
107
  list_preview_chars: int # first-line preview length for memory_list (0 = none)
79
108
  # embeddings
@@ -95,14 +124,26 @@ class Config:
95
124
  raise ValueError("MEMGRES_MAX_SOURCE_BYTES must be >= 1")
96
125
  if self.max_reason_bytes < 1:
97
126
  raise ValueError("MEMGRES_MAX_REASON_BYTES must be >= 1")
127
+ if self.max_title_bytes < 1:
128
+ raise ValueError("MEMGRES_MAX_TITLE_BYTES must be >= 1")
98
129
  if self.embed_max_seq < 0:
99
130
  raise ValueError("MEMGRES_EMBED_MAX_SEQ must be >= 0")
100
131
  if self.list_preview_chars < 0:
101
132
  raise ValueError("MEMGRES_LIST_PREVIEW_CHARS must be >= 0")
102
- if self.snippet_seg_chars < 1:
103
- raise ValueError("MEMGRES_SNIPPET_SEG_CHARS must be >= 1")
104
- if self.snippet_seg_overlap < 0:
105
- raise ValueError("MEMGRES_SNIPPET_SEG_OVERLAP must be >= 0")
133
+ if self.chunk_chars < 1:
134
+ raise ValueError("MEMGRES_CHUNK_CHARS must be >= 1")
135
+ if self.chunk_overlap < 0:
136
+ raise ValueError("MEMGRES_CHUNK_OVERLAP must be >= 0")
137
+ if self.embed_dispatch not in ("inline", "async"):
138
+ raise ValueError(f"unknown MEMGRES_EMBED_DISPATCH: {self.embed_dispatch}")
139
+ if self.full_body_max_chars < 0:
140
+ raise ValueError("MEMGRES_FULL_BODY_MAX_CHARS must be >= 0")
141
+ if self.embed_worker_interval <= 0:
142
+ raise ValueError("MEMGRES_EMBED_WORKER_INTERVAL must be > 0")
143
+ if self.embed_max_attempts < 1:
144
+ raise ValueError("MEMGRES_EMBED_MAX_ATTEMPTS must be >= 1")
145
+ if self.embed_retry_backoff_s < 0:
146
+ raise ValueError("MEMGRES_EMBED_RETRY_BACKOFF_S must be >= 0")
106
147
  if self.max_write_bytes > self.max_body_bytes:
107
148
  raise ValueError(
108
149
  "MEMGRES_MAX_WRITE_BYTES must be <= MEMGRES_MAX_BODY_BYTES"
@@ -132,9 +173,10 @@ def load() -> Config:
132
173
  max_write_bytes=_int("MEMGRES_MAX_WRITE_BYTES", 16_384), # 16 KB
133
174
  max_source_bytes=_int("MEMGRES_MAX_SOURCE_BYTES", 2_048), # 2 KB
134
175
  max_reason_bytes=_int("MEMGRES_MAX_REASON_BYTES", 1_024), # 1 KB
176
+ max_title_bytes=_int("MEMGRES_MAX_TITLE_BYTES", 256), # 256 B
135
177
  retention_days=_int("MEMGRES_RETENTION_DAYS", 0),
136
178
  renew_on_read=_bool("MEMGRES_RENEW_ON_READ", True),
137
- token=_str("MEMGRES_TOKEN", ""),
179
+ default_token=_str("MEMGRES_TOKEN", ""),
138
180
  key_mode=_str("MEMGRES_KEY_MODE", "single"),
139
181
  admin_token=_str("MEMGRES_ADMIN_TOKEN", ""),
140
182
  tree_enabled=_bool("MEMGRES_TREE", True),
@@ -144,10 +186,18 @@ def load() -> Config:
144
186
  lexical_match=_str("MEMGRES_LEXICAL_MATCH", "any"),
145
187
  vector_backend=_str("MEMGRES_VECTOR_BACKEND", "pgvector"),
146
188
  snippet=_bool("MEMGRES_SNIPPET", True),
147
- full_body=_bool("MEMGRES_FULL_BODY", True),
189
+ full_body=_bool("MEMGRES_FULL_BODY", False),
190
+ full_body_max_chars=_int("MEMGRES_FULL_BODY_MAX_CHARS", 500),
148
191
  snippet_semantic=_bool("MEMGRES_SNIPPET_SEMANTIC", True),
149
- snippet_seg_chars=_int("MEMGRES_SNIPPET_SEG_CHARS", 400),
150
- snippet_seg_overlap=_int("MEMGRES_SNIPPET_SEG_OVERLAP", 80),
192
+ chunk_chars=_int("MEMGRES_CHUNK_CHARS",
193
+ _int("MEMGRES_SNIPPET_SEG_CHARS", 400)),
194
+ chunk_overlap=_int("MEMGRES_CHUNK_OVERLAP",
195
+ _int("MEMGRES_SNIPPET_SEG_OVERLAP", 80)),
196
+ embed_dispatch=_str("MEMGRES_EMBED_DISPATCH", "inline"),
197
+ embed_worker=_bool("MEMGRES_EMBED_WORKER", True),
198
+ embed_worker_interval=_float("MEMGRES_EMBED_WORKER_INTERVAL", 1.0),
199
+ embed_max_attempts=_int("MEMGRES_EMBED_MAX_ATTEMPTS", 5),
200
+ embed_retry_backoff_s=_float("MEMGRES_EMBED_RETRY_BACKOFF_S", 60.0),
151
201
  list_preview_chars=_int("MEMGRES_LIST_PREVIEW_CHARS", 120),
152
202
  embed_provider=_str("MEMGRES_EMBED_PROVIDER", "none"),
153
203
  embed_model=_str("MEMGRES_EMBED_MODEL", ""),
@@ -0,0 +1,134 @@
1
+ """Background embed worker: builds chunk vectors off the write path.
2
+
3
+ When a server runs this worker, writes only flag ``embed_pending`` and return
4
+ immediately; the worker drains those rows — segment, embed, index — on its own
5
+ connection and thread. That's what keeps a write fast even for a large body (the
6
+ embedding no longer runs inside the request).
7
+
8
+ One daemon thread, one dedicated connection. It backfills on start (so a restart
9
+ catches up any rows left pending, including the one-time re-chunk after the
10
+ schema upgrade), then polls. ``drain_once`` is the same code the loop runs and is
11
+ directly callable from a test or a CLI. The real work lives in
12
+ :func:`memgres.indexing.drain`; this is just its lifecycle.
13
+ """
14
+
15
+ from __future__ import annotations
16
+
17
+ import logging
18
+ import threading
19
+ from typing import Callable, Optional
20
+
21
+ from .indexing import drain
22
+
23
+ _log = logging.getLogger("memgres.embed_worker")
24
+
25
+
26
+ class EmbedWorker:
27
+ def __init__(self, cfg, embedder, backend,
28
+ connect: Callable[[], "object"]):
29
+ self.cfg = cfg
30
+ self.embedder = embedder
31
+ self.backend = backend
32
+ self._connect = connect # () -> a fresh psycopg connection
33
+ self._conn = None
34
+ self._stop = threading.Event()
35
+ self._thread: Optional[threading.Thread] = None
36
+
37
+ def _conn_ok(self):
38
+ if self._conn is None or getattr(self._conn, "closed", False):
39
+ self._conn = self._connect()
40
+ return self._conn
41
+
42
+ def drain_once(self) -> int:
43
+ """One synchronous drain pass over all currently-pending rows. Returns the
44
+ count embedded. Used by the loop and directly by tests."""
45
+ return drain(self._conn_ok(), self.cfg, self.embedder, self.backend)
46
+
47
+ def _run(self) -> None:
48
+ # The first iteration IS the backfill (catch up rows left pending across a
49
+ # restart / the schema upgrade). Done in the thread, not in start(), so
50
+ # building a server never blocks on embedding a backlog.
51
+ while not self._stop.is_set():
52
+ try:
53
+ self.drain_once()
54
+ except Exception:
55
+ _log.exception("embed worker drain failed; dropping connection, retrying")
56
+ self._reset_conn()
57
+ self._stop.wait(self.cfg.embed_worker_interval)
58
+
59
+ def _reset_conn(self) -> None:
60
+ try:
61
+ if self._conn is not None:
62
+ self._conn.close()
63
+ except Exception:
64
+ pass
65
+ self._conn = None
66
+
67
+ def start(self) -> "EmbedWorker":
68
+ if self._thread is not None:
69
+ return self
70
+ self._thread = threading.Thread(target=self._run, name="memgres-embed",
71
+ daemon=True)
72
+ self._thread.start()
73
+ return self
74
+
75
+ def serve(self) -> None:
76
+ """Run the drain loop in the CURRENT thread, blocking until ``stop()``.
77
+ Used by the standalone ``memgres-worker`` process (a signal handler calls
78
+ ``stop()``); the in-process server path uses ``start()`` instead."""
79
+ self._run()
80
+ self._reset_conn()
81
+
82
+ def stop(self) -> None:
83
+ self._stop.set()
84
+ if self._thread is not None:
85
+ self._thread.join(timeout=5)
86
+ self._thread = None
87
+ self._reset_conn()
88
+
89
+
90
+ def maybe_start_worker(cfg, embedder, backend,
91
+ connect: Callable[[], "object"]) -> Optional[EmbedWorker]:
92
+ """Start an :class:`EmbedWorker` when there's something to embed and the
93
+ deployment wants one (``MEMGRES_EMBED_WORKER``, default on). Returns the
94
+ running worker, or ``None`` — in which case the caller must keep writes
95
+ synchronous (embed inline), so semantic recall never silently lags."""
96
+ if embedder is None or backend is None or not cfg.embed_worker:
97
+ return None
98
+ return EmbedWorker(cfg, embedder, backend, connect).start()
99
+
100
+
101
+ def wire_server(cfg, embedder):
102
+ """Server-side setup shared by the HTTP and MCP entrypoints: build the vector
103
+ backend ONCE, start the in-process embed worker if warranted, and return
104
+ ``(worker, cfg, backend)`` with ``cfg.embed_dispatch`` set to what actually
105
+ holds. The caller injects ``backend`` into every per-request ``Store`` so a
106
+ qdrant client isn't rebuilt each call.
107
+
108
+ Dispatch resolution:
109
+ * an in-process worker started (``embed_worker`` on, embedder present) →
110
+ writes defer to it → ``async``. The all-in-one server default: fast writes
111
+ with a local drainer, never a flag nothing embeds.
112
+ * no in-process worker → keep the operator's ``embed_dispatch``. That is how
113
+ a SPLIT deployment works: an API container sets ``async`` + ``embed_worker
114
+ =off`` and a separate ``memgres-worker`` drains. Left at the default
115
+ (``inline``), a workerless server just embeds inline — safe, never a
116
+ silent gap."""
117
+ import psycopg
118
+ from dataclasses import replace
119
+
120
+ from .vector.base import make_backend
121
+
122
+ backend = make_backend(cfg, embedder)
123
+ worker = maybe_start_worker(
124
+ cfg, embedder, backend,
125
+ connect=lambda: psycopg.connect(cfg.database_url or ""))
126
+ dispatch = "async" if worker is not None else cfg.embed_dispatch
127
+ if dispatch == "async" and worker is None and backend is not None:
128
+ # async + no local worker: writes will flag embed_pending and this process
129
+ # embeds nothing. Correct ONLY in a split deployment with a separate
130
+ # memgres-worker. Warn loudly so a missing/failed worker isn't a silent gap.
131
+ _log.warning("MEMGRES_EMBED_DISPATCH=async with no in-process worker: writes "
132
+ "will be flagged but NOT embedded here — a separate "
133
+ "memgres-worker MUST be running, or semantic recall will lag.")
134
+ return worker, replace(cfg, embed_dispatch=dispatch), backend
@@ -32,6 +32,19 @@ from typing import List, Optional, Tuple
32
32
  # ─── token format: mgk_ + 43 url-safe chars (256-bit) ────────────────────────
33
33
  TOKEN_RE = re.compile(r"^mgk_[A-Za-z0-9_-]{43}$")
34
34
 
35
+
36
+ def bearer_token(authorization: Optional[str],
37
+ x_memgres_token: Optional[str]) -> Optional[str]:
38
+ """Extract a raw memgres token from request auth headers — ``Authorization:
39
+ Bearer <tok>`` first, else the ``X-Memgres-Token`` header — or ``None`` if
40
+ neither is present. One definition for both the HTTP and MCP transports so
41
+ header parsing can't drift between them."""
42
+ if authorization and authorization[:7].lower() == "bearer ":
43
+ return authorization[7:].strip() or None
44
+ if x_memgres_token:
45
+ return x_memgres_token.strip() or None
46
+ return None
47
+
35
48
  # permission lattice
36
49
  _RANK = {"read": 1, "write": 2, "admin": 3}
37
50
 
@@ -0,0 +1,144 @@
1
+ """Chunk embedding: turn a memory body into chunk vectors, sync or via worker.
2
+
3
+ One place builds the chunk index for a memory (`index_memory`), reached two ways:
4
+
5
+ * **synchronously**, inline in the write path (embedded/library mode, or a
6
+ server with the worker turned off) — the default, so semantic recall never
7
+ silently lags behind a write;
8
+ * **asynchronously**, by the background embed worker draining `embed_pending`
9
+ rows (the server's default: writes return fast, embedding runs off the
10
+ request path).
11
+
12
+ Idempotent and crash-safe: `index_memory` skips a row whose chunks already match
13
+ the body's content_hash, and clears `embed_pending` only when the body it
14
+ embedded is still current — a concurrent edit bumps the hash and re-flags the
15
+ row, so it's picked up again rather than lost. `drain` commits per row, so a
16
+ crash mid-batch leaves the unfinished rows flagged for the next pass.
17
+ """
18
+
19
+ from __future__ import annotations
20
+
21
+ import logging
22
+ from typing import Optional
23
+
24
+ from .diffing import content_hash
25
+ from .segments import segment
26
+
27
+ _log = logging.getLogger("memgres.indexing")
28
+
29
+
30
+ def index_memory(conn, cfg, embedder, backend, memory_id: str, body: str,
31
+ ns: str, src_hash: Optional[str] = None) -> bool:
32
+ """Build and store the chunk vectors for one memory, then clear its pending
33
+ flag. Returns True if it (re)embedded, False if the chunks were already
34
+ current (or there's nothing to embed). Does NOT manage the transaction — the
35
+ caller owns commit/rollback (the write path folds this into its own tx; the
36
+ worker commits per row)."""
37
+ src = src_hash or content_hash(body)
38
+ if backend is None or embedder is None:
39
+ _clear_pending(conn, memory_id, src) # no vectors; don't leave it pending
40
+ return False
41
+ if backend.chunk_src_hash(conn, memory_id, ns) == src:
42
+ _clear_pending(conn, memory_id, src) # already current for this body
43
+ return False
44
+ spans = segment(body, cfg.chunk_chars, cfg.chunk_overlap)
45
+ if spans:
46
+ vecs = embedder.embed_documents([body[s:e] for (s, e) in spans])
47
+ chunks = [(i, s, e, v) for i, ((s, e), v) in enumerate(zip(spans, vecs))]
48
+ backend.index_chunks(conn, memory_id, ns, src, chunks)
49
+ else:
50
+ backend.delete_chunks(conn, memory_id, ns) # empty body → no chunks
51
+ _clear_pending(conn, memory_id, src)
52
+ return True
53
+
54
+
55
+ def _clear_pending(conn, memory_id: str, src: str) -> None:
56
+ """Clear ``embed_pending`` (and reset the retry counters) only if the body is
57
+ still the one we embedded (guarded by content_hash). A concurrent edit changed
58
+ the hash and re-set the flag, so leaving it set hands the row to the next
59
+ pass. A success zeroes ``embed_attempts``/``embed_failed_at`` so a later
60
+ legitimate edit re-embeds cleanly rather than inheriting a stale failure."""
61
+ with conn.cursor() as cur:
62
+ cur.execute(
63
+ "UPDATE memory SET embed_pending=false, embed_attempts=0, "
64
+ "embed_failed_at=NULL "
65
+ "WHERE id=%s AND content_hash=%s AND embed_pending",
66
+ (memory_id, src))
67
+
68
+
69
+ def _record_failure(conn, cfg, memory_id: str) -> None:
70
+ """Record a failed embed attempt in its OWN transaction (the embed tx rolled
71
+ back). The claim then skips this row for a back-off window, and once it has
72
+ failed ``embed_max_attempts`` times the claim drops it entirely — a dead
73
+ letter, left flagged and logged, so one poison body can't wedge the queue."""
74
+ try:
75
+ with conn.transaction():
76
+ with conn.cursor() as cur:
77
+ cur.execute(
78
+ "UPDATE memory SET embed_attempts=embed_attempts+1, "
79
+ "embed_failed_at=now() WHERE id=%s RETURNING embed_attempts",
80
+ (memory_id,))
81
+ r = cur.fetchone()
82
+ attempts = r[0] if r else 0
83
+ if attempts >= cfg.embed_max_attempts:
84
+ _log.error("embed: memory %s failed %d× — dead-lettered (still flagged, "
85
+ "out of rotation); investigate its body or the model",
86
+ memory_id, attempts)
87
+ else:
88
+ _log.warning("embed: memory %s failed (attempt %d/%d); backing off %.0fs",
89
+ memory_id, attempts, cfg.embed_max_attempts,
90
+ cfg.embed_retry_backoff_s)
91
+ except Exception:
92
+ _log.exception("embed: could not record failure for %s", memory_id)
93
+
94
+
95
+ def drain(conn, cfg, embedder, backend, limit: Optional[int] = None) -> int:
96
+ """Embed eligible pending memories oldest-first until none remain. Returns the
97
+ count successfully embedded.
98
+
99
+ Claim-based, so MANY workers (separate memgres-worker containers, or several
100
+ server replicas each with an in-process worker) drain the same queue without
101
+ duplicating work: each row is claimed with ``FOR UPDATE SKIP LOCKED`` inside
102
+ the transaction that embeds it, so a concurrent worker skips a row already
103
+ being handled rather than embedding it again or blocking on it. The lock is
104
+ held only for that one row's embed and released on commit; the flag is cleared
105
+ in the same transaction, so a crash mid-embed rolls back and leaves the row
106
+ pending for another pass (crash-safe).
107
+
108
+ A row that FAILS to embed does not stop the pass and does not wedge the queue:
109
+ its attempt count + failure time are recorded (``_record_failure``), the claim
110
+ skips it for a back-off window, and after ``embed_max_attempts`` it drops out
111
+ of rotation (a logged dead letter) so newer rows always make progress. Only a
112
+ claim/connection-level error ends the pass (the next tick reconnects).
113
+
114
+ ``limit`` caps how many rows this call embeds (default: everything eligible)."""
115
+ total = 0
116
+ while limit is None or total < limit:
117
+ row = None
118
+ try:
119
+ with conn.transaction():
120
+ with conn.cursor() as cur:
121
+ cur.execute(
122
+ "SELECT id, body, namespace, content_hash FROM memory "
123
+ "WHERE embed_pending AND embed_attempts < %s "
124
+ "AND (embed_failed_at IS NULL "
125
+ " OR embed_failed_at < now() - make_interval(secs => %s)) "
126
+ "ORDER BY updated_at LIMIT 1 FOR UPDATE SKIP LOCKED",
127
+ (cfg.embed_max_attempts, cfg.embed_retry_backoff_s))
128
+ row = cur.fetchone()
129
+ if row is None:
130
+ break # nothing eligible & unclaimed → done
131
+ mid, body, ns, chash = row
132
+ index_memory(conn, cfg, embedder, backend, str(mid),
133
+ body or "", ns, chash)
134
+ total += 1 # committed → row done
135
+ except Exception:
136
+ if row is None: # the claim itself failed → connection issue
137
+ _log.exception("embed worker: claim failed; ending this pass")
138
+ break
139
+ # A single row's embed failed: record it and SKIP FORWARD, so a poison
140
+ # row never blocks the rest of the queue.
141
+ _log.warning("embed worker: embedding memory %s failed", row[0])
142
+ _record_failure(conn, cfg, str(row[0]))
143
+ continue
144
+ return total
@@ -11,20 +11,29 @@ from __future__ import annotations
11
11
 
12
12
  from typing import Optional
13
13
 
14
+ from ._version import __version__
14
15
  from .config import Config
16
+ from .schema import SCHEMA_VERSION
15
17
 
16
18
 
17
19
  def server_info(cfg: Config, embed_dim: Optional[int] = None) -> dict:
18
20
  """Effective limits + capabilities, from the loaded config (and the live
19
- embedder's dimension when available). Never includes secrets."""
21
+ embedder's dimension when available). Never includes secrets.
22
+
23
+ ``version`` is the running package version (from code, so an editable/dev
24
+ checkout reports what it's actually running); ``schema_version`` is the DB
25
+ layout this build migrates to."""
20
26
  lexical_only = cfg.embed_provider == "none"
21
27
  dim = embed_dim if embed_dim is not None else (cfg.embed_dim or None)
22
28
  return {
29
+ "version": __version__,
30
+ "schema_version": SCHEMA_VERSION,
23
31
  "limits": {
24
32
  "max_body_bytes": cfg.max_body_bytes,
25
33
  "max_write_bytes": cfg.max_write_bytes,
26
34
  "max_source_bytes": cfg.max_source_bytes,
27
35
  "max_reason_bytes": cfg.max_reason_bytes,
36
+ "max_title_bytes": cfg.max_title_bytes,
28
37
  },
29
38
  "embed": {
30
39
  "provider": cfg.embed_provider,