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 +21 -0
- memgres-0.1.0/PKG-INFO +231 -0
- memgres-0.1.0/README.md +197 -0
- memgres-0.1.0/memgres/__init__.py +34 -0
- memgres-0.1.0/memgres/blame.py +143 -0
- memgres-0.1.0/memgres/config.py +111 -0
- memgres-0.1.0/memgres/diffing.py +130 -0
- memgres-0.1.0/memgres/embeddings.py +144 -0
- memgres-0.1.0/memgres/mcp_server.py +114 -0
- memgres-0.1.0/memgres/migrations/0001_core.sql +72 -0
- memgres-0.1.0/memgres/qdrant_backend.py +82 -0
- memgres-0.1.0/memgres/schema.py +133 -0
- memgres-0.1.0/memgres/search.py +125 -0
- memgres-0.1.0/memgres/server.py +222 -0
- memgres-0.1.0/memgres/store.py +402 -0
- memgres-0.1.0/memgres.egg-info/PKG-INFO +231 -0
- memgres-0.1.0/memgres.egg-info/SOURCES.txt +29 -0
- memgres-0.1.0/memgres.egg-info/dependency_links.txt +1 -0
- memgres-0.1.0/memgres.egg-info/entry_points.txt +3 -0
- memgres-0.1.0/memgres.egg-info/requires.txt +18 -0
- memgres-0.1.0/memgres.egg-info/top_level.txt +1 -0
- memgres-0.1.0/pyproject.toml +49 -0
- memgres-0.1.0/setup.cfg +4 -0
- memgres-0.1.0/tests/test_blame_integration.py +134 -0
- memgres-0.1.0/tests/test_config.py +61 -0
- memgres-0.1.0/tests/test_diffing.py +64 -0
- memgres-0.1.0/tests/test_embeddings.py +83 -0
- memgres-0.1.0/tests/test_qdrant_integration.py +134 -0
- memgres-0.1.0/tests/test_search_integration.py +132 -0
- memgres-0.1.0/tests/test_server_integration.py +149 -0
- memgres-0.1.0/tests/test_store_integration.py +178 -0
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
|
+
[](https://github.com/mozgsml/memgres/actions/workflows/ci.yml)
|
|
38
|
+
[](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.
|
memgres-0.1.0/README.md
ADDED
|
@@ -0,0 +1,197 @@
|
|
|
1
|
+
# memgres
|
|
2
|
+
|
|
3
|
+
[](https://github.com/mozgsml/memgres/actions/workflows/ci.yml)
|
|
4
|
+
[](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)
|