memgres 0.2.0__tar.gz → 0.3.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 (51) hide show
  1. {memgres-0.2.0 → memgres-0.3.0}/PKG-INFO +33 -13
  2. {memgres-0.2.0 → memgres-0.3.0}/README.md +31 -12
  3. {memgres-0.2.0 → memgres-0.3.0}/memgres/config.py +39 -0
  4. {memgres-0.2.0 → memgres-0.3.0}/memgres/embeddings.py +4 -2
  5. memgres-0.3.0/memgres/info.py +39 -0
  6. {memgres-0.2.0 → memgres-0.3.0}/memgres/mcp_server.py +72 -10
  7. {memgres-0.2.0 → memgres-0.3.0}/memgres/schema.py +22 -1
  8. memgres-0.3.0/memgres/search.py +188 -0
  9. memgres-0.3.0/memgres/segments.py +112 -0
  10. {memgres-0.2.0 → memgres-0.3.0}/memgres/server.py +32 -3
  11. {memgres-0.2.0 → memgres-0.3.0}/memgres/store.py +67 -42
  12. memgres-0.3.0/memgres/vector/__init__.py +9 -0
  13. memgres-0.3.0/memgres/vector/base.py +90 -0
  14. memgres-0.3.0/memgres/vector/pgvector.py +83 -0
  15. memgres-0.3.0/memgres/vector/qdrant.py +220 -0
  16. {memgres-0.2.0 → memgres-0.3.0}/memgres.egg-info/PKG-INFO +33 -13
  17. {memgres-0.2.0 → memgres-0.3.0}/memgres.egg-info/SOURCES.txt +15 -1
  18. {memgres-0.2.0 → memgres-0.3.0}/memgres.egg-info/requires.txt +1 -0
  19. {memgres-0.2.0 → memgres-0.3.0}/pyproject.toml +2 -1
  20. {memgres-0.2.0 → memgres-0.3.0}/tests/test_config.py +16 -0
  21. memgres-0.3.0/tests/test_lexical_match.py +104 -0
  22. memgres-0.3.0/tests/test_limits.py +135 -0
  23. memgres-0.3.0/tests/test_list.py +149 -0
  24. memgres-0.3.0/tests/test_mcp_recall_schema.py +73 -0
  25. memgres-0.3.0/tests/test_qdrant_ca.py +79 -0
  26. {memgres-0.2.0 → memgres-0.3.0}/tests/test_qdrant_integration.py +10 -0
  27. memgres-0.3.0/tests/test_segments.py +91 -0
  28. memgres-0.3.0/tests/test_segments_store.py +277 -0
  29. memgres-0.3.0/tests/test_server_info.py +97 -0
  30. {memgres-0.2.0 → memgres-0.3.0}/tests/test_server_integration.py +28 -0
  31. memgres-0.3.0/tests/test_snippets.py +262 -0
  32. memgres-0.2.0/memgres/qdrant_backend.py +0 -82
  33. memgres-0.2.0/memgres/search.py +0 -125
  34. {memgres-0.2.0 → memgres-0.3.0}/LICENSE +0 -0
  35. {memgres-0.2.0 → memgres-0.3.0}/memgres/__init__.py +0 -0
  36. {memgres-0.2.0 → memgres-0.3.0}/memgres/blame.py +0 -0
  37. {memgres-0.2.0 → memgres-0.3.0}/memgres/diffing.py +0 -0
  38. {memgres-0.2.0 → memgres-0.3.0}/memgres/identity.py +0 -0
  39. {memgres-0.2.0 → memgres-0.3.0}/memgres/migrations/0001_core.sql +0 -0
  40. {memgres-0.2.0 → memgres-0.3.0}/memgres/migrations/0002_identity.sql +0 -0
  41. {memgres-0.2.0 → memgres-0.3.0}/memgres.egg-info/dependency_links.txt +0 -0
  42. {memgres-0.2.0 → memgres-0.3.0}/memgres.egg-info/entry_points.txt +0 -0
  43. {memgres-0.2.0 → memgres-0.3.0}/memgres.egg-info/top_level.txt +0 -0
  44. {memgres-0.2.0 → memgres-0.3.0}/setup.cfg +0 -0
  45. {memgres-0.2.0 → memgres-0.3.0}/tests/test_blame_integration.py +0 -0
  46. {memgres-0.2.0 → memgres-0.3.0}/tests/test_diffing.py +0 -0
  47. {memgres-0.2.0 → memgres-0.3.0}/tests/test_embeddings.py +0 -0
  48. {memgres-0.2.0 → memgres-0.3.0}/tests/test_identity_integration.py +0 -0
  49. {memgres-0.2.0 → memgres-0.3.0}/tests/test_search_integration.py +0 -0
  50. {memgres-0.2.0 → memgres-0.3.0}/tests/test_security_integration.py +0 -0
  51. {memgres-0.2.0 → memgres-0.3.0}/tests/test_store_integration.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: memgres
3
- Version: 0.2.0
3
+ Version: 0.3.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
@@ -18,6 +18,7 @@ Requires-Python: >=3.10
18
18
  Description-Content-Type: text/markdown
19
19
  License-File: LICENSE
20
20
  Requires-Dist: psycopg[binary]>=3.1
