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.
- {memgres-0.3.2 → memgres-0.4.0}/PKG-INFO +16 -5
- {memgres-0.3.2 → memgres-0.4.0}/README.md +15 -4
- {memgres-0.3.2 → memgres-0.4.0}/memgres/__init__.py +2 -0
- memgres-0.4.0/memgres/_version.py +11 -0
- {memgres-0.3.2 → memgres-0.4.0}/memgres/blame.py +2 -1
- {memgres-0.3.2 → memgres-0.4.0}/memgres/config.py +64 -14
- memgres-0.4.0/memgres/embed_worker.py +134 -0
- {memgres-0.3.2 → memgres-0.4.0}/memgres/identity.py +13 -0
- memgres-0.4.0/memgres/indexing.py +144 -0
- {memgres-0.3.2 → memgres-0.4.0}/memgres/info.py +10 -1
- {memgres-0.3.2 → memgres-0.4.0}/memgres/mcp_server.py +95 -46
- memgres-0.4.0/memgres/migrations/0003_history_author.sql +25 -0
- memgres-0.4.0/memgres/migrations/0004_title.sql +22 -0
- memgres-0.4.0/memgres/migrations/0005_chunk_index.sql +41 -0
- memgres-0.4.0/memgres/migrations/0006_reader_floor.sql +15 -0
- memgres-0.4.0/memgres/migrations/0007_embed_retry.sql +13 -0
- memgres-0.4.0/memgres/reembed.py +139 -0
- memgres-0.4.0/memgres/schema.py +237 -0
- memgres-0.4.0/memgres/search.py +212 -0
- {memgres-0.3.2 → memgres-0.4.0}/memgres/server.py +39 -26
- {memgres-0.3.2 → memgres-0.4.0}/memgres/store.py +254 -80
- memgres-0.4.0/memgres/vector/base.py +209 -0
- memgres-0.4.0/memgres/vector/pgvector.py +69 -0
- memgres-0.4.0/memgres/vector/qdrant.py +185 -0
- memgres-0.4.0/memgres/worker.py +59 -0
- {memgres-0.3.2 → memgres-0.4.0}/memgres.egg-info/PKG-INFO +16 -5
- {memgres-0.3.2 → memgres-0.4.0}/memgres.egg-info/SOURCES.txt +15 -0
- {memgres-0.3.2 → memgres-0.4.0}/memgres.egg-info/entry_points.txt +2 -0
- {memgres-0.3.2 → memgres-0.4.0}/pyproject.toml +6 -1
- memgres-0.4.0/tests/test_chunk_index.py +234 -0
- memgres-0.4.0/tests/test_claim_and_reembed.py +232 -0
- memgres-0.4.0/tests/test_diffing.py +176 -0
- memgres-0.4.0/tests/test_embed_worker.py +250 -0
- {memgres-0.3.2 → memgres-0.4.0}/tests/test_lexical_match.py +1 -1
- {memgres-0.3.2 → memgres-0.4.0}/tests/test_list.py +2 -1
- memgres-0.4.0/tests/test_mcp_instructions.py +99 -0
- {memgres-0.3.2 → memgres-0.4.0}/tests/test_mcp_recall_schema.py +1 -0
- memgres-0.4.0/tests/test_migration_upgrade.py +169 -0
- {memgres-0.3.2 → memgres-0.4.0}/tests/test_qdrant_integration.py +14 -9
- {memgres-0.3.2 → memgres-0.4.0}/tests/test_search_integration.py +3 -3
- {memgres-0.3.2 → memgres-0.4.0}/tests/test_security_integration.py +3 -3
- {memgres-0.3.2 → memgres-0.4.0}/tests/test_segments_store.py +85 -87
- {memgres-0.3.2 → memgres-0.4.0}/tests/test_server_info.py +17 -3
- {memgres-0.3.2 → memgres-0.4.0}/tests/test_server_integration.py +27 -5
- {memgres-0.3.2 → memgres-0.4.0}/tests/test_snippets.py +59 -26
- memgres-0.4.0/tests/test_store_integration.py +438 -0
- memgres-0.3.2/memgres/schema.py +0 -156
- memgres-0.3.2/memgres/search.py +0 -188
- memgres-0.3.2/memgres/vector/base.py +0 -90
- memgres-0.3.2/memgres/vector/pgvector.py +0 -83
- memgres-0.3.2/memgres/vector/qdrant.py +0 -220
- memgres-0.3.2/tests/test_diffing.py +0 -87
- memgres-0.3.2/tests/test_store_integration.py +0 -193
- {memgres-0.3.2 → memgres-0.4.0}/LICENSE +0 -0
- {memgres-0.3.2 → memgres-0.4.0}/memgres/diffing.py +0 -0
- {memgres-0.3.2 → memgres-0.4.0}/memgres/embeddings.py +0 -0
- {memgres-0.3.2 → memgres-0.4.0}/memgres/migrations/0001_core.sql +0 -0
- {memgres-0.3.2 → memgres-0.4.0}/memgres/migrations/0002_identity.sql +0 -0
- {memgres-0.3.2 → memgres-0.4.0}/memgres/segments.py +0 -0
- {memgres-0.3.2 → memgres-0.4.0}/memgres/vector/__init__.py +0 -0
- {memgres-0.3.2 → memgres-0.4.0}/memgres.egg-info/dependency_links.txt +0 -0
- {memgres-0.3.2 → memgres-0.4.0}/memgres.egg-info/requires.txt +0 -0
- {memgres-0.3.2 → memgres-0.4.0}/memgres.egg-info/top_level.txt +0 -0
- {memgres-0.3.2 → memgres-0.4.0}/setup.cfg +0 -0
- {memgres-0.3.2 → memgres-0.4.0}/tests/test_blame_integration.py +0 -0
- {memgres-0.3.2 → memgres-0.4.0}/tests/test_config.py +0 -0
- {memgres-0.3.2 → memgres-0.4.0}/tests/test_embeddings.py +0 -0
- {memgres-0.3.2 → memgres-0.4.0}/tests/test_identity_integration.py +0 -0
- {memgres-0.3.2 → memgres-0.4.0}/tests/test_limits.py +0 -0
- {memgres-0.3.2 → memgres-0.4.0}/tests/test_qdrant_ca.py +0 -0
- {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
|
+
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:
|
|
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
|
|
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
|
-
###
|
|
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` |
|
|
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:
|
|
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
|
|
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
|
-
###
|
|
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` |
|
|
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
|
-
|
|
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
|
|
70
|
-
snippet: bool #
|
|
71
|
-
full_body: bool #
|
|
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
|
-
|
|
76
|
-
|
|
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.
|
|
103
|
-
raise ValueError("
|
|
104
|
-
if self.
|
|
105
|
-
raise ValueError("
|
|
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
|
-
|
|
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",
|
|
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
|
-
|
|
150
|
-
|
|
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,
|