memgres 0.1.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.1.0/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 pg-memory contributors
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
memgres-0.1.0/PKG-INFO ADDED
@@ -0,0 +1,231 @@
1
+ Metadata-Version: 2.4
2
+ Name: memgres
3
+ Version: 0.1.0
4
+ Summary: Drop-in memory for AI agents: one Postgres, lexical + semantic recall, diff-versioned history, GDPR-erasable.
5
+ Author: mozgsml
6
+ License-Expression: MIT
7
+ Project-URL: Homepage, https://github.com/mozgsml/memgres
8
+ Project-URL: Repository, https://github.com/mozgsml/memgres
9
+ Project-URL: Issues, https://github.com/mozgsml/memgres/issues
10
+ Keywords: agent,memory,postgres,pgvector,qdrant,embeddings,semantic-search,llm,mcp,rag
11
+ Classifier: Development Status :: 4 - Beta
12
+ Classifier: Intended Audience :: Developers
13
+ Classifier: Programming Language :: Python :: 3
14
+ Classifier: Topic :: Database
15
+ Classifier: Topic :: Scientific/Engineering :: Artificial Intelligence
16
+ Classifier: Topic :: Software Development :: Libraries
17
+ Requires-Python: >=3.10
18
+ Description-Content-Type: text/markdown
19
+ License-File: LICENSE
20
+ Requires-Dist: psycopg[binary]>=3.1
21
+ Provides-Extra: local
22
+ Requires-Dist: sentence-transformers>=3.0; extra == "local"
23
+ Provides-Extra: qdrant
24
+ Requires-Dist: qdrant-client>=1.7; extra == "qdrant"
25
+ Provides-Extra: server
26
+ Requires-Dist: fastapi>=0.110; extra == "server"
27
+ Requires-Dist: uvicorn>=0.29; extra == "server"
28
+ Requires-Dist: psycopg-pool>=3.2; extra == "server"
29
+ Provides-Extra: mcp
30
+ Requires-Dist: mcp>=1.2; extra == "mcp"
31
+ Provides-Extra: dev
32
+ Requires-Dist: pytest>=8.0; extra == "dev"
33
+ Dynamic: license-file
34
+
35
+ # memgres
36
+
37
+ [![CI](https://github.com/mozgsml/memgres/actions/workflows/ci.yml/badge.svg)](https://github.com/mozgsml/memgres/actions/workflows/ci.yml)
38
+ [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](LICENSE)
39
+
40
+ **Versioned document memory for AI agents — one Postgres, lexical *or* semantic recall, diff-based history, GDPR-erasable.**
41
+
42
+ > Status: early. Core library, search, HTTP API and MCP server are implemented and tested against a live pgvector Postgres.
43
+
44
+ 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.
45
+
46
+ ---
47
+
48
+ ## Why memgres exists
49
+
50
+ memgres is a **document** store where **you** own the write path — not a fact store that lets a model decide what to remember for you.
51
+
52
+ - You write the **whole body** or a **unified diff** — nothing re-interprets your text on the way in.
53
+ - Concurrency is guarded by **content hash** (optimistic locking): you send the hash of the body you edited; the write applies only if the current body still matches, otherwise you get a `409` and re-read. No lost updates, no silent overwrites.
54
+ - Every change is stored as a **hash-chained unified diff** with `source`/`reason` provenance — git-like history you can replay and attribute line by line.
55
+ - **History rows are deletable**: real GDPR erasure, not "hidden from results but still on disk".
56
+
57
+ Reach for memgres when you want **auditable, authored, versioned text memory**.
58
+
59
+ ---
60
+
61
+ ## Advantages
62
+
63
+ | Advantage | Why it matters |
64
+ |---|---|
65
+ | **No LLM on the write path** | Writes are instant and free; nothing invents, summarizes, or drops your content behind your back. |
66
+ | **Authored unified-diff writes** | You control exactly what changes; the diff *is* the audit record. |
67
+ | **Content-hash optimistic concurrency (409)** | Concurrent writers can't silently clobber each other — a stale write is rejected, not merged blind. |
68
+ | **Postgres on disk** | The corpus can far exceed RAM, and concurrent writes are safe — no in-memory bound, no single-writer lock. |
69
+ | **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). |
70
+ | **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. |
71
+ | **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. |
72
+ | **Embedding-model safety by construction** | The model id + dimension are stamped into the schema; a mismatch **hard-fails** instead of silently returning garbage. |
73
+ | **TTL renewed on read** | Active memory persists because it's used; abandoned memory expires itself. Storage self-cleans instead of growing forever. |
74
+ | **Optional token namespaces** | Multi-tenant isolation when you need it (secret token → namespace); nothing to configure for single-user. |
75
+ | **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. |
76
+ | **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. |
77
+ | **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. |
78
+ | **Drop-in module, not a framework** | `pip install`, or `docker compose up`, or point at your own Postgres. No platform to adopt. |
79
+
80
+ ---
81
+
82
+ ## Design at a glance
83
+
84
+ ```
85
+ memgres (core library, no HTTP dependency)
86
+ ├─ store write (whole body OR unified diff) · get · recall · move · forget
87
+ ├─ diffing unified diff make/apply + content hash (optimistic locking)
88
+ ├─ blame line-attributed document + reconstruct any past version
89
+ ├─ organize tags (text[] + GIN) · tree (ltree path + GiST, fast subtree select)
90
+ ├─ search lexical (Postgres FTS) + semantic (pgvector or Qdrant) + hybrid
91
+ ├─ embeddings provider via env: none | local (sentence-transformers) | cloud (Jina/OpenAI)
92
+ └─ config every limit via env (body/write size, TTL, namespaces, …)
93
+
94
+ optional layers on top of the same core:
95
+ ├─ HTTP API (FastAPI) REST + OpenAPI
96
+ └─ MCP server write/recall/get/blame/move/forget as MCP tools
97
+ ```
98
+
99
+ **Record model:** one memory = one mutable body (up to a configurable ceiling, default 256 KB) plus metadata — `tags` (cross-cutting labels, `text[]` + GIN), a `path` (its place in an `ltree` tree), timestamps, and per-diff provenance (`source`/`reason`, kept in history). A single write/diff is capped smaller (default 16 KB), so large bodies accrue over many authored diffs. **Organization is two orthogonal axes:** the tree is *where a memory lives* (one place, subtree-selectable); tags are *what it's about* (many, overlapping). Both filter either search — narrow a semantic query to a subtree, or list a tag across the tree.
100
+
101
+ **Isolation:** optional token *namespaces* keep tenants from seeing each other's memories (namespace = hash of a secret token, so one wallet can back many clients). Encryption at rest is left to the deployment — Postgres/managed-PG/disk TDE stays transparent to queries, so search keeps working; memgres deliberately does **not** encrypt bodies application-side (that would make them unsearchable, which is why no comparable tool does it either). GDPR erasure is real: `forget()` hard-deletes the row, its vectors, and crypto-shreds the history chain. All limits are env-configurable, so the same code serves a single-user embed and a capped multi-tenant service.
102
+
103
+ ---
104
+
105
+ ## Quickstart
106
+
107
+ **The whole thing, one command** — brings up `pgvector` + the memgres service on `http://localhost:8080`, schema auto-migrated on startup:
108
+
109
+ ```bash
110
+ docker compose up # → http://localhost:8080 (GET /healthz → {"ok":true})
111
+ ```
112
+
113
+ ```bash
114
+ # create a memory
115
+ curl -sX POST localhost:8080/memories \
116
+ -H 'content-type: application/json' \
117
+ -d '{"body":"Postgres tuning notes\nshared_buffers = 25% RAM\n","tags":["db"],"path":"ops.postgres","source":"me"}'
118
+ # → {"id":"…","content_hash":"…","seq":1, …}
119
+
120
+ # recall (lexical out of the box; semantic once you set an embedding provider)
121
+ curl -s 'localhost:8080/recall?q=postgres%20tuning'
122
+
123
+ # edit by unified diff, guarded by the hash you edited (409 if stale)
124
+ curl -sX PATCH localhost:8080/memories/$ID \
125
+ -H 'content-type: application/json' \
126
+ -d '{"diff":"--- \n+++ \n@@ -2 +2 @@\n-shared_buffers = 25% RAM\n+shared_buffers = 40% RAM\n","base_hash":"'$HASH'","source":"me","reason":"bump"}'
127
+
128
+ # who wrote each line (grouped into author-blocks by default)
129
+ curl -s localhost:8080/memories/$ID/blame
130
+ ```
131
+
132
+ ### As a Python library (no HTTP)
133
+
134
+ ```python
135
+ from memgres import Store, load_config, migrate
136
+ import psycopg
137
+
138
+ cfg = load_config() # reads MEMGRES_* env
139
+ conn = psycopg.connect(cfg.database_url)
140
+ migrate(conn, cfg) # idempotent; stamps embed model/dim
141
+
142
+ s = Store(cfg, conn=conn)
143
+ m = s.write(body="remember this\n", tags=["note"], path="misc.reminder", source="me")
144
+
145
+ # edit: whole body OR a diff carrying the base hash (optimistic concurrency)
146
+ m = s.write(id=m.id, body="remember this, updated\n", base_hash=m.content_hash, reason="tweak")
147
+
148
+ hits = s.recall(None, "what did I remember?", k=5) # lexical / semantic / hybrid / auto
149
+ blame = s.annotate_grouped(None, m.id) # [{start,end,source,reason,…}]
150
+ old = s.reconstruct(None, m.id, 1) # body as of version 1
151
+ s.forget(None, m.id) # hard-erase + history
152
+ ```
153
+
154
+ ### Install
155
+
156
+ > Not on PyPI yet — install from git (or clone and `pip install -e .`):
157
+
158
+ ```bash
159
+ pip install "git+https://github.com/mozgsml/memgres" # core library
160
+ pip install "memgres[server] @ git+https://github.com/mozgsml/memgres" # + HTTP API
161
+ pip install "memgres[mcp] @ git+https://github.com/mozgsml/memgres" # + MCP server
162
+ # extras: local (sentence-transformers), qdrant (Qdrant backend)
163
+ ```
164
+
165
+ Or pull the container image:
166
+
167
+ ```bash
168
+ docker pull ghcr.io/mozgsml/memgres:latest
169
+ ```
170
+
171
+ ### Three ways to run it
172
+
173
+ 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).
174
+ 2. **Your own Postgres** — install the `[server]` extra (above), point `MEMGRES_DATABASE_URL` at it, run `memgres-server` (migrates on startup).
175
+ 3. **Embedded library** — install the core package, use `Store` directly, no HTTP at all.
176
+
177
+ 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.
178
+
179
+ ## Configuration
180
+
181
+ Everything is env, all optional (defaults suit a single-user embed). Full list in [`.env.example`](.env.example).
182
+
183
+ | Variable | Default | Meaning |
184
+ |---|---|---|
185
+ | `MEMGRES_DATABASE_URL` | libpq env | Postgres connection string |
186
+ | `MEMGRES_MAX_BODY_BYTES` | `262144` | ceiling for a whole record body (256 KB) |
187
+ | `MEMGRES_MAX_WRITE_BYTES` | `16384` | ceiling for one write/diff payload (≤ body) |
188
+ | `MEMGRES_RETENTION_DAYS` | `0` | `0` = keep forever; `>0` = expire N days after last touch |
189
+ | `MEMGRES_RENEW_ON_READ` | `true` | a read pushes the expiry clock forward |
190
+ | `MEMGRES_NAMESPACES` | `false` | `true` = each caller sends a secret token; namespace = its hash |
191
+ | `MEMGRES_TREE` | `true` | `ltree` path column + GiST index (fast subtree select) |
192
+ | `MEMGRES_REQUIRE_PARENT` | `false` | `true` = a node's parent path must already exist |
193
+ | `MEMGRES_HISTORY` | `true` | keep the hash-chained diff history (deleted with the record) |
194
+ | `MEMGRES_FTS_LANGUAGE` | `simple` | Postgres FTS dictionary (`simple`/`english`/…) |
195
+ | `MEMGRES_VECTOR_BACKEND` | `pgvector` | `pgvector` (same DB) or `qdrant` (set `QDRANT_URL`, `QDRANT_API_KEY`, `MEMGRES_QDRANT_COLLECTION`) |
196
+ | `MEMGRES_EMBED_PROVIDER` | `none` | `none` / `local` / `openai` / `jina` / `openai-compatible` (LM Studio, Ollama, vLLM, TEI…) |
197
+ | `MEMGRES_EMBED_MODEL` / `_DIM` / `_API_KEY` / `_API_BASE` | — | model id · dimension (HTTP providers require it, `local` infers) · token · server URL |
198
+
199
+ ## HTTP API
200
+
201
+ | Method | Path | Purpose |
202
+ |---|---|---|
203
+ | `POST` | `/memories` | create |
204
+ | `GET` | `/memories/{id}` | read (renews TTL) |
205
+ | `PATCH` | `/memories/{id}` | edit: whole `body` **or** `diff`+`base_hash`; move; retag |
206
+ | `POST` | `/memories/{id}/move` | reparent a node (cascades its subtree) |
207
+ | `DELETE` | `/memories/{id}` | forget (hard-erase + history) |
208
+ | `GET` | `/memories/{id}/history` | raw change chain |
209
+ | `GET` | `/memories/{id}/blame` | line attribution; `?group`, `?text`, `?lines=1,3-5` |
210
+ | `GET` | `/memories/{id}/at/{seq}` | body reconstructed at a version |
211
+ | `GET` | `/recall` | `?q=&k=&mode=&tags=&path_prefix=` |
212
+ | `GET` | `/healthz` | liveness |
213
+
214
+ Namespace token (when `MEMGRES_NAMESPACES=true`) goes in `Authorization: Bearer <token>` or `X-Memgres-Token`. OpenAPI/Swagger is served at `/docs`. Store errors map to status codes: `409` stale-hash conflict, `404` not found, `413` too large, `401` missing token.
215
+
216
+ ## MCP server
217
+
218
+ The same store is exposed to MCP clients (Claude Desktop, etc.) over stdio:
219
+
220
+ ```bash
221
+ pip install "memgres[mcp] @ git+https://github.com/mozgsml/memgres"
222
+ memgres-mcp # needs MEMGRES_DATABASE_URL; migrates on startup
223
+ ```
224
+
225
+ Tools: `memory_write` (create or edit by body/diff), `memory_get`, `memory_recall`, `memory_blame`, `memory_history`, `memory_move`, `memory_forget`. Point your MCP client's config at the `memgres-mcp` command.
226
+
227
+ ---
228
+
229
+ ## License
230
+
231
+ MIT — see [LICENSE](LICENSE). Fully self-hostable, no gated features.
@@ -0,0 +1,197 @@
1
+ # memgres
2
+
3
+ [![CI](https://github.com/mozgsml/memgres/actions/workflows/ci.yml/badge.svg)](https://github.com/mozgsml/memgres/actions/workflows/ci.yml)
4
+ [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](LICENSE)
5
+
6
+ **Versioned document memory for AI agents — one Postgres, lexical *or* semantic recall, diff-based history, GDPR-erasable.**
7
+
8
+ > Status: early. Core library, search, HTTP API and MCP server are implemented and tested against a live pgvector Postgres.
9
+
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
+
12
+ ---
13
+
14
+ ## Why memgres exists
15
+
16
+ memgres is a **document** store where **you** own the write path — not a fact store that lets a model decide what to remember for you.
17
+
18
+ - You write the **whole body** or a **unified diff** — nothing re-interprets your text on the way in.
19
+ - Concurrency is guarded by **content hash** (optimistic locking): you send the hash of the body you edited; the write applies only if the current body still matches, otherwise you get a `409` and re-read. No lost updates, no silent overwrites.
20
+ - Every change is stored as a **hash-chained unified diff** with `source`/`reason` provenance — git-like history you can replay and attribute line by line.
21
+ - **History rows are deletable**: real GDPR erasure, not "hidden from results but still on disk".
22
+
23
+ Reach for memgres when you want **auditable, authored, versioned text memory**.
24
+
25
+ ---
26
+
27
+ ## Advantages
28
+
29
+ | Advantage | Why it matters |
30
+ |---|---|
31
+ | **No LLM on the write path** | Writes are instant and free; nothing invents, summarizes, or drops your content behind your back. |
32
+ | **Authored unified-diff writes** | You control exactly what changes; the diff *is* the audit record. |
33
+ | **Content-hash optimistic concurrency (409)** | Concurrent writers can't silently clobber each other — a stale write is rejected, not merged blind. |
34
+ | **Postgres on disk** | The corpus can far exceed RAM, and concurrent writes are safe — no in-memory bound, no single-writer lock. |
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
+ | **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
+ | **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
+ | **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 token namespaces** | Multi-tenant isolation when you need it (secret token → namespace); nothing to configure for single-user. |
41
+ | **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
+ | **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
+ | **Drop-in module, not a framework** | `pip install`, or `docker compose up`, or point at your own Postgres. No platform to adopt. |
45
+
46
+ ---
47
+
48
+ ## Design at a glance
49
+
50
+ ```
51
+ memgres (core library, no HTTP dependency)
52
+ ├─ store write (whole body OR unified diff) · get · recall · move · forget
53
+ ├─ diffing unified diff make/apply + content hash (optimistic locking)
54
+ ├─ blame line-attributed document + reconstruct any past version
55
+ ├─ organize tags (text[] + GIN) · tree (ltree path + GiST, fast subtree select)
56
+ ├─ search lexical (Postgres FTS) + semantic (pgvector or Qdrant) + hybrid
57
+ ├─ embeddings provider via env: none | local (sentence-transformers) | cloud (Jina/OpenAI)
58
+ └─ config every limit via env (body/write size, TTL, namespaces, …)
59
+
60
+ optional layers on top of the same core:
61
+ ├─ HTTP API (FastAPI) REST + OpenAPI
62
+ └─ MCP server write/recall/get/blame/move/forget as MCP tools
63
+ ```
64
+
65
+ **Record model:** one memory = one mutable body (up to a configurable ceiling, default 256 KB) plus metadata — `tags` (cross-cutting labels, `text[]` + GIN), a `path` (its place in an `ltree` tree), timestamps, and per-diff provenance (`source`/`reason`, kept in history). A single write/diff is capped smaller (default 16 KB), so large bodies accrue over many authored diffs. **Organization is two orthogonal axes:** the tree is *where a memory lives* (one place, subtree-selectable); tags are *what it's about* (many, overlapping). Both filter either search — narrow a semantic query to a subtree, or list a tag across the tree.
66
+
67
+ **Isolation:** optional token *namespaces* keep tenants from seeing each other's memories (namespace = hash of a secret token, so one wallet can back many clients). Encryption at rest is left to the deployment — Postgres/managed-PG/disk TDE stays transparent to queries, so search keeps working; memgres deliberately does **not** encrypt bodies application-side (that would make them unsearchable, which is why no comparable tool does it either). GDPR erasure is real: `forget()` hard-deletes the row, its vectors, and crypto-shreds the history chain. All limits are env-configurable, so the same code serves a single-user embed and a capped multi-tenant service.
68
+
69
+ ---
70
+
71
+ ## Quickstart
72
+
73
+ **The whole thing, one command** — brings up `pgvector` + the memgres service on `http://localhost:8080`, schema auto-migrated on startup:
74
+
75
+ ```bash
76
+ docker compose up # → http://localhost:8080 (GET /healthz → {"ok":true})
77
+ ```
78
+
79
+ ```bash
80
+ # create a memory
81
+ curl -sX POST localhost:8080/memories \
82
+ -H 'content-type: application/json' \
83
+ -d '{"body":"Postgres tuning notes\nshared_buffers = 25% RAM\n","tags":["db"],"path":"ops.postgres","source":"me"}'
84
+ # → {"id":"…","content_hash":"…","seq":1, …}
85
+
86
+ # recall (lexical out of the box; semantic once you set an embedding provider)
87
+ curl -s 'localhost:8080/recall?q=postgres%20tuning'
88
+
89
+ # edit by unified diff, guarded by the hash you edited (409 if stale)
90
+ curl -sX PATCH localhost:8080/memories/$ID \
91
+ -H 'content-type: application/json' \
92
+ -d '{"diff":"--- \n+++ \n@@ -2 +2 @@\n-shared_buffers = 25% RAM\n+shared_buffers = 40% RAM\n","base_hash":"'$HASH'","source":"me","reason":"bump"}'
93
+
94
+ # who wrote each line (grouped into author-blocks by default)
95
+ curl -s localhost:8080/memories/$ID/blame
96
+ ```
97
+
98
+ ### As a Python library (no HTTP)
99
+
100
+ ```python
101
+ from memgres import Store, load_config, migrate
102
+ import psycopg
103
+
104
+ cfg = load_config() # reads MEMGRES_* env
105
+ conn = psycopg.connect(cfg.database_url)
106
+ migrate(conn, cfg) # idempotent; stamps embed model/dim
107
+
108
+ s = Store(cfg, conn=conn)
109
+ m = s.write(body="remember this\n", tags=["note"], path="misc.reminder", source="me")
110
+
111
+ # edit: whole body OR a diff carrying the base hash (optimistic concurrency)
112
+ m = s.write(id=m.id, body="remember this, updated\n", base_hash=m.content_hash, reason="tweak")
113
+
114
+ hits = s.recall(None, "what did I remember?", k=5) # lexical / semantic / hybrid / auto
115
+ blame = s.annotate_grouped(None, m.id) # [{start,end,source,reason,…}]
116
+ old = s.reconstruct(None, m.id, 1) # body as of version 1
117
+ s.forget(None, m.id) # hard-erase + history
118
+ ```
119
+
120
+ ### Install
121
+
122
+ > Not on PyPI yet — install from git (or clone and `pip install -e .`):
123
+
124
+ ```bash
125
+ pip install "git+https://github.com/mozgsml/memgres" # core library
126
+ pip install "memgres[server] @ git+https://github.com/mozgsml/memgres" # + HTTP API
127
+ pip install "memgres[mcp] @ git+https://github.com/mozgsml/memgres" # + MCP server
128
+ # extras: local (sentence-transformers), qdrant (Qdrant backend)
129
+ ```
130
+
131
+ Or pull the container image:
132
+
133
+ ```bash
134
+ docker pull ghcr.io/mozgsml/memgres:latest
135
+ ```
136
+
137
+ ### Three ways to run it
138
+
139
+ 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).
140
+ 2. **Your own Postgres** — install the `[server]` extra (above), point `MEMGRES_DATABASE_URL` at it, run `memgres-server` (migrates on startup).
141
+ 3. **Embedded library** — install the core package, use `Store` directly, no HTTP at all.
142
+
143
+ 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.
144
+
145
+ ## Configuration
146
+
147
+ Everything is env, all optional (defaults suit a single-user embed). Full list in [`.env.example`](.env.example).
148
+
149
+ | Variable | Default | Meaning |
150
+ |---|---|---|
151
+ | `MEMGRES_DATABASE_URL` | libpq env | Postgres connection string |
152
+ | `MEMGRES_MAX_BODY_BYTES` | `262144` | ceiling for a whole record body (256 KB) |
153
+ | `MEMGRES_MAX_WRITE_BYTES` | `16384` | ceiling for one write/diff payload (≤ body) |
154
+ | `MEMGRES_RETENTION_DAYS` | `0` | `0` = keep forever; `>0` = expire N days after last touch |
155
+ | `MEMGRES_RENEW_ON_READ` | `true` | a read pushes the expiry clock forward |
156
+ | `MEMGRES_NAMESPACES` | `false` | `true` = each caller sends a secret token; namespace = its hash |
157
+ | `MEMGRES_TREE` | `true` | `ltree` path column + GiST index (fast subtree select) |
158
+ | `MEMGRES_REQUIRE_PARENT` | `false` | `true` = a node's parent path must already exist |
159
+ | `MEMGRES_HISTORY` | `true` | keep the hash-chained diff history (deleted with the record) |
160
+ | `MEMGRES_FTS_LANGUAGE` | `simple` | Postgres FTS dictionary (`simple`/`english`/…) |
161
+ | `MEMGRES_VECTOR_BACKEND` | `pgvector` | `pgvector` (same DB) or `qdrant` (set `QDRANT_URL`, `QDRANT_API_KEY`, `MEMGRES_QDRANT_COLLECTION`) |
162
+ | `MEMGRES_EMBED_PROVIDER` | `none` | `none` / `local` / `openai` / `jina` / `openai-compatible` (LM Studio, Ollama, vLLM, TEI…) |
163
+ | `MEMGRES_EMBED_MODEL` / `_DIM` / `_API_KEY` / `_API_BASE` | — | model id · dimension (HTTP providers require it, `local` infers) · token · server URL |
164
+
165
+ ## HTTP API
166
+
167
+ | Method | Path | Purpose |
168
+ |---|---|---|
169
+ | `POST` | `/memories` | create |
170
+ | `GET` | `/memories/{id}` | read (renews TTL) |
171
+ | `PATCH` | `/memories/{id}` | edit: whole `body` **or** `diff`+`base_hash`; move; retag |
172
+ | `POST` | `/memories/{id}/move` | reparent a node (cascades its subtree) |
173
+ | `DELETE` | `/memories/{id}` | forget (hard-erase + history) |
174
+ | `GET` | `/memories/{id}/history` | raw change chain |
175
+ | `GET` | `/memories/{id}/blame` | line attribution; `?group`, `?text`, `?lines=1,3-5` |
176
+ | `GET` | `/memories/{id}/at/{seq}` | body reconstructed at a version |
177
+ | `GET` | `/recall` | `?q=&k=&mode=&tags=&path_prefix=` |
178
+ | `GET` | `/healthz` | liveness |
179
+
180
+ Namespace token (when `MEMGRES_NAMESPACES=true`) goes in `Authorization: Bearer <token>` or `X-Memgres-Token`. OpenAPI/Swagger is served at `/docs`. Store errors map to status codes: `409` stale-hash conflict, `404` not found, `413` too large, `401` missing token.
181
+
182
+ ## MCP server
183
+
184
+ The same store is exposed to MCP clients (Claude Desktop, etc.) over stdio:
185
+
186
+ ```bash
187
+ pip install "memgres[mcp] @ git+https://github.com/mozgsml/memgres"
188
+ memgres-mcp # needs MEMGRES_DATABASE_URL; migrates on startup
189
+ ```
190
+
191
+ Tools: `memory_write` (create or edit by body/diff), `memory_get`, `memory_recall`, `memory_blame`, `memory_history`, `memory_move`, `memory_forget`. Point your MCP client's config at the `memgres-mcp` command.
192
+
193
+ ---
194
+
195
+ ## License
196
+
197
+ MIT — see [LICENSE](LICENSE). Fully self-hostable, no gated features.
@@ -0,0 +1,34 @@
1
+ """memgres — versioned document memory for AI agents, backed by one Postgres.
2
+
3
+ Quick start::
4
+
5
+ from memgres import Store, load_config, migrate
6
+ import psycopg
7
+
8
+ cfg = load_config() # reads MEMGRES_* env
9
+ conn = psycopg.connect(cfg.database_url)
10
+ migrate(conn, cfg) # idempotent; stamps embed model/dim
11
+
12
+ store = Store(cfg, conn=conn)
13
+ m = store.write(body="remember this\n", tags=["note"], source="me")
14
+ store.write(id=m.id, diff=patch, base_hash=m.content_hash) # authored edit
15
+ hits = store.recall(None, "what did I remember?") # lexical or semantic
16
+ """
17
+
18
+ from .config import Config, load as load_config
19
+ from .diffing import apply_diff, content_hash, make_diff, DiffConflict
20
+ from .embeddings import Embedder, get_embedder
21
+ from .schema import migrate, SchemaMismatch, SCHEMA_VERSION
22
+ from .search import Hit, recall
23
+ from .blame import annotate, annotate_grouped, reconstruct, replay
24
+ from .store import Store, Memory, Conflict, NotFound, TooLarge, NoParent
25
+
26
+ __all__ = [
27
+ "Config", "load_config",
28
+ "Store", "Memory", "Conflict", "NotFound", "TooLarge", "NoParent",
29
+ "make_diff", "apply_diff", "content_hash", "DiffConflict",
30
+ "Embedder", "get_embedder",
31
+ "migrate", "SchemaMismatch", "SCHEMA_VERSION",
32
+ "Hit", "recall",
33
+ "annotate", "annotate_grouped", "reconstruct", "replay",
34
+ ]
@@ -0,0 +1,143 @@
1
+ """Turn the diff history into an annotated document (git-blame) and reconstruct
2
+ any past version — so callers never replay diffs themselves.
3
+
4
+ Because the store keeps a canonical diff for *every* body change (create is a
5
+ diff-from-empty), the history is a self-contained chain empty → current. We
6
+ replay it forward line by line, carrying an attribution with each surviving line:
7
+
8
+ * an added line is attributed to the change that introduced it,
9
+ * a context (unchanged) line keeps the attribution it already had,
10
+ * a removed line drops out.
11
+
12
+ That is exactly blame semantics — a modified line reads as remove+add, so the new
13
+ text is credited to the change that wrote it. Metadata-only rows (move/retag)
14
+ carry no diff and don't affect line attribution.
15
+ """
16
+
17
+ from __future__ import annotations
18
+
19
+ from typing import Iterable, List, Optional, Tuple
20
+
21
+ from .diffing import _HUNK
22
+
23
+ _ATTR_KEYS = ("seq", "op", "source", "reason", "created_at")
24
+
25
+
26
+ def _apply_attributed(src: List[Tuple[str, dict]], patch: str,
27
+ attrib: dict) -> List[Tuple[str, dict]]:
28
+ """Apply one diff to a list of (line_text, attribution) pairs. Added lines get
29
+ `attrib`; context/removed lines are matched against src text exactly (the base
30
+ is exact, so hunks line up — a mismatch is a real bug, surfaced loudly)."""
31
+ out: List[Tuple[str, dict]] = []
32
+ i = 0
33
+ lines = patch.split("\n")
34
+ n = len(lines)
35
+ p = 0
36
+
37
+ def marker_next(idx: int) -> bool:
38
+ return idx + 1 < n and lines[idx + 1].startswith("\\ No newline")
39
+
40
+ while p < n:
41
+ line = lines[p]
42
+ if line.startswith(("--- ", "+++ ")):
43
+ p += 1
44
+ continue
45
+ m = _HUNK.match(line)
46
+ if not m:
47
+ p += 1
48
+ continue
49
+ old_start = int(m.group(1))
50
+ target = old_start - 1 if old_start > 0 else 0
51
+ out.extend(src[i:target])
52
+ i = target
53
+ p += 1
54
+ while p < n and not lines[p].startswith("@@"):
55
+ hl = lines[p]
56
+ if hl.startswith("\\ No newline") or hl.startswith(("--- ", "+++ ")) or hl == "":
57
+ p += 1
58
+ continue
59
+ tag, text = hl[0], hl[1:]
60
+ if tag == " ":
61
+ out.append(src[i]); i += 1
62
+ elif tag == "-":
63
+ i += 1
64
+ elif tag == "+":
65
+ out.append((text if marker_next(p) else text + "\n", attrib))
66
+ p += 1
67
+ out.extend(src[i:])
68
+ return out
69
+
70
+
71
+ def replay(history: List[dict], upto_seq: Optional[int] = None) -> List[Tuple[str, dict]]:
72
+ """Forward-replay history (ordered by seq) into a list of (line, attribution).
73
+ `upto_seq` stops after that version (default: all → current body)."""
74
+ lines: List[Tuple[str, dict]] = []
75
+ for row in history:
76
+ if upto_seq is not None and row["seq"] > upto_seq:
77
+ break
78
+ diff = row.get("diff")
79
+ if not diff:
80
+ continue # metadata-only change: no line touched
81
+ attrib = {k: row.get(k) for k in _ATTR_KEYS}
82
+ lines = _apply_attributed(lines, diff, attrib)
83
+ return lines
84
+
85
+
86
+ def reconstruct(history: List[dict], upto_seq: Optional[int] = None) -> str:
87
+ """The exact body text at a version (default current)."""
88
+ return "".join(text for text, _ in replay(history, upto_seq))
89
+
90
+
91
+ def annotate(history: List[dict], upto_seq: Optional[int] = None,
92
+ lines: Optional[Iterable[int]] = None) -> List[dict]:
93
+ """Blame view: one entry per line of the (reconstructed) body, each tagged
94
+ with the change that last wrote it.
95
+
96
+ Attribution always needs the full replay, but `lines` (an iterable of 1-based
97
+ line numbers) restricts the *returned* entries — so you can blame one line or
98
+ a slice without shipping the whole document. Out-of-range numbers are ignored.
99
+ """
100
+ want = set(lines) if lines is not None else None
101
+ result = []
102
+ for lineno, (text, attrib) in enumerate(replay(history, upto_seq), start=1):
103
+ if want is not None and lineno not in want:
104
+ continue
105
+ result.append({"line": lineno, "text": text, **attrib})
106
+ return result
107
+
108
+
109
+ def annotate_grouped(history: List[dict], upto_seq: Optional[int] = None,
110
+ include_text: bool = True) -> List[dict]:
111
+ """Blame as runs: consecutive lines with the same attribution collapse into one
112
+ block ``{start, end, lines, seq, op, source, reason, created_at[, text]}``.
113
+
114
+ A 5000-line document edited by two authors returns a handful of blocks, not
115
+ 5000 rows. Set ``include_text=False`` for a pure ownership map (ranges only,
116
+ no body) — tiny even for huge documents.
117
+ """
118
+ blocks: List[dict] = []
119
+ cur: Optional[dict] = None
120
+ cur_texts: List[str] = []
121
+ for lineno, (text, attrib) in enumerate(replay(history, upto_seq), start=1):
122
+ key = (attrib.get("seq"), attrib.get("source"), attrib.get("reason"),
123
+ attrib.get("op"))
124
+ if cur is not None and key == cur["_key"]:
125
+ cur["end"] = lineno
126
+ cur["lines"] += 1
127
+ cur_texts.append(text)
128
+ else:
129
+ if cur is not None:
130
+ _finish_block(cur, cur_texts, include_text, blocks)
131
+ cur = {"_key": key, "start": lineno, "end": lineno, "lines": 1, **attrib}
132
+ cur_texts = [text]
133
+ if cur is not None:
134
+ _finish_block(cur, cur_texts, include_text, blocks)
135
+ return blocks
136
+
137
+
138
+ def _finish_block(block: dict, texts: List[str], include_text: bool,
139
+ out: List[dict]) -> None:
140
+ block.pop("_key", None)
141
+ if include_text:
142
+ block["text"] = "".join(texts)
143
+ out.append(block)