21
+ Requires-Dist: pysbd>=0.3
21
22
  Provides-Extra: local
22
23
  Requires-Dist: sentence-transformers>=3.0; extra == "local"
23
24
  Provides-Extra: qdrant
@@ -70,11 +71,12 @@ Reach for memgres when you want **auditable, authored, versioned text memory**.
70
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). |
71
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. |
72
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. |
73
75
  | **Embedding-model safety by construction** | The model id + dimension are stamped into the schema; a mismatch **hard-fails** instead of silently returning garbage. |
74
- | **TTL renewed on read** | Active memory persists because it's used; abandoned memory expires itself. Storage self-cleans instead of growing forever. |
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. |
75
77
  | **Optional multi-tenant identity** | Users, namespaces and rotatable scoped tokens when you need isolation; nothing to configure for single-user. |
76
78
  | **Fast subtree recall via `ltree`** | Memories form a real tree; `path <@ 'a.b'` pulls a whole subtree in one GiST index scan, no recursive walk that degrades with depth. |
77
- | **Git-blame + version reconstruct** | Every line carries who last changed it (grouped into author-blocks); any past version reconstructs from history — no replaying diffs yourself. |
79
+ | **Git-like blame + version reconstruct** | Every line carries who last changed it (grouped into author-blocks); any past version reconstructs from history — no replaying diffs yourself. |
78
80
  | **One Postgres, one backup** | The whole thing is `pg_dump`-able; the vector index rebuilds from the source of truth. No second datastore to run or back up. |
79
81
  | **Drop-in module, not a framework** | `pip install`, or `docker compose up`, or point at your own Postgres. No platform to adopt. |
80
82
 
@@ -114,7 +116,7 @@ docker compose up
114
116
 
115
117
  Defaults suit a single-user setup with no auth. To change limits, the embedding provider, tokens, … drop a `.env` beside it — every `MEMGRES_*` is optional (see [Configuration](#configuration) or [.env.example](.env.example)).
116
118
 
117
- **Give it to an LLM / agent — no code (MCP).** Point any URL-capable MCP client (Cursor, Cline, Claude Desktop, …) at the running server; the model gets `memory_write`, `memory_recall`, `memory_get`, `memory_blame`, `memory_history`, `memory_move`, `memory_forget` as tools:
119
+ **Give it to an LLM / agent — no code (MCP).** Point any URL-capable MCP client (Cursor, Cline, Claude Desktop, …) at the running server; the model gets `memory_write`, `memory_recall`, `memory_get`, `memory_list`, `memory_blame`, `memory_history`, `memory_move`, `memory_forget`, `memory_server_info` as tools:
118
120
 
119
121
  ```json
120
122
  {
@@ -188,7 +190,7 @@ docker pull ghcr.io/mozgsml/memgres:latest
188
190
  2. **Your own Postgres** — install the `[server]` extra (above), point `MEMGRES_DATABASE_URL` at it, run `memgres-server` (migrates on startup).
189
191
  3. **Embedded library** — install the core package, use `Store` directly, no HTTP at all.
190
192
 
191
- 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/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.
193
+ 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.
192
194
 
193
195
  ## Configuration
194
196
 
@@ -200,7 +202,8 @@ Everything is env, all optional (defaults suit a single-user embed). Full list i
200
202
  | `MEMGRES_POOL_SIZE` | `4` | max pooled DB connections (HTTP + http-MCP servers); raise for many concurrent clients, `1` to serialize |
201
203
  | `MEMGRES_MAX_BODY_BYTES` | `262144` | ceiling for a whole record body (256 KB) |
202
204
  | `MEMGRES_MAX_WRITE_BYTES` | `16384` | ceiling for one write/diff payload (≤ body) |
203
- | `MEMGRES_RETENTION_DAYS` | `0` | `0` = keep forever; `>0` = expire N days after last touch |
205
+ | `MEMGRES_MAX_SOURCE_BYTES` / `_MAX_REASON_BYTES` | `2048` / `1024` | ceilings for a write's `source` / `reason` provenance |
206
+ | `MEMGRES_RETENTION_DAYS` | `0` | `0` = keep forever (TTL off); `>0` = expire N days after last touch |
204
207
  | `MEMGRES_RENEW_ON_READ` | `true` | a read pushes the expiry clock forward |
205
208
  | `MEMGRES_KEY_MODE` | `single` | `single` (no auth, one space) · `open` (bring-your-own token, self-registers) · `managed` (admin-provisioned). See [docs/TENANCY.md](docs/TENANCY.md) |
206
209
  | `MEMGRES_ADMIN_TOKEN` | — | global admin bearer for provisioning (managed mode) |
@@ -209,9 +212,13 @@ Everything is env, all optional (defaults suit a single-user embed). Full list i
209
212
  | `MEMGRES_REQUIRE_PARENT` | `false` | `true` = a node's parent path must already exist |
210
213
  | `MEMGRES_HISTORY` | `true` | keep the hash-chained diff history (deleted with the record) |
211
214
  | `MEMGRES_FTS_LANGUAGE` | `simple` | Postgres FTS dictionary (`simple`/`english`/…) |
215
+ | `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) |
217
+ | `MEMGRES_LIST_PREVIEW_CHARS` | `120` | first-line preview length returned by `memory_list` |
212
218
  | `MEMGRES_VECTOR_BACKEND` | `pgvector` | `pgvector` (same DB) or `qdrant` (set `QDRANT_URL`, `QDRANT_API_KEY`, `MEMGRES_QDRANT_COLLECTION`) |
213
219
  | `MEMGRES_EMBED_PROVIDER` | `none` | `none` / `local` / `openai` / `jina` / `openai-compatible` (LM Studio, Ollama, vLLM, TEI…) |
214
220
  | `MEMGRES_EMBED_MODEL` / `_DIM` / `_API_KEY` / `_API_BASE` | — | model id · dimension (HTTP providers require it, `local` infers) · token · server URL |
221
+ | `MEMGRES_EMBED_MAX_SEQ` | `0` | override the local model's max input length in tokens (`0` = the model's default) |
215
222
 
216
223
  ## HTTP API
217
224
 
@@ -225,8 +232,10 @@ Everything is env, all optional (defaults suit a single-user embed). Full list i
225
232
  | `GET` | `/memories/{id}/history` | raw change chain |
226
233
  | `GET` | `/memories/{id}/blame` | line attribution; `?group`, `?text`, `?lines=1,3-5` |
227
234
  | `GET` | `/memories/{id}/at/{seq}` | body reconstructed at a version |
228
- | `GET` | `/recall` | `?q=&k=&mode=&tags=&path_prefix=` |
235
+ | `GET` | `/recall` | `?q=&k=&mode=&tags=&path_prefix=&match=&snippet=&full_body=` |
236
+ | `GET` | `/memories` | list a subtree, no query: `?path_prefix=&tags=&limit=&offset=` |
229
237
  | `GET` | `/spaces` | namespaces this token can reach (identity modes) |
238
+ | `GET` | `/info` | effective config: limits, embed provider/model/dim, recall modes, backend |
230
239
  | `GET` | `/healthz` | liveness |
231
240
 
232
241
  Every memory/recall route also takes optional `space` (one of your namespaces by
@@ -237,6 +246,14 @@ also request-access and `/admin/*` provisioning routes — see
237
246
  map to status codes: `409` stale-hash conflict, `404` not found, `413` too large,
238
247
  `401`/`403` auth.
239
248
 
249
+ `/healthz` and `/info` are intentionally unauthenticated (and the MCP
250
+ `memory_server_info` tool is always available): `/info` returns only effective
251
+ configuration — limits, embedding provider/model/dimension, available recall
252
+ modes, vector backend, key mode — and **never** a secret (no DB URL, token, API
253
+ key, or admin token). It exists so a client can discover a deployment's
254
+ capabilities before authenticating. If a `managed` deployment must not disclose
255
+ even that metadata, put it behind your reverse proxy's auth.
256
+
240
257
  ## Use it with an LLM / agent (MCP)
241
258
 
242
259
  memgres itself **never calls an LLM** — it's the memory, not the model. Your LLM
@@ -278,12 +295,15 @@ pip install "memgres[mcp]"
278
295
  ```
279
296
 
280
297
  Either way the model gets tools `memory_write`, `memory_recall`, `memory_get`,
281
- `memory_blame`, `memory_history`, `memory_move`, `memory_forget`. Tell it *"remember
282
- X"* / *"what do you know about Y?"* and it calls them. (For semantic recall add the
283
- embedding env vars — see [docs/BACKENDS.md](docs/BACKENDS.md).)
298
+ `memory_list`, `memory_blame`, `memory_history`, `memory_move`, `memory_forget`,
299
+ `memory_server_info`. Tell it *"remember X"* / *"what do you know about Y?"* and it
300
+ calls them. (For semantic recall add the embedding env vars — see
301
+ [docs/BACKENDS.md](docs/BACKENDS.md).)
302
+
303
+ ### Isolation — pin the identity in the client config
284
304
 
285
- **Isolation pin the identity in the client config; the agent never handles the
286
- token** (so the model spends nothing echoing a secret and can't switch user):
305
+ The agent **never handles the token** (so the model spends nothing echoing a
306
+ secret and can't switch user):
287
307
 
288
308
  - **stdio**: set `MEMGRES_KEY_MODE=open` + `MEMGRES_TOKEN=<mgk_…>` in the client's
289
309
  `env` block (above).
@@ -305,7 +325,7 @@ argument for the model to supply — force it either way with
305
325
  library after the model produces text (see the examples above). Use this when you
306
326
  control the agent loop and decide when to write/recall.
307
327
 
308
- ## Tokens & auth
328
+ ## Multi-tenant, tokens & isolation
309
329
 
310
330
  There is **no token for single-user / local use** — leave everything default
311
331
  (`MEMGRES_KEY_MODE=single`) and it just works. Two token concepts exist, unrelated:
@@ -35,11 +35,12 @@ 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
39
  | **Embedding-model safety by construction** | The model id + dimension are stamped into the schema; a mismatch **hard-fails** instead of silently returning garbage. |
39
- | **TTL renewed on read** | Active memory persists because it's used; abandoned memory expires itself. Storage self-cleans instead of growing forever. |
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. |
40
41
  | **Optional multi-tenant identity** | Users, namespaces and rotatable scoped tokens when you need isolation; nothing to configure for single-user. |
41
42
  | **Fast subtree recall via `ltree`** | Memories form a real tree; `path <@ 'a.b'` pulls a whole subtree in one GiST index scan, no recursive walk that degrades with depth. |
42
- | **Git-blame + version reconstruct** | Every line carries who last changed it (grouped into author-blocks); any past version reconstructs from history — no replaying diffs yourself. |
43
+ | **Git-like blame + version reconstruct** | Every line carries who last changed it (grouped into author-blocks); any past version reconstructs from history — no replaying diffs yourself. |
43
44
  | **One Postgres, one backup** | The whole thing is `pg_dump`-able; the vector index rebuilds from the source of truth. No second datastore to run or back up. |
44
45
  | **Drop-in module, not a framework** | `pip install`, or `docker compose up`, or point at your own Postgres. No platform to adopt. |
45
46
 
@@ -79,7 +80,7 @@ docker compose up
79
80
 
80
81
  Defaults suit a single-user setup with no auth. To change limits, the embedding provider, tokens, … drop a `.env` beside it — every `MEMGRES_*` is optional (see [Configuration](#configuration) or [.env.example](.env.example)).
81
82
 
82
- **Give it to an LLM / agent — no code (MCP).** Point any URL-capable MCP client (Cursor, Cline, Claude Desktop, …) at the running server; the model gets `memory_write`, `memory_recall`, `memory_get`, `memory_blame`, `memory_history`, `memory_move`, `memory_forget` as tools:
83
+ **Give it to an LLM / agent — no code (MCP).** Point any URL-capable MCP client (Cursor, Cline, Claude Desktop, …) at the running server; the model gets `memory_write`, `memory_recall`, `memory_get`, `memory_list`, `memory_blame`, `memory_history`, `memory_move`, `memory_forget`, `memory_server_info` as tools:
83
84
 
84
85
  ```json
85
86
  {
@@ -153,7 +154,7 @@ docker pull ghcr.io/mozgsml/memgres:latest
153
154
  2. **Your own Postgres** — install the `[server]` extra (above), point `MEMGRES_DATABASE_URL` at it, run `memgres-server` (migrates on startup).
154
155
  3. **Embedded library** — install the core package, use `Store` directly, no HTTP at all.
155
156
 
156
- 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/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.
157
+ 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.
157
158
 
158
159
  ## Configuration
159
160
 
@@ -165,7 +166,8 @@ Everything is env, all optional (defaults suit a single-user embed). Full list i
165
166
  | `MEMGRES_POOL_SIZE` | `4` | max pooled DB connections (HTTP + http-MCP servers); raise for many concurrent clients, `1` to serialize |
166
167
  | `MEMGRES_MAX_BODY_BYTES` | `262144` | ceiling for a whole record body (256 KB) |
167
168
  | `MEMGRES_MAX_WRITE_BYTES` | `16384` | ceiling for one write/diff payload (≤ body) |
168
- | `MEMGRES_RETENTION_DAYS` | `0` | `0` = keep forever; `>0` = expire N days after last touch |
169
+ | `MEMGRES_MAX_SOURCE_BYTES` / `_MAX_REASON_BYTES` | `2048` / `1024` | ceilings for a write's `source` / `reason` provenance |
170
+ | `MEMGRES_RETENTION_DAYS` | `0` | `0` = keep forever (TTL off); `>0` = expire N days after last touch |
169
171
  | `MEMGRES_RENEW_ON_READ` | `true` | a read pushes the expiry clock forward |
170
172
  | `MEMGRES_KEY_MODE` | `single` | `single` (no auth, one space) · `open` (bring-your-own token, self-registers) · `managed` (admin-provisioned). See [docs/TENANCY.md](docs/TENANCY.md) |
171
173
  | `MEMGRES_ADMIN_TOKEN` | — | global admin bearer for provisioning (managed mode) |
@@ -174,9 +176,13 @@ Everything is env, all optional (defaults suit a single-user embed). Full list i
174
176
  | `MEMGRES_REQUIRE_PARENT` | `false` | `true` = a node's parent path must already exist |
175
177
  | `MEMGRES_HISTORY` | `true` | keep the hash-chained diff history (deleted with the record) |
176
178
  | `MEMGRES_FTS_LANGUAGE` | `simple` | Postgres FTS dictionary (`simple`/`english`/…) |
179
+ | `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) |
181
+ | `MEMGRES_LIST_PREVIEW_CHARS` | `120` | first-line preview length returned by `memory_list` |
177
182
  | `MEMGRES_VECTOR_BACKEND` | `pgvector` | `pgvector` (same DB) or `qdrant` (set `QDRANT_URL`, `QDRANT_API_KEY`, `MEMGRES_QDRANT_COLLECTION`) |
178
183
  | `MEMGRES_EMBED_PROVIDER` | `none` | `none` / `local` / `openai` / `jina` / `openai-compatible` (LM Studio, Ollama, vLLM, TEI…) |
179
184
  | `MEMGRES_EMBED_MODEL` / `_DIM` / `_API_KEY` / `_API_BASE` | — | model id · dimension (HTTP providers require it, `local` infers) · token · server URL |
185
+ | `MEMGRES_EMBED_MAX_SEQ` | `0` | override the local model's max input length in tokens (`0` = the model's default) |
180
186
 
181
187
  ## HTTP API
182
188
 
@@ -190,8 +196,10 @@ Everything is env, all optional (defaults suit a single-user embed). Full list i
190
196
  | `GET` | `/memories/{id}/history` | raw change chain |
191
197
  | `GET` | `/memories/{id}/blame` | line attribution; `?group`, `?text`, `?lines=1,3-5` |
192
198
  | `GET` | `/memories/{id}/at/{seq}` | body reconstructed at a version |
193
- | `GET` | `/recall` | `?q=&k=&mode=&tags=&path_prefix=` |
199
+ | `GET` | `/recall` | `?q=&k=&mode=&tags=&path_prefix=&match=&snippet=&full_body=` |
200
+ | `GET` | `/memories` | list a subtree, no query: `?path_prefix=&tags=&limit=&offset=` |
194
201
  | `GET` | `/spaces` | namespaces this token can reach (identity modes) |
202
+ | `GET` | `/info` | effective config: limits, embed provider/model/dim, recall modes, backend |
195
203
  | `GET` | `/healthz` | liveness |
196
204
 
197
205
  Every memory/recall route also takes optional `space` (one of your namespaces by
@@ -202,6 +210,14 @@ also request-access and `/admin/*` provisioning routes — see
202
210
  map to status codes: `409` stale-hash conflict, `404` not found, `413` too large,
203
211
  `401`/`403` auth.
204
212
 
213
+ `/healthz` and `/info` are intentionally unauthenticated (and the MCP
214
+ `memory_server_info` tool is always available): `/info` returns only effective
215
+ configuration — limits, embedding provider/model/dimension, available recall
216
+ modes, vector backend, key mode — and **never** a secret (no DB URL, token, API
217
+ key, or admin token). It exists so a client can discover a deployment's
218
+ capabilities before authenticating. If a `managed` deployment must not disclose
219
+ even that metadata, put it behind your reverse proxy's auth.
220
+
205
221
  ## Use it with an LLM / agent (MCP)
206
222
 
207
223
  memgres itself **never calls an LLM** — it's the memory, not the model. Your LLM
@@ -243,12 +259,15 @@ pip install "memgres[mcp]"
243
259
  ```
244
260
 
245
261
  Either way the model gets tools `memory_write`, `memory_recall`, `memory_get`,
246
- `memory_blame`, `memory_history`, `memory_move`, `memory_forget`. Tell it *"remember
247
- X"* / *"what do you know about Y?"* and it calls them. (For semantic recall add the
248
- embedding env vars — see [docs/BACKENDS.md](docs/BACKENDS.md).)
262
+ `memory_list`, `memory_blame`, `memory_history`, `memory_move`, `memory_forget`,
263
+ `memory_server_info`. Tell it *"remember X"* / *"what do you know about Y?"* and it
264
+ calls them. (For semantic recall add the embedding env vars — see
265
+ [docs/BACKENDS.md](docs/BACKENDS.md).)
266
+
267
+ ### Isolation — pin the identity in the client config
249
268
 
250
- **Isolation pin the identity in the client config; the agent never handles the
251
- token** (so the model spends nothing echoing a secret and can't switch user):
269
+ The agent **never handles the token** (so the model spends nothing echoing a
270
+ secret and can't switch user):
252
271
 
253
272
  - **stdio**: set `MEMGRES_KEY_MODE=open` + `MEMGRES_TOKEN=<mgk_…>` in the client's
254
273
  `env` block (above).
@@ -270,7 +289,7 @@ argument for the model to supply — force it either way with
270
289
  library after the model produces text (see the examples above). Use this when you
271
290
  control the agent loop and decide when to write/recall.
272
291
 
273
- ## Tokens & auth
292
+ ## Multi-tenant, tokens & isolation
274
293
 
275
294
  There is **no token for single-user / local use** — leave everything default
276
295
  (`MEMGRES_KEY_MODE=single`) and it just works. Two token concepts exist, unrelated:
@@ -45,6 +45,8 @@ class Config:
45
45
  # storage limits (bytes)
46
46
  max_body_bytes: int # whole-record ceiling; grows to this via diffs
47
47
  max_write_bytes: int # one write/diff payload ceiling (<= max_body)
48
+ max_source_bytes: int # provenance `source` field ceiling (per write)
49
+ max_reason_bytes: int # provenance `reason` field ceiling (per write)
48
50
  # retention
49
51
  retention_days: int # 0 = forever; >0 = expire N days after last touch
50
52
  renew_on_read: bool # a read pushes the expiry clock forward
@@ -62,13 +64,26 @@ class Config:
62
64
  history_enabled: bool # keep hash-chained diff history (deleted with record)
63
65
  # search
64
66
  fts_language: str # Postgres FTS dict: simple | english | russian | …
67
+ lexical_match: str # any (OR-any words, default) | all (AND-all words)
65
68
  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)
72
+ snippet_semantic: bool # semantic/hybrid hits use the best-matching
73
+ # segment (needs the model); off = ts_headline,
74
+ # 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
77
+ # listing / browse
78
+ list_preview_chars: int # first-line preview length for memory_list (0 = none)
66
79
  # embeddings
67
80
  embed_provider: str # none | local | jina | openai | openai-compatible
68
81
  embed_model: str
69
82
  embed_dim: int # 0 = infer from provider
70
83
  embed_api_key: str
71
84
  embed_api_base: str
85
+ embed_max_seq: int # 0 = leave the model's default; >0 overrides
86
+ # the local model's max sequence length (tokens)
72
87
  # database
73
88
  database_url: str
74
89
  pool_size: int # max pooled connections (HTTP + http-MCP servers)
@@ -76,6 +91,18 @@ class Config:
76
91
  def validate(self) -> None:
77
92
  if self.pool_size < 1:
78
93
  raise ValueError("MEMGRES_POOL_SIZE must be >= 1")
94
+ if self.max_source_bytes < 1:
95
+ raise ValueError("MEMGRES_MAX_SOURCE_BYTES must be >= 1")
96
+ if self.max_reason_bytes < 1:
97
+ raise ValueError("MEMGRES_MAX_REASON_BYTES must be >= 1")
98
+ if self.embed_max_seq < 0:
99
+ raise ValueError("MEMGRES_EMBED_MAX_SEQ must be >= 0")
100
+ if self.list_preview_chars < 0:
101
+ 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")
79
106
  if self.max_write_bytes > self.max_body_bytes:
80
107
  raise ValueError(
81
108
  "MEMGRES_MAX_WRITE_BYTES must be <= MEMGRES_MAX_BODY_BYTES"
@@ -84,6 +111,8 @@ class Config:
84
111
  "none", "local", "jina", "openai",
85
112
  "openai-compatible", "compatible", "custom"):
86
113
  raise ValueError(f"unknown MEMGRES_EMBED_PROVIDER: {self.embed_provider}")
114
+ if self.lexical_match not in ("any", "all"):
115
+ raise ValueError(f"unknown MEMGRES_LEXICAL_MATCH: {self.lexical_match}")
87
116
  if self.vector_backend not in ("pgvector", "qdrant"):
88
117
  raise ValueError(f"unknown MEMGRES_VECTOR_BACKEND: {self.vector_backend}")
89
118
  if self.key_mode not in ("single", "open", "managed"):
@@ -101,6 +130,8 @@ def load() -> Config:
101
130
  cfg = Config(
102
131
  max_body_bytes=_int("MEMGRES_MAX_BODY_BYTES", 262_144), # 256 KB
103
132
  max_write_bytes=_int("MEMGRES_MAX_WRITE_BYTES", 16_384), # 16 KB
133
+ max_source_bytes=_int("MEMGRES_MAX_SOURCE_BYTES", 2_048), # 2 KB
134
+ max_reason_bytes=_int("MEMGRES_MAX_REASON_BYTES", 1_024), # 1 KB
104
135
  retention_days=_int("MEMGRES_RETENTION_DAYS", 0),
105
136
  renew_on_read=_bool("MEMGRES_RENEW_ON_READ", True),
106
137
  token=_str("MEMGRES_TOKEN", ""),
@@ -110,12 +141,20 @@ def load() -> Config:
110
141
  require_parent=_bool("MEMGRES_REQUIRE_PARENT", False),
111
142
  history_enabled=_bool("MEMGRES_HISTORY", True),
112
143
  fts_language=_str("MEMGRES_FTS_LANGUAGE", "simple"),
144
+ lexical_match=_str("MEMGRES_LEXICAL_MATCH", "any"),
113
145
  vector_backend=_str("MEMGRES_VECTOR_BACKEND", "pgvector"),
146
+ snippet=_bool("MEMGRES_SNIPPET", True),
147
+ full_body=_bool("MEMGRES_FULL_BODY", True),
148
+ 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),
151
+ list_preview_chars=_int("MEMGRES_LIST_PREVIEW_CHARS", 120),
114
152
  embed_provider=_str("MEMGRES_EMBED_PROVIDER", "none"),
115
153
  embed_model=_str("MEMGRES_EMBED_MODEL", ""),
116
154
  embed_dim=_int("MEMGRES_EMBED_DIM", 0),
117
155
  embed_api_key=_str("MEMGRES_EMBED_API_KEY", ""),
118
156
  embed_api_base=_str("MEMGRES_EMBED_API_BASE", ""),
157
+ embed_max_seq=_int("MEMGRES_EMBED_MAX_SEQ", 0),
119
158
  database_url=_str("MEMGRES_DATABASE_URL", ""),
120
159
  pool_size=_int("MEMGRES_POOL_SIZE", 4),
121
160
  )
@@ -42,12 +42,14 @@ class Embedder:
42
42
 
43
43
  # ─── local: sentence-transformers ────────────────────────────────────────────
44
44
  class _LocalEmbedder(Embedder):
45
- def __init__(self, model_name: str, want_dim: int):
45
+ def __init__(self, model_name: str, want_dim: int, max_seq: int = 0):
46
46
  from sentence_transformers import SentenceTransformer # lazy: heavy import
47
47
 
48
48
  if not model_name:
49
49
  raise ValueError("MEMGRES_EMBED_MODEL is required for the local provider")
50
50
  self._model = SentenceTransformer(model_name, device="cpu")
51
+ if max_seq > 0: # override the model's context window (tokens)
52
+ self._model.max_seq_length = max_seq
51
53
  self.dim = self._model.get_sentence_embedding_dimension()
52
54
  if want_dim and want_dim != self.dim:
53
55
  raise ValueError(
@@ -122,7 +124,7 @@ def get_embedder(cfg: Config) -> Optional[Embedder]:
122
124
  if p == "none":
123
125
  return None
124
126
  if p == "local":
125
- return _LocalEmbedder(cfg.embed_model, cfg.embed_dim)
127
+ return _LocalEmbedder(cfg.embed_model, cfg.embed_dim, cfg.embed_max_seq)
126
128
  if p == "jina":
127
129
  return _HttpEmbedder(
128
130
  cfg.embed_model, cfg.embed_dim, cfg.embed_api_key,
@@ -0,0 +1,39 @@
1
+ """Effective server configuration, exposed read-only for introspection.
2
+
3
+ An agent shouldn't have to guess the write ceilings, which recall modes are
4
+ available, or how memories are embedded. ``server_info`` distills the loaded
5
+ ``Config`` (plus the live embedder's dimension, if one is built) into a small,
6
+ non-sensitive dict. It deliberately carries **no** secrets — no token, no api
7
+ key, no database url — so it is safe to return unauthenticated.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ from typing import Optional
13
+
14
+ from .config import Config
15
+
16
+
17
+ def server_info(cfg: Config, embed_dim: Optional[int] = None) -> dict:
18
+ """Effective limits + capabilities, from the loaded config (and the live
19
+ embedder's dimension when available). Never includes secrets."""
20
+ lexical_only = cfg.embed_provider == "none"
21
+ dim = embed_dim if embed_dim is not None else (cfg.embed_dim or None)
22
+ return {
23
+ "limits": {
24
+ "max_body_bytes": cfg.max_body_bytes,
25
+ "max_write_bytes": cfg.max_write_bytes,
26
+ "max_source_bytes": cfg.max_source_bytes,
27
+ "max_reason_bytes": cfg.max_reason_bytes,
28
+ },
29
+ "embed": {
30
+ "provider": cfg.embed_provider,
31
+ "model": cfg.embed_model or None,
32
+ "dim": dim,
33
+ },
34
+ "recall_modes": ["lexical"] if lexical_only
35
+ else ["lexical", "semantic", "hybrid", "auto"],
36
+ "vector_backend": cfg.vector_backend,
37
+ "key_mode": cfg.key_mode,
38
+ "fts_language": cfg.fts_language,
39
+ }
@@ -25,7 +25,7 @@ either. (``single`` mode needs no token at all.)
25
25
 
26
26
  from __future__ import annotations
27
27
 
28
- from typing import List, Optional
28
+ from typing import List, Literal, Optional
29
29
 
30
30
  from mcp.server.fastmcp import Context
31
31
 
@@ -165,21 +165,63 @@ def build_server(cfg: Optional[Config] = None):
165
165
  space=space, space_id=space_id))
166
166
 
167
167
  @mcp.tool()
168
- def memory_recall(query: str, k: int = 10, mode: str = "auto",
168
+ def memory_recall(query: str, k: int = 10,
169
+ mode: Literal["lexical", "semantic", "hybrid", "auto"] = "auto",
170
+ match: Optional[Literal["any", "all"]] = None,
169
171
  tags: Optional[List[str]] = None,
170
172
  path_prefix: Optional[str] = None,
173
+ snippet: Optional[bool] = None,
174
+ full_body: Optional[bool] = None,
171
175
  space: Optional[str] = None, space_id: Optional[str] = None,
172
176
  token: Optional[str] = None, ctx: Context = None) -> List[dict]:
173
- """Search memories. `mode`: lexical | semantic | hybrid | auto. Optionally
174
- scope to a tag set (`tags`) or a subtree (`path_prefix`, e.g. 'ops.postgres').
177
+ """Search memories. `mode`: lexical | semantic | hybrid | auto. `match`
178
+ governs lexical word combination defaults to OR-any (any query word
179
+ matches, forgiving recall); set 'all' to require every word (narrow).
180
+ Optionally scope to a tag set (`tags`) or a subtree (`path_prefix`, e.g.
181
+ 'ops.postgres'). Each hit carries a `snippet` (+`line`) by default —
182
+ semantic/hybrid use the best-matching segment, lexical uses ts_headline;
183
+ pass `full_body=false` to get just the snippet, `snippet=false` for none.
175
184
  `space`/`space_id` pick which namespace to search (default: yours)."""
176
185
  with pool.connection() as conn:
177
- return [{"id": h.id, "body": h.body, "tags": h.tags, "path": h.path,
178
- "score": h.score}
179
- for h in _store(conn).recall(
180
- _token(ctx, token), query, k=k, tags=tags,
181
- path_prefix=path_prefix, mode=mode,
182
- space=space, space_id=space_id)]
186
+ out = []
187
+ for h in _store(conn).recall(
188
+ _token(ctx, token), query, k=k, tags=tags,
189
+ path_prefix=path_prefix, mode=mode, match=match,
190
+ snippet=snippet, full_body=full_body,
191
+ space=space, space_id=space_id):
192
+ d = {"id": h.id, "tags": h.tags, "path": h.path,
193
+ "score": h.score, "snippet": h.snippet, "line": h.line}
194
+ if h.body is not None:
195
+ d["body"] = h.body
196
+ out.append(d)
197
+ return out
198
+
199
+ @mcp.tool()
200
+ def memory_list(path_prefix: Optional[str] = None,
201
+ tags: Optional[List[str]] = None, limit: int = 50,
202
+ offset: int = 0, space: Optional[str] = None,
203
+ space_id: Optional[str] = None,
204
+ token: Optional[str] = None, ctx: Context = None) -> List[dict]:
205
+ """BROWSE (enumerate) a subtree — NOT a search. Lists memories under
206
+ `path_prefix` (e.g. survey all of 'decisions.*') ordered by path, with a
207
+ short first-line `preview` of each. No query, no ranking; use
208
+ `memory_recall` when you want relevance search. Optionally narrow by
209
+ `tags`; `limit`/`offset` paginate. `space`/`space_id` pick the namespace
210
+ (default: yours)."""
211
+ with pool.connection() as conn:
212
+ return _store(conn).list(
213
+ _token(ctx, token), path_prefix=path_prefix, tags=tags,
214
+ limit=limit, offset=offset, space=space, space_id=space_id)
215
+
216
+ @mcp.tool()
217
+ def memory_server_info(ctx: Context = None) -> dict:
218
+ """The server's effective limits and capabilities (write ceilings, embed
219
+ provider/model/dim, available recall modes, vector backend, key mode, FTS
220
+ language). Non-sensitive config only — no secrets. Read it once so you
221
+ aren't guessing the limits."""
222
+ from .info import server_info
223
+ dim = embedder.dim if embedder is not None else None
224
+ return server_info(cfg, embed_dim=dim)
183
225
 
184
226
  @mcp.tool()
185
227
  def memory_blame(id: str, grouped: bool = True,
@@ -299,6 +341,26 @@ def build_server(cfg: Optional[Config] = None):
299
341
  if isinstance(_req, list) and "token" in _req:
300
342
  _req.remove("token")
301
343
 
344
+ # Best-effort: with no embedder configured there is no vector backend, so
345
+ # semantic/hybrid recall can't run — drop them from memory_recall's `mode`
346
+ # enum so the model isn't offered modes that will only ever error. Purely
347
+ # cosmetic (same defensive style as the token pruning above): recall()'s
348
+ # backstop still raises on semantic-without-backend, and `lexical`/`auto`
349
+ # (auto resolves to lexical here) stay. Missing keys simply no-op.
350
+ if cfg.embed_provider == "none":
351
+ for _t in getattr(getattr(mcp, "_tool_manager", None), "_tools", {}).values():
352
+ if getattr(_t, "name", None) != "memory_recall":
353
+ continue
354
+ _params = getattr(_t, "parameters", None)
355
+ if not isinstance(_params, dict):
356
+ continue
357
+ _mode = _params.get("properties", {}).get("mode")
358
+ if not isinstance(_mode, dict):
359
+ continue
360
+ _enum = _mode.get("enum")
361
+ if isinstance(_enum, list):
362
+ _mode["enum"] = [m for m in _enum if m not in ("semantic", "hybrid")]
363
+
302
364
  return mcp
303
365
 
304
366
 
@@ -17,7 +17,7 @@ from pathlib import Path
17
17
 
18
18
  from .config import Config
19
19
 
20
- SCHEMA_VERSION = 2
20
+ SCHEMA_VERSION = 3
21
21
 
22
22
  # Dev layout: repo/migrations next to the package. When packaged, migrations are
23
23
  # shipped inside the package (see pyproject) and this still resolves.
@@ -88,6 +88,27 @@ def _apply_vector(cur, cfg: Config) -> None:
88
88
  "CREATE INDEX IF NOT EXISTS memory_embedding_hnsw ON memory "
89
89
  "USING hnsw (embedding vector_cosine_ops)"
90
90
  )
91
+ # Per-memory segment vectors: a durable cache the (future) snippet flow fills
92
+ # lazily, keyed by the memory's content_hash (`src_hash`) so a body edit — a
93
+ # new hash — invalidates the cache and `forget` cascades them away. Offsets,
94
+ # not text: the snippet is sliced from the live body. A memory has few
95
+ # segments, ranked by a plain scan, so no HNSW here.
96
+ cur.execute(
97
+ f"""CREATE TABLE IF NOT EXISTS memory_segment (
98
+ memory_id uuid NOT NULL REFERENCES memory(id) ON DELETE CASCADE,
99
+ seq int NOT NULL,
100
+ seg_start int NOT NULL,
101
+ seg_end int NOT NULL,
102
+ embedding vector({cfg.embed_dim}) NOT NULL,
103
+ src_hash text NOT NULL,
104
+ namespace text NOT NULL,
105
+ PRIMARY KEY (memory_id, seq)
106
+ )"""
107
+ )
108
+ cur.execute(
109
+ "CREATE INDEX IF NOT EXISTS memory_segment_mid "
110
+ "ON memory_segment (memory_id)"
111
+ )
91
112
 
92
113
 
93
114
  def _stamp(cur, cfg: Config) -> None: