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.
- {memgres-0.2.0 → memgres-0.3.0}/PKG-INFO +33 -13
- {memgres-0.2.0 → memgres-0.3.0}/README.md +31 -12
- {memgres-0.2.0 → memgres-0.3.0}/memgres/config.py +39 -0
- {memgres-0.2.0 → memgres-0.3.0}/memgres/embeddings.py +4 -2
- memgres-0.3.0/memgres/info.py +39 -0
- {memgres-0.2.0 → memgres-0.3.0}/memgres/mcp_server.py +72 -10
- {memgres-0.2.0 → memgres-0.3.0}/memgres/schema.py +22 -1
- memgres-0.3.0/memgres/search.py +188 -0
- memgres-0.3.0/memgres/segments.py +112 -0
- {memgres-0.2.0 → memgres-0.3.0}/memgres/server.py +32 -3
- {memgres-0.2.0 → memgres-0.3.0}/memgres/store.py +67 -42
- memgres-0.3.0/memgres/vector/__init__.py +9 -0
- memgres-0.3.0/memgres/vector/base.py +90 -0
- memgres-0.3.0/memgres/vector/pgvector.py +83 -0
- memgres-0.3.0/memgres/vector/qdrant.py +220 -0
- {memgres-0.2.0 → memgres-0.3.0}/memgres.egg-info/PKG-INFO +33 -13
- {memgres-0.2.0 → memgres-0.3.0}/memgres.egg-info/SOURCES.txt +15 -1
- {memgres-0.2.0 → memgres-0.3.0}/memgres.egg-info/requires.txt +1 -0
- {memgres-0.2.0 → memgres-0.3.0}/pyproject.toml +2 -1
- {memgres-0.2.0 → memgres-0.3.0}/tests/test_config.py +16 -0
- memgres-0.3.0/tests/test_lexical_match.py +104 -0
- memgres-0.3.0/tests/test_limits.py +135 -0
- memgres-0.3.0/tests/test_list.py +149 -0
- memgres-0.3.0/tests/test_mcp_recall_schema.py +73 -0
- memgres-0.3.0/tests/test_qdrant_ca.py +79 -0
- {memgres-0.2.0 → memgres-0.3.0}/tests/test_qdrant_integration.py +10 -0
- memgres-0.3.0/tests/test_segments.py +91 -0
- memgres-0.3.0/tests/test_segments_store.py +277 -0
- memgres-0.3.0/tests/test_server_info.py +97 -0
- {memgres-0.2.0 → memgres-0.3.0}/tests/test_server_integration.py +28 -0
- memgres-0.3.0/tests/test_snippets.py +262 -0
- memgres-0.2.0/memgres/qdrant_backend.py +0 -82
- memgres-0.2.0/memgres/search.py +0 -125
- {memgres-0.2.0 → memgres-0.3.0}/LICENSE +0 -0
- {memgres-0.2.0 → memgres-0.3.0}/memgres/__init__.py +0 -0
- {memgres-0.2.0 → memgres-0.3.0}/memgres/blame.py +0 -0
- {memgres-0.2.0 → memgres-0.3.0}/memgres/diffing.py +0 -0
- {memgres-0.2.0 → memgres-0.3.0}/memgres/identity.py +0 -0
- {memgres-0.2.0 → memgres-0.3.0}/memgres/migrations/0001_core.sql +0 -0
- {memgres-0.2.0 → memgres-0.3.0}/memgres/migrations/0002_identity.sql +0 -0
- {memgres-0.2.0 → memgres-0.3.0}/memgres.egg-info/dependency_links.txt +0 -0
- {memgres-0.2.0 → memgres-0.3.0}/memgres.egg-info/entry_points.txt +0 -0
- {memgres-0.2.0 → memgres-0.3.0}/memgres.egg-info/top_level.txt +0 -0
- {memgres-0.2.0 → memgres-0.3.0}/setup.cfg +0 -0
- {memgres-0.2.0 → memgres-0.3.0}/tests/test_blame_integration.py +0 -0
- {memgres-0.2.0 → memgres-0.3.0}/tests/test_diffing.py +0 -0
- {memgres-0.2.0 → memgres-0.3.0}/tests/test_embeddings.py +0 -0
- {memgres-0.2.0 → memgres-0.3.0}/tests/test_identity_integration.py +0 -0
- {memgres-0.2.0 → memgres-0.3.0}/tests/test_search_integration.py +0 -0
- {memgres-0.2.0 → memgres-0.3.0}/tests/test_security_integration.py +0 -0
- {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.
|
|
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** |
|
|
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
|
-
| `
|
|
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
|
|
282
|
-
X"* / *"what do you know about Y?"* and it
|
|
283
|
-
embedding env vars — see
|
|
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
|
-
**
|
|
286
|
-
|
|
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
|
-
##
|
|
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** |
|
|
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
|
-
| `
|
|
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
|
|
247
|
-
X"* / *"what do you know about Y?"* and it
|
|
248
|
-
embedding env vars — see
|
|
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
|
-
**
|
|
251
|
-
|
|
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
|
-
##
|
|
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,
|
|
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.
|
|
174
|
-
|
|
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
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
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 =
|
|
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:
